// =====================================================
// Odinote — item renderers v4
// • Light mode = white-dominant
// • Columns: drop targets (items physically move IN); no internal picker
// • Board: vibrant category color + double-click rename in foot
// • Calendar: month view like Notion, Google or Apple, with photos per day
// =====================================================

function resolveMediaSrc(src) {
  if (!src) return '';
  if (src.startsWith('data:') || src.startsWith('http://') || src.startsWith('https://')) {
    return src;
  }
  if (src.startsWith('file:///') || src.includes(':/') || src.includes(':\\')) {
    const mediaMatch = src.match(/media\/[^/]+$/i);
    if (mediaMatch) {
      return `/vault-media/${mediaMatch[0]}`;
    }
  }
  if (src.startsWith('media/')) {
    // Guardado relativo a la carpeta del proyecto, así que hay que ponerle
    // delante cuál es. Si no se sabe —al arrancar, antes de abrir ninguno— se
    // pide sin ella y el servidor la busca en el montón de siempre.
    const carpeta = window.odiCarpetaProyecto;
    return carpeta ? `/vault-media/${encodeURIComponent(carpeta)}/${src}` : `/vault-media/${src}`;
  }
  if (src.startsWith('/vault-media/')) {
    return src;
  }
  return src;
}
window.resolveMediaSrc = resolveMediaSrc;

// Src a mostrar para un medio: en el .exe se prioriza la copia local de la bóveda
// (srcLocal) para que todo cargue sin internet; en la web se usa el src remoto.
function displayMediaSrc(item) {
  if (window.electronAPI && item.srcLocal) return resolveMediaSrc(item.srcLocal);
  return resolveMediaSrc(item.src);
}
window.displayMediaSrc = displayMediaSrc;

// Fondo de un nodo respetando el tema: si el usuario NO eligió color, el nodo
// sigue el tema (papel oscuro en modo oscuro) en vez de quedar blanco fijo.
// Un color elegido explícitamente (incluido el blanco) se respeta tal cual.
// Un color de fondo puede venir de dos sitios: un nombre de la paleta de
// siempre ("yellow", "sage") o un color libre elegido con el selector, que se
// guarda tal cual en hexadecimal. Se distinguen por la almohadilla.
function esColorLibre(c) {
  return typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c);
}
window.esColorLibre = esColorLibre;

// Que blanco o que negro pedir para el texto encima de un color libre. Se mira
// el brillo percibido y no la media de los canales: el ojo ve el verde mucho
// mas claro que el azul, y con la media un verde vivo salia "oscuro" y le
// tocaba texto blanco, que sobre el no se lee.
function brilloDe(hex) {
  let h = hex.replace('#', '');
  if (h.length === 3) h = h.split('').map(x => x + x).join('');
  const n = parseInt(h.slice(0, 6), 16);
  if (!isFinite(n)) return 1;
  const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
  return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}

// Un hexadecimal con la transparencia que se le pida. Se usa en los marcos,
// que no son un nodo mas: son una caja que agrupa a otros, y si se pintan
// opacos tapan lo que hay debajo, que es justo lo que fueron a rodear.
function conAlfa(hex, alfa) {
  let h = String(hex || '').replace('#', '');
  if (h.length === 3) h = h.split('').map(x => x + x).join('');
  const n = parseInt(h.slice(0, 6), 16);
  if (!isFinite(n)) return hex;
  return 'rgba(' + ((n >> 16) & 255) + ', ' + ((n >> 8) & 255) + ', ' + (n & 255) + ', ' + alfa + ')';
}
window.conAlfa = conAlfa;

function nodeBg(item) {
  if (!item.color) return 'var(--paper)';
  // Un color libre se deja pasar sin traducir: la tabla de nombres no lo
  // conoce y devolveria blanco, asi que el color elegido se perderia.
  if (esColorLibre(item.color)) return item.color;
  return window.resolveStickyColor ? window.resolveStickyColor(item.color) : '#FFFFFF';
}
// Color de texto acorde al fondo: sin color → tinta del tema; fondo oscuro →
// blanco; fondo claro elegido → tinta oscura fija.
function nodeInk(item) {
  if (!item.color) return 'var(--ink)';
  if (esColorLibre(item.color)) return brilloDe(item.color) < 0.6 ? '#FFFFFF' : '#1A1A1A';
  const dark = ['olive','wine','dark','green','red','purple','navy','indigo','plum','forest','black'].includes(item.color);
  return dark ? '#FFFFFF' : '#1A1A1A';
}
window.nodeBg = nodeBg;
window.nodeInk = nodeInk;

// Cadena de respaldo si una imagen no carga:
// 1) la copia local de la bóveda falló → probar la URL remota original
// 2) la URL lh3 de Drive falló (permiso propagándose) → probar el endpoint de miniaturas
function driveImageFallback(e) {
  const img = e.currentTarget || e.target;
  if (!img) return;
  const remote = img.dataset.remoteSrc;
  if (remote && !img.dataset.odiTriedRemote && img.src !== remote && !img.src.startsWith('https://lh3.') && !img.src.startsWith('https://drive.google.com/thumbnail')) {
    img.dataset.odiTriedRemote = '1';
    img.src = remote;
    return;
  }
  if (img.dataset.odiFallback) return;
  const m = (img.src || '').match(/^https:\/\/lh3\.googleusercontent\.com\/d\/([\w-]+)/);
  if (m) {
    img.dataset.odiFallback = '1';
    img.src = `https://drive.google.com/thumbnail?id=${m[1]}&sz=w2000`;
  }
}
window.driveImageFallback = driveImageFallback;

// Safety net: if a single node throws during render, show a small fallback for THAT node
// instead of blanking the whole app.
class NodeErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { err: false }; }
  static getDerivedStateFromError() { return { err: true }; }
  componentDidCatch(e) { console.error('[Odinote] node render error:', e); }
  render() {
    if (this.state.err) {
      return (
        <div style={{ width:'100%', height:'100%', display:'grid', placeItems:'center', background:'var(--paper)', border:'1.5px solid var(--wine)', borderRadius:2, color: 'var(--wine-texto)', fontSize:11, fontWeight:600, padding:8, textAlign:'center' }}>
          ⚠ {window.t('Error al mostrar este nodo', 'Could not show this node')}
        </div>
      );
    }
    return this.props.children;
  }
}
window.NodeErrorBoundary = NodeErrorBoundary;

const COLOR_HEX_RESOLVED = {
  white: '#FFFFFF', cream: '#FEF7E0', sage: '#E8F0DA', rose: '#FBDFDD',
  stone: '#F5F4F6', mist: '#E1DFE3', olive: '#90B968', wine: '#E6544F',
  yellow: '#F7DA84', pink: '#FBDFDD', mint: '#E8F0DA', sky: '#F5F4F6',
  lavender: '#E6544F', lav: '#E6544F', coral: '#F7DA84', lime: '#E8F0DA',
  paper: '#FFFFFF',
};
const STICKY_COLORS_NEW = ['white','cream','sage','rose','stone','mist','olive','wine'];

// categorization palette — maps to user's 5-color palette
const CATEGORY_COLORS = ['red','orange','yellow','green','teal','blue','purple','pink'];
const CATEGORY_HEX = {
  red:    '#E6544F', orange: '#E6544F', yellow: '#F7DA84', green:  '#90B968',
  teal:   '#90B968', blue:   '#595459', purple: '#595459', pink:   '#E6544F',
};

function colorClass(c) {
  // Un color libre no tiene clase. Se devuelve la de blanco para no emitir un
  // nombre de clase con almohadilla, que no es valido en CSS; el fondo de
  // verdad lo pone `nodeBg` en linea.
  if (typeof c === 'string' && c.charAt(0) === '#') return 'white';
  const map = {
    yellow:'cream', pink:'rose', mint:'sage', sky:'stone',
    lavender:'wine', lav:'wine', coral:'cream', lime:'sage', paper:'white',
    green: 'olive', red: 'wine',
  };
  return map[c] || c || 'white';
}

// Saca el texto de un contenido guardado en varios idiomas y devuelve SIEMPRE
// una cadena. Lo segundo es lo importante: el contenido que escribe la gente se
// guarda como {es, en} y nada mas, aunque la interfaz tenga once idiomas. Con la
// aplicacion en frances, `titulo[lang]` no existe, y cualquier reserva del tipo
// `titulo[lang] || titulo` acaba devolviendo el objeto entero. React no sabe
// pintar un objeto: lanza el error 31 y deja la pantalla en blanco.
function pickLang(v, lang) {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  // En los otros nueve idiomas, el inglés antes que el español: a quien usa la
  // app en francés le sirve más. Lo que escribe la gente se guarda igual en
  // las dos, así que esto solo cambia lo que trae la app (plantillas, títulos
  // por defecto).
  if (lang !== 'es') {
    if (v[lang]) return v[lang];
    // Un texto que puso la app (el título de una lista nueva) se guarda como
    // { es, en } y en francés salía «To-do». Si la pareja exacta está en el
    // catálogo se traduce; lo que escribe la gente no casa con ninguna y sigue
    // igual.
    if (lang !== 'en' && window.odiTraduce && window.odiTraduce.fija && typeof v.es === 'string') {
      const t = window.odiTraduce.fija(lang, v.es, v.en);
      if (t) return t;
    }
    return v.en || v.es || '';
  }
  return v.es || v.en || '';
}
// Se cuelga de window a proposito. Antes funcionaba solo porque Babel deja las
// funciones sueltas en el ambito global, que es un accidente y no un contrato.
window.pickLang = pickLang;

// ──────────────── NOTE ────────────────
// ── Al entrar a escribir, el cursor dentro ──
//
// Encontrado probando la aplicación: se ponía una nota, entraba sola a modo
// escritura… y lo que se tecleaba no aparecía por ningún lado. Había que
// pinchar dentro antes de escribir.
//
// El motivo: tras CADA cambio de selección, a los 50 ms, el lienzo le da el foco
// a un campo invisible que existe solo para que Ctrl+V pegue imágenes — salvo
// que ya haya algo editable con el foco. El nodo nuevo entraba en modo escritura
// pero nadie le daba el foco, así que el campo invisible se lo quedaba y lo
// tecleado iba a parar ahí, donde no se ve. Lo mismo al entrar con el botón
// "Editar", que en el móvil es la única forma de hacerlo.
//
// Ahora el propio nodo coge el foco al entrar a escribir, y con eso el campo
// invisible ya no se lo quita. Si se entró con doble clic sobre una palabra, esa
// selección se respeta; si no, el cursor va al FINAL. Hay que mirarlo antes de
// enfocar: al enfocar, Chrome planta el cursor al principio, y la primera
// versión lo tomaba por un cursor puesto a propósito. Un cursor suelto dentro
// tampoco cuenta: lo deja el clic con el que se seleccionó el nodo.
//
// seleccionarTodo: para cuando lo que hay escrito es solo el texto de ejemplo
// con el que nace el nodo ("Título Grande"). Ahí lo que se teclea tiene que
// SUSTITUIRLO, no ponerse detrás: probando, un título nuevo acababa diciendo
// "Título GrandeMi título".
function usaCursorAlEditar(ref, editing, seleccionarTodo) {
  React.useEffect(() => {
    if (!editing || !ref.current) return;
    const el = ref.current;
    if (!el.isContentEditable) return;
    if (el.contains(document.activeElement)) return;
    const sel = window.getSelection();
    const palabra = sel && sel.rangeCount && !sel.isCollapsed && el.contains(sel.anchorNode)
      ? sel.getRangeAt(0).cloneRange()
      : null;
    el.focus();
    const r = palabra || document.createRange();
    if (!palabra) { r.selectNodeContents(el); if (!seleccionarTodo) r.collapse(false); }
    const s2 = window.getSelection();
    s2.removeAllRanges();
    s2.addRange(r);
  }, [editing]);
}

// Rich text via contentEditable + execCommand (per-selection styling)
function NoteItem({ item, lang, editing, onUpdate }) {
  const cls = colorClass(item.color || 'white');
  const text = pickLang(item.content, lang);
  // Sin fondo: texto flotando sobre el lienzo, como los títulos. El aspecto
  // lo pone .note--sin-fondo; aquí no se pinta fondo ni tinta en línea.
  const sinFondo = item.color === 'transparent';
  const bg = sinFondo ? undefined : window.nodeBg(item);
  const textColor = sinFondo ? undefined : window.nodeInk(item);
  const clase = sinFondo ? 'note note--sin-fondo' : `note c-${cls}`;
  const estiloNota = { width: '100%', height: '100%', ...window.varsDeFuente(item.fuente) };
  const ref = React.useRef(null);
  usaCursorAlEditar(ref, editing);

  // Ensure every <pre> has an editable <p> sibling after it (so user can click below)
  const ensureTrailingParagraph = (root) => {
    if (!root) return false;
    let changed = false;
    root.querySelectorAll('pre').forEach(pre => {
      const next = pre.nextElementSibling;
      if (!next || next.tagName !== 'P') {
        const p = document.createElement('p');
        p.appendChild(document.createElement('br'));
        pre.parentNode.insertBefore(p, pre.nextSibling);
        changed = true;
      }
    });
    return changed;
  };

  // Set HTML once when entering edit mode / item changes
  React.useEffect(() => {
    if (!ref.current) return;
    if (ref.current.innerHTML !== (text || '')) {
      ref.current.innerHTML = text || '';
    }
    // Always guarantee a free <p> after every code block
    if (ensureTrailingParagraph(ref.current) && editing) {
      onUpdate({ content: { es: ref.current.innerHTML, en: ref.current.innerHTML } });
    }
    // Syntax highlight code blocks (only when NOT editing, to avoid cursor jumps)
    if (!editing && window.hljs) {
      ref.current.querySelectorAll('pre code').forEach(el => {
        if (!el.textContent.trim()) return; // skip empty code blocks to prevent highlight.js warning
        try {
          if (!el.dataset.highlighted) {
            el.textContent = el.textContent; // Strip existing HTML spans to prevent warnings
            window.hljs.highlightElement(el);
          }
        } catch {}
      });
    }
  // eslint-disable-next-line
  }, [editing, item.id, text]);

  // Re-highlight on blur (when user finishes editing a code block)
  const reHighlight = () => {
    if (!ref.current || !window.hljs) return;
    ref.current.querySelectorAll('pre code').forEach(el => {
      if (!el.textContent.trim()) return;
      try {
        delete el.dataset.highlighted;
        el.className = el.className.replace(/\bhljs\b[^\s]*/g, '').trim();
        el.textContent = el.textContent; // Strip existing HTML spans to prevent warnings
        window.hljs.highlightElement(el);
      } catch {}
    });
  };

  const onInput = () => {
    if (!ref.current) return;
    // Always keep a free paragraph after any <pre> so the user can click below
    ensureTrailingParagraph(ref.current);
    onUpdate({ content: { es: ref.current.innerHTML, en: ref.current.innerHTML } });
  };

  // Live highlight code blocks while preserving caret
  const liveHighlight = () => {
    if (!ref.current || !window.hljs) return;
    const sel = window.getSelection();
    let caretOffset = null;
    let activeCode = null;
    if (sel && sel.rangeCount) {
      let node = sel.anchorNode;
      while (node && node !== ref.current && !(node.tagName === 'CODE' && node.parentNode?.tagName === 'PRE')) {
        node = node.parentNode;
      }
      if (node && node.tagName === 'CODE') {
        activeCode = node;
        // compute caret offset as plain text position
        const range = sel.getRangeAt(0);
        const pre = range.cloneRange();
        pre.selectNodeContents(node);
        pre.setEnd(range.endContainer, range.endOffset);
        caretOffset = pre.toString().length;
      }
    }
    ref.current.querySelectorAll('pre code').forEach(el => {
      if (!el.textContent.trim()) return;
      try {
        delete el.dataset.highlighted;
        el.className = el.className.replace(/\bhljs\b[^\s]*|\blanguage-[^\s]+/g, '').trim();
        el.textContent = el.textContent; // Strip existing HTML spans to prevent warnings
        window.hljs.highlightElement(el);
      } catch {}
    });
    if (activeCode && caretOffset != null) {
      // restore caret by walking text nodes
      const walker = document.createTreeWalker(activeCode, NodeFilter.SHOW_TEXT);
      let total = 0, target = null, targetOffset = 0;
      let n;
      while ((n = walker.nextNode())) {
        const len = n.nodeValue.length;
        if (total + len >= caretOffset) {
          target = n; targetOffset = caretOffset - total; break;
        }
        total += len;
      }
      if (target) {
        const range = document.createRange();
        range.setStart(target, Math.min(targetOffset, target.nodeValue.length));
        range.collapse(true);
        sel.removeAllRanges();
        sel.addRange(range);
      }
    }
  };

  // Throttle highlighting to avoid breaking typing flow
  const highlightTimer = React.useRef(null);
  const scheduleHighlight = () => {
    if (highlightTimer.current) clearTimeout(highlightTimer.current);
    highlightTimer.current = setTimeout(liveHighlight, 350);
  };

  React.useEffect(() => {
    if (!ref.current) return;
    // Mientras se arrastra/redimensiona no recalculamos: antes este efecto
    // revertía la altura en cada frame y por eso la nota "solo se estiraba
    // horizontalmente".
    if (document.body.classList.contains('odi-busy')) return;
    const contentEl = ref.current;
    const origHeight = contentEl.style.height;
    contentEl.style.height = 'auto';
    const naturalHeight = contentEl.scrollHeight;
    contentEl.style.height = origHeight;
    const totalHeight = Math.max(80, Math.round(naturalHeight + 3));
    // Con altura fijada a mano respetamos ese tamaño, pero SÍ crecemos si el
    // texto ya no cabe (p. ej. al subir el tamaño con los botones A+). Nunca
    // encogemos, para no deshacer la altura elegida.
    if (item.manualH) {
      if (totalHeight > (item.h || 0)) onUpdate({ h: totalHeight });
      return;
    }
    if (Math.abs((item.h || 0) - totalHeight) > 3) {
      onUpdate({ h: totalHeight });
    }
  // eslint-disable-next-line
  }, [item.content, editing, item.h, lang, item.manualH, item.textScale]);

  if (editing) {
    return (
      <div className={clase} style={estiloNota}>
        <div className="item-card" style={{ background: bg }}>
          <div
            ref={ref}
            className="note-edit rich"
            style={{ fontSize: `calc(13.5px * var(--node-scale, 1) * var(--vista-escala, ${item.textScale || 1}))` }}
            contentEditable
            suppressContentEditableWarning
            spellCheck={true}
            data-placeholder={window.t('Escribe tu nota…', 'Write your note…')}
            onInput={(e) => { onInput(); scheduleHighlight(); }}
            onBlur={reHighlight}
            onClick={(e)=>e.stopPropagation()}
            onMouseDown={(e)=>e.stopPropagation()}
            onKeyDown={(e)=>{
              if (e.key === 'Tab') {
                const sel = window.getSelection();
                if (sel && sel.rangeCount) {
                  let node = sel.anchorNode;
                  while (node && node !== ref.current && node.tagName !== 'PRE') {
                    node = node.parentNode;
                  }
                  if (node && node.tagName === 'PRE') {
                    e.preventDefault();
                    const range = sel.getRangeAt(0);
                    
                    if (!range.collapsed) {
                      // Selection exists: indent or unindent all selected lines
                      const selectedText = range.toString();
                      let processedText = '';
                      if (e.shiftKey) {
                        // Shift+Tab: remove up to 4 spaces from start of each line
                        processedText = selectedText.split('\n').map(line => line.replace(/^ {1,4}/, '')).join('\n');
                      } else {
                        // Tab: add 4 spaces to start of each line
                        processedText = selectedText.split('\n').map(line => '    ' + line).join('\n');
                      }
                      range.deleteContents();
                      const textNode = document.createTextNode(processedText);
                      range.insertNode(textNode);
                      
                      // Select the new text
                      const newRange = document.createRange();
                      newRange.selectNodeContents(textNode);
                      sel.removeAllRanges();
                      sel.addRange(newRange);
                    } else {
                      // No selection (collapsed cursor)
                      if (e.shiftKey) {
                        // Shift+Tab: unindent (remove up to 4 spaces before or after the cursor)
                        const startNode = range.startContainer;
                        const startOffset = range.startOffset;
                        if (startNode.nodeType === Node.TEXT_NODE) {
                          const textBefore = startNode.textContent.substring(0, startOffset);
                          const matchBefore = textBefore.match(/ {1,4}$/);
                          if (matchBefore) {
                            const numSpaces = matchBefore[0].length;
                            const newRange = document.createRange();
                            newRange.setStart(startNode, startOffset - numSpaces);
                            newRange.setEnd(startNode, startOffset);
                            sel.removeAllRanges();
                            sel.addRange(newRange);
                            newRange.deleteContents();
                          } else {
                            const textAfter = startNode.textContent.substring(startOffset);
                            const matchAfter = textAfter.match(/^ {1,4}/);
                            if (matchAfter) {
                              const numSpaces = matchAfter[0].length;
                              const newRange = document.createRange();
                              newRange.setStart(startNode, startOffset);
                              newRange.setEnd(startNode, startOffset + numSpaces);
                              sel.removeAllRanges();
                              sel.addRange(newRange);
                              newRange.deleteContents();
                            }
                          }
                        }
                      } else {
                        // Tab: insert 4 spaces at cursor
                        const spaces = '    ';
                        range.deleteContents();
                        const textNode = document.createTextNode(spaces);
                        range.insertNode(textNode);
                        
                        const r2 = document.createRange();
                        r2.setStartAfter(textNode);
                        r2.collapse(true);
                        sel.removeAllRanges();
                        sel.addRange(r2);
                      }
                    }
                    ref.current.dispatchEvent(new Event('input', { bubbles: true }));
                    onInput();
                    scheduleHighlight();
                    return;
                  }
                }
              }
              if (e.key === 'Escape') { e.target.blur(); return; }

              // Exit blockquote/heading/pre on backspace when empty
              if (e.key === 'Backspace') {
                const sel = window.getSelection();
                if (sel && sel.isCollapsed && sel.anchorOffset === 0) {
                  let node = sel.anchorNode;
                  let liNode = sel.anchorNode;
                  while (liNode && liNode !== ref.current && liNode.tagName !== 'LI') {
                    liNode = liNode.parentNode;
                  }
                  if (liNode && liNode.tagName === 'LI') {
                    e.preventDefault();
                    const list = liNode.parentNode;
                    const p = document.createElement('p');
                    p.innerHTML = liNode.innerHTML || '<br>';
                    if (list.children.length === 1) {
                      list.parentNode.replaceChild(p, list);
                    } else {
                      list.parentNode.insertBefore(p, list.nextSibling);
                      liNode.remove();
                    }
                    const range = document.createRange();
                    range.selectNodeContents(p);
                    range.collapse(true);
                    sel.removeAllRanges();
                    sel.addRange(range);
                    ref.current.dispatchEvent(new Event('input', { bubbles: true }));
                    return;
                  }
                  while (node && node !== ref.current && !['BLOCKQUOTE','H1','H2','PRE'].includes(node.tagName)) {
                    node = node.parentNode;
                  }
                  if (node && node !== ref.current) {
                    // If the block has no content, remove the whole block
                    if (!node.textContent.trim()) {
                      e.preventDefault();
                      const p = document.createElement('p');
                      p.appendChild(document.createElement('br'));
                      node.parentNode.replaceChild(p, node);
                      const range = document.createRange();
                      range.setStart(p, 0);
                      range.collapse(true);
                      sel.removeAllRanges();
                      sel.addRange(range);
                      ref.current.dispatchEvent(new Event('input', { bubbles: true }));
                    } else if (node.tagName !== 'PRE') {
                      e.preventDefault();
                      document.execCommand('formatBlock', false, 'P');
                      ref.current.dispatchEvent(new Event('input', { bubbles: true }));
                    }
                  }
                }
              }
              // Enter inside PRE: always insert a newline of code (intuitive).
              // To exit the block, click the free paragraph below or press ArrowDown.
              if (e.key === 'Enter') {
                const sel = window.getSelection();
                if (sel && sel.isCollapsed) {
                  let node = sel.anchorNode;
                  while (node && node !== ref.current && node.tagName !== 'PRE') {
                    node = node.parentNode;
                  }
                  if (node && node.tagName === 'PRE') {
                    e.preventDefault();
                    const range = sel.getRangeAt(0);
                    range.deleteContents();
                    // Detect if caret is at the end of the code block. If so we need
                    // to insert TWO newlines (and place caret between them) because
                    // a trailing single \n is invisible in pre/contenteditable.
                    const probe = document.createRange();
                    probe.selectNodeContents(node);
                    probe.setStart(range.endContainer, range.endOffset);
                    const atEnd = probe.toString().replace(/\s+$/, '') === '';
                    const nl = document.createTextNode('\n');
                    range.insertNode(nl);
                    if (atEnd) {
                      const trail = document.createTextNode('\n');
                      nl.parentNode.insertBefore(trail, nl.nextSibling);
                    }
                    const r2 = document.createRange();
                    r2.setStartAfter(nl);
                    r2.collapse(true);
                    sel.removeAllRanges();
                    sel.addRange(r2);
                    ref.current.dispatchEvent(new Event('input', { bubbles: true }));
                    return;
                  }
                  // Enter on empty blockquote/heading line exits it
                  if (!e.shiftKey) {
                    let bnode = sel.anchorNode;
                    while (bnode && bnode !== ref.current && !['BLOCKQUOTE','H1','H2'].includes(bnode.tagName)) {
                      bnode = bnode.parentNode;
                    }
                    if (bnode && bnode !== ref.current && (bnode.textContent || '').trim() === '') {
                      e.preventDefault();
                      document.execCommand('formatBlock', false, 'P');
                      ref.current.dispatchEvent(new Event('input', { bubbles: true }));
                    }
                  }
                }
              }
              // Arrow Down at end of pre → exit
              if (e.key === 'ArrowDown') {
                const sel = window.getSelection();
                if (sel && sel.isCollapsed) {
                  let node = sel.anchorNode;
                  while (node && node !== ref.current && node.tagName !== 'PRE') {
                    node = node.parentNode;
                  }
                  if (node && node.tagName === 'PRE' && !node.nextSibling) {
                    e.preventDefault();
                    const p = document.createElement('p');
                    p.appendChild(document.createElement('br'));
                    node.parentNode.appendChild(p);
                    const range = document.createRange();
                    range.setStart(p, 0);
                    range.collapse(true);
                    sel.removeAllRanges();
                    sel.addRange(range);
                    ref.current.dispatchEvent(new Event('input', { bubbles: true }));
                  }
                }
              }
            }}
            style={{ color: textColor }}
          />
        </div>
      </div>
    );
  }

  // Read-only render — HTML content with syntax highlight
  return (
    <div className={clase} style={estiloNota}>
      <div className="item-card" style={{ background: bg }}>
        <div
          ref={ref}
          className="note-inner rich"
          style={{ color: textColor, fontSize: `calc(13.5px * var(--node-scale, 1) * var(--vista-escala, ${item.textScale || 1}))` }}
        />
      </div>
    </div>
  );
}

// ──────────────── CAPTION ("leyenda" — shared rich text) ────────────────
// A small contentEditable used by image/link/board/comment/calendar/audio nodes.
// Stores HTML in item.caption[lang] and reports focus/blur so the reduced
// caption format sidebar (Color/H1/B/I/S/U) can show next to it.
function NodeCaption({ item, lang, onUpdate, className, placeholder, style, autoGrow }) {
  const ref = React.useRef(null);
  const focusedRef = React.useRef(false);
  const html = (item.caption && item.caption[lang]) || '';

  // Keep current height / latest onUpdate in refs so the ResizeObserver isn't stale
  const itemHRef = React.useRef(item.h); itemHRef.current = item.h;
  const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate;

  React.useEffect(() => {
    if (!ref.current) return;
    if (!focusedRef.current && ref.current.innerHTML !== html) {
      ref.current.innerHTML = html;
    }
  }, [html, lang]);

  // Grow / shrink the NODE so the caption expands downward instead of being squashed.
  React.useEffect(() => {
    if (!autoGrow || !ref.current || typeof ResizeObserver === 'undefined') return;
    const el = ref.current;
    let last = null;
    const ro = new ResizeObserver(() => {
      const h = el.offsetHeight;
      if (last == null) { last = h; return; }      // ignore the initial measurement
      const delta = h - last;
      if (Math.abs(delta) >= 1) {
        last = h;
        onUpdateRef.current({ h: Math.max(40, Math.round((itemHRef.current || 0) + delta)) });
      }
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [autoGrow]);

  const commit = () => {
    if (!ref.current) return;
    onUpdate({ caption: { es: ref.current.innerHTML, en: ref.current.innerHTML } });
  };

  return (
    <div
      ref={ref}
      className={`node-caption rich-caption${className ? ' ' + className : ''}`}
      style={{ ...(style || {}), ...(item.captionSize ? { fontSize: `${item.captionSize}px` } : {}) }}
      contentEditable
      suppressContentEditableWarning
      spellCheck={true}
      data-placeholder={placeholder || (window.t('Añade una leyenda…', 'Add a caption…'))}
      onInput={commit}
      onClick={(e)=>e.stopPropagation()}
      onMouseDown={(e)=>e.stopPropagation()}
      onDoubleClick={(e)=>e.stopPropagation()}
      onFocus={()=>{ focusedRef.current = true; if (window.__odiCaptionFocus) window.__odiCaptionFocus(item.id); }}
      onBlur={()=>{ focusedRef.current = false; commit(); if (window.__odiCaptionBlur) window.__odiCaptionBlur(item.id); }}
      onKeyDown={(e)=>{ if (e.key === 'Escape') { e.preventDefault(); e.target.blur(); } }}
    />
  );
}

// ──────────────── IMAGE ────────────────
function ImageItem({ item, lang, onUpdate, callbacks }) {
  const cb = callbacks || {};
  const fileRef = React.useRef(null);
  const hasImage = !!item.src;
  const bg = window.nodeBg(item);
  const [naturalRatio, setNaturalRatio] = React.useState(null);
  const onUpdateRef = React.useRef(onUpdate);
  React.useEffect(() => { onUpdateRef.current = onUpdate; }, [onUpdate]);
  const itemWRef = React.useRef(item.w);
  React.useEffect(() => { itemWRef.current = item.w; }, [item.w]);

  const loadFile = (file) => {
    if (!file || !file.type.startsWith('image/')) return false;
    const reader = new FileReader();
    reader.onload = () => {
      const src = reader.result;
      const img = new Image();
      const rutaOrigen = window.rutaDeArchivo ? window.rutaDeArchivo(file) : null;
      img.onload = () => {
        const ratio = img.naturalWidth / img.naturalHeight;
        setNaturalRatio(ratio);
        const w = itemWRef.current || 260;
        onUpdateRef.current({ src, w, h: Math.max(60, Math.round(w / ratio)), naturalRatio: ratio, rutaOrigen });
      };
      img.onerror = () => onUpdateRef.current({ src, rutaOrigen });
      img.src = src;
    };
    reader.readAsDataURL(file);
    return true;
  };

  const onPick = (e) => {
    const f = e.target.files && e.target.files[0];
    if (!f) return;
    loadFile(f);
    e.target.value = '';
  };

  React.useEffect(() => {
    if (item._triggerImagePick) {
      fileRef.current?.click();
      onUpdate({ _triggerImagePick: false });
    }
  }, [item._triggerImagePick]);

  const crop = item.crop || { x: 0, y: 0, w: 100, h: 100 };
  const isCropping = cb.croppingId === item.id;

  const startCropDrag = (e, handle) => {
    e.stopPropagation();
    e.preventDefault();

    const startX = e.clientX;
    const startY = e.clientY;
    
    const imgFrame = e.currentTarget.parentElement;
    if (!imgFrame) return;
    const rect = imgFrame.getBoundingClientRect();

    const initialCrop = { ...crop };

    const onMove = (ev) => {
      const dx = ev.clientX - startX;
      const dy = ev.clientY - startY;

      const dpX = (dx / rect.width) * 100;
      const dpY = (dy / rect.height) * 100;

      let newX = initialCrop.x;
      let newY = initialCrop.y;
      let newW = initialCrop.w;
      let newH = initialCrop.h;

      if (handle === 'tl') {
        newX = Math.max(0, Math.min(initialCrop.x + initialCrop.w - 10, initialCrop.x + dpX));
        newW = initialCrop.x + initialCrop.w - newX;
        newY = Math.max(0, Math.min(initialCrop.y + initialCrop.h - 10, initialCrop.y + dpY));
        newH = initialCrop.y + initialCrop.h - newY;
      } else if (handle === 'tr') {
        newW = Math.max(10, Math.min(100 - initialCrop.x, initialCrop.w + dpX));
        newY = Math.max(0, Math.min(initialCrop.y + initialCrop.h - 10, initialCrop.y + dpY));
        newH = initialCrop.y + initialCrop.h - newY;
      } else if (handle === 'bl') {
        newX = Math.max(0, Math.min(initialCrop.x + initialCrop.w - 10, initialCrop.x + dpX));
        newW = initialCrop.x + initialCrop.w - newX;
        newH = Math.max(10, Math.min(100 - initialCrop.y, initialCrop.h + dpY));
      } else if (handle === 'br') {
        newW = Math.max(10, Math.min(100 - initialCrop.x, initialCrop.w + dpX));
        newH = Math.max(10, Math.min(100 - initialCrop.y, initialCrop.h + dpY));
      } else if (handle === 'move') {
        // Arrastrar el área completa de recorte sin cambiar su tamaño
        newX = Math.max(0, Math.min(100 - initialCrop.w, initialCrop.x + dpX));
        newY = Math.max(0, Math.min(100 - initialCrop.h, initialCrop.y + dpY));
      }

      onUpdate({
        crop: {
          x: parseFloat(newX.toFixed(2)),
          y: parseFloat(newY.toFixed(2)),
          w: parseFloat(newW.toFixed(2)),
          h: parseFloat(newH.toFixed(2))
        }
      });
    };

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

    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };
  
  // ── La imagen llena su caja, aunque eso la deforme ──
  //
  // Antes, si la caja no tenía la proporción de la imagen, la imagen se quedaba
  // con la suya, centrada, y dejaba bandas vacías: estirabas una foto cuadrada
  // hasta hacerla alargada y lo único que crecía era el hueco alrededor. No
  // había forma de deformarla aposta.
  //
  // Ahora la imagen ocupa la caja entera: estirarla la estira. Para devolverle
  // su proporción está el botón "Ajustar" de la barra lateral, que ciñe la caja
  // a la forma real de la imagen (recortada, si lo está).
  //
  // Las imágenes que se ponen desde la aplicación nacen ya con la caja a su
  // proporción, así que a casi ninguna de las que ya existen les cambia nada:
  // solo a las que alguien había estirado, que son justo las que tenían hueco.
  const frameW = '100%';
  const frameH = '100%';
  const frameLeft = '0px';
  const frameTop = '0px';

  return (
    <div className={`image-card${hasImage ? '' : ' empty'}`} style={{width:'100%', height:'100%'}}>
      <div className="item-card" style={{ backgroundColor: hasImage ? 'transparent' : bg }}>
        {hasImage ? (
          <div
            className="image-frame"
            style={{ width: frameW, height: frameH, overflow: 'hidden', position: 'absolute', left: frameLeft, top: frameTop }}
            onDoubleClick={(e)=>{ e.stopPropagation(); if (!isCropping) fileRef.current?.click(); }}
          >
            <img
              src={displayMediaSrc(item)}
              data-remote-src={/^https?:\/\//.test(item.src || '') ? item.src : undefined}
              draggable={false}
              onError={driveImageFallback}
              onLoad={(e) => {
                const img = e.currentTarget;
                if (img.naturalWidth && img.naturalHeight) {
                  const ratio = img.naturalWidth / img.naturalHeight;
                  setNaturalRatio(ratio);
                  if (item.naturalRatio !== ratio) {
                    onUpdate({ naturalRatio: ratio });
                  }
                }
              }}
              style={{
                position: 'absolute',
                width: isCropping ? '100%' : `${(100 / crop.w) * 100}%`,
                height: isCropping ? '100%' : `${(100 / crop.h) * 100}%`,
                left: isCropping ? 0 : `${(-crop.x / crop.w) * 100}%`,
                top: isCropping ? 0 : `${(-crop.y / crop.h) * 100}%`,
                objectFit: 'fill',
                pointerEvents: 'none',
                transform: `${item.flipH ? 'scaleX(-1) ' : ''}${item.flipV ? 'scaleY(-1)' : ''}`.trim() || undefined
              }}
            />
            {isCropping && (
              <>
                {/* Capas de mascara blanca semitransparente */}
                <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: `${crop.y}%`, background: 'rgba(0,0,0,0.45)', pointerEvents: 'none' }} />
                <div style={{ position: 'absolute', top: `${crop.y + crop.h}%`, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.45)', pointerEvents: 'none' }} />
                <div style={{ position: 'absolute', top: `${crop.y}%`, left: 0, width: `${crop.x}%`, height: `${crop.h}%`, background: 'rgba(0,0,0,0.45)', pointerEvents: 'none' }} />
                <div style={{ position: 'absolute', top: `${crop.y}%`, left: `${crop.x + crop.w}%`, right: 0, height: `${crop.h}%`, background: 'rgba(0,0,0,0.45)', pointerEvents: 'none' }} />

                {/* Rectángulo punteado del área visible — arrastrable para mover
                    el recorte completo sin cambiar su tamaño */}
                <div
                  onMouseDown={(e) => startCropDrag(e, 'move')}
                  title={window.t('Arrastra para mover el recorte', 'Drag to move the crop area')}
                  style={{
                    position: 'absolute',
                    left: `${crop.x}%`,
                    top: `${crop.y}%`,
                    width: `${crop.w}%`,
                    height: `${crop.h}%`,
                    border: '1.5px dashed var(--olive, #6A8546)',
                    boxSizing: 'border-box',
                    cursor: 'move',
                    pointerEvents: 'auto',
                    zIndex: 14
                  }}
                />

                {/* Tiradores de recorte interactivos */}
                <div 
                  className="crop-handle tl" 
                  onMouseDown={(e) => startCropDrag(e, 'tl')}
                  style={{
                    position: 'absolute',
                    left: `calc(${crop.x}% - 6px)`,
                    top: `calc(${crop.y}% - 6px)`,
                    width: '12px',
                    height: '12px',
                    background: '#FFFFFF',
                    border: '2px solid var(--olive, #6A8546)',
                    borderRadius: '2px',
                    cursor: 'nwse-resize',
                    zIndex: 15
                  }}
                />
                <div 
                  className="crop-handle tr" 
                  onMouseDown={(e) => startCropDrag(e, 'tr')}
                  style={{
                    position: 'absolute',
                    left: `calc(${crop.x + crop.w}% - 6px)`,
                    top: `calc(${crop.y}% - 6px)`,
                    width: '12px',
                    height: '12px',
                    background: '#FFFFFF',
                    border: '2px solid var(--olive, #6A8546)',
                    borderRadius: '2px',
                    cursor: 'nesw-resize',
                    zIndex: 15
                  }}
                />
                <div 
                  className="crop-handle bl" 
                  onMouseDown={(e) => startCropDrag(e, 'bl')}
                  style={{
                    position: 'absolute',
                    left: `calc(${crop.x}% - 6px)`,
                    top: `calc(${crop.y + crop.h}% - 6px)`,
                    width: '12px',
                    height: '12px',
                    background: '#FFFFFF',
                    border: '2px solid var(--olive, #6A8546)',
                    borderRadius: '2px',
                    cursor: 'nesw-resize',
                    zIndex: 15
                  }}
                />
                <div 
                  className="crop-handle br" 
                  onMouseDown={(e) => startCropDrag(e, 'br')}
                  style={{
                    position: 'absolute',
                    left: `calc(${crop.x + crop.w}% - 6px)`,
                    top: `calc(${crop.y + crop.h}% - 6px)`,
                    width: '12px',
                    height: '12px',
                    background: '#FFFFFF',
                    border: '2px solid var(--olive, #6A8546)',
                    borderRadius: '2px',
                    cursor: 'nwse-resize',
                    zIndex: 15
                  }}
                />

              </>
            )}
            {!isCropping && (
              <button
                className="image-change-btn"
                onClick={(e)=>{ e.stopPropagation(); fileRef.current?.click(); }}
                onMouseDown={(e)=>e.stopPropagation()}
                title={window.t('Cambiar imagen', 'Change image')}
              >
                <span className="material-symbols-rounded">swap_horiz</span>
              </button>
            )}
          </div>
        ) : item.bg ? (
          <div
            className="image-frame image-mood"
            style={{ background: item.bg }}
            onDoubleClick={(e)=>{ e.stopPropagation(); fileRef.current?.click(); }}
          >
            {item.content && <div className="image-mood-caption">{pickLang(item.content, lang)}</div>}
          </div>
        ) : (
          <div
            className="image-empty"
            onClick={(e)=>{ e.stopPropagation(); fileRef.current?.click(); }}
          >
            <span className="material-symbols-rounded">add_photo_alternate</span>
            <span style={{fontSize:11, fontWeight: 600, textAlign:'center', lineHeight:1.3}}>
              {window.t('Clic · pegar · soltar imagen', 'Click · paste · drop image')}
            </span>
          </div>
        )}
        {/* La leyenda va FUERA del marco de la imagen a propósito. Dentro
            quedaba superpuesta sobre la foto, y el marco tiene overflow:hidden,
            así que tampoco se podía sacar solo con CSS: se recortaba. Aquí
            abajo tiene su propia franja y la imagen se ve entera. */}
        {item.showCaption && (
          <NodeCaption item={item} lang={lang} onUpdate={onUpdate} className="image-caption"/>
        )}
        <input ref={fileRef} type="file" accept="image/*" style={{display:'none'}} onChange={onPick}/>
      </div>
    </div>
  );
}

// ──────────────── LINK ────────────────
function parseLink(url) {
  if (!url) return null;
  let u = url.trim();
  if (!u) return null;
  if (!/^https?:\/\//i.test(u)) u = 'https://' + u;
  try {
    const parsed = new URL(u);
    const host = parsed.hostname.replace(/^www\./, '');
    let yt = null;
    if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com') yt = parsed.searchParams.get('v');
    else if (host === 'youtu.be') yt = parsed.pathname.slice(1);
    if (yt) return { kind: 'youtube', host: 'youtube.com', url: u, thumb: `https://i.ytimg.com/vi/${yt}/hqdefault.jpg`, title: 'YouTube · ' + yt, favicon: 'https://www.google.com/s2/favicons?domain=youtube.com&sz=32' };
    if (host === 'vimeo.com')           return { kind: 'vimeo',   host, url: u, title: 'Vimeo',   favicon: 'https://www.google.com/s2/favicons?domain=vimeo.com&sz=32' };
    if (host === 'open.spotify.com' || host === 'spotify.com')
                                        return { kind: 'spotify', host, url: u, title: 'Spotify', favicon: 'https://www.google.com/s2/favicons?domain=spotify.com&sz=32' };
    return {
      kind: 'generic', host, url: u,
      title: parsed.pathname === '/' ? host : (parsed.pathname.split('/').filter(Boolean).pop() || host),
      favicon: `https://www.google.com/s2/favicons?domain=${host}&sz=32`,
    };
  } catch { return null; }
}

// link-preview metadata cache (sessions)
const LINK_META_CACHE = {};
async function fetchLinkMeta(url) {
  if (LINK_META_CACHE[url]) return LINK_META_CACHE[url];
  try {
    // Abort if the preview API takes too long so the UI doesn't feel stuck
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), 7000);
    const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(url)}`, { signal: ctrl.signal });
    clearTimeout(timer);
    if (!res.ok) throw new Error('http ' + res.status);
    const json = await res.json();
    if (json.status !== 'success') return null;
    const d = json.data || {};
    const meta = {
      title: d.title || '',
      description: d.description || '',
      image: d.image?.url || null,
      logo: d.logo?.url || null,
      publisher: d.publisher || '',
    };
    LINK_META_CACHE[url] = meta;
    // Preload the image so it appears instantly once metadata arrives
    if (meta.image) { const im = new Image(); im.src = meta.image; }
    return meta;
  } catch (e) {
    return null;
  }
}

// Extract YouTube video id from a URL (supports youtube.com, music.youtube.com, youtu.be)
function extractYouTubeId(url) {
  if (!url) return null;
  try {
    const u = new URL(url);
    const host = u.hostname.replace(/^www\./, '');
    if (host === 'youtu.be') return u.pathname.slice(1).split('/')[0] || null;
    if (host.endsWith('youtube.com')) return u.searchParams.get('v');
  } catch { }
  return null;
}

function LinkItem({ item, lang, onUpdate, editing, onEndEdit }) {
  const hasUrl = !!item.url;
  const meta = hasUrl ? parseLink(item.url) : null;
  const [fetched, setFetched] = React.useState(null);
  const [playing, setPlaying] = React.useState(false);
  const bg = window.nodeBg(item);
  const isDarkBg = ['olive','wine','dark','green','red','purple'].includes(item.color);
  const showPreview = item.showPreview !== false;
  const showInfo = item.showInfo !== false;
  const showCaption = item.showCaption === true;
  const caption = (item.caption && item.caption[lang]) || '';
  const ytId = hasUrl ? extractYouTubeId(item.url) : null;
  const [loadingMeta, setLoadingMeta] = React.useState(false);
  const onUpdateRef = React.useRef(onUpdate);
  React.useEffect(() => { onUpdateRef.current = onUpdate; }, [onUpdate]);

  React.useEffect(() => {
    if (!hasUrl) { setFetched(null); setLoadingMeta(false); return; }
    if (meta?.thumb) { setFetched(null); setLoadingMeta(false); return; }
    let cancelled = false;
    setLoadingMeta(true);
    fetchLinkMeta(item.url).then(m => { if (!cancelled) { setFetched(m); setLoadingMeta(false); } });
    return () => { cancelled = true; };
  }, [item.url, hasUrl]);

  // Reset playback when URL changes
  React.useEffect(() => { setPlaying(false); }, [item.url]);

  if (!hasUrl || editing) {
    return (
      <div className="link-card empty" style={{width:'100%', height:'100%'}}>
        <div className="item-card" style={{ background: bg, color: window.nodeInk(item) }}>
          <div className="link-edit-row">
            <span className="material-symbols-rounded link-prefix" style={{fontSize: 18, color:'var(--ink-3)'}}>link</span>
            <input
              className="link-input"
              autoFocus
              placeholder={window.t('Pega un enlace…', 'Paste a link…')}
              value={item.url || ''}
              onClick={(e)=>e.stopPropagation()}
              onMouseDown={(e)=>e.stopPropagation()}
              onChange={(e)=>onUpdate({ url: e.target.value })}
              onBlur={(e)=>{
                // Apply the link and exit edit mode automatically — no Enter required
                const v = e.target.value.trim();
                const grow = v
                  ? (!item._grewForUrl ? { h: Math.max(item.h || 0, 290), _grewForUrl: true } : {})
                  : { h: 74, _grewForUrl: false };
                onUpdate({ url: v, _editing: false, ...grow });
                onEndEdit && onEndEdit();
              }}
              onKeyDown={(e)=>{
                if (e.key === 'Enter' || e.key === 'Escape') e.target.blur();
              }}
            />
          </div>
          <div className="link-edit-hint">
            {window.t('Enter para guardar · Esc para salir', 'Enter to save · Esc to exit')}
          </div>
        </div>
      </div>
    );
  }

  const thumb = showPreview ? (meta?.thumb || fetched?.image) : null;
  const titleVal = item.title != null ? item.title : (fetched?.title || meta?.title || '');
  const titlePlaceholder = window.t('Título del enlace…', 'Link title…');
  const desc = fetched?.description;
  const openLink = () => item.url && window.open(item.url, '_blank', 'noopener,noreferrer');

  return (
    <div className="link-card" style={{width:'100%', height:'100%'}}>
      <div className="item-card" style={{ background: bg, color: window.nodeInk(item) }}>
        {showPreview && (
        <div
          className="link-thumb"
          style={ thumb && !playing ? { backgroundImage: `url(${thumb})` } : null }
          onClick={(e)=>{ if (!ytId) { e.stopPropagation(); openLink(); } }}
          title={!ytId ? (window.t('Abrir enlace', 'Open link')) : null}
        >
          {ytId && playing ? (
            <iframe
              src={`https://www.youtube.com/embed/${ytId}?autoplay=1`}
              title="YouTube video"
              frameBorder="0"
              allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
              allowFullScreen
              style={{ position:'absolute', inset: 0, width:'100%', height:'100%', border: 0 }}
              onMouseDown={(e)=>e.stopPropagation()}
            />
          ) : (
            <>
              {ytId && (
                <button
                  className="link-play-btn"
                  onClick={(e)=>{ e.stopPropagation(); setPlaying(true); }}
                  onMouseDown={(e)=>e.stopPropagation()}
                  title={window.t('Reproducir', 'Play')}
                >
                  <span className="material-symbols-rounded">play_arrow</span>
                </button>
              )}
              {!ytId && !thumb && (
                <div className="link-thumb-placeholder">
                  {meta?.favicon ? (
                    <img src={meta.favicon} alt="" className="link-thumb-favicon" onError={(e)=>e.target.style.display='none'}/>
                  ) : (
                    <span className="material-symbols-rounded">
                      {meta?.kind === 'spotify' ? 'music_note' : meta?.kind === 'vimeo' ? 'play_circle' : 'language'}
                    </span>
                  )}
                  {loadingMeta && <div className="link-thumb-spinner"/>}
                </div>
              )}
            </>
          )}
        </div>
        )}
        {showInfo && (
        <div className="link-body">
          <div className="link-host">
            {meta?.favicon && <img src={meta.favicon} alt="" onError={(e)=>e.target.style.display='none'}/>}
            <span>{meta?.host || item.url}</span>
            <button
              className="link-open-btn"
              onClick={(e)=>{ e.stopPropagation(); openLink(); }}
              onMouseDown={(e)=>e.stopPropagation()}
              title={window.t('Abrir enlace', 'Open link')}
            >
              <span className="material-symbols-rounded">open_in_new</span>
            </button>
          </div>
          {item.showTitle !== false && (
            <textarea
              className="link-title-input"
              value={titleVal}
              placeholder={titlePlaceholder}
              onChange={(e)=>{
                onUpdate({ title: e.target.value });
                e.target.style.height = 'auto';
                e.target.style.height = e.target.scrollHeight + 'px';
              }}
              ref={(el) => {
                if (el) {
                  el.style.height = 'auto';
                  el.style.height = el.scrollHeight + 'px';
                }
              }}
              onClick={(e)=>e.stopPropagation()}
              onMouseDown={(e)=>e.stopPropagation()}
              onDoubleClick={(e)=>e.stopPropagation()}
              onKeyDown={(e)=>{ if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); } }}
              rows={1}
              style={{
                resize: 'none',
                overflowY: 'hidden',
                height: 'auto',
                display: 'block',
                width: '100%',
                background: 'transparent',
                border: 'none',
                outline: 'none',
                fontFamily: 'inherit',
                fontWeight: '700',
                color: 'inherit',
                padding: 0
              }}
            />
          )}
          {showCaption && (
            <NodeCaption item={item} lang={lang} onUpdate={onUpdate} className="link-caption-input" autoGrow/>
          )}
        </div>
        )}
      </div>
    </div>
  );
}

// ──────────────── TODO ────────────────
// Las tareas se guardaban como texto plano y ahora se guardan con formato. Al
// abrir una lista escrita antes hay que decidir cuál de las dos cosas es lo que
// hay: si el texto no trae ninguna etiqueta de las que genera el programa, es
// texto plano y se escapa. Sin esto, una tarea que dijera "arreglar x < y" se
// interpretaría como marcado y desaparecería media línea.
const ETIQUETAS_DE_FILA = /<\/?(b|i|u|s|em|strong|strike|br|span|a|font)[ >\/]/i;
function htmlDeFila(txt) {
  const t = txt == null ? '' : String(txt);
  if (ETIQUETAS_DE_FILA.test(t)) return t;
  return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function TodoItem({ item, lang, onUpdate, editing, callbacks }) {
  const subs = item.items || [];
  const bg = window.nodeBg(item);
  const isDarkBg = ['olive','wine','dark','green','red','purple'].includes(item.color);

  const [focusedRowId, setFocusedRowId] = React.useState(() => {
    if (window._focusedTodoRow && window._focusedTodoRow.todoId === item.id) {
      return window._focusedTodoRow.rowId;
    }
    return subs[0]?.id || null;
  });

  React.useEffect(() => {
    if (window._focusedTodoRow && window._focusedTodoRow.todoId === item.id) {
      setFocusedRowId(window._focusedTodoRow.rowId);
    } else {
      setFocusedRowId(subs[0]?.id || null);
    }
  }, [item.id, subs[0]?.id]);

  const setFocusedRow = (rowId) => {
    setFocusedRowId(rowId);
    window._focusedTodoRow = {
      todoId: item.id,
      rowId: rowId,
    };
    window._notifyFocusedRowChanged?.();
  };

  const computeRowState = (idx) => {
    const row = subs[idx];
    if (!row) return { state: 'empty' };
    const myIndent = row.indent || 0;
    const children = [];
    for (let i = idx + 1; i < subs.length; i++) {
      const r = subs[i];
      const rIndent = r.indent || 0;
      if (rIndent <= myIndent) break;
      if (rIndent === myIndent + 1) children.push(r);
    }
    if (!children.length) return { state: row.done ? 'done' : 'open' };
    const doneCount = children.filter(c => c.done).length;
    if (doneCount === children.length) return { state: 'done' };
    if (doneCount > 0) return { state: 'partial' };
    return { state: 'open' };
  };

  const toggleRow = (idx) => {
    const row = subs[idx];
    const myIndent = row.indent || 0;
    const nowDone = !row.done;
    const next = subs.map(r => ({ ...r }));
    next[idx].done = nowDone;
    for (let i = idx + 1; i < next.length; i++) {
      const rIndent = next[i].indent || 0;
      if (rIndent <= myIndent) break;
      next[i].done = nowDone;
    }
    onUpdate({ items: next });
  };

  const updateRow = (idx, patch) => {
    onUpdate({ items: subs.map((r, i) => i === idx ? { ...r, ...patch } : r) });
  };
  const indentRow = (idx, delta) => {
    onUpdate({ items: subs.map((r, i) => i === idx ? { ...r, indent: Math.max(0, Math.min(2, (r.indent || 0) + delta)) } : r) });
  };
  const addRow = (afterIdx) => {
    const indent = afterIdx >= 0 ? (subs[afterIdx]?.indent || 0) : 0;
    const newRow = { id: `t-${Date.now()}-${Math.floor(Math.random()*9999)}`, text: { es: '', en: '' }, done: false, indent };
    onUpdate({ items: [...subs.slice(0, afterIdx + 1), newRow, ...subs.slice(afterIdx + 1)] });
  };
  const deleteRow = (idx) => {
    if (subs.length === 1) return;
    onUpdate({ items: subs.filter((_, i) => i !== idx) });
  };

  const cardRef = React.useRef(null);

  const moveRow = (idx, direction) => {
    const next = [...subs];
    const targetIdx = idx + direction;
    if (targetIdx < 0 || targetIdx >= subs.length) return;
    const temp = next[idx];
    next[idx] = next[targetIdx];
    next[targetIdx] = temp;
    onUpdate({ items: next });
  };

  React.useEffect(() => {
    if (!cardRef.current) return;
    const cardEl = cardRef.current;
    const titleEl = cardEl.querySelector('.todo-title');
    const listEl = cardEl.querySelector('.todo-list');
    if (!listEl) return;

    const overrideStyle = (el, prop, val) => {
      if (!el) return null;
      const origVal = el.style.getPropertyValue(prop);
      const origPrio = el.style.getPropertyPriority(prop);
      el.style.setProperty(prop, val, 'important');
      return () => {
        if (origVal) {
          el.style.setProperty(prop, origVal, origPrio);
        } else {
          el.style.removeProperty(prop);
        }
      };
    };

    // 1. Temporarily unconstrain height of all parent and container elements for natural layout measurement
    const itemEl = cardEl.parentElement;
    const itemCardEl = cardEl.querySelector('.item-card');
    const restores = [];

    restores.push(overrideStyle(itemEl, 'height', 'auto'));
    restores.push(overrideStyle(cardEl, 'height', 'auto'));
    restores.push(overrideStyle(itemCardEl, 'height', 'auto'));
    restores.push(overrideStyle(listEl, 'height', 'auto'));
    restores.push(overrideStyle(listEl, 'min-height', '0'));
    restores.push(overrideStyle(listEl, 'flex', 'none'));
    restores.push(overrideStyle(listEl, 'overflow-y', 'visible'));

    // 2. Adjust height of title edit if present under unconstrained height
    const titleEdits = cardEl.querySelectorAll('.todo-title-edit');
    titleEdits.forEach(el => {
      el.style.height = '0px';
      el.style.height = el.scrollHeight + 'px';
    });

    const padding = 22; // default padding in pixels (11 * 2)
    let totalHeight = padding;

    if (titleEl) {
      totalHeight += titleEl.offsetHeight + 8; // title + margin-bottom
    }

    const children = Array.from(listEl.children);
    let listHeight = 0;
    children.forEach((child) => {
      let chH = 0;
      if (child.classList.contains('todo-row')) {
        const inputEl = child.querySelector('.todo-input');
        if (inputEl) {
          inputEl.style.height = '0px';
          const sh = inputEl.scrollHeight;
          inputEl.style.height = sh + 'px';

          const metaEl = child.querySelector('.todo-row-meta');
          const controlsEl = child.querySelector('.todo-row-controls');
          const metaH = metaEl ? metaEl.offsetHeight : 0;
          const controlsH = controlsEl ? controlsEl.offsetHeight : 0;

          // textarea scrollHeight + meta height + controls height + padding
          chH = sh + metaH + controlsH + (metaEl || controlsEl ? 4 : 0) + 8;
        } else {
          chH = child.offsetHeight;
        }
      } else {
        chH = child.offsetHeight;
      }
      listHeight += chH + 3; // gap is 3px
    });
    totalHeight += listHeight;
    totalHeight += 4; // borders and minor spacing

    const calculatedHeight = Math.max(100, Math.round(totalHeight));

    // Restore the original styles so DOM matches state until update completes
    restores.forEach(restore => restore?.());

    // La altura que se pone sola va en silencio, sin paso de deshacer: al
    // elegir la lista aparece la barra de la tarea enfocada y crece, y al
    // soltarla vuelve. Con onUpdate, solo elegir un to-do gastaba un Ctrl+Z
    // (de 22 tipos de nodo, era el único; lo vio el bot del lienzo).
    // Dentro de una columna no: el silencioso busca el nodo en el lienzo y
    // la tarea de una columna no está ahí, así que no crecería nunca.
    const ponAlto = (h) => (callbacks && callbacks.resizeItemSilent && !item._inColumn ? callbacks.resizeItemSilent(item.id, { h }) : onUpdate({ h }));
    // Con altura fijada a mano respetamos ese tamaño, pero crecemos si el
    // contenido ya no cabe (si no, "Nueva tarea" quedaría cortado abajo).
    if (item.manualH) {
      if (calculatedHeight > (item.h || 0)) ponAlto(calculatedHeight);
      return;
    }
    if (Math.abs((item.h || 0) - calculatedHeight) > 3) {
      ponAlto(calculatedHeight);
    }
  // focusedRowId y la selección entran en las dependencias: al enfocar una tarea
  // aparece su barra de controles (~20px) y sin recalcular aquí el contenido
  // desbordaba y escondía el botón "Nueva tarea".
  }, [subs, item.title, editing, item.h, lang, item.manualH, item.textScale, focusedRowId,
      callbacks?.isSelectedItem ? callbacks.isSelectedItem(item.id) : false]);

  return (
    <div ref={cardRef} className="todo-card" style={{width:'100%', height:'100%'}}>
      <div className="item-card" style={{ background: bg, color: window.nodeInk(item) }}>
        {item.showTitle !== false && (
          <div className="todo-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
            {editing ? (
              <textarea
                className="todo-title-edit"
                autoFocus
                value={pickLang(item.title, lang)}
                onChange={(e)=>{
                  onUpdate({ title: { es: e.target.value, en: e.target.value } });
                  e.target.style.height = 'auto';
                  e.target.style.height = e.target.scrollHeight + 'px';
                }}
                ref={(el) => {
                  if (el) {
                    el.style.height = 'auto';
                    el.style.height = el.scrollHeight + 'px';
                  }
                }}
                onClick={(e)=>e.stopPropagation()}
                onMouseDown={(e)=>e.stopPropagation()}
                onFocus={(e)=>{ if (item._new) e.target.select(); }}
                onKeyDown={(e)=>{ if (e.key==='Enter') { e.preventDefault(); e.target.blur(); } }}
                rows={1}
                style={{
                  resize: 'none',
                  overflowY: 'hidden',
                  height: 'auto',
                  display: 'block',
                  width: '100%',
                  background: 'transparent',
                  border: 'none',
                  outline: 'none',
                  fontFamily: 'inherit',
                  fontWeight: '700',
                  color: 'inherit',
                  padding: 0
                }}
              />
            ) : (
              <span>{pickLang(item.title, lang) || (window.t('Pendientes', 'To-do'))}</span>
            )}
            {!editing && subs.some(ti => ti.done) && (
              <button
                className="todo-clear-btn"
                onClick={(e) => {
                  e.stopPropagation();
                  const remaining = subs.filter(ti => !ti.done);
                  if (remaining.length === 0) {
                    remaining.push({ id: `t-${Date.now()}-1`, text: { es: '', en: '' }, done: false, indent: 0 });
                  }
                  onUpdate({ items: remaining });
                }}
                onMouseDown={(e)=>e.stopPropagation()}
                title={window.t('Eliminar tareas completadas', 'Clear completed tasks')}
                style={{
                  background: 'none',
                  border: 'none',
                  color: 'var(--wine-texto)',
                  cursor: 'pointer',
                  display: 'flex',
                  alignItems: 'center',
                  padding: '2px 4px',
                  borderRadius: '4px',
                  opacity: 0.8
                }}
              >
                <span className="material-symbols-rounded" style={{ fontSize: '16px' }}>delete_sweep</span>
              </button>
            )}
          </div>
        )}
        <div className="todo-list">
          {subs.map((ti, idx) => {
            const state = computeRowState(idx).state;
            const indent = Math.min(2, ti.indent || 0);
            const hasMeta = ti.dueDate || ti.assignee;
            const isSelected = callbacks?.isSelectedItem ? callbacks.isSelectedItem(item.id) : false;
            const isRowFocused = isSelected && focusedRowId === ti.id;

            return (
              <div
                key={ti.id}
                className={`todo-row ${ti.done ? 'done' : ''} indent-${indent} ${isRowFocused ? 'focused' : ''}`}
                onClick={(e)=>{
                  setFocusedRow(ti.id);
                }}
              >
                <div
                  className="todo-drag-handle"
                  onMouseDown={(e) => {
                    if (callbacks?.startDragTaskRow) {
                      callbacks.startDragTaskRow(e, item.id, idx);
                    }
                  }}
                  title={window.t('Arrastrar tarea fuera o reordenar', 'Drag task out or reorder')}
                >
                  <span className="material-symbols-rounded">drag_indicator</span>
                </div>
                <div
                  className={`todo-check ${state === 'done' ? 'done' : ''} ${state === 'partial' ? 'partial' : ''}`}
                  onClick={(e)=>{e.stopPropagation(); toggleRow(idx);}}
                  onMouseDown={(e)=>{ callbacks?.selectItem?.(item.id); e.stopPropagation(); }}
                />
                <div className="todo-row-body">
                  {/* Texto con formato, no texto plano.
                      Antes esto era un <textarea>, y en un <textarea> no cabe el
                      formato: la negrita tenía que guardarse como propiedad de
                      la TAREA ENTERA, así que se aplicaba a toda la línea aunque
                      solo hubieras marcado una palabra. Siendo un editable con
                      formato, la negrita, la cursiva, el color y los enlaces se
                      aplican a lo seleccionado, igual que en una nota. Las
                      propiedades antiguas se respetan como estilo de base de la
                      fila, para no cambiar lo que ya estaba escrito. */}
                  <div
                    className="todo-input"
                    contentEditable
                    suppressContentEditableWarning
                    spellCheck={true}
                    data-placeholder={window.t('Tarea…', 'Task…')}
                    ref={(el) => {
                      if (!el) return;
                      const html = htmlDeFila(pickLang(ti.text, lang));
                      // Solo se escribe si hace falta, y nunca mientras se está
                      // escribiendo: rehacer el contenido manda el cursor al
                      // principio en cada tecla.
                      if (el.innerHTML !== html && document.activeElement !== el) {
                        el.innerHTML = html;
                      }
                    }}
                    onInput={(e)=>{
                      const html = e.currentTarget.innerHTML;
                      updateRow(idx, { text: { es: html, en: html } });
                    }}
                    onClick={(e)=>e.stopPropagation()}
                    onMouseDown={(e)=>{ callbacks?.selectItem?.(item.id); e.stopPropagation(); }}
                    onFocus={()=>{
                      setFocusedRow(ti.id);
                      // Al escribir en una tarea se entra en modo edición, igual
                      // que en una nota. Sin esto el lienzo seguía creyendo que
                      // el nodo solo estaba SELECCIONADO, así que la barra
                      // lateral seguía enseñando las opciones del to-do en vez
                      // de las del texto que estabas escribiendo.
                      callbacks?.startEdit?.(item.id);
                    }}
                    onPaste={(e)=>{
                      // Se pega como texto plano: si no, una tarea arrastra los
                      // colores y los tamaños de la página de la que venga.
                      e.preventDefault();
                      const t = (e.clipboardData || window.clipboardData).getData('text/plain');
                      document.execCommand('insertText', false, t);
                    }}
                    onKeyDown={(e)=>{
                      const vacia = !(e.currentTarget.textContent || '').trim();
                      if (e.key === 'Enter') {
                        if (e.shiftKey) {
                          // Shift+Intro parte la línea dentro de la misma tarea
                        } else {
                          e.preventDefault();
                          addRow(idx);
                          setTimeout(()=>{
                            const inputs = e.target.closest('.todo-list').querySelectorAll('.todo-input');
                            inputs[idx+1]?.focus();
                          }, 10);
                        }
                      }
                      if (e.key === 'Tab') {
                        e.preventDefault();
                        indentRow(idx, e.shiftKey ? -1 : +1);
                      }
                      if (e.key === 'Backspace' && vacia) {
                        // Primero se sale de la sangría; ya en el nivel 0, se borra la fila
                        if ((ti.indent || 0) > 0) {
                          e.preventDefault();
                          indentRow(idx, -1);
                          return;
                        }
                        if (subs.length > 1) {
                          e.preventDefault();
                          const prevInput = e.target.closest('.todo-list').querySelectorAll('.todo-input')[idx-1];
                          deleteRow(idx);
                          setTimeout(()=>{ prevInput?.focus(); }, 10);
                        }
                      }
                    }}
                    style={{
                      // Tamaño desde los botones A+/A− (inline: la variable CSS
                      // heredada no llegaba hasta aquí). Lo demás son las
                      // propiedades antiguas por fila, que siguen valiendo como
                      // estilo de base de la tarea.
                      fontSize: `calc(13px * var(--node-scale, 1) * var(--vista-escala, ${item.textScale || 1}))`,
                      fontWeight: ti.bold ? 700 : undefined,
                      fontStyle: ti.italic ? 'italic' : undefined,
                      textDecoration: [ti.underline && 'underline', ti.strike && 'line-through'].filter(Boolean).join(' ') || undefined,
                      color: ti.color || undefined,
                    }}
                  />
                  {hasMeta && (
                    <div className="todo-row-meta">
                      {ti.assignee && (
                        <span
                          className="todo-meta-chip assignee"
                          onClick={(e)=>{ e.stopPropagation(); updateRow(idx, { assignee: null }); }}
                          title={window.t('Clic para quitar', 'Click to remove')}
                        >
                          @{ti.assignee}
                        </span>
                      )}
                      {ti.dueDate && (
                        <span
                          className="todo-meta-chip due"
                          onClick={(e)=>{ e.stopPropagation(); updateRow(idx, { dueDate: null }); }}
                          title={window.t('Clic para quitar', 'Click to remove')}
                        >
                          <span className="material-symbols-rounded">notifications</span>
                          {window.t('Vence', 'Due')} {(() => {
                            const d = new Date(ti.dueDate + 'T00:00:00');
                            return isNaN(d.getTime()) ? ti.dueDate : d.toLocaleDateString(lang, { day:'numeric', month:'short' });
                          })()}
                        </span>
                      )}
                    </div>
                  )}
                  {isRowFocused && (
                    <div className="todo-row-controls" onMouseDown={(e)=>e.stopPropagation()} style={{ marginTop: '4px', marginLeft: 0 }}>
                      <button
                        disabled={ti.indent === 0}
                        onClick={(e) => { e.stopPropagation(); indentRow(idx, -1); }}
                        title={window.t('Disminuir sangría', 'Decrease indent')}
                      >
                        <span className="material-symbols-rounded">format_indent_decrease</span>
                      </button>
                      <button
                        disabled={ti.indent >= 2}
                        onClick={(e) => { e.stopPropagation(); indentRow(idx, 1); }}
                        title={window.t('Aumentar sangría', 'Increase indent')}
                      >
                        <span className="material-symbols-rounded">format_indent_increase</span>
                      </button>
                      <button
                        disabled={idx === 0}
                        onClick={(e) => { e.stopPropagation(); moveRow(idx, -1); }}
                        title={window.t('Subir tarea', 'Move up')}
                      >
                        <span className="material-symbols-rounded">arrow_upward</span>
                      </button>
                      <button
                        disabled={idx === subs.length - 1}
                        onClick={(e) => { e.stopPropagation(); moveRow(idx, 1); }}
                        title={window.t('Bajar tarea', 'Move down')}
                      >
                        <span className="material-symbols-rounded">arrow_downward</span>
                      </button>
                      <button
                        onClick={(e) => { e.stopPropagation(); deleteRow(idx); }}
                        title={window.t('Eliminar tarea', 'Delete task')}
                        className="danger"
                      >
                        <span className="material-symbols-rounded">delete</span>
                      </button>
                    </div>
                  )}
                </div>
              </div>
            );
          })}
          <div
            className="todo-add"
            onClick={(e)=>{
              e.stopPropagation();
              addRow(subs.length - 1);
              setTimeout(()=>{
                const inputs = e.target.closest('.todo-list').querySelectorAll('.todo-input');
                inputs[inputs.length - 1]?.focus();
              }, 50);
            }}
            onMouseDown={(e)=>e.stopPropagation()}
          >
            <span className="material-symbols-rounded">add</span>
            <span>{window.t('Nueva tarea', 'New task')}</span>
          </div>
        </div>
      </div>
    </div>
  );
}

// ──────────────── COLUMN — drop target for items ────────────────
function ColumnItem({ item, lang, onUpdate, editing, callbacks }) {
  const stripColor = window.resolveStickyColor ? window.resolveStickyColor(item.color || 'white') : '#FFFFFF';
  const stripIsDark = isColorDark(stripColor);
  const children = item.children || [];

  // La columna calcula su alto sumando el de sus tarjetas y el de la franja de
  // arriba (colNeededHeight, en Canvas.jsx). La franja mide 58 px con una línea
  // de título, pero un título largo salta a dos y entonces la última tarjeta
  // quedaba cortada. Así que se mide de verdad y, si no es lo que se contó, se
  // apunta en la columna (en silencio: no es un paso de deshacer, es la app
  // ajustándose). offsetHeight va en px del lienzo: el zoom es una transformación
  // y no lo cambia.
  const tiraRef = React.useRef(null);
  const colRef = React.useRef(item);
  colRef.current = item;
  React.useLayoutEffect(() => {
    const el = tiraRef.current;
    if (!el || !callbacks || !callbacks.resizeItemSilent || !window.colNeededHeight) return;
    const mira = () => {
      const col = colRef.current;
      if (!(col.children || []).length) return;
      const alto = Math.round(el.offsetHeight + 3); // + el borde de la tarjeta, arriba y abajo
      // Con un margen: por un píxel no merece la pena tocar la columna (y
      // guardarla) cada vez que se abre el proyecto.
      if (!alto || Math.abs(alto - (col.altoCabecera || 58)) < 3) return;
      const conAlto = Object.assign({}, col, { altoCabecera: alto });
      callbacks.resizeItemSilent(col.id, { altoCabecera: alto, h: window.colNeededHeight(conAlto, false) });
    };
    mira();
    if (typeof ResizeObserver === 'undefined') return;
    const ro = new ResizeObserver(mira);
    ro.observe(el);
    return () => ro.disconnect();
  }, [children.length, item.altoCabecera]);

  return (
    <div className="column-card" style={{width:'100%', height:'100%'}}>
      <div className="item-card" style={{ overflow: 'hidden', padding: 0 }}>
        <div
          ref={tiraRef}
          className={`column-strip ${stripIsDark ? 'dark' : ''}`}
          style={{ background: stripColor }}
        >
          <div className="column-strip-head">
            <span className="material-symbols-rounded column-strip-icon">{item.icon || 'view_column'}</span>
            {item.showTitle !== false && (editing ? (
              <textarea
                autoFocus
                value={pickLang(item.content, lang)}
                onChange={(e)=>{
                  onUpdate({ content: { es: e.target.value, en: e.target.value } });
                  e.target.style.height = 'auto';
                  e.target.style.height = e.target.scrollHeight + 'px';
                }}
                ref={(el) => {
                  if (el) {
                    el.style.height = 'auto';
                    el.style.height = el.scrollHeight + 'px';
                  }
                }}
                onClick={(e)=>e.stopPropagation()}
                onMouseDown={(e)=>e.stopPropagation()}
                onFocus={(e)=>{ if (item._new) e.target.select(); }}
                onKeyDown={(e)=>{ if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); } }}
                rows={1}
                style={{
                  color: 'inherit',
                  textAlign: 'center',
                  resize: 'none',
                  overflowY: 'hidden',
                  height: 'auto',
                  display: 'block',
                  width: '100%',
                  background: 'transparent',
                  border: 'none',
                  outline: 'none',
                  fontFamily: 'inherit',
                  fontWeight: '700',
                  padding: 0
                }}
              />
            ) : (
              <span className="column-strip-title">{pickLang(item.content, lang) || (window.t('Nueva Columna', 'New Column'))}</span>
            ))}
          </div>
          <div className="column-strip-meta">
            {children.length} {children.length === 1 ? (window.t('nodo', 'node')) : (window.t('nodos', 'nodes'))}
          </div>
        </div>
        <div className="column-body">
          {children.length === 0 && (
            <div className="column-empty">
              <span className="material-symbols-rounded">stacks</span>
              <span>{window.t('Arrastra tarjetas aquí', 'Drag cards here')}</span>
            </div>
          )}
          {children.map(child => {
            const isBoardCompact = child.type === 'board' && child.showPreview === false;
            const h = isBoardCompact ? 58 : (child.h || (child.type === 'board' ? 200 :
                                  child.type === 'note' ? 90 :
                                  child.type === 'todo' ? 140 :
                                  child.type === 'link' ? 180 :
                                  child.type === 'image' ? 140 :
                                  child.type === 'doc' ? 90 :
                                  child.type === 'comment' ? 80 :
                                  child.type === 'calendar' ? 220 : 90));
            const w = (item.w || 240) - 36;
            const isEditingChild = callbacks.editingChild && callbacks.editingChild.colId === item.id && callbacks.editingChild.childId === child.id;
            const isSelected = callbacks.isSelectedItem?.(child.id);
            return (
              <div
                key={child.id}
                className={`col-child-wrap ${isSelected ? 'selected' : ''}`}
                data-col-child-id={child.id}
                data-col-parent-id={item.id}
                data-item-id={child.id}
                style={{
                  height: h, width: '100%', position: 'relative',
                  // El giro también dentro de una columna: el botón Girar lo
                  // guardaba (rot) pero aquí no se pintaba. Gira en su sitio,
                  // sobre su centro, y queda por encima de las de al lado.
                  transform: child.rot ? `rotate(${child.rot}deg)` : undefined,
                  transition: 'transform 320ms var(--muelle)',
                  zIndex: child.rot ? 1 : undefined,
                }}
                onMouseDown={(e) => {
                  if (isEditingChild) return;
                  callbacks.startColChildDrag && callbacks.startColChildDrag(e, item.id, child.id);
                }}
                onClick={(e) => {
                  e.stopPropagation();
                  callbacks.setEditingChild && callbacks.setEditingChild(item.id, child.id);
                  callbacks.selectItem && callbacks.selectItem(child.id);
                }}
                onDoubleClick={(e) => {
                  e.stopPropagation();
                  if (child.type === 'doc') { callbacks.openDoc && callbacks.openDoc(child.id, item.id); return; }
                  if (child.type === 'board') { callbacks.openBoard && callbacks.openBoard(child.canvasId, child.id); return; }
                  callbacks.setEditingChild && callbacks.setEditingChild(item.id, child.id);
                }}
              >
                <window.ItemRenderer
                  item={{ ...child, w, h, _inColumn: true }}
                  lang={lang}
                  editing={isEditingChild}
                  callbacks={{
                    ...callbacks,
                    updateItem: (id, patch) => callbacks.updateColChild(item.id, child.id, patch),
                  }}
                />
                {!isEditingChild && callbacks.startAnchorDrag && (
                  <div className="anchors">
                    <div
                      className="connect-handle"
                      title={window.t('Arrastra para conectar', 'Drag to connect')}
                      onMouseDown={(e)=>callbacks.startAnchorDrag(e, child.id, 'center')}
                    >
                      <span className="material-symbols-rounded">trip_origin</span>
                    </div>
                  </div>
                )}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

// ──────────────── BOARD ────────────────
const BOARD_ICONS = ['dashboard','folder','category','widgets','book','map','palette','psychology','rocket_launch','sports_esports','castle','science'];

// Icon glyph shown for each node type in the board preview
const TYPE_ICON = {
  note: 'sticky_note_2', todo: 'checklist', doc: 'description', image: 'image',
  link: 'link', calendar: 'calendar_month', table: 'table_chart', board: 'dashboard',
  column: 'view_column', comment: 'forum', audio: 'audiotrack', color: 'palette', file: 'draft',
};

// Decide if a hex color is dark (so text on it should be white)
function isColorDark(hex) {
  if (!hex || hex[0] !== '#' || hex.length < 7) return false;
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return (0.299 * r + 0.587 * g + 0.114 * b) < 140;
}

function BoardItem({ item, lang, onUpdate, onOpenBoard, editing, getNestedItems, onStartEdit }) {
  const accent = window.resolveStickyColor ? window.resolveStickyColor(item.color || 'white') : '#FFFFFF';
  const darkAccent = isColorDark(accent);
  const nestedItems = getNestedItems ? getNestedItems(item.canvasId) : [];

  const cardRef = React.useRef(null);
  const lastCalculatedH = React.useRef(item.h);
  // El título del tablero pasó de <textarea> a un editable con formato,
  // igual que el título grande: así puede llevar un enlace a otro nodo
  // (Enlazar necesita seleccionar texto de verdad, y un textarea no lo
  // permite), y el color/negrita/cursiva siguen siendo propiedades del
  // nodo entero —no por selección—, para que el pie del tablero no acabe
  // con dos tamaños de letra distintos en dos palabras.
  const titleRef = React.useRef(null);
  const boardTitleText = pickLang(item.content, lang);
  React.useEffect(() => {
    if (!titleRef.current) return;
    const sano = window.repairEscapedMarkup ? window.repairEscapedMarkup(boardTitleText || '') : (boardTitleText || '');
    if (titleRef.current.innerHTML !== sano) titleRef.current.innerHTML = sano;
    if (sano !== (boardTitleText || '')) onUpdate({ content: { es: sano, en: sano } });
  }, [item.id, boardTitleText]);

  // null hasta el primer efecto: se calibra con las medidas REALES del pie.
  // (Antes se estimaba con un -60 fijo que no coincidía con el pie medido —
  // sobre todo con títulos de dos líneas — y el nodo crecía ~12px en cada arranque.)
  const desiredPreviewH = React.useRef(null);
  const [iconPickerOpen, setIconPickerOpen] = React.useState(false);

  // If item.h changes from the outside (e.g. user drag resize), update our desired preview height
  if (desiredPreviewH.current !== null && item.h !== lastCalculatedH.current) {
    const footEl = cardRef.current?.querySelector('.board-foot');
    const captionEl = cardRef.current?.querySelector('.node-caption-row');
    const footH = footEl ? footEl.offsetHeight : 52;
    const captionH = captionEl ? captionEl.offsetHeight : 0;
    const colorBarH = 8;
    const borderH = 3;
    const currentPreviewH = item.h - colorBarH - footH - captionH - borderH;
    desiredPreviewH.current = Math.max(50, currentPreviewH);
    lastCalculatedH.current = item.h;
  }

  const compact = item.showPreview === false; // compact = no preview, just a small square with logo + title

  React.useEffect(() => {
    if (compact) return; // compact mode uses a fixed small square, no preview auto-height
    if (!cardRef.current) return;
    const footEl = cardRef.current.querySelector('.board-foot');
    const captionEl = cardRef.current.querySelector('.node-caption-row');
    if (!footEl) return;

    const overrideStyle = (el, prop, val) => {
      if (!el) return null;
      const origVal = el.style.getPropertyValue(prop);
      const origPrio = el.style.getPropertyPriority(prop);
      el.style.setProperty(prop, val, 'important');
      return () => {
        if (origVal) {
          el.style.setProperty(prop, origVal, origPrio);
        } else {
          el.style.removeProperty(prop);
        }
      };
    };

    // Temporarily unconstrain height of all parent and container elements for natural layout measurement
    const cardEl = cardRef.current;
    const itemEl = cardEl.parentElement;
    const itemCardEl = cardEl.querySelector('.item-card');
    const restores = [];

    restores.push(overrideStyle(itemEl, 'height', 'auto'));
    restores.push(overrideStyle(cardEl, 'height', 'auto'));
    restores.push(overrideStyle(itemCardEl, 'height', 'auto'));

    const footH = footEl.offsetHeight;
    const captionH = captionEl ? captionEl.offsetHeight : 0;
    const colorBarH = 8;
    const borderH = 3;

    // Restore the original styles
    restores.forEach(restore => restore?.());

    // Primer render: derivar el alto del preview de item.h con las medidas reales.
    // item.h guardado ya es correcto — no hay nada que actualizar.
    if (desiredPreviewH.current === null) {
      desiredPreviewH.current = Math.max(50, item.h - colorBarH - footH - captionH - borderH);
      lastCalculatedH.current = item.h;
      return;
    }

    const totalHeight = Math.round(desiredPreviewH.current + colorBarH + footH + captionH + borderH);

    if (Math.abs((item.h || 0) - totalHeight) > 3) {
      lastCalculatedH.current = totalHeight;
      onUpdate({ h: totalHeight });
    }
  }, [item.content, editing, item.h, lang, item.showCaption, compact]);

  const previewW = item.w - 16;
  const colorBarH = 8;
  const borderH = 3;
  
  // Estimate or calculate the actual preview height for scale math
  const footElForScale = cardRef.current?.querySelector('.board-foot');
  const captionElForScale = cardRef.current?.querySelector('.node-caption-row');
  const footHForScale = footElForScale ? footElForScale.offsetHeight : 52;
  const captionHForScale = captionElForScale ? captionElForScale.offsetHeight : 0;
  const previewH = Math.max(50, item.h - colorBarH - footHForScale - captionHForScale - borderH);

  let scale = 1, bx = 8, by = 8;
  if (nestedItems.length) {
    let mx = 0, my = 0;
    nestedItems.forEach(it => { mx = Math.max(mx, it.x + it.w); my = Math.max(my, it.y + it.h); });
    const sx = (previewW - 16) / Math.max(mx, 1);
    const sy = (previewH - 16) / Math.max(my, 1);
    scale = Math.min(sx, sy, 0.35);
  }

  // ── Inside a column (compact) OR Compact Mode on Canvas: Milanote-style horizontal card (icon left, name + count right) ──
  if (compact) {
    return (
      <div
        className={`board-card board-in-column ${editing ? 'editing' : ''}`}
        style={{width:'100%', height:'100%'}}
        onDoubleClick={!item._inColumn ? (e)=>{ e.stopPropagation(); onOpenBoard && onOpenBoard(item.canvasId, item.id); } : undefined}
      >
        <div className="board-col-card" style={compact ? { border: '1.5px solid var(--line)', background: 'var(--paper)', borderRadius: '6px', overflow: 'hidden' } : {}}>
          <div
            className="board-col-icon"
            style={{ background: accent, color: darkAccent ? '#fff' : '#1A1A1A', cursor: editing ? 'pointer' : 'inherit', position: 'relative' }}
            onClick={editing ? (e)=>{ e.stopPropagation(); setIconPickerOpen(o=>!o); } : undefined}
            onMouseDown={editing ? (e)=>e.stopPropagation() : undefined}
            title={editing ? window.t('Cambiar icono', 'Change icon') : undefined}
          >
            <span className="material-symbols-rounded">{item.icon || 'dashboard'}</span>
            {editing && iconPickerOpen && (
              <div className="board-col-iconpicker" onMouseDown={(e)=>e.stopPropagation()} onClick={(e)=>e.stopPropagation()}>
                {BOARD_ICONS.map(ic => (
                  <button key={ic} className={`board-col-iconopt ${(item.icon||'dashboard')===ic?'active':''}`}
                    onClick={(e)=>{ e.stopPropagation(); onUpdate({ icon: ic }); setIconPickerOpen(false); }}>
                    <span className="material-symbols-rounded">{ic}</span>
                  </button>
                ))}
              </div>
            )}
          </div>
          <div className="board-col-meta">
            {editing ? (
              <input
                className="board-col-title-edit"
                autoFocus
                value={pickLang(item.content, lang)}
                onChange={(e)=>onUpdate({ content: { es: e.target.value, en: e.target.value } })}
                onClick={(e)=>e.stopPropagation()}
                onMouseDown={(e)=>e.stopPropagation()}
                onFocus={(e)=>e.target.select()}
                onKeyDown={(e)=>{ if (e.key==='Enter' || e.key==='Escape') e.target.blur(); }}
              />
            ) : (
              <div className="board-col-title">{pickLang(item.content, lang) || (window.t('Tablero', 'Board'))}</div>
            )}
            <div className="board-col-count">{nestedItems.length} {window.t('nodos', 'nodes')} · {window.t('doble clic para abrir', 'double-click to open')}</div>
          </div>
        </div>
      </div>
    );
  }

  return (
    <div
      ref={cardRef}
      className="board-card"
      style={{width:'100%', height:'100%'}}
      onDoubleClick={(e)=>{ e.stopPropagation(); onOpenBoard && onOpenBoard(item.canvasId, item.id); }}
    >
      <div className="item-card" style={{borderColor: 'var(--line)'}}>
        <div className="board-color-bar" style={{background: accent}}/>
        <div className="board-cover" style={{ height: previewH, flex: 'none' }}>
          <div className="board-cover-grid"/>
          {nestedItems.length === 0 && (
            <div className="board-preview-item empty-message">
              {window.t('Tablero vacío', 'Empty board')}
            </div>
          )}
          {nestedItems.slice(0, 12).map((it) => {
            const x = bx + it.x * scale;
            const y = by + it.y * scale;
            const w = Math.max(30, it.w * scale);
            const h = Math.max(26, it.h * scale);
            // Always coerce to a string — some titles/contents are bilingual objects ({es,en})
            const rawLabel = pickLang(it.content, lang) || pickLang(it.title, lang) || '';
            const label = typeof rawLabel === 'string' ? rawLabel : '';
            return (
              <div
                key={it.id}
                className={`board-preview-item kind-${it.type}`}
                style={{ left: x, top: y, width: w, height: h }}
                title={label}
              >
                <span className="material-symbols-rounded board-preview-icon">{TYPE_ICON[it.type] || 'crop_square'}</span>
                <span className="board-preview-label">{label.slice(0, 24)}</span>
              </div>
            );
          })}
        </div>
        <div
          className={`board-foot ${darkAccent ? 'dark' : ''} ${item.showTitle === false ? 'no-title' : ''}`}
          style={{ background: accent }}
          onDoubleClick={(e)=>{ e.stopPropagation(); onStartEdit && onStartEdit(item.id); }}
        >
          <span className="material-symbols-rounded board-foot-icon">{item.icon || 'dashboard'}</span>
          {item.showTitle !== false && (
            <div
              ref={titleRef}
              className="board-foot-title"
              contentEditable={editing}
              suppressContentEditableWarning
              data-placeholder={window.t('Tablero', 'Board')}
              onInput={()=>{
                if (!titleRef.current) return;
                onUpdate({ content: { es: titleRef.current.innerHTML, en: titleRef.current.innerHTML } });
              }}
              onClick={(e)=>{ if (editing) e.stopPropagation(); }}
              onMouseDown={(e)=>{ if (editing) e.stopPropagation(); }}
              onFocus={()=>{
                if (item._new && titleRef.current) {
                  const r = document.createRange();
                  r.selectNodeContents(titleRef.current);
                  const s = window.getSelection();
                  s.removeAllRanges();
                  s.addRange(r);
                }
              }}
              onBlur={()=>{
                const val = (titleRef.current?.textContent || '').trim();
                if (!val) {
                  const defaultName = window.t('Nuevo tablero', 'New board');
                  onUpdate({ content: { es: defaultName, en: defaultName }, _editing: false });
                } else {
                  onUpdate({ _editing: false });
                }
              }}
              onKeyDown={(e)=>{ if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
              style={{
                flex: 1, minWidth: 0, cursor: 'text',
                fontWeight: item.bold === false ? 500 : 700,
                fontStyle: item.italic ? 'italic' : 'normal',
                textDecoration: [item.underline && 'underline', item.strike && 'line-through'].filter(Boolean).join(' ') || 'none',
                color: item.textColor && item.textColor !== 'inherit' ? item.textColor : 'inherit',
                outline: 'none',
              }}
            />
          )}
          <span className="board-count">{nestedItems.length}</span>
        </div>
        {item.showCaption && (
          <div className="node-caption-row">
            <NodeCaption item={item} lang={lang} onUpdate={onUpdate} autoGrow/>
          </div>
        )}
      </div>
    </div>
  );
}

// ──────────────── DOC ────────────────
function DocItem({ item, lang, onOpenDoc }) {
  const body = pickLang(item.body, lang);
  const bg = window.nodeBg(item);
  const isDarkBg = ['olive','wine','dark','green','red','purple'].includes(item.color);
  const compact = item.showPreview === false; // preview off → just the doc logo
  const title = pickLang(item.title, lang) || (window.t('Documento', 'Document'));
  return (
    <div className={`doc-card ${compact ? 'doc-compact-mode' : ''}`} style={{width:'100%', height:'100%'}}>
      <div
        className="item-card"
        style={{ background: compact ? 'transparent' : bg, color: window.nodeInk(item) }}
        onDoubleClick={(e)=>{ e.stopPropagation(); onOpenDoc && onOpenDoc(item.id); }}
      >
        {compact ? (
          <div className="doc-compact">
            <div className="doc-compact-paper">
              <span className="material-symbols-rounded doc-compact-icon">description</span>
              <div className="doc-compact-lines"><i/><i/><i/><i/></div>
            </div>
            <div className="doc-compact-title">{title}</div>
          </div>
        ) : (
          <>
            <div className="doc-head">
              <span className="material-symbols-rounded">description</span>
              <span style={{flex:1, wordBreak:'break-word', whiteSpace:'pre-wrap'}}>{title}</span>
            </div>
            <div className="doc-body-preview" dangerouslySetInnerHTML={{ __html: body || '' }}/>
          </>
        )}
      </div>
    </div>
  );
}

// ──────────────── TABLE ────────────────
// Column letter from index: 0 → A, 1 → B, ..., 25 → Z, 26 → AA
function colLetter(n) {
  let s = '';
  n = n + 1;
  while (n > 0) {
    const m = (n - 1) % 26;
    s = String.fromCharCode(65 + m) + s;
    n = Math.floor((n - 1) / 26);
  }
  return s;
}

// Convert "A" → 0, "B" → 1, "AA" → 26
function colIndexFromLetters(s) {
  let c = 0;
  for (let i = 0; i < s.length; i++) c = c * 26 + (s.toUpperCase().charCodeAt(i) - 64);
  return c - 1;
}

// Evaluate a formula. Supports SUM/SUBTRACT/AVERAGE/COUNT/MIN/MAX with comma args and ranges (A1:C1),
// cell references (including refs to other formula cells), and basic arithmetic (=A1+B2*2).
function evaluateFormula(formula, cells, _depth = 0) {
  if (!formula || !formula.startsWith('=')) return null;
  if (_depth > 20) return '#REF'; // circular reference guard
  let expr = formula.slice(1).trim();
  try {
    // Resolve a cell's numeric value — evaluating its formula recursively if needed
    const cellNum = (r, c) => {
      const raw = cells[`${r},${c}`]?.value;
      if (raw == null || raw === '') return 0;
      if (typeof raw === 'string' && raw.startsWith('=')) {
        const sub = evaluateFormula(raw, cells, _depth + 1);
        const n = parseFloat(sub);
        return isNaN(n) ? 0 : n;
      }
      const v = parseFloat(raw);
      return isNaN(v) ? 0 : v;
    };
    const refToVal = (ref) => {
      const m = ref.match(/^([A-Za-z]+)(\d+)$/);
      if (!m) return 0;
      return cellNum(parseInt(m[2], 10) - 1, colIndexFromLetters(m[1]));
    };
    const expandRange = (a, b) => {
      const ma = a.match(/^([A-Za-z]+)(\d+)$/), mb = b.match(/^([A-Za-z]+)(\d+)$/);
      if (!ma || !mb) return [];
      const A = { r: parseInt(ma[2],10)-1, c: colIndexFromLetters(ma[1]) };
      const B = { r: parseInt(mb[2],10)-1, c: colIndexFromLetters(mb[1]) };
      const vals = [];
      for (let r = Math.min(A.r,B.r); r <= Math.max(A.r,B.r); r++)
        for (let c = Math.min(A.c,B.c); c <= Math.max(A.c,B.c); c++)
          vals.push(cellNum(r, c));
      return vals;
    };
    const argsToValues = (argStr) => {
      const vals = [];
      argStr.split(',').map(s=>s.trim()).filter(Boolean).forEach(p => {
        if (p.includes(':')) { const [a,b] = p.split(':'); vals.push(...expandRange(a,b)); }
        else if (/^[A-Za-z]+\d+$/.test(p)) vals.push(refToVal(p));
        else { const n = parseFloat(p); if (!isNaN(n)) vals.push(n); }
      });
      return vals;
    };
    // Aggregate functions
    expr = expr.replace(/(SUM|SUBTRACT|AVERAGE|COUNT|MIN|MAX)\s*\(([^)]*)\)/gi, (_, fn, args) => {
      const vals = argsToValues(args);
      let res = 0;
      switch (fn.toUpperCase()) {
        case 'SUM':      res = vals.reduce((a,b)=>a+b,0); break;
        case 'SUBTRACT': res = vals.length ? vals.reduce((a,b)=>a-b) : 0; break;
        case 'AVERAGE':  res = vals.length ? vals.reduce((a,b)=>a+b,0)/vals.length : 0; break;
        case 'COUNT':    res = vals.length; break;
        case 'MIN':      res = vals.length ? Math.min(...vals) : 0; break;
        case 'MAX':      res = vals.length ? Math.max(...vals) : 0; break;
      }
      return String(res);
    });
    // Remaining single cell references
    expr = expr.replace(/([A-Za-z]+)(\d+)/g, (m) => String(refToVal(m)));
    if (!/^[\d\s+\-*/().,]*$/.test(expr)) return '#ERR';
    // eslint-disable-next-line no-new-func
    const res = Function('"use strict";return (' + expr + ')')();
    return typeof res === 'number' && !isNaN(res) ? String(Math.round(res * 1e6) / 1e6) : '#ERR';
  } catch { return '#ERR'; }
}

// Formula function suggestions for the cell autocomplete
const FORMULA_FUNCTIONS = [
  { name: 'SUM',      desc: { es:'Suma de un rango',     en:'Sum of a range' } },
  { name: 'SUBTRACT', desc: { es:'Resta (A1-B1-…)',      en:'Subtract (A1-B1-…)' } },
  { name: 'AVERAGE',  desc: { es:'Promedio',             en:'Average' } },
  { name: 'COUNT',    desc: { es:'Contar números',       en:'Count numbers' } },
  { name: 'MIN',      desc: { es:'Mínimo',               en:'Minimum' } },
  { name: 'MAX',      desc: { es:'Máximo',               en:'Maximum' } },
];

function TableItem({ item, lang, onUpdate, editing, selected, onSelectItem, onResize }) {
  const rows = item.rows || 4;
  const cols = item.cols || 3;
  const cells = item.cells || {};
  const title = pickLang(item.title, lang) || '';
  const caption = pickLang(item.caption, lang) || '';
  const bg = window.nodeBg(item);
  const isWhite = !item.color || item.color === 'white';

  const MIN_COL_W = 80;
  const colTemplate = `30px repeat(${cols}, minmax(${MIN_COL_W}px, 1fr))`;

  const [selectedCell, setSelectedCell] = React.useState(null); // 'r,c' — the focus/anchor cell
  const [selRange, setSelRange] = React.useState(null); // { anchor:'r,c', focus:'r,c' } for multi-cell selection
  const [editingCell, setEditingCell] = React.useState(null); // 'r,c'
  const [cellDraft, setCellDraft] = React.useState('');
  const [suggestActive, setSuggestActive] = React.useState(0);
  const boxRef = React.useRef(null);
  const cellInputRef = React.useRef(null);
  // Tracks whether the table item was ALREADY selected at the moment the current gesture started.
  const gestureSelectedRef = React.useRef(false);
  // Fresh refs so the global focusedTableCell methods never use stale editing state
  const editingCellRef = React.useRef(editingCell);
  editingCellRef.current = editingCell;
  const cellDraftRef = React.useRef(cellDraft);
  cellDraftRef.current = cellDraft;
  // Keep fresh dims for the ResizeObserver callback
  const dimsRef = React.useRef({ w: item.w, h: item.h });
  dimsRef.current = { w: item.w, h: item.h };

  // Set of cell keys currently selected (single cell or rectangle)
  const selectedKeys = React.useMemo(() => {
    const set = new Set();
    if (selRange) {
      const [ar, ac] = selRange.anchor.split(',').map(Number);
      const [fr, fc] = selRange.focus.split(',').map(Number);
      for (let r = Math.min(ar,fr); r <= Math.max(ar,fr); r++)
        for (let c = Math.min(ac,fc); c <= Math.max(ac,fc); c++)
          set.add(`${r},${c}`);
    } else if (selectedCell) {
      set.add(selectedCell);
    }
    return set;
  }, [selRange, selectedCell]);

  // Clear cell selection when the canvas item is no longer selected
  React.useEffect(() => {
    if (!selected) {
      // Persist any in-progress edit before clearing (onBlur won't fire on unmount)
      if (editingCellRef.current != null) updateCellValue(editingCellRef.current, cellDraftRef.current);
      setSelectedCell(null);
      setSelRange(null);
      setEditingCell(null);
      setCellDraft('');
    }
  }, [selected]);

  // Enforce a minimum height (rows fill the rest) and a minimum width (columns need room).
  // Rows stretch to fill any extra height, so the user can resize taller without white space.
  React.useEffect(() => {
    const TITLE = (item.showTitle === true) ? 36 : 0;
    const minH = 32 + TITLE + rows * 30 + 4;
    const minW = 30 + cols * MIN_COL_W + 4;
    const patch = {};
    if ((item.h || 0) < minH) patch.h = minH;
    if ((item.w || 0) < minW) patch.w = minW;
    // Silent resize so it doesn't pollute the undo history
    if (Object.keys(patch).length) (onResize || onUpdate)(patch);
  }, [rows, cols, item.showTitle]);

  // Are we currently building a formula in some cell?
  const buildingFormula = editingCell && cellDraft.trim().startsWith('=');
  // Live preview of a range being dragged while building a formula
  const [rangePreview, setRangePreview] = React.useState(null); // { start: 'r,c', end: 'r,c' }

  // Start a click/drag selection over cells to insert a reference or range into the formula
  const startRefDrag = (startKey, e) => {
    e.preventDefault(); // keep the editing input focused
    let endKey = startKey;
    setRangePreview({ start: startKey, end: startKey });
    const onMove = (ev) => {
      const el = document.elementFromPoint(ev.clientX, ev.clientY);
      const cellEl = el && el.closest ? el.closest('[data-cell-key]') : null;
      // Only accept cells that belong to THIS table (avoids selecting cells from another table)
      if (cellEl && cellEl.dataset.cellKey && cellEl.dataset.tableId === item.id) {
        const k = cellEl.dataset.cellKey;
        const [r, c] = k.split(',').map(Number);
        if (r >= 0 && r < rows && c >= 0 && c < cols) {
          endKey = k;
          setRangePreview({ start: startKey, end: endKey });
        }
      }
    };
    const onUp = () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
      setRangePreview(null);
      // Single click (no drag): toggle — if the reference is already in the formula, remove it
      if (startKey === endKey) {
        const ref = keyToRef(startKey);
        const tokenRe = new RegExp('\\b' + ref + '\\b');
        if (tokenRe.test(cellDraftRef.current)) { removeRef(ref); return; }
        insertRef(ref);
        return;
      }
      insertRef(`${keyToRef(startKey)}:${keyToRef(endKey)}`);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  // Normal cell selection drag (Excel-style): click to select one, drag to select a rectangle
  const startSelectDrag = (startKey, e) => {
    e.stopPropagation();
    // Persist any in-progress edit before switching cells (onBlur won't fire on unmount)
    if (editingCellRef.current != null) updateCellValue(editingCellRef.current, cellDraftRef.current);
    setSelectedCell(startKey);
    setSelRange({ anchor: startKey, focus: startKey });
    setEditingCell(null);
    setCellDraft('');
    let moved = false;
    const onMove = (ev) => {
      const el = document.elementFromPoint(ev.clientX, ev.clientY);
      const cellEl = el && el.closest ? el.closest('[data-cell-key]') : null;
      if (cellEl && cellEl.dataset.cellKey && cellEl.dataset.tableId === item.id) {
        const k = cellEl.dataset.cellKey;
        const [r, c] = k.split(',').map(Number);
        if (r >= 0 && r < rows && c >= 0 && c < cols) {
          moved = true;
          setSelRange({ anchor: startKey, focus: k });
          setSelectedCell(k);
        }
      }
    };
    const onUp = () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
      if (!moved) setSelRange({ anchor: startKey, focus: startKey });
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  // Move cell selection helper for keyboard navigation
  const moveCell = (dr, dc, save = true) => {
    if (!selectedCell) return;
    const [r, c] = selectedCell.split(',').map(Number);
    const nr = Math.max(0, Math.min(rows - 1, r + dr));
    const nc = Math.max(0, Math.min(cols - 1, c + dc));
    if (save && editingCell) {
      updateCellValue(editingCell, cellDraft);
    }
    const key = `${nr},${nc}`;
    setSelectedCell(key);
    setSelRange({ anchor: key, focus: key });
    setEditingCell(key);
    setCellDraft(cells[key]?.value || '');
  };

  const clampCell = (key, newRows, newCols) => {
    if (!key) return null;
    const [r, c] = key.split(',').map(Number);
    if (r >= newRows || c >= newCols) return null;
    return `${Math.min(r, newRows - 1)},${Math.min(c, newCols - 1)}`;
  };

  // Expose the selected cell + updater globally so the context sidebar can edit it
  React.useEffect(() => {
    if (selectedCell != null) {
      window._focusedTableCell = {
        itemId: item.id,
        cellKey: selectedCell,
        cellData: cells[selectedCell] || {},
        // Apply a patch to ALL currently selected cells (range or single)
        updateCell: (patch) => {
          const next = { ...cells };
          const keys = selectedKeys.size ? Array.from(selectedKeys) : [selectedCell];
          keys.forEach(k => { next[k] = { ...(next[k] || {}), ...patch }; });
          onUpdate({ cells: next });
        },
        addRow: () => {
          // Grow the node by one row's current height so rows stay consistent
          const headersH = 32 + (item.showTitle === true ? 36 : 0) + 4;
          const rowH = Math.max(30, ((item.h || 220) - headersH) / rows);
          onUpdate({ rows: rows + 1, h: Math.round((item.h || 220) + rowH) });
        },
        addCol: () => {
          const colW = Math.max(MIN_COL_W, ((item.w || 380) - 30) / cols);
          onUpdate({ cols: cols + 1, w: Math.round((item.w || 380) + colW) });
        },
        removeRow: () => {
          if (rows <= 1) return;
          // Remove the LAST row (preserves data in rows 1, 2, 3…)
          const lastR = rows - 1;
          const next = {};
          Object.keys(cells).forEach(k => {
            const [r, c] = k.split(',').map(Number);
            if (r < lastR) next[k] = cells[k];
          });
          const headersH = 32 + (item.showTitle === true ? 36 : 0) + 4;
          const rowH = Math.max(30, ((item.h || 220) - headersH) / rows);
          onUpdate({ rows: rows - 1, cells: next, h: Math.round(Math.max(headersH + 30, (item.h || 220) - rowH)) });
          // Keep selection valid
          const [sr, sc] = selectedCell.split(',').map(Number);
          if (sr >= lastR) { const k = `${lastR - 1},${sc}`; setSelectedCell(k); setSelRange({ anchor: k, focus: k }); }
        },
        removeCol: () => {
          if (cols <= 1) return;
          // Remove the LAST column (preserves data in columns A, B, C…)
          const lastC = cols - 1;
          const next = {};
          Object.keys(cells).forEach(k => {
            const [r, c] = k.split(',').map(Number);
            if (c < lastC) next[k] = cells[k];
          });
          const colW = Math.max(MIN_COL_W, ((item.w || 380) - 30) / cols);
          onUpdate({ cols: cols - 1, cells: next, w: Math.round(Math.max(30 + MIN_COL_W, (item.w || 380) - colW)) });
          const [sr, sc] = selectedCell.split(',').map(Number);
          if (sc >= lastC) { const k = `${sr},${lastC - 1}`; setSelectedCell(k); setSelRange({ anchor: k, focus: k }); }
        },
        clearCellSelection: () => { setSelectedCell(null); setSelRange(null); setEditingCell(null); },
        startFormula: () => {
          setSelRange({ anchor: selectedCell, focus: selectedCell });
          setEditingCell(selectedCell);
          setCellDraft('=');
          setSuggestActive(0);
        },
        isEditingCell: () => editingCellRef.current != null,
        moveSelection: (dr, dc) => {
          const [r, c] = selectedCell.split(',').map(Number);
          const nr = Math.max(0, Math.min(rows - 1, r + dr));
          const nc = Math.max(0, Math.min(cols - 1, c + dc));
          const key = `${nr},${nc}`;
          setSelectedCell(key);
          setSelRange({ anchor: key, focus: key });
        },
        typeChar: (ch) => {
          // Excel-style: typing on a selected cell enters edit mode. If already editing
          // this cell (focus race), append instead of replacing — prevents text from resetting.
          if (editingCellRef.current === selectedCell) {
            setCellDraft(cellDraftRef.current + ch);
          } else {
            setSelRange({ anchor: selectedCell, focus: selectedCell });
            setEditingCell(selectedCell);
            setCellDraft(ch);
          }
          setSuggestActive(0);
        },
        editCell: () => {
          // F2 / Enter to edit keeping existing content
          setEditingCell(selectedCell);
          setCellDraft(cells[selectedCell]?.value || '');
        },
        clearContent: () => {
          // Clear value of ALL selected cells (keep formatting). Used by Delete/Backspace.
          const next = { ...cells };
          const keys = selectedKeys.size ? Array.from(selectedKeys) : [selectedCell];
          keys.forEach(k => { if (next[k]) next[k] = { ...next[k], value: '' }; });
          onUpdate({ cells: next });
        },
      };
      window._notifyFocusedRowChanged?.();
    } else {
      if (window._focusedTableCell?.itemId === item.id) {
        window._focusedTableCell = null;
        window._notifyFocusedRowChanged?.();
      }
    }
  }, [selectedCell, selectedKeys, cells, rows, cols, item.id]);

  const updateCellValue = (key, value) => {
    let v = value;
    // Auto-close unbalanced parentheses for formulas
    if (v && v.startsWith('=')) {
      const open = (v.match(/\(/g) || []).length;
      const close = (v.match(/\)/g) || []).length;
      if (open > close) v = v + ')'.repeat(open - close);
    }
    const next = { ...cells };
    next[key] = { ...(next[key] || {}), value: v };
    onUpdate({ cells: next });
  };

  // Save the in-progress edit (the input doesn't fire onBlur when it unmounts on cell switch,
  // so we must persist the draft explicitly before changing/clearing the editing cell).
  const commitEdit = () => {
    if (editingCellRef.current != null) {
      updateCellValue(editingCellRef.current, cellDraftRef.current);
    }
  };

  // key "r,c" → "A1"
  const keyToRef = (key) => { const [r, c] = key.split(',').map(Number); return colLetter(c) + (r + 1); };

  // Insert arbitrary text at the cursor of the editing input
  const insertText = (text) => {
    const input = cellInputRef.current;
    if (!input) { setCellDraft(d => d + text); return; }
    const start = input.selectionStart ?? cellDraft.length;
    const end = input.selectionEnd ?? cellDraft.length;
    const nd = cellDraft.slice(0, start) + text + cellDraft.slice(end);
    setCellDraft(nd);
    requestAnimationFrame(() => {
      const el = cellInputRef.current;
      if (el) { el.focus(); const pos = start + text.length; el.setSelectionRange(pos, pos); }
    });
  };

  // Insert a reference (single "A1" or range "A1:C3"), auto-adding a comma when inside function args
  const insertRef = (refStr) => {
    const input = cellInputRef.current;
    const start = input ? (input.selectionStart ?? cellDraft.length) : cellDraft.length;
    const before = cellDraft.slice(0, start);
    const openParens = (before.match(/\(/g) || []).length - (before.match(/\)/g) || []).length;
    const lastChar = before.replace(/\s+$/, '').slice(-1);
    const needsComma = openParens > 0 && /[A-Za-z0-9)]/.test(lastChar);
    insertText((needsComma ? ',' : '') + refStr);
  };

  // Remove a reference token (e.g. "A1") from the formula, cleaning up an adjacent comma
  const removeRef = (refStr) => {
    let d = cellDraftRef.current;
    const tries = [
      new RegExp(',\\s*' + refStr + '\\b'),   // ",A1"
      new RegExp('\\b' + refStr + '\\s*,'),    // "A1,"
      new RegExp('\\b' + refStr + '\\b'),      // "A1"
    ];
    for (const re of tries) {
      if (re.test(d)) { d = d.replace(re, ''); break; }
    }
    setCellDraft(d);
    requestAnimationFrame(() => cellInputRef.current?.focus());
  };

  const showTitle = item.showTitle === true;
  const showCaption = item.showCaption === true;

  // Compute filtered formula suggestions based on what's typed after =
  const showSuggest = editingCell && cellDraft.startsWith('=') && !cellDraft.includes('(');
  const suggestFilter = showSuggest ? cellDraft.slice(1).toUpperCase().trim() : '';
  const suggestList = showSuggest
    ? FORMULA_FUNCTIONS.filter(f => f.name.startsWith(suggestFilter))
    : [];
  const applySuggest = (fnName) => {
    // Re-assert edit mode in case the input blurred when the dropdown was clicked
    if (selectedCell) setEditingCell(selectedCell);
    setCellDraft(`=${fnName}(`);
    requestAnimationFrame(() => cellInputRef.current?.focus());
  };

  // Dropdown screen position — placed right below the selected cell (portal, so it's never clipped)
  const [suggestPos, setSuggestPos] = React.useState(null);
  React.useLayoutEffect(() => {
    if (showSuggest && suggestList.length && cellInputRef.current) {
      const cellEl = cellInputRef.current.closest('.tbl-cell') || cellInputRef.current;
      const r = cellEl.getBoundingClientRect();
      setSuggestPos({ left: r.left, top: r.bottom + 2, width: Math.max(160, r.width) });
    } else {
      setSuggestPos(null);
    }
  }, [showSuggest, editingCell, cellDraft, suggestList.length]);

  // Highlight cells referenced in the formula being built (parsed refs + live drag preview)
  const referencedKeys = React.useMemo(() => {
    const set = new Set();
    if (buildingFormula) {
      // Expand ranges A1:C3 and single refs from the draft
      const upper = cellDraft.toUpperCase();
      const rangeRe = /([A-Z]+)(\d+):([A-Z]+)(\d+)/g;
      let consumed = upper;
      let rm;
      while ((rm = rangeRe.exec(upper))) {
        const c1 = colIndexFromLetters(rm[1]), r1 = parseInt(rm[2],10)-1;
        const c2 = colIndexFromLetters(rm[3]), r2 = parseInt(rm[4],10)-1;
        for (let r = Math.min(r1,r2); r <= Math.max(r1,r2); r++)
          for (let c = Math.min(c1,c2); c <= Math.max(c1,c2); c++)
            if (r >= 0 && c >= 0) set.add(`${r},${c}`);
        consumed = consumed.replace(rm[0], ' '.repeat(rm[0].length));
      }
      const singleRe = /([A-Z]+)(\d+)/g;
      let m;
      while ((m = singleRe.exec(consumed))) {
        const cc = colIndexFromLetters(m[1]);
        const rr = parseInt(m[2], 10) - 1;
        if (rr >= 0 && cc >= 0) set.add(`${rr},${cc}`);
      }
    }
    // Live drag preview
    if (rangePreview) {
      const [sr, sc] = rangePreview.start.split(',').map(Number);
      const [er, ec] = rangePreview.end.split(',').map(Number);
      for (let r = Math.min(sr,er); r <= Math.max(sr,er); r++)
        for (let c = Math.min(sc,ec); c <= Math.max(sc,ec); c++)
          set.add(`${r},${c}`);
    }
    return set;
  }, [buildingFormula, cellDraft, rangePreview]);

  return (
    <div className="table-card" style={{width:'100%', height:'100%'}}>
      <div className="item-card">
        <div ref={boxRef} className="tbl-box" style={{ background: bg }}>
          {/* Title row (if enabled) — above the A,B,C references */}
          {showTitle && (
            <div className="tbl-title-row">
              <input
                className="tbl-title"
                value={title}
                placeholder={window.t('Nueva Tabla', 'New Table')}
                onChange={(e)=>onUpdate({ title: { es: e.target.value, en: e.target.value } })}
                onClick={(e)=>e.stopPropagation()}
                onMouseDown={(e)=>e.stopPropagation()}
                onDoubleClick={(e)=>e.stopPropagation()}
              />
            </div>
          )}
          {/* Column header row */}
          <div className="tbl-colhead-row" style={{gridTemplateColumns: colTemplate, background: isWhite ? null : bg}}>
            <div className="tbl-corner" style={!isWhite ? { background: bg } : null}/>
            {Array.from({length: cols}, (_, c) => (
              <div
                key={c}
                className={`tbl-colhead ${selectedCell && selectedCell.split(',')[1] === String(c) ? 'active' : ''}`}
                style={!isWhite ? { background: bg } : null}
              >
                {colLetter(c)}
              </div>
            ))}
          </div>
          {/* Data rows */}
          <div className="tbl-grid">
            {Array.from({length: rows}, (_, r) => (
              <div key={r} className="tbl-row" style={{gridTemplateColumns: colTemplate}}>
                <div
                  className={`tbl-rownum ${selectedCell && selectedCell.split(',')[0] === String(r) ? 'active' : ''}`}
                  style={!isWhite ? { background: bg } : null}
                >{r + 1}</div>
                {Array.from({length: cols}, (_, c) => {
                  const key = `${r},${c}`;
                  const cell = cells[key] || {};
                  const isSel = selectedCell === key;
                  const isEd = editingCell === key;
                  const displayValue = cell.value && cell.value.startsWith('=')
                    ? evaluateFormula(cell.value, cells)
                    : (cell.value || '');
                  const style = {
                    background: cell.color || 'transparent',
                    fontWeight: cell.bold ? 700 : 400,
                    fontStyle: cell.italic ? 'italic' : 'normal',
                    textDecoration: [cell.underline && 'underline', cell.strike && 'line-through'].filter(Boolean).join(' ') || 'none',
                    textAlign: cell.align || 'left',
                    color: cell.textColor || 'inherit',
                  };
                  return (
                    <div
                      key={c}
                      data-cell-key={key}
                      data-table-id={item.id}
                      className={`tbl-cell ${selectedKeys.has(key) ? 'selected' : ''} ${selectedCell === key ? 'focus' : ''} ${referencedKeys.has(key) ? 'ref-highlight' : ''}`}
                      style={style}
                      onClick={(e)=>e.stopPropagation()}
                      onDoubleClick={(e)=>{
                        e.stopPropagation();
                        if (buildingFormula && editingCell !== key) return;
                        setSelectedCell(key);
                        setSelRange({ anchor: key, focus: key });
                        setEditingCell(key);
                        setCellDraft(cell.value || '');
                      }}
                      onMouseDown={(e)=>{
                        e.stopPropagation();
                        // Formula building: click/drag over cells to insert a reference or range
                        if (buildingFormula && editingCell !== key) {
                          startRefDrag(key, e);
                          return;
                        }
                        // First gesture on a not-yet-selected table only selects the node
                        gestureSelectedRef.current = !!selected;
                        if (!selected) { if (onSelectItem) onSelectItem(item.id); return; }
                        // Excel-style: click selects the cell, drag selects a range (no edit until double-click)
                        startSelectDrag(key, e);
                      }}
                    >
                      {isEd ? (
                        <>
                          <input
                            autoFocus
                            key="tbl-cell-edit"
                            ref={cellInputRef}
                            name="tbl-cell-edit"
                            autoComplete="off"
                            data-lpignore="true"
                            className="tbl-cell-input"
                            value={cellDraft}
                            style={{textAlign: cell.align || 'left'}}
                            onClick={(e)=>e.stopPropagation()}
                            onMouseDown={(e)=>e.stopPropagation()}
                            onChange={(e)=>{ setCellDraft(e.target.value); setSuggestActive(0); }}
                            onBlur={()=>{ updateCellValue(key, cellDraft); setEditingCell(null); setCellDraft(''); }}
                            onKeyDown={(e)=>{
                              // Formula suggestion navigation takes priority
                              if (showSuggest && suggestList.length) {
                                if (e.key === 'ArrowDown') { e.preventDefault(); setSuggestActive(i => (i + 1) % suggestList.length); return; }
                                if (e.key === 'ArrowUp')   { e.preventDefault(); setSuggestActive(i => (i - 1 + suggestList.length) % suggestList.length); return; }
                                if (e.key === 'Tab' || e.key === 'Enter') {
                                  e.preventDefault();
                                  const fn = suggestList[suggestActive] || suggestList[0];
                                  if (fn) applySuggest(fn.name);
                                  return;
                                }
                                if (e.key === 'Escape') { setEditingCell(null); setCellDraft(''); return; }
                              }
                              // Excel-like navigation
                              if (e.key === 'Tab') {
                                e.preventDefault();
                                moveCell(0, e.shiftKey ? -1 : 1);
                                return;
                              }
                              if (e.key === 'Enter') {
                                e.preventDefault();
                                moveCell(e.shiftKey ? -1 : 1, 0);
                                return;
                              }
                              if (e.key === 'Escape') { setEditingCell(null); setCellDraft(''); e.target.blur(); return; }
                              // (arrow keys move the text cursor while editing — like Excel)
                            }}
                          />
                          {showSuggest && suggestList.length > 0 && suggestPos && ReactDOM.createPortal(
                            <div
                              className="tbl-formula-suggest"
                              style={{ position:'fixed', left: suggestPos.left, top: suggestPos.top, minWidth: suggestPos.width }}
                              onMouseDown={(e)=>e.preventDefault()}
                            >
                              {suggestList.map((f, i) => (
                                <button
                                  key={f.name}
                                  className={`tbl-formula-suggest-item ${i === suggestActive ? 'active' : ''}`}
                                  onMouseDown={(e)=>{ e.preventDefault(); e.stopPropagation(); }}
                                  onClick={(e)=>{ e.stopPropagation(); applySuggest(f.name); }}
                                  onMouseEnter={()=>setSuggestActive(i)}
                                >
                                  {f.name}
                                  <span className="desc">{f.desc[lang]}</span>
                                </button>
                              ))}
                              <div className="tbl-formula-help">
                                {window.t('↑↓ navegar · Tab/Enter elegir', '↑↓ navigate · Tab/Enter pick')}
                              </div>
                            </div>,
                            document.body
                          )}
                        </>
                      ) : (
                        <span className="tbl-cell-view">{displayValue}</span>
                      )}
                    </div>
                  );
                })}
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

// ──────────────── COMMENT ────────────────
// Behaves like a Note (rich contentEditable + text-format sidebar) but with a
// user header (avatar + name) on top.
function CommentItem({ item, lang, onUpdate, editing }) {
  const ref = React.useRef(null);
  usaCursorAlEditar(ref, editing);
  const html = (item.text && (item.text[lang] || item.text.es || item.text.en)) || '';
  const bg = window.resolveStickyColor ? window.resolveStickyColor(item.color || 'cream') : null;
  const isDarkBg = ['olive','wine','dark','green','red','purple'].includes(item.color);

  React.useEffect(() => {
    if (!ref.current) return;
    if (ref.current.innerHTML !== html) ref.current.innerHTML = html;
  }, [html, lang, editing]);

  // Focus the editor and drop the caret at the end when entering edit mode
  React.useEffect(() => {
    if (!editing || !ref.current) return;
    ref.current.focus();
    const r = document.createRange();
    r.selectNodeContents(ref.current);
    r.collapse(false);
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(r);
  }, [editing]);

  const onInput = () => {
    if (!ref.current) return;
    onUpdate({ text: { es: ref.current.innerHTML, en: ref.current.innerHTML } });
  };

  return (
    <div className="comment-card" style={{width:'100%', height:'100%'}}>
      <div className="item-card" style={{ background: bg, color: window.nodeInk(item) }}>
        <div className="comment-head">
          <div className="comment-avatar" style={{background: COLOR_HEX_RESOLVED[colorClass(item.avatarColor || 'sage')]}}>
            {item.avatar || 'A'}
          </div>
          <div className="comment-name">{item.name}</div>
        </div>
        {editing ? (
          <div
            ref={ref}
            className="note-edit rich comment-rich"
            contentEditable
            suppressContentEditableWarning
            spellCheck={true}
            data-placeholder={window.t('Escribe un comentario…', 'Write a comment…')}
            onInput={onInput}
            onClick={(e)=>e.stopPropagation()}
            onMouseDown={(e)=>e.stopPropagation()}
            onKeyDown={(e)=>{ if (e.key==='Escape') e.target.blur(); }}
            style={{ color: window.nodeInk(item), fontSize: `calc(13.5px * var(--node-scale, 1) * var(--vista-escala, ${item.textScale || 1}))` }}
          />
        ) : (
          <div ref={ref} className="note-inner rich comment-rich" style={{ color: window.nodeInk(item), fontSize: `calc(13.5px * var(--node-scale, 1) * var(--vista-escala, ${item.textScale || 1}))` }}/>
        )}
        {item.showCaption && (
          <div className="node-caption-row">
            <NodeCaption item={item} lang={lang} onUpdate={onUpdate} autoGrow/>
          </div>
        )}
      </div>
    </div>
  );
}

// ──────────────── AUDIO ────────────────
// Helper: luminance-based dark-color check (same logic as Column strip)
function isAudioColorDark(hex) {
  if (!hex || hex === '#FFFFFF' || hex === '#ffffff') return false;
  const c = hex.replace('#', '');
  const r = parseInt(c.substring(0, 2), 16);
  const g = parseInt(c.substring(2, 4), 16);
  const b = parseInt(c.substring(4, 6), 16);
  // Relative luminance formula
  const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
  return lum < 0.5;
}

function AudioItem({ item, lang, onUpdate }) {
  const audioRef = React.useRef(null);
  const fileInputRef = React.useRef(null);
  const hasAudio = !!item.src;

  const [playing, setPlaying] = React.useState(false);
  const [muted, setMuted] = React.useState(false);
  const [currentTime, setCurrentTime] = React.useState(0);
  const [duration, setDuration] = React.useState(0);
  const [editingName, setEditingName] = React.useState(false);
  const [nameInput, setNameInput] = React.useState('');
  const nameInputRef = React.useRef(null);

  // Sync loop state to DOM
  React.useEffect(() => {
    if (audioRef.current) {
      audioRef.current.loop = !!item.loop;
    }
  }, [item.loop]);

  // Handle source changes - restart playback state if src changes
  React.useEffect(() => {
    setPlaying(false);
    setCurrentTime(0);
    setDuration(0);
  }, [item.src]);

  // Focus input when entering edit mode
  React.useEffect(() => {
    if (editingName && nameInputRef.current) {
      nameInputRef.current.focus();
      nameInputRef.current.select();
    }
  }, [editingName]);

  const togglePlay = (e) => {
    e.stopPropagation();
    if (!audioRef.current) return;
    if (playing) {
      audioRef.current.pause();
      setPlaying(false);
    } else {
      audioRef.current.play()
        .then(() => setPlaying(true))
        .catch(err => console.error("Playback failed:", err));
    }
  };

  const toggleMute = (e) => {
    e.stopPropagation();
    if (!audioRef.current) return;
    const nextMute = !muted;
    audioRef.current.muted = nextMute;
    setMuted(nextMute);
  };

  const handleTimeUpdate = () => {
    if (!audioRef.current) return;
    setCurrentTime(audioRef.current.currentTime);
  };

  const handleLoadedMetadata = () => {
    if (!audioRef.current) return;
    setDuration(audioRef.current.duration);
  };

  const handleEnded = () => {
    setPlaying(false);
    setCurrentTime(0);
  };

  const handleScrub = (e) => {
    e.stopPropagation();
    if (!audioRef.current) return;
    const val = parseFloat(e.target.value);
    audioRef.current.currentTime = val;
    setCurrentTime(val);
  };

  const triggerFilePick = (e) => {
    e.stopPropagation();
    fileInputRef.current?.click();
  };

  const onFileChange = (e) => {
    const file = e.target.files?.[0];
    if (!file) return;
    if (!file.type.startsWith('audio/')) {
      alert(window.t('Por favor selecciona un archivo de audio válido.', 'Please select a valid audio file.'));
      return;
    }
    const reader = new FileReader();
    reader.onload = () => {
      onUpdate({
        src: reader.result,
        name: file.name,
        _originalName: file.name,
        size: file.size,
        rutaOrigen: window.rutaDeArchivo ? window.rutaDeArchivo(file) : null
      });
    };
    reader.readAsDataURL(file);
    e.target.value = '';
  };

  const startEditName = (e) => {
    e.stopPropagation();
    setNameInput(item.name || item._originalName || 'audio.mp3');
    setEditingName(true);
  };

  const commitName = () => {
    const trimmed = nameInput.trim();
    // If empty, revert to the original filename stored when the file was first loaded
    const resolved = trimmed || item._originalName || 'audio.mp3';
    onUpdate({ name: resolved });
    setEditingName(false);
  };

  const formatTime = (time) => {
    if (isNaN(time) || time === Infinity) return '0:00';
    const mins = Math.floor(time / 60);
    const secs = Math.floor(time % 60);
    return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
  };

  const formatSize = (bytes) => {
    if (!bytes) return '';
    const mb = bytes / (1024 * 1024);
    return `${mb.toFixed(1).replace('.', ',')} MB`;
  };

  const bg = window.resolveStickyColor ? window.resolveStickyColor(item.color || 'white') : '#FFFFFF';
  const isDark = isAudioColorDark(bg);
  // Sobre fondo claro la tinta va FIJA en oscuro, no 'inherit' ni var(--ink-3).
  // La tarjeta de audio se pinta siempre clara si no se le eligió color, pero
  // esos dos valores los decide el tema: en modo oscuro salían casi blancos y
  // el nombre del archivo desaparecía sobre el blanco de la propia tarjeta.
  const nameColor = isDark ? 'white' : '#1A1A1A';
  const metaColor = isDark ? 'rgba(255,255,255,0.65)' : '#595459';

  if (!hasAudio) {
    return (
      <div className="audio-card empty" style={{ width: '100%', height: '100%' }}>
        <div className="item-card" style={{ backgroundColor: bg }}>
          <div className="audio-empty" onClick={triggerFilePick}>
            <span className="material-symbols-rounded">cloud_upload</span>
            <span style={{ fontSize: 11, fontWeight: 700, textAlign: 'center', lineHeight: 1.3 }}>
              {window.t('Clic para subir un audio', 'Click to upload audio')}
            </span>
            <input ref={fileInputRef} type="file" accept="audio/*" style={{ display: 'none' }} onChange={onFileChange} />
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="audio-card" style={{ width: '100%', height: '100%' }}>
      <div className="item-card" style={{ backgroundColor: bg }}>
        {/* Custom audio element */}
        <audio
          ref={audioRef}
          src={displayMediaSrc(item)}
          onTimeUpdate={handleTimeUpdate}
          onLoadedMetadata={handleLoadedMetadata}
          onEnded={handleEnded}
        />

        {/* Custom Player Header — header itself is draggable; only the controls stop propagation */}
        <div className="audio-player-header">
          <button className="audio-play-btn" onClick={togglePlay} onMouseDown={(e)=>e.stopPropagation()} title={playing ? 'Pause' : 'Play'}>
            <span className="material-symbols-rounded">{playing ? 'pause' : 'play_arrow'}</span>
          </button>

          <button className="audio-volume-btn" onClick={toggleMute} onMouseDown={(e)=>e.stopPropagation()} title={muted ? 'Unmute' : 'Mute'}>
            <span className="material-symbols-rounded">{muted ? 'volume_off' : 'volume_up'}</span>
          </button>

          <div className="audio-scrubber-container">
            <input
              type="range"
              className="audio-scrubber"
              min={0}
              max={duration || 100}
              value={currentTime || 0}
              onChange={handleScrub}
              onMouseDown={(e)=>e.stopPropagation()}
            />
            <div
              className="audio-scrubber-progress"
              style={{ width: `${(currentTime / (duration || 1)) * 100}%` }}
            />
          </div>

          <div className="audio-time">
            {formatTime(currentTime)} / {formatTime(duration)}
          </div>
        </div>

        {/* Card Body */}
        <div className="audio-body">
          {/* Music file icon */}
          <svg className="audio-file-icon" viewBox="0 0 32 38" fill="none" xmlns="http://www.w3.org/2000/svg">
            <path d="M22 0H3C1.35 0 0 1.35 0 3V35C0 36.65 1.35 38 3 38H29C30.65 38 32 36.65 32 35V10L22 0Z" fill="#F1F3F5"/>
            <path d="M22 0V10H32L22 0Z" fill="#DCDCE0"/>
            <path d="M3 36.5H29C29.8284 36.5 30.5 35.8284 30.5 35V11H21V1.5H3C2.17157 1.5 1.5 2.17157 1.5 3V35C1.5 35.8284 2.17157 36.5 3 36.5Z" stroke="var(--line)" strokeWidth="1.5"/>
            <path d="M21 1.5L30.5 11" stroke="var(--line)" strokeWidth="1.5"/>
            <circle cx="11" cy="24" r="2.5" fill="#4A90E2" stroke="var(--line)" strokeWidth="1.25"/>
            <circle cx="19" cy="21" r="2.5" fill="#4A90E2" stroke="var(--line)" strokeWidth="1.25"/>
            <path d="M13.5 24V14H21.5V21" stroke="var(--line)" strokeWidth="1.25" fill="none"/>
            <path d="M13.5 17.5H21.5" stroke="var(--line)" strokeWidth="1.25"/>
          </svg>

          <div className="audio-details">
            {item.showTitle !== false && (editingName ? (
              <input
                ref={nameInputRef}
                className="audio-name-input"
                value={nameInput}
                style={{ color: nameColor }}
                onChange={(e) => setNameInput(e.target.value)}
                onBlur={commitName}
                onKeyDown={(e) => {
                  if (e.key === 'Enter' || e.key === 'Escape') {
                    if (e.key === 'Escape') setNameInput(item.name || item._originalName || 'audio.mp3');
                    nameInputRef.current?.blur();
                  }
                }}
                onClick={(e) => e.stopPropagation()}
                onMouseDown={(e) => e.stopPropagation()}
              />
            ) : (
              <div
                className="audio-file-name"
                title={item.name || 'audio.mp3'}
                style={{ color: nameColor, cursor: 'text' }}
                onDoubleClick={startEditName}
              >
                {item.name || 'audio.mp3'}
              </div>
            ))}
            <div className="audio-actions" onMouseDown={(e) => e.stopPropagation()} style={{ color: metaColor }}>
              <a
                className="audio-download-link"
                href={item.src}
                download={item.name || 'audio.mp3'}
                style={{ color: metaColor }}
              >
                {window.t('Descargar', 'Download')}
              </a>
              <span>-</span>
              <span>{formatSize(item.size)}</span>
            </div>
          </div>
        </div>

        {/* Caption Row */}
        {item.showCaption && (
          <div className="audio-caption-row">
            <NodeCaption item={item} lang={lang} onUpdate={onUpdate} className="audio-caption-input" autoGrow/>
          </div>
        )}
      </div>
    </div>
  );
}

// ──────────────── COLOR ────────────────
// Curated named colors (Name-That-Color style) — used to label a swatch by nearest match.
const COLOR_NAMES = [
  ['Black','#000000'],['White','#FFFFFF'],['Gray','#808080'],['Silver','#C0C0C0'],
  ['Gallery','#EFEFEF'],['Dove Gray','#666666'],['Mine Shaft','#333333'],['Boulder','#7A7A7A'],
  ['Red','#E6544F'],['Crimson','#D7263D'],['Monza','#C00A1E'],['Cardinal','#C41E3A'],
  ['Coral','#FF7F50'],['Salmon','#FA8072'],['Tomato','#FF6347'],['Burnt Sienna','#E97451'],
  ['Orange','#F5A66B'],['Carrot Orange','#ED9121'],['Tangerine','#F28500'],['Pumpkin','#FF7518'],
  ['Gold','#DDAF2C'],['Sunglow','#FFCC33'],['Mustard','#FFDB58'],['Cream Can','#F7DA84'],
  ['Yellow','#F2D14E'],['Khaki','#C3B091'],['Olive','#6A8546'],['Olivine','#9AB973'],
  ['Sushi','#90B968'],['Green','#3DA35D'],['Forest Green','#228B22'],['Fern','#5FB878'],
  ['Sea Green','#2E8B57'],['Jade','#00A86B'],['Emerald','#2ECC71'],['Spring Green','#00FA9A'],
  ['Teal','#008080'],['Tradewind','#56B3A7'],['Gossamer','#079E8B'],['Persian Green','#00A693'],
  ['Turquoise','#40E0D0'],['Cyan','#22B8CF'],['Pacific Blue','#0093C4'],['Cerulean','#02A4D3'],
  ['Sky','#8AC1E8'],['Picton Blue','#45B1E8'],['Dodger Blue','#3B82F6'],['Blue','#3D5A80'],
  ['Royal Blue','#4169E1'],['Navy','#2C3E70'],['Denim','#1560BD'],['Indigo','#4B0082'],
  ['Lavender','#B084D0'],['Purple','#955BA5'],['Amethyst','#9966CC'],['Violet','#7C5CBF'],
  ['Plum','#8E4585'],['Magenta','#C2185B'],['Pink','#E58AB8'],['Rose','#F472B6'],
  ['Cerise','#DE3163'],['Hot Pink','#FF69B4'],['Wine','#993844'],['Maroon','#7B1E2B'],
  ['Brown','#8B5A2B'],['Chocolate','#7B3F00'],['Tan','#D2B48C'],['Beige','#E8DCC0'],
  ['Cream','#FEF7E0'],['Ivory','#FFFFF0'],['Mist','#ECEBEB'],['Slate','#595459'],
];
function nearestColorName(hex) {
  const h = (hex || '').replace('#', '');
  if (h.length < 6) return hex || '';
  const r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16);
  let best = COLOR_NAMES[0], bestD = Infinity;
  for (const [name, chex] of COLOR_NAMES) {
    const c = chex.slice(1);
    const dr = r - parseInt(c.slice(0,2),16), dg = g - parseInt(c.slice(2,4),16), db = b - parseInt(c.slice(4,6),16);
    const d = dr*dr + dg*dg + db*db;
    if (d < bestD) { bestD = d; best = [name, chex]; }
  }
  return best[0];
}

function ColorItem({ item, lang, onUpdate }) {
  const hex = (item.hex || '#56B3A7').toUpperCase();
  const showHex = item.showHex !== false;
  const dark = isAudioColorDark(hex);
  const txt = dark ? 'rgba(255,255,255,0.95)' : '#1A1A1A';
  const name = nearestColorName(hex);
  return (
    <div className="color-card" style={{width:'100%', height:'100%'}}>
      <div className="item-card" style={{ display:'flex', flexDirection:'column', overflow:'hidden' }}>
        <div className="color-swatch" style={{ background: hex, color: txt }}>
          {showHex && <span className="color-hex">{hex}</span>}
        </div>
        <div className="color-name-row">
          <span className="color-name">{name}</span>
        </div>
        {item.showCaption && (
          <div className="node-caption-row">
            <NodeCaption item={item} lang={lang} onUpdate={onUpdate} autoGrow/>
          </div>
        )}
      </div>
    </div>
  );
}

// ──────────────── TIEMPO ────────────────
//
// Tres en uno: cuenta atrás, cronómetro y pomodoro. Cuál de los tres se elige
// desde la barra del nodo.
//
// El tiempo NO se cuenta sumando en un intervalo: un intervalo se retrasa en
// cuanto la pestaña pierde el foco o el lienzo va cargado, y a los diez
// minutos la cuenta va desfasada. Se guarda el instante en que se puso en
// marcha y se resta del reloj — así da igual que el intervalo llegue tarde,
// porque solo sirve para repintar.

const MODOS_TIEMPO = ['cuentaAtras', 'cronometro', 'pomodoro'];

function nombreModoTiempo(modo) {
  if (modo === 'cronometro') return window.t('Cronómetro', 'Stopwatch');
  if (modo === 'pomodoro') return window.t('Pomodoro', 'Pomodoro');
  return window.t('Cuenta atrás', 'Countdown');
}
window.MODOS_TIEMPO = MODOS_TIEMPO;
window.nombreModoTiempo = nombreModoTiempo;

// De milisegundos a "12:34" (o "1:02:03" si pasa de una hora).
function formateaTiempo(ms) {
  const total = Math.max(0, Math.round(ms / 1000));
  const h = Math.floor(total / 3600);
  const m = Math.floor((total % 3600) / 60);
  const s = total % 60;
  const dos = (n) => (n < 10 ? '0' : '') + n;
  return h > 0 ? `${h}:${dos(m)}:${dos(s)}` : `${m}:${dos(s)}`;
}
window.formateaTiempo = formateaTiempo;

// Cuánto dura la fase actual, en milisegundos.
function duracionFase(item) {
  const modo = item.modoTiempo || 'cuentaAtras';
  if (modo === 'cronometro') return 0;   // hacia arriba, sin final
  if (modo === 'pomodoro') {
    // El pomodoro también cuenta segundos: el reloj se edita en el propio
    // nodo con dos casillas (mm:ss) y sería raro que una de las dos no se
    // guardara según en qué fase estuvieras.
    const trabajo = ((item.minutosTrabajo != null ? item.minutosTrabajo : 25) * 60 + (item.segundosTrabajo || 0)) * 1000;
    const descanso = ((item.minutosDescanso != null ? item.minutosDescanso : 5) * 60 + (item.segundosDescanso || 0)) * 1000;
    return item.faseDescanso ? descanso : trabajo;
  }
  const min = item.minutos != null ? item.minutos : 5;
  const seg = item.segundos != null ? item.segundos : 0;
  return (min * 60 + seg) * 1000;
}
window.duracionFase = duracionFase;

// Qué toca al terminar una fase del pomodoro. Vive aquí fuera, sin tocar el
// reloj ni React, por una razón concreta: el salto de trabajo a descanso solo
// se podía ver esperando los minutos de verdad, así que nunca se comprobó.
// Siendo una función aparte se le pueden pasar los cuatro casos y medirlos.
//
// `sigueCorriendo` distingue las dos formas de cambiar de fase: encadenando
// sola (arranca la siguiente) y a mano (arranca solo si ya estaba en marcha).
function siguienteFasePomodoro(item, ahora, sigueCorriendo) {
  const enDescanso = !!item.faseDescanso;
  return {
    faseDescanso: !enDescanso,
    // La ronda se cuenta al acabar el TRABAJO, no al acabar el descanso: lo
    // que uno cuenta al final del día son los ratos trabajados.
    rondas: enDescanso ? (item.rondas || 0) : (item.rondas || 0) + 1,
    arrancadoEn: sigueCorriendo ? ahora : null,
    acumulado: 0,
  };
}
window.siguienteFasePomodoro = siguienteFasePomodoro;

function TimerItem({ item, lang, onUpdate }) {
  const modo = MODOS_TIEMPO.indexOf(item.modoTiempo) !== -1 ? item.modoTiempo : 'cuentaAtras';
  const enMarcha = !!item.arrancadoEn;
  const acumulado = item.acumulado || 0;      // lo que ya corrió antes de la última pausa
  const duracion = duracionFase(item);
  // El cronómetro no tiene nada que poner: cuenta desde cero hacia arriba.
  const seAjusta = modo !== 'cronometro';

  // Solo sirve para repintar: la cuenta de verdad sale del reloj, no de aquí.
  const [, repinta] = React.useState(0);
  React.useEffect(() => {
    if (!enMarcha) return;
    const id = setInterval(() => repinta(n => n + 1), 250);
    return () => clearInterval(id);
  }, [enMarcha]);

  // El reloj se escribe EN EL NODO, no en el menú de la izquierda: poner tres
  // minutos es lo que más se hace con este nodo y mandarte a un panel lateral
  // para eso convertía el gesto más corto en el más largo.
  const [borrador, setBorrador] = React.useState(null);
  // Qué modo se enseña al pulsar la insignia de la barra.
  const [eligiendoModo, setEligiendoModo] = React.useState(false);

  const transcurrido = acumulado + (enMarcha ? Date.now() - item.arrancadoEn : 0);
  const restante = Math.max(0, duracion - transcurrido);
  const seAcabo = modo !== 'cronometro' && duracion > 0 && restante <= 0;

  // Que suene UNA vez por vencimiento, no en cada repintado.
  const yaSono = React.useRef(false);
  React.useEffect(() => {
    if (!seAcabo) { yaSono.current = false; return; }
    if (yaSono.current) return;
    yaSono.current = true;
    window.playAudioTone && window.playAudioTone('alarma');
    if (modo === 'pomodoro' && item.autoCambio !== false) {
      // Encadenar solo es lo que uno quiere no tener que recordar… pero solo
      // si lo ha pedido: con el interruptor apagado se para y espera.
      onUpdate(siguienteFasePomodoro(item, Date.now(), true));
    } else {
      onUpdate({ arrancadoEn: null, acumulado: duracion });
    }
    // eslint-disable-next-line
  }, [seAcabo]);

  const arranca = () => {
    window.playAudioTone && window.playAudioTone('click');
    onUpdate({ arrancadoEn: Date.now() });
  };
  const pausa = () => {
    window.playAudioTone && window.playAudioTone('click');
    onUpdate({ arrancadoEn: null, acumulado: transcurrido });
  };
  const reinicia = () => {
    window.playAudioTone && window.playAudioTone('delete');
    onUpdate({ arrancadoEn: null, acumulado: 0, faseDescanso: false, rondas: 0 });
  };
  const cambiaFase = () => {
    window.playAudioTone && window.playAudioTone('click');
    onUpdate(siguienteFasePomodoro(item, Date.now(), enMarcha));
  };

  // ── Escribir el tiempo en el propio reloj ──
  const abreReloj = () => {
    if (!seAjusta) return;
    const total = Math.round(duracion / 1000);
    setBorrador({ min: String(Math.floor(total / 60)), seg: String(total % 60).padStart(2, '0') });
  };
  const guardaReloj = () => {
    if (!borrador) return;
    const min = Math.max(0, Math.min(600, parseInt(borrador.min, 10) || 0));
    const seg = Math.max(0, Math.min(59, parseInt(borrador.seg, 10) || 0));
    setBorrador(null);
    // Un reloj a cero no cuenta nada, y casi siempre es una casilla que se
    // vació para escribir y se dejó a medias: se queda como estaba.
    if (min === 0 && seg === 0) return;
    const patch = { arrancadoEn: null, acumulado: 0 };
    if (modo === 'pomodoro') {
      if (item.faseDescanso) { patch.minutosDescanso = min; patch.segundosDescanso = seg; }
      else { patch.minutosTrabajo = min; patch.segundosTrabajo = seg; }
    } else {
      patch.minutos = min; patch.segundos = seg;
    }
    onUpdate(patch);
  };

  // Lo que se enseña en grande.
  const mostrado = modo === 'cronometro' ? transcurrido : restante;
  // Cuánto queda, de 0 a 1, para el aro de progreso.
  const avance = duracion > 0 ? Math.min(1, transcurrido / duracion) : 0;

  const colorSolido = item.color && item.color !== 'white'
    ? (window.resolveStickyColor ? window.resolveStickyColor(item.color) : item.color)
    : '#FFFFFF';
  // Semitransparente y desenfocando lo de detrás: un reloj es un cacharro que
  // se pone ENCIMA del tablero, no un papel más pegado al tablero. Opaco
  // tapaba las notas sobre las que uno lo deja.
  const colorFondo = window.conAlfa ? window.conAlfa(colorSolido, 0.58) : colorSolido;
  // El color de los números lo elige el usuario; si no ha elegido, se saca del
  // fondo SÓLIDO —no del transparente— para que siempre se lea.
  const colorNumeros = item.numberColor || (window.tintaLegible ? window.tintaLegible(colorSolido) : '#1A1A1A');

  // La barra de arriba lleva color de verdad, que es lo que la separa del
  // cuerpo translúcido y le da sitio al nombre. Nace en el rojo del propio
  // nodo, el mismo de su botón en la barra de herramientas.
  const colorBarra = item.barColor || '#E6544F';
  const titulo = item.timerTitle || '';
  // El título se edita desde su propio botón del menú del nodo, igual que el
  // del bloque de código: el aviso viaja en el item porque la barra lateral
  // también tiene que verlo para enseñar las opciones de texto.
  const editandoTitulo = item._editingTitle === true;
  const colorTitulo = item.titleColor && item.titleColor !== 'inherit'
    ? item.titleColor
    : (window.tintaLegible ? window.tintaLegible(colorBarra) : '#FFFFFF');
  // Los mismos campos que escribe la barra de texto en el marco, el mapa y el
  // bloque de código, para que este título se comporte como los demás.
  const estiloTitulo = {
    color: colorTitulo,
    fontWeight: item.bold === false ? 500 : 700,
    fontStyle: item.italic ? 'italic' : 'normal',
    textDecoration: [item.underline ? 'underline' : '', item.strike ? 'line-through' : ''].filter(Boolean).join(' ') || 'none',
    textAlign: item.titleAlign || 'left',
  };

  const faseTexto = item.faseDescanso ? window.t('Descanso', 'Break') : window.t('Trabajo', 'Focus');

  return (
    <div className="timer-card" style={{ width: '100%', height: '100%' }}>
      <div
        className="item-card timer-body"
        style={{ background: colorFondo, borderColor: window.conAlfa ? window.conAlfa(colorNumeros, 0.22) : undefined }}
      >
        {/* La barra de arriba: el nombre y qué está contando */}
        <div className="timer-bar" style={{ background: colorBarra }}>
          {editandoTitulo ? (
            <input
              className="timer-title-input"
              style={estiloTitulo}
              /* Se guarda según se escribe y no al perder el foco: la barra
                 de texto se cierra con su propia flecha, y eso desmonta este
                 campo sin que llegue a haber un blur — lo tecleado se perdía
                 sin decir nada. */
              value={titulo}
              autoFocus
              onMouseDown={(e)=>e.stopPropagation()}
              onChange={(e)=>onUpdate({ timerTitle: e.target.value })}
              onKeyDown={(e)=>{ if (e.key === 'Enter' || e.key === 'Escape') e.currentTarget.blur(); }}
            />
          ) : (
            <span
              className={`timer-title${titulo ? '' : ' vacio'}`}
              style={estiloTitulo}
              onDoubleClick={(e)=>{ e.stopPropagation(); onUpdate({ _editingTitle: true }); }}
              title={window.t('Doble clic para ponerle nombre', 'Double-click to name it')}
            >
              {titulo || window.t('sin nombre', 'untitled')}
            </span>
          )}
          {/* La insignia dice qué de los tres es Y sirve para cambiarlo: el
              modo es lo primero que se toca al poner el nodo, y tenerlo solo
              en el menú de la izquierda lo escondía. */}
          <button
            className="timer-modo"
            style={{ color: colorTitulo, borderColor: window.conAlfa ? window.conAlfa(colorTitulo, 0.45) : colorTitulo }}
            onMouseDown={(e)=>e.stopPropagation()}
            onClick={(e)=>{ e.stopPropagation(); setEligiendoModo(v => !v); }}
            title={window.t('Cuenta atrás, cronómetro o pomodoro', 'Countdown, stopwatch or pomodoro')}
          >
            {nombreModoTiempo(modo)}
            <span className="material-symbols-rounded">expand_more</span>
          </button>
        </div>

        {eligiendoModo && (
          <>
            {/* Una tapa por debajo para que el siguiente clic cierre la lista,
                sin salirse del nodo (la tarjeta recorta lo que sobresale). */}
            <div className="timer-modos-tapa" onMouseDown={(e)=>e.stopPropagation()} onClick={()=>setEligiendoModo(false)}/>
            <div className="timer-modos" onMouseDown={(e)=>e.stopPropagation()}>
              {MODOS_TIEMPO.map(m => (
                <button
                  key={m}
                  className={`timer-modo-item ${modo === m ? 'activo' : ''}`}
                  /* Cambiar de modo pone el reloj a cero: dejar corriendo la
                     cuenta de otro modo enseñaría un número que no significa
                     nada en el que acabas de elegir. */
                  onClick={()=>{
                    setEligiendoModo(false);
                    setBorrador(null);
                    window.playAudioTone && window.playAudioTone('click');
                    onUpdate({ modoTiempo: m, arrancadoEn: null, acumulado: 0, faseDescanso: false, rondas: 0 });
                  }}
                >
                  <span>{nombreModoTiempo(m)}</span>
                  {modo === m && <span className="material-symbols-rounded">check</span>}
                </button>
              ))}
            </div>
          </>
        )}

        <div className="timer-cuerpo">
          {/* Solo en el pomodoro: en los otros dos modos esta fila repetía
              letra por letra la insignia de la barra de arriba. */}
          {modo === 'pomodoro' && (
            <div className="timer-fase" style={{ color: colorNumeros, opacity: 0.65 }}>
              {faseTexto}
              {(item.rondas || 0) > 0 && (
                <span className="timer-rondas">
                  {'·'} {item.rondas} {item.rondas === 1 ? window.t('ronda', 'round') : window.t('rondas', 'rounds')}
                </span>
              )}
            </div>
          )}

          {borrador ? (
            <div className="timer-reloj editando" style={{ color: colorNumeros }} onMouseDown={(e)=>e.stopPropagation()}>
              <input
                className="timer-num"
                type="text"
                inputMode="numeric"
                value={borrador.min}
                autoFocus
                onFocus={(e)=>e.target.select()}
                onChange={(e)=>setBorrador(b => ({ ...b, min: e.target.value.replace(/\D/g, '').slice(0, 3) }))}
                onKeyDown={(e)=>{ if (e.key === 'Enter') guardaReloj(); if (e.key === 'Escape') setBorrador(null); }}
                onBlur={(e)=>{ if (!e.relatedTarget || !e.relatedTarget.classList.contains('timer-num')) guardaReloj(); }}
              />
              <span className="timer-dosp">:</span>
              <input
                className="timer-num"
                type="text"
                inputMode="numeric"
                value={borrador.seg}
                onFocus={(e)=>e.target.select()}
                onChange={(e)=>setBorrador(b => ({ ...b, seg: e.target.value.replace(/\D/g, '').slice(0, 2) }))}
                onKeyDown={(e)=>{ if (e.key === 'Enter') guardaReloj(); if (e.key === 'Escape') setBorrador(null); }}
                onBlur={(e)=>{ if (!e.relatedTarget || !e.relatedTarget.classList.contains('timer-num')) guardaReloj(); }}
              />
            </div>
          ) : (
            <div
              className={`timer-reloj${seAjusta ? ' ajustable' : ''}`}
              style={{ color: colorNumeros }}
              onMouseDown={(e)=>{ if (seAjusta) e.stopPropagation(); }}
              onClick={abreReloj}
              title={seAjusta ? window.t('Clic para poner el tiempo', 'Click to set the time') : undefined}
            >
              {formateaTiempo(mostrado)}
            </div>
          )}

          {/* La barra de avance solo tiene sentido si hay un final al que llegar. */}
          {modo !== 'cronometro' && (
            <div className="timer-barra" style={{ borderColor: colorNumeros }}>
              <i style={{ width: `${avance * 100}%`, background: colorNumeros }}/>
            </div>
          )}

          <div className="timer-botones" onMouseDown={(e)=>e.stopPropagation()}>
            <button
              className="timer-btn principal"
              style={{ borderColor: colorNumeros, color: colorNumeros }}
              onClick={enMarcha ? pausa : arranca}
              title={enMarcha ? window.t('Pausar', 'Pause') : window.t('Empezar', 'Start')}
            >
              <span className="material-symbols-rounded">{enMarcha ? 'pause' : 'play_arrow'}</span>
            </button>
            <button
              className="timer-btn"
              style={{ borderColor: colorNumeros, color: colorNumeros }}
              onClick={reinicia}
              title={window.t('Volver a empezar', 'Reset')}
            >
              <span className="material-symbols-rounded">refresh</span>
            </button>
            {/* Pasar a descanso (o volver al trabajo) a mano. Hace falta
                incluso con el encadenado puesto: se acaba antes de tiempo, o
                se alarga, y hasta ahora la única salida era reiniciarlo todo. */}
            {modo === 'pomodoro' && (
              <button
                className="timer-btn"
                style={{ borderColor: colorNumeros, color: colorNumeros }}
                onClick={cambiaFase}
                title={item.faseDescanso
                  ? window.t('Pasar a trabajo', 'Switch to focus')
                  : window.t('Pasar a descanso', 'Switch to break')}
              >
                <span className="material-symbols-rounded">{item.faseDescanso ? 'work' : 'coffee'}</span>
              </button>
            )}
          </div>

          {modo === 'pomodoro' && (
            <button
              className={`timer-auto${item.autoCambio === false ? '' : ' on'}`}
              style={{ color: colorNumeros }}
              role="switch"
              aria-checked={item.autoCambio !== false}
              onMouseDown={(e)=>e.stopPropagation()}
              onClick={()=>{
                window.playAudioTone && window.playAudioTone('click');
                onUpdate({ autoCambio: item.autoCambio === false });
              }}
              title={window.t('Al acabar, empezar la fase siguiente sin preguntar',
                              'When one phase ends, start the next on its own')}
            >
              <span className="timer-auto-carril" style={{ borderColor: colorNumeros, background: item.autoCambio !== false ? colorNumeros : 'transparent' }}>
                <i style={{ background: item.autoCambio !== false ? colorSolido : colorNumeros }}/>
              </span>
              <span>{window.t('Encadenar solo', 'Auto switch')}</span>
            </button>
          )}
        </div>
      </div>
    </div>
  );
}

// ──────────────── CÓDIGO ────────────────
//
// Un bloque de código de verdad: fondo azul oscuro, el texto coloreado solo
// según el lenguaje, y una barra arriba con el nombre y el lenguaje.
//
// El coloreado lo hace highlight.js, que YA viaja dentro de la aplicación
// (App/lib/highlight.min.js, 36 lenguajes) — no hace falta traer nada nuevo ni
// pedir nada por internet, que es justo lo que no puede hacer una aplicación
// que tiene que funcionar sin conexión.

// Lo que se ofrece en el menú, con el nombre que usa la gente y no el interno.
const LENGUAJES_CODIGO = [
  // El primero y el de por defecto: que lo adivine él. Nadie viene a un lienzo
  // a decirle a un cuadro de texto en qué lenguaje está escribiendo, y si te
  // equivocas de lenguaje el código sale sin colores y parece que está roto.
  // highlight.js compara con los 34 y se queda con el que mejor encaja.
  { id: 'auto',       nombre: 'Automático',  comentario: '// ' },
  { id: 'plaintext',  nombre: 'Texto plano', comentario: '# ' },
  { id: 'javascript', nombre: 'JavaScript',  comentario: '// ' },
  { id: 'typescript', nombre: 'TypeScript',  comentario: '// ' },
  { id: 'python',     nombre: 'Python',      comentario: '# ' },
  { id: 'csharp',     nombre: 'C#',          comentario: '// ' },
  { id: 'cpp',        nombre: 'C++',         comentario: '// ' },
  { id: 'c',          nombre: 'C',           comentario: '// ' },
  { id: 'java',       nombre: 'Java',        comentario: '// ' },
  { id: 'lua',        nombre: 'Lua',         comentario: '-- ' },
  { id: 'rust',       nombre: 'Rust',        comentario: '// ' },
  { id: 'go',         nombre: 'Go',          comentario: '// ' },
  { id: 'ruby',       nombre: 'Ruby',        comentario: '# ' },
  { id: 'php',        nombre: 'PHP',         comentario: '// ' },
  { id: 'swift',      nombre: 'Swift',       comentario: '// ' },
  { id: 'kotlin',     nombre: 'Kotlin',      comentario: '// ' },
  { id: 'sql',        nombre: 'SQL',         comentario: '-- ' },
  { id: 'bash',       nombre: 'Bash',        comentario: '# ' },
  { id: 'shell',      nombre: 'Shell',       comentario: '# ' },
  { id: 'json',       nombre: 'JSON',        comentario: '// ' },
  { id: 'yaml',       nombre: 'YAML',        comentario: '# ' },
  { id: 'xml',        nombre: 'HTML / XML',  comentario: '<!-- ' },
  { id: 'css',        nombre: 'CSS',         comentario: '/* ' },
  { id: 'scss',       nombre: 'SCSS',        comentario: '// ' },
  { id: 'markdown',   nombre: 'Markdown',    comentario: '<!-- ' },
  { id: 'ini',        nombre: 'INI / TOML',  comentario: '; ' },
  { id: 'diff',       nombre: 'Diff',        comentario: '# ' },
  { id: 'r',          nombre: 'R',           comentario: '# ' },
  { id: 'perl',       nombre: 'Perl',        comentario: '# ' },
  { id: 'objectivec', nombre: 'Objective-C', comentario: '// ' },
  { id: 'vbnet',      nombre: 'VB.NET',      comentario: "' " },
  { id: 'makefile',   nombre: 'Makefile',    comentario: '# ' },
  { id: 'graphql',    nombre: 'GraphQL',     comentario: '# ' },
  { id: 'less',       nombre: 'Less',        comentario: '// ' },
  { id: 'wasm',       nombre: 'WebAssembly', comentario: ';; ' },
];
window.LENGUAJES_CODIGO = LENGUAJES_CODIGO;

function lenguajeCodigo(id) {
  const l = LENGUAJES_CODIGO.find(x => x.id === id) || LENGUAJES_CODIGO[0];
  return Object.assign({}, l, { nombre: nombreLenguaje(l) });
}
// Los dos que no son un lenguaje de verdad se dicen en el idioma de la
// interfaz; los demás se llaman igual en todas partes.
function nombreLenguaje(l) {
  if (!l) return '';
  if (l.id === 'auto') return window.t('Automático', 'Auto');
  if (l.id === 'plaintext') return window.t('Texto plano', 'Plain text');
  return l.nombre;
}
window.nombreLenguaje = nombreLenguaje;
window.lenguajeCodigo = lenguajeCodigo;

// Colorea un trozo de código. Si el lenguaje no está o el resaltador falla, se
// devuelve el texto escapado: mejor sin color que sin texto.
function coloreaCodigo(texto, lenguaje) {
  const plano = String(texto || '');
  const escapado = plano
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  const hljs = window.hljs;
  if (!hljs || !lenguaje || lenguaje === 'plaintext') return escapado;
  try {
    // Automático: se lo pregunta a la propia librería, que compara con los 34
    // que trae y se queda con el que más encaja. Así el código sale coloreado
    // aunque el lenguaje elegido no sea el que se está escribiendo.
    if (lenguaje === 'auto') return hljs.highlightAuto(plano).value;
    if (hljs.listLanguages && hljs.listLanguages().indexOf(lenguaje) === -1) return escapado;
    return hljs.highlight(plano, { language: lenguaje, ignoreIllegals: true }).value;
  } catch (e) {
    return escapado;
  }
}

// Qué lenguaje ha adivinado, para poder enseñarlo en la barra del nodo: si no
// se dice, "Automático" no informa de nada y no se sabe si acertó.
function adivinaLenguaje(texto) {
  const hljs = window.hljs;
  if (!hljs || !hljs.highlightAuto || !String(texto || '').trim()) return null;
  try {
    const r = hljs.highlightAuto(String(texto));
    if (!r.language) return null;
    const conocido = LENGUAJES_CODIGO.find(l => l.id === r.language);
    return conocido ? conocido.nombre : r.language;
  } catch (e) { return null; }
}
window.adivinaLenguaje = adivinaLenguaje;
window.coloreaCodigo = coloreaCodigo;

// Comentar o descomentar las líneas que abarque la selección.
//
// Se decide una sola vez para todo el bloque: si TODAS las líneas ya están
// comentadas, se descomenta; si hay una sola sin comentar, se comenta todo.
// Es lo que hace cualquier editor, y evita el efecto cremallera de ir línea
// por línea alternando.
function alternaComentario(texto, ini, fin, marca) {
  const lineas = String(texto).split('\n');
  // De posición de carácter a número de línea.
  let pos = 0, primera = 0, ultima = 0;
  for (let i = 0; i < lineas.length; i++) {
    const finLinea = pos + lineas[i].length;
    if (pos <= ini && ini <= finLinea) primera = i;
    if (pos <= fin && fin <= finLinea) { ultima = i; break; }
    ultima = i;
    pos = finLinea + 1;
  }
  const marcaSinEspacio = marca.trim();
  const afectadas = lineas.slice(primera, ultima + 1);
  const conTexto = afectadas.filter(l => l.trim() !== '');
  const todasComentadas = conTexto.length > 0 &&
    conTexto.every(l => l.trimStart().startsWith(marcaSinEspacio));

  for (let i = primera; i <= ultima; i++) {
    const l = lineas[i];
    if (todasComentadas) {
      const sangria = l.length - l.trimStart().length;
      const resto = l.slice(sangria);
      if (resto.startsWith(marca)) lineas[i] = l.slice(0, sangria) + resto.slice(marca.length);
      else if (resto.startsWith(marcaSinEspacio)) lineas[i] = l.slice(0, sangria) + resto.slice(marcaSinEspacio.length);
    } else if (l.trim() !== '') {
      const sangria = l.length - l.trimStart().length;
      lineas[i] = l.slice(0, sangria) + marca + l.slice(sangria);
    }
  }
  return lineas.join('\n');
}
window.alternaComentario = alternaComentario;

function CodeItem({ item, lang, editing, onUpdate, callbacks }) {
  const areaRef = React.useRef(null);
  const preRef = React.useRef(null);
  const cb = callbacks || {};
  const codigo = typeof item.code === 'string' ? item.code : '';
  const lenguaje = lenguajeCodigo(item.codeLang || 'auto');
  const titulo = item.codeTitle || '';
  // El título se edita desde su propio botón de la barra del nodo, que además
  // abre SU menú (color, negrita…). Por eso el aviso viaja en el item y no en
  // un estado de aquí dentro: la barra lateral también tiene que verlo.
  const editandoTitulo = item._editingTitle === true;

  // El color del título y el de su barra sí se pueden cambiar; el del fondo no,
  // porque el azul oscuro con el degradado ES el nodo.
  const colorBarra = item.barColor || 'rgba(255,255,255,0.06)';
  const colorTitulo = item.titleColor && item.titleColor !== 'inherit' ? item.titleColor : '#DDE6F5';
  // Los mismos campos que escribe la barra de texto en el marco y en el mapa,
  // para que el titulo de aqui se comporte como los demas titulos.
  const estiloTitulo = {
    color: colorTitulo,
    fontWeight: item.bold === false ? 500 : 700,
    fontStyle: item.italic ? 'italic' : 'normal',
    textDecoration: [item.underline ? 'underline' : '', item.strike ? 'line-through' : ''].filter(Boolean).join(' ') || 'none',
    textAlign: item.titleAlign || 'left',
  };

  // Comentar la selección se pide desde la barra del nodo, que no tiene acceso
  // al textarea: se deja aquí una función colgada del nodo mientras se edita.
  // `editing` llega como SÍ/NO, no como el id del nodo: así se lo pasa el
  // lienzo a todos los nodos.
  React.useEffect(() => {
    if (!editing) return;
    const clave = `__codigoComentar_${item.id}`;
    window[clave] = () => {
      const ta = areaRef.current;
      if (!ta) return;
      const ini = ta.selectionStart, fin = ta.selectionEnd;
      const nuevo = alternaComentario(ta.value, ini, fin, lenguaje.comentario);
      onUpdate({ code: nuevo });
      // El texto cambia de largo: se deja el cursor donde estaba en vez de
      // mandarlo al final, que es lo que hace perder el sitio.
      requestAnimationFrame(() => {
        const t = areaRef.current;
        if (!t) return;
        t.focus();
        const desplaza = nuevo.length - ta.value.length;
        t.setSelectionRange(ini, Math.max(ini, fin + desplaza));
      });
    };
    return () => { delete window[clave]; };
  }, [editing, item.id, lenguaje.comentario, onUpdate]);

  // El resaltado se pinta detrás y el textarea encima, transparente. Así se
  // escribe con el cursor y la selección de siempre PERO con los colores
  // debajo, en vez de tener que salir del modo edición para verlos.
  const alDesplazar = () => {
    const ta = areaRef.current, pre = preRef.current;
    if (ta && pre) { pre.scrollTop = ta.scrollTop; pre.scrollLeft = ta.scrollLeft; }
  };

  const alEscribirTecla = (e) => {
    // El tabulador dentro del código es un tabulador, no el salto al siguiente
    // control: sin esto no se puede sangrar nada.
    if (e.key === 'Tab') {
      e.preventDefault();
      const ta = e.currentTarget;
      const ini = ta.selectionStart, fin = ta.selectionEnd;
      const nuevo = ta.value.slice(0, ini) + '  ' + ta.value.slice(fin);
      onUpdate({ code: nuevo });
      requestAnimationFrame(() => {
        const t = areaRef.current;
        if (t) { t.focus(); t.setSelectionRange(ini + 2, ini + 2); }
      });
      return;
    }
    if (e.key === 'Escape') { e.stopPropagation(); cb.endEdit && cb.endEdit(); }
    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
      e.preventDefault();
      e.stopPropagation();
      ejecutar();
    }
  };

  const estaEditando = !!editing;

  // ── La terminal ──
  // Ejecuta el bloque (solo JavaScript: es lo que el navegador sabe correr sin
  // descargar nada) en un worker aislado, sin red ni acceso a la aplicación
  // (ejecuta-codigo.js), y enseña debajo lo que imprime. Lo que sale no se
  // guarda en el proyecto: es del momento, como la consola de cualquier editor.
  const adivinado = lenguaje.id === 'auto' ? adivinaLenguaje(codigo) : null;
  const ejecutable = !!window.OdiEjecuta && (lenguaje.id === 'javascript' || adivinado === 'JavaScript');
  const [terminal, setTerminal] = React.useState(null);
  const enMarcha = React.useRef(null);
  const salidaRef = React.useRef(null);
  React.useEffect(() => () => { if (enMarcha.current) enMarcha.current.para(); }, []);
  React.useEffect(() => {
    const el = salidaRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [terminal]);
  const ejecutar = () => {
    // Con otro lenguaje no se ejecuta, pero se dice por qué y cómo cambiarlo:
    // un ▶ que no hace nada, o que no sale, parece que la terminal no existe.
    if (!ejecutable) {
      const nombre = lenguaje.id === 'auto' ? (adivinado || window.t('un lenguaje que no se reconoce', 'an unrecognised language')) : lenguaje.nombre;
      setTerminal({ lineas: [{ tipo: 'info', texto: window.t(
        `Esta terminal ejecuta JavaScript, y este bloque está en ${nombre}. Para ejecutarlo, elige «JavaScript» en el botón del lenguaje de la barra del nodo (el de < >).`,
        `This terminal runs JavaScript, and this block is in ${nombre}. To run it, pick «JavaScript» with the language button in the node bar (the < > one).`) }], estado: 'nojs' });
      return;
    }
    if (enMarcha.current) enMarcha.current.para();
    const lineas = [];
    setTerminal({ lineas: [], estado: 'corriendo' });
    const c = window.OdiEjecuta.ejecuta(codigo, (l) => {
      if (l.tipo === 'limpia') lineas.length = 0;
      else { lineas.push(l); if (lineas.length > 500) lineas.shift(); }
      setTerminal(t => (t ? Object.assign({}, t, { lineas: lineas.slice() }) : t));
    }, { palabraLinea: window.t('línea', 'line') });
    enMarcha.current = c;
    c.hecho.then(r => {
      if (enMarcha.current !== c) return;
      enMarcha.current = null;
      const estado = r.motivo === 'fin' ? (r.fallo ? 'fallo' : 'hecho') : r.motivo;
      setTerminal(t => (t ? { lineas: lineas.slice(), estado, ms: r.ms, resultado: r.hay ? r.resultado : null } : t));
    });
  };
  const paraYa = () => { if (enMarcha.current) enMarcha.current.para(); };
  const estadoTerminal = (t) => {
    if (t.estado === 'corriendo') return window.t('ejecutando…', 'running…');
    if (t.estado === 'hecho') return window.t('listo', 'done') + ' · ' + Math.round(t.ms) + ' ms';
    if (t.estado === 'fallo') return window.t('con un error', 'with an error');
    if (t.estado === 'tiempo') return window.t('parado: más de 5 s (¿un bucle sin fin?)', 'stopped: over 5 s (an endless loop?)');
    if (t.estado === 'nojs') return window.t('solo JavaScript', 'JavaScript only');
    return window.t('parado', 'stopped');
  };
  const sinArrastrar = (e) => e.stopPropagation();

  return (
    <div className="code-card" style={{ width: '100%', height: '100%' }}>
      <div className="item-card code-body">
        {/* Barra de arriba: nombre y lenguaje */}
        <div className="code-bar" style={{ background: colorBarra }}>
          <span className="code-dots"><i/><i/><i/></span>
          {editandoTitulo ? (
            <input
              className="code-title-input"
              style={estiloTitulo}
              /* Se guarda según se escribe. Guardándolo solo al perder el
                 foco se perdía entero: la barra de texto se cierra con su
                 flecha de volver, eso desmonta este campo, y de un campo
                 desmontado el navegador no manda ningún blur. */
              value={titulo}
              autoFocus
              onMouseDown={(e)=>e.stopPropagation()}
              onChange={(e)=>onUpdate({ codeTitle: e.target.value })}
              onKeyDown={(e)=>{ if (e.key === 'Enter' || e.key === 'Escape') e.currentTarget.blur(); }}
            />
          ) : (
            <span
              className={`code-title${titulo ? '' : ' vacio'}`}
              style={estiloTitulo}
              onDoubleClick={(e)=>{ e.stopPropagation(); onUpdate({ _editingTitle: true }); }}
              title={window.t('Doble clic para ponerle nombre', 'Double-click to name it')}
            >
              {titulo || window.t('sin nombre', 'untitled')}
            </span>
          )}
          {/* En automático se enseña lo que ha adivinado, con un punto delante:
              si solo pusiera "Automático" no habría forma de saber si acertó. */}
          <span className="code-lang">
            {lenguaje.id === 'auto'
              ? (adivinado ? `• ${adivinado}` : window.t('Automático', 'Auto'))
              : lenguaje.nombre}
          </span>
          {window.OdiEjecuta && (
            <button
              type="button"
              className={`code-run ${terminal && terminal.estado === 'corriendo' ? 'corriendo' : ''} ${ejecutable ? '' : 'otro-lenguaje'}`}
              onMouseDown={sinArrastrar}
              onDoubleClick={sinArrastrar}
              onClick={(e) => { e.stopPropagation(); if (terminal && terminal.estado === 'corriendo') paraYa(); else ejecutar(); }}
              title={terminal && terminal.estado === 'corriendo'
                ? window.t('Parar', 'Stop')
                : window.t('Ejecutar (Ctrl+Enter al escribir)', 'Run (Ctrl+Enter while typing)')}
              aria-label={window.t('Ejecutar', 'Run')}
            >
              <span className="material-symbols-rounded">{terminal && terminal.estado === 'corriendo' ? 'stop' : 'play_arrow'}</span>
            </button>
          )}
        </div>

        {/* El código */}
        <div className="code-scroll">
          <pre ref={preRef} className="code-pre" aria-hidden="true">
            <code
              className="hljs"
              /* Ni un salto de línea de más: la capa de color y la de escribir
                 tienen que medir EXACTAMENTE lo mismo. Con el '\n' que había
                 aquí, la de color era una línea más alta, y al desplazarse
                 hacia abajo las dos capas dejaban de coincidir — el texto de
                 colores quedaba una línea por encima del cursor. */
              dangerouslySetInnerHTML={{ __html: coloreaCodigo(codigo, lenguaje.id) }}
            />
          </pre>
          {/* Mientras se escribe el TÍTULO, el código no recibe nada: si no,
              se podía picar dentro y acababas escribiendo en dos sitios a la
              vez, o entrando al código sin que saliera su menú. */}
          {estaEditando && !editandoTitulo && (
            <textarea
              ref={areaRef}
              className="code-area"
              value={codigo}
              spellCheck={false}
              autoFocus
              onChange={(e)=>onUpdate({ code: e.target.value })}
              onScroll={alDesplazar}
              onKeyDown={alEscribirTecla}
              onMouseDown={(e)=>e.stopPropagation()}
              onDoubleClick={(e)=>e.stopPropagation()}
              placeholder={window.t('Escribe o pega tu código…', 'Type or paste your code…')}
            />
          )}
        </div>

        {terminal && (
          <div className="code-terminal" onMouseDown={sinArrastrar} onDoubleClick={sinArrastrar} onClick={sinArrastrar}>
            <div className="code-terminal-barra">
              <span className="material-symbols-rounded code-terminal-icono">terminal</span>
              <span className="code-terminal-tit">{window.t('Terminal', 'Terminal')}</span>
              <span className={`code-terminal-estado ${terminal.estado}`}>{estadoTerminal(terminal)}</span>
              <button type="button" className="code-terminal-btn" onClick={() => setTerminal(t => Object.assign({}, t, { lineas: [], resultado: null }))}
                title={window.t('Limpiar', 'Clear')} aria-label={window.t('Limpiar', 'Clear')}>
                <span className="material-symbols-rounded">block</span>
              </button>
              <button type="button" className="code-terminal-btn" onClick={() => { paraYa(); setTerminal(null); }}
                title={window.t('Cerrar la terminal', 'Close the terminal')} aria-label={window.t('Cerrar la terminal', 'Close the terminal')}>
                <span className="material-symbols-rounded">close</span>
              </button>
            </div>
            <div className="code-terminal-salida" ref={salidaRef}>
              {terminal.lineas.map((l, i) => (
                <div key={i} className={`code-linea ${l.tipo}`}>{l.texto}</div>
              ))}
              {terminal.resultado != null && (
                <div className="code-linea resultado">← {terminal.resultado}</div>
              )}
              {!terminal.lineas.length && terminal.resultado == null && terminal.estado !== 'corriendo' && (
                <div className="code-linea vacia">{window.t('(sin salida: usa console.log para ver algo aquí)', '(no output: use console.log to see something here)')}</div>
              )}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

// ──────────────── FILE ────────────────
function fileKind(ext) {
  const e = (ext || '').toLowerCase();
  if (['png','jpg','jpeg','gif','webp','svg','bmp'].includes(e)) return 'image';
  // Un vídeo y un audio no son "otros": son lo que la gente suelta más a menudo
  // después de una imagen, y hasta ahora los dos acababan en un cuadrado gris
  // con un logo en medio. Mkv y avi entran a propósito aunque el navegador no
  // sepa reproducirlos casi nunca: si no puede, lo dice, que es mejor que
  // fingir que el archivo no es un vídeo.
  if (['mp4','webm','ogv','mov','m4v','mkv','avi'].includes(e)) return 'video';
  if (['mp3','wav','ogg','oga','m4a','flac','aac'].includes(e)) return 'audio';
  if (e === 'pdf') return 'pdf';
  if (['mp4','webm','ogv','m4v','mov','mkv','avi'].includes(e)) return 'video';
  if (['mp3','wav','ogg','oga','m4a','flac','aac'].includes(e)) return 'audio';
  if (e === 'docx' || e === 'doc') return 'docx';
  if (['xlsx','xls','ods'].includes(e)) return 'excel';   // SheetJS also reads .ods
  if (['pptx','odp'].includes(e)) return 'slides';        // son zip: se saca el texto de cada diapositiva
  if (e === 'odt') return 'odt';                          // zip también: texto del documento
  if (['txt','md','csv','json','js','jsx','ts','tsx','html','css','xml','log','yml','yaml'].includes(e)) return 'text';
  return 'other';   // .ppt y .doc viejos (binarios OLE) caen aquí: no hay con qué abrirlos
}

// Los bytes de un archivo de la bóveda.
//
// Antes esto decodificaba base64 del propio `src` dando por hecho que era una
// data: URL. En la bóveda NO lo es: un archivo se guarda como
// "media/algo.xlsx" (o "file:///…/media/algo.xlsx"), así que el decodificador
// devolvía CERO bytes y las vistas de Word y Excel salían en blanco. `fetch`
// sirve para los tres casos: data:, /vault-media/… y http(s).
function fetchFileBytes(src) {
  return fetch(window.resolveMediaSrc(src)).then(r => {
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    return r.arrayBuffer();
  });
}

// Un pptx, un odt y un odp son carpetas comprimidas. La librería de Excel que
// ya viaja dentro (SheetJS) sabe descomprimir, así que se aprovecha en vez de
// añadir otra librería solo para esto.
function zipEntries(buf) {
  if (!window.XLSX || !window.XLSX.CFB) return null;
  const cfb = window.XLSX.CFB.read(new Uint8Array(buf), { type: 'array' });
  const out = {};
  (cfb.FullPaths || []).forEach((full, i) => {
    const f = cfb.FileIndex && cfb.FileIndex[i];
    if (!f || !f.content) return;
    out[String(full).replace(/^[^/]*\//, '')] = f.content;   // fuera el "Root Entry/"
  });
  return out;
}
function zipText(entries, name) {
  const c = entries && entries[name];
  if (!c) return '';
  try { return new TextDecoder('utf-8').decode(new Uint8Array(c)); } catch (e) { return ''; }
}
function escapaHtml(s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Deshace las entidades XML que salen del texto ya sin etiquetas.
function desescapaXml(s) {
  return String(s)
    .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"').replace(/&apos;/g, "'")
    .replace(/&#(\d+);/g, (m, d) => String.fromCharCode(Number(d)))
    .replace(/&amp;/g, '&');
}

// PowerPoint (.pptx): cada diapositiva es un XML con el texto en <a:t>.
function renderPptx(entries) {
  const nombres = Object.keys(entries)
    .filter(n => /^ppt\/slides\/slide\d+\.xml$/.test(n))
    .sort((a, b) => {
      const na = Number(a.match(/(\d+)/)[1]), nb = Number(b.match(/(\d+)/)[1]);
      return na - nb;
    });
  if (!nombres.length) return null;
  const bloques = nombres.map((n, i) => {
    const xml = zipText(entries, n);
    // Los <a:t> de un mismo <a:p> son trozos de un mismo párrafo (cambios de
    // formato dentro de la línea), así que se pegan por párrafo.
    const parrafos = [...xml.matchAll(/<a:p\b[^>]*>([\s\S]*?)<\/a:p>/g)]
      .map(m => [...m[1].matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map(t => desescapaXml(t[1])).join(''))
      .map(t => t.trim())
      .filter(Boolean);
    const cuerpo = parrafos.length
      ? parrafos.map((p, j) => j === 0
          ? `<h2>${escapaHtml(p)}</h2>`
          : `<p>${escapaHtml(p)}</p>`).join('')
      : '<p class="file-slide-vacia">(diapositiva sin texto)</p>';
    return `<section class="file-slide"><div class="file-slide-num">${i + 1}</div>${cuerpo}</section>`;
  });
  return `<div class="file-slides">${bloques.join('')}</div>`;
}

// OpenDocument (.odt texto, .odp presentación): el contenido está en
// content.xml, con los párrafos en <text:p> / <text:h>.
function renderOdf(entries, kind) {
  const xml = zipText(entries, 'content.xml');
  if (!xml) return null;
  const trozo = (frag) => [...frag.matchAll(/<text:(p|h)\b[^>]*>([\s\S]*?)<\/text:\1>/g)]
    .map(m => desescapaXml(m[2].replace(/<[^>]+>/g, '')).trim())
    .filter(Boolean);

  if (kind === 'slides') {
    // .odp: una <draw:page> por diapositiva.
    const paginas = [...xml.matchAll(/<draw:page\b[^>]*>([\s\S]*?)<\/draw:page>/g)];
    if (paginas.length) {
      const bloques = paginas.map((pg, i) => {
        const ps = trozo(pg[1]);
        const cuerpo = ps.length
          ? ps.map((p, j) => j === 0 ? `<h2>${escapaHtml(p)}</h2>` : `<p>${escapaHtml(p)}</p>`).join('')
          : '<p class="file-slide-vacia">(diapositiva sin texto)</p>';
        return `<section class="file-slide"><div class="file-slide-num">${i + 1}</div>${cuerpo}</section>`;
      });
      return `<div class="file-slides">${bloques.join('')}</div>`;
    }
  }
  const parrafos = trozo(xml);
  if (!parrafos.length) return null;
  return parrafos.map(p => `<p>${escapaHtml(p)}</p>`).join('');
}

// docx → html (mammoth) · xlsx/ods → tabla (SheetJS) · pptx/odp → texto de las
// diapositivas · odt → texto del documento. Resuelve null si no se puede.
function renderOfficeFile(kind, src, ext) {
  return fetchFileBytes(src).then(buf => {
    if (kind === 'docx') {
      if (!window.mammoth) return null;
      return window.mammoth.convertToHtml({ arrayBuffer: buf }).then(r => r.value || '<p></p>');
    }
    if (kind === 'excel') {
      if (!window.XLSX) return null;
      const wb = window.XLSX.read(new Uint8Array(buf), { type: 'array' });
      const nombre = wb.SheetNames[0];
      const ws = wb.Sheets[nombre];
      // Clip the range so a sheet with a huge declared area can't freeze the UI
      if (ws && ws['!ref']) {
        const r = window.XLSX.utils.decode_range(ws['!ref']);
        r.e.r = Math.min(r.e.r, r.s.r + 200);
        r.e.c = Math.min(r.e.c, r.s.c + 30);
        ws['!ref'] = window.XLSX.utils.encode_range(r);
      }
      const otras = wb.SheetNames.length > 1 ? ` · +${wb.SheetNames.length - 1}` : '';
      return `<div class="file-xlsx"><div class="file-hoja">${escapaHtml(nombre || '')}${otras}</div>${ws ? window.XLSX.utils.sheet_to_html(ws) : ''}</div>`;
    }
    if (kind === 'slides' || kind === 'odt') {
      const entries = zipEntries(buf);
      if (!entries) return null;
      const e = (ext || '').toLowerCase();
      if (e === 'pptx') return renderPptx(entries);
      return renderOdf(entries, kind);
    }
    return null;
  });
}

// Visual metadata (badge label + accent) for common file types.
function fileMeta(ext) {
  const e = (ext || '').toLowerCase();
  if (e === 'doc' || e === 'docx') return { label: 'W',   color: '#2B579A' };
  if (e === 'xls' || e === 'xlsx' || e === 'csv') return { label: 'X', color: '#217346' };
  if (e === 'ppt' || e === 'pptx') return { label: 'P',   color: '#D24726' };
  if (e === 'odt') return { label: 'ODT', color: '#2B579A' };
  if (e === 'ods') return { label: 'ODS', color: '#217346' };
  if (e === 'odp') return { label: 'ODP', color: '#D24726' };
  if (e === 'pdf') return { label: 'PDF', color: '#E6544F' };
  if (['zip','rar','7z','tar','gz'].includes(e)) return { label: 'ZIP', color: '#9A969A' };
  if (['png','jpg','jpeg','gif','webp','svg','bmp'].includes(e)) return { label: 'IMG', color: '#3CA59E' };
  if (['mp3','wav','ogg','m4a','flac'].includes(e)) return { label: '♪', color: '#955BA5' };
  if (['mp4','mov','avi','webm','mkv'].includes(e)) return { label: '▶', color: '#3D5A80' };
  return { label: (e ? e.slice(0, 4).toUpperCase() : 'FILE'), color: '#595459' };
}

// Un vídeo no cabe en la misma caja que un PDF. Al llegar el archivo se le da
// al nodo la forma de lo que lleva dentro: apaisado para el vídeo, de página
// para los documentos, una tira para el audio, y la caja pequeña de siempre
// para lo que no se puede enseñar.
const FORMA_POR_TIPO = {
  video: { w: 340, h: 230 },
  image: { w: 300, h: 220 },
  audio: { w: 320, h: 110 },
  pdf:   { w: 260, h: 340 },
  docx:  { w: 260, h: 340 },
  excel: { w: 320, h: 240 },
  text:  { w: 280, h: 300 },
};

function formaDeArchivo(ext) {
  return FORMA_POR_TIPO[fileKind(ext)] || null;
}
window.formaDeArchivo = formaDeArchivo;
window.fileKind = fileKind;

function FileItem({ item, lang, onUpdate, onOpenFile }) {
  const fileInputRef = React.useRef(null);
  const [uploading, setUploading] = React.useState(false);
  const [progress, setProgress] = React.useState(0);
  const [docHtml, setDocHtml] = React.useState(null); // rendered docx preview
  const [mediaError, setMediaError] = React.useState(false); // vídeo que Chromium no sabe reproducir
  const hasFile = !!item.src;
  const ext = item.fileType || (item.name ? item.name.split('.').pop() : '');
  const meta = fileMeta(ext);
  const showPreview = item.showPreview === true;
  const showInfo = item.showInfo === true;
  const showCaption = item.showCaption === true;

  const fmtSize = (b) => {
    if (!b) return '';
    if (b < 1024) return `${b} B`;
    if (b < 1024 * 1024) return `${Math.round(b / 1024)} KB`;
    return `${(b / (1024 * 1024)).toFixed(1).replace('.', ',')} MB`;
  };

  const onFileChange = (e) => {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    setUploading(true); setProgress(0);
    const reader = new FileReader();
    reader.onprogress = (ev) => { if (ev.lengthComputable) setProgress(Math.round((ev.loaded / ev.total) * 100)); };
    reader.onload = () => {
      const fext = (file.name.split('.').pop() || '').toLowerCase();
      const src = reader.result;
      const k = fileKind(fext);
      const rutaOrigen = window.rutaDeArchivo ? window.rutaDeArchivo(file) : null;
      const base = { src, name: file.name, size: file.size, fileType: fext, rutaOrigen };

      // Un nodo de archivo que recibe una imagen o un audio SE CONVIERTE en el
      // nodo de imagen o de audio. Ahí la cosa se ve y se escucha de verdad,
      // con el reproductor y el recorte que ya tienen esos nodos, en vez de
      // quedarse en una tarjeta con el nombre del fichero. El resto
      // (documentos, vídeo, comprimidos…) se queda como archivo.
      if (k === 'image') {
        const img = new Image();
        const conRatio = (ratio) => {
          const w = Math.max(item.w || 260, 200);
          onUpdate({ ...base, type: 'image', naturalRatio: ratio,
                     w, h: Math.max(60, Math.round(w / (ratio || 1))) });
          setUploading(false); setProgress(100);
        };
        img.onload = () => conRatio(img.naturalWidth / img.naturalHeight);
        img.onerror = () => conRatio(1);
        img.src = src;
        return;
      }
      if (k === 'audio') {
        onUpdate({ ...base, type: 'audio',
                   w: Math.max(item.w || 320, 300), h: 140,
                   loop: false, autoplay: false });
        setUploading(false); setProgress(100);
        return;
      }

      // Solo la primera vez: si ya se cambió el tamaño a mano, se respeta.
      const forma = formaDeArchivo(fext);
      onUpdate(Object.assign({
        ...base,
        // Nace enseñando lo que es. Un cuadrado con un logo en medio no dice
        // nada que el nombre del archivo no dijera ya.
        showPreview: item.showPreview !== false,
      }, (forma && !item.manualH) ? forma : {}));
      setUploading(false); setProgress(100);
    };
    reader.onerror = () => setUploading(false);
    reader.readAsDataURL(file);
    e.target.value = '';
  };

  // Auto-open the picker right after the node is created — SOLO en el montaje.
  // Antes dependía de `item._triggerFilePick` en el array de dependencias, así
  // que si esa bandera volvía a valer true por cualquier motivo ajeno a la
  // creación (una recarga del proyecto, deshacer, sincronización) el efecto
  // se disparaba otra vez y reabría el selector de Windows sin que el usuario
  // tocara nada. Con la referencia solo puede abrirse una vez por cada vez que
  // el componente se monta de verdad, y solo si de verdad no hay archivo.
  const autoOpenedRef = React.useRef(false);
  React.useEffect(() => {
    if (autoOpenedRef.current) return;
    autoOpenedRef.current = true;
    if (item._triggerFilePick) {
      if (!hasFile) fileInputRef.current?.click();
      onUpdate({ _triggerFilePick: false });
    }
    // eslint-disable-next-line
  }, []);

  // Cambiar el archivo es ahora una petición EXPLÍCITA desde la barra del nodo
  // ("Cambiar archivo"). Antes la única forma era doble clic sobre la tarjeta,
  // y ese gesto es el que uno hace para VER el archivo — además de ser el que
  // se cuela solo: al elegir un archivo con doble clic en la ventana de
  // Windows, el segundo clic aterriza en la tarjeta que acaba de aparecer
  // debajo del cursor y volvía a abrir el selector.
  React.useEffect(() => {
    if (!item._replaceFile) return;
    onUpdate({ _replaceFile: false });
    fileInputRef.current?.click();
    // eslint-disable-next-line
  }, [item._replaceFile]);

  const kind = fileKind(ext);
  const esOffice = kind === 'docx' || kind === 'excel' || kind === 'slides' || kind === 'odt';
  const REF = kind === 'excel' ? 1100 : 794; // excel is landscape/wide; docs are A4-portrait
  const scale = (item.w || 230) / REF;        // render at page width then scale to the node ("like an image")

  // Render docx (mammoth) / excel (SheetJS) / pptx-odp-odt (zip) → html when previewing
  React.useEffect(() => {
    if (!showPreview || !item.src) return;
    let cancelled = false;
    if (esOffice) {
      setDocHtml('loading');
      renderOfficeFile(kind, item.src, ext).then(h => { if (!cancelled) setDocHtml(h); }).catch(() => { if (!cancelled) setDocHtml(null); });
    }
    return () => { cancelled = true; };
  }, [showPreview, kind, item.src]);

  // Otro archivo, otra oportunidad: si se cambia el archivo del nodo, el aviso
  // de "no se puede reproducir" del anterior no debe quedarse pegado.
  React.useEffect(() => { setMediaError(false); }, [item.src]);

  // La vista previa no se monta hasta que se enseña por PRIMERA vez, y a
  // partir de ahí ya no se desmonta.
  //
  // Las dos mitades importan. Sin la segunda, un PDF se quedaba en blanco al
  // apagar y encender la vista. Y sin la PRIMERA pasaba algo peor: el
  // <iframe> existía desde el arranque, así que al abrir un proyecto se pedían
  // de golpe todos los archivos — y un PDF alojado en Google Drive lo sirve
  // como descarga, con lo que Electron sacaba su ventana de "Guardar como"
  // cada vez que se abría el proyecto, sin que nadie hubiera pedido nada.
  const [yaSeMostro, setYaSeMostro] = React.useState(showPreview);
  React.useEffect(() => { if (showPreview) setYaSeMostro(true); }, [showPreview]);

  // Qué dirección se le da al visor.
  //
  // Primero la copia local de la bóveda (srcLocal): en el .exe está y evita
  // ir a la nube para algo que ya está en el disco. Antes este nodo usaba
  // siempre `item.src` y se saltaba esa copia, que existe precisamente para
  // esto.
  // La copia local solo vale si es del MISMO tipo que el archivo.
  //
  // Las copias guardadas antes de arreglar la caché llevan extensión `.png`
  // aunque dentro haya un PDF: el servidor las sirve como imagen y el visor
  // no puede pintarlas. Cuando la extensión no cuadra con el tipo del nodo,
  // se tira de la dirección original en vez de enseñar una vista rota — y así
  // las notas que ya estaban guardadas se arreglan solas, sin tocar el disco.
  const extLocal = item.srcLocal ? String(item.srcLocal).split('.').pop().toLowerCase() : '';
  const copiaLocalSirve = !!item.srcLocal && (!ext || fileKind(extLocal) === fileKind(ext));
  const srcVisor = (window.displayMediaSrc && copiaLocalSirve)
    ? window.displayMediaSrc(item)
    : window.resolveMediaSrc(item.src);
  // Un PDF de Google Drive NO se puede meter en un <iframe>: Drive lo sirve
  // como descarga, así que Chromium no lo pinta y Electron acaba sacando su
  // ventana de "Guardar como". Se detecta y se enseña una tarjeta con un
  // botón, en vez de disparar una descarga que nadie pidió.
  const pdfRemoto = kind === 'pdf' && /^https?:\/\//i.test(String(srcVisor || ''));

  // Uploading state
  if (uploading) {
    return (
      <div className="file-card" style={{width:'100%', height:'100%'}}>
        <div className="item-card file-uploading">
          <div className="file-icon" style={{ '--file-accent': '#9A969A' }}>
            <span className="file-icon-label">{window.t('Subiendo', 'Uploading')}</span>
            <div className="file-progress"><div className="file-progress-bar" style={{ width: `${progress}%` }}/></div>
          </div>
        </div>
      </div>
    );
  }

  // Empty state (no file yet) — click to upload
  if (!hasFile) {
    return (
      <div className="file-card" style={{width:'100%', height:'100%'}}>
        <div className="item-card">
          <div className="file-empty" onClick={(e)=>{ e.stopPropagation(); fileInputRef.current?.click(); }}>
            <span className="material-symbols-rounded">upload_file</span>
            <span style={{fontSize:11, fontWeight:700, textAlign:'center'}}>{window.t('Clic para subir un archivo', 'Click to upload a file')}</span>
          </div>
          <input ref={fileInputRef} type="file" style={{display:'none'}} onChange={onFileChange}/>
        </div>
      </div>
    );
  }

  const cardBg = window.resolveStickyColor ? window.resolveStickyColor(item.color || 'white') : '#fff';
  return (
    <div className="file-card" style={{width:'100%', height:'100%'}}>
      <div className="item-card" style={{ display:'flex', flexDirection:'column', overflow:'hidden', backgroundColor: cardBg }}>
        {/* La vista previa se queda SIEMPRE montada (solo se le cambia el
            display) en vez de quitarla del DOM cuando showPreview es false.
            Un PDF dentro de un <iframe> es frágil: desmontarlo y volver a
            montarlo hacía que el visor de Chromium se quedara en blanco la
            segunda vez que se activaba la vista previa. Ocultándolo con CSS
            el <iframe> nunca se recarga. */}
        <div
          className="file-preview"
          style={{ display: showPreview ? undefined : 'none' }}
          onDoubleClick={(e)=>{ e.stopPropagation(); onOpenFile && onOpenFile(item.id); }}
          title={window.t('Doble clic para ver completo', 'Double-click to view full')}
        >
          {kind === 'image' && <img src={srcVisor} alt={item.name} onError={driveImageFallback}/>}
          {kind === 'pdf' && (
            pdfRemoto
              ? <div className="file-preview-empty">
                  <div className="file-icon" style={{ '--file-accent': meta.color }}><span className="file-icon-label">{meta.label}</span></div>
                  <span>{window.t('En la nube · doble clic para abrirlo', 'In the cloud · double-click to open')}</span>
                </div>
              : (yaSeMostro && <iframe title={item.name} src={srcVisor}/>)
          )}
          {/* Vídeo y audio se reproducen aquí mismo, en el lienzo.
              El reparto del ratón tiene truco: el reproductor necesita los
              clics para que funcione "play", pero si se los queda TODOS el
              nodo deja de poder arrastrarse y hay que rodearlo con un
              recuadro de selección para moverlo. Así que encima del vídeo va
              una capa transparente que cubre todo MENOS la tira de controles
              de abajo: por ahí se arrastra el nodo como por cualquier otro
              sitio, y los controles siguen respondiendo.
              Si Chromium no sabe con el formato (un .mkv, un .avi), onError
              lo dice en vez de dejar un reproductor roto. */}
          {kind === 'video' && (
            mediaError
              ? <div className="file-preview-empty">
                  <div className="file-icon" style={{ '--file-accent': meta.color }}><span className="file-icon-label">{meta.label}</span></div>
                  <span>{window.t('Este vídeo no se puede reproducir aquí', 'This video cannot play here')}</span>
                </div>
              : <div className="file-video-wrap">
                  <video
                    className="file-media"
                    src={srcVisor}
                    controls preload="metadata" playsInline
                    onMouseDown={(e)=>e.stopPropagation()}
                    onDoubleClick={(e)=>e.stopPropagation()}
                    onError={()=>setMediaError(true)}
                  />
                  <div
                    className="file-video-drag"
                    title={window.t('Arrastra aquí para mover · doble clic para verlo grande', 'Drag here to move · double-click for full view')}
                    onDoubleClick={(e)=>{ e.stopPropagation(); onOpenFile && onOpenFile(item.id); }}
                  />
                </div>
          )}
          {kind === 'audio' && (
            <div className="file-audio-wrap" onMouseDown={(e)=>e.stopPropagation()} onDoubleClick={(e)=>e.stopPropagation()}>
              <div className="file-icon" style={{ '--file-accent': meta.color }}><span className="file-icon-label">{meta.label}</span></div>
              <audio
                className="file-media"
                src={srcVisor}
                controls preload="metadata"
              />
            </div>
          )}
          {kind === 'text' && (
            <div className="file-page-scale" style={{ width: REF, transform: `scale(${scale})`, transformOrigin: 'top left' }}>
              <FilePreviewText src={srcVisor}/>
            </div>
          )}
          {esOffice && (
            docHtml && docHtml !== 'loading'
              ? <div className="file-page-scale" style={{ width: REF, transform: `scale(${scale})`, transformOrigin: 'top left' }}>
                  <div className="file-doc-html" dangerouslySetInnerHTML={{ __html: docHtml }}/>
                </div>
              : <div className="file-preview-empty"><span className="material-symbols-rounded">{docHtml==='loading'?'hourglass_top':'description'}</span><span>{docHtml==='loading'?(window.t('Generando vista…', 'Rendering…')):(window.t('Sin vista previa', 'No preview'))}</span></div>
          )}
          {kind === 'other' && (
            <div className="file-preview-empty">
              <div className="file-icon" style={{ '--file-accent': meta.color }}><span className="file-icon-label">{meta.label}</span></div>
              <span>{window.t('Sin vista previa', 'No preview')}</span>
            </div>
          )}
        </div>
        {showPreview && showInfo && (
          <div className="file-info-row">
            <span className="file-info-name" title={item.name}>{item.name}</span>
            <span className="file-info-size">{fmtSize(item.size)}</span>
          </div>
        )}
        {!showPreview && (
          <div
            className="file-compact"
            onDoubleClick={(e)=>{ e.stopPropagation(); onOpenFile && onOpenFile(item.id); }}
            title={window.t('Doble clic para ver completo', 'Double-click to view full')}
          >
            <div className="file-icon" style={{ '--file-accent': meta.color }}>
              <span className="file-icon-label">{meta.label}</span>
            </div>
            <div className="file-name" title={item.name}>{item.name}</div>
            <div className="file-size">{fmtSize(item.size)}</div>
          </div>
        )}
        {showCaption && (
          <div className="node-caption-row">
            <NodeCaption item={item} lang={lang} onUpdate={onUpdate} autoGrow/>
          </div>
        )}
        <input ref={fileInputRef} type="file" style={{display:'none'}} onChange={onFileChange}/>
      </div>
    </div>
  );
}

// El texto de un .txt/.md/.csv para la vista previa.
//
// Esto también daba por hecho que el src era una data: URL y decodificaba
// base64 a mano; con un archivo de la bóveda ("media/notas.txt") no sacaba
// nada y la vista salía vacía. Se pide con fetch, que vale para data:, para
// /vault-media/… y para http(s). Se corta a 20.000 caracteres: un log de
// varios megas no tiene por qué colgar el lienzo para una miniatura.
function FilePreviewText({ src }) {
  const [txt, setTxt] = React.useState('');
  React.useEffect(() => {
    let cancelado = false;
    fetch(src)
      .then(r => r.ok ? r.text() : Promise.reject(new Error(`HTTP ${r.status}`)))
      .then(t => { if (!cancelado) setTxt(t.length > 20000 ? t.slice(0, 20000) + '\n…' : t); })
      .catch(() => { if (!cancelado) setTxt(''); });
    return () => { cancelado = true; };
  }, [src]);
  return <pre className="file-text-preview">{txt}</pre>;
}

// ──────────────── FILE VIEWER (read-only, large, dark backdrop) ────────────────
function FileViewerModal({ fileItem, lang, onClose }) {
  const ext = (fileItem.fileType || (fileItem.name ? fileItem.name.split('.').pop() : '') || '').toLowerCase();
  const kind = fileKind(ext);
  const meta = fileMeta(ext);
  const [docHtml, setDocHtml] = React.useState(null);

  const esOffice = kind === 'docx' || kind === 'excel' || kind === 'slides' || kind === 'odt';

  React.useEffect(() => {
    if (!esOffice || !fileItem.src) return;
    let cancelled = false;
    renderOfficeFile(kind, fileItem.src, ext).then(h => { if (!cancelled) setDocHtml(h); }).catch(() => { if (!cancelled) setDocHtml(null); });
    return () => { cancelled = true; };
  }, [kind, fileItem.src]);

  React.useEffect(() => {
    const onKey = (ev) => { if (ev.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);

  return (
    <div className="doc-modal-backdrop file-viewer-backdrop" onMouseDown={onClose}>
      <div className="file-viewer" onMouseDown={(ev)=>ev.stopPropagation()}>
        <div className="file-viewer-head">
          <div className="file-icon file-icon-sm" style={{ '--file-accent': meta.color }}><span className="file-icon-label">{meta.label}</span></div>
          <span className="file-viewer-name" title={fileItem.name}>{fileItem.name}</span>
          {/* El enlace tiene que pasar por resolveMediaSrc: un archivo de la
              bóveda se guarda como "media/algo.docx", y así tal cual el
              navegador lo buscaba donde no está y la descarga daba 404. */}
          {kind !== 'pdf' && (
            <a className="btn btn-ghost" href={window.resolveMediaSrc(fileItem.src)} download={fileItem.name}>
              <span className="material-symbols-rounded">download</span>
              {window.t('Descargar', 'Download')}
            </a>
          )}
          <button className="btn btn-ghost" onClick={onClose}>
            <span className="material-symbols-rounded">close</span>
            {window.t('Cerrar', 'Close')}
          </button>
        </div>
        <div className={`file-viewer-body kind-${kind}`}>
          {kind === 'image' && <img src={window.resolveMediaSrc(fileItem.src)} alt={fileItem.name} onError={driveImageFallback}/>}
          {kind === 'video' && (
            <video className="file-media" src={window.resolveMediaSrc(fileItem.src)} controls autoPlay playsInline/>
          )}
          {kind === 'audio' && (
            <audio className="file-media" src={window.resolveMediaSrc(fileItem.src)} controls autoPlay/>
          )}
          {kind === 'pdf' && <iframe title={fileItem.name} src={window.resolveMediaSrc(fileItem.src)}/>}
          {kind === 'video' && <video src={window.resolveMediaSrc(fileItem.src)} controls autoPlay/>}
          {kind === 'audio' && (
            <div className="file-viewer-audio">
              <div className="file-icon" style={{ '--file-accent': meta.color }}><span className="file-icon-label">{meta.label}</span></div>
              <audio src={window.resolveMediaSrc(fileItem.src)} controls autoPlay/>
            </div>
          )}
          {kind === 'text' && <FilePreviewText src={window.resolveMediaSrc(fileItem.src)}/>}
          {esOffice && (
            docHtml
              ? <div className="file-doc-html file-doc-page" dangerouslySetInnerHTML={{ __html: docHtml }}/>
              : <div className="file-preview-empty"><span className="material-symbols-rounded">hourglass_top</span><span>{window.t('Generando vista…', 'Rendering…')}</span></div>
          )}
          {kind === 'other' && (
            <div className="file-preview-empty">
              <div className="file-icon" style={{ '--file-accent': meta.color }}><span className="file-icon-label">{meta.label}</span></div>
              <span>{window.t('Sin vista previa · usa Descargar', 'No preview · use Download')}</span>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function FrameItem({ item, lang, editing, onUpdate, callbacks }) {
  const text = pickLang(item.title, lang);
  // Un marco pinta su fondo con una clase de CSS (`c-yellow`, `c-olive`), asi
  // que un color libre no le llegaba: salia `c-#FFFFFF`, que no existe, y el
  // marco se quedaba igual hiciera lo que hiciera. Cuando es libre se pinta en
  // linea y la clase se queda en transparente para que no le pise el fondo.
  const colorLibre = window.esColorLibre && window.esColorLibre(item.color) ? item.color : null;
  const cls = colorLibre ? 'transparent' : (item.color === 'green' ? 'olive' : (item.color || 'transparent'));
  const align = item.titleAlign || 'left';
  const titleColor = item.titleColor || 'inherit';
  const titleSize = item.titleSize || 14;

  const handleTitleChange = (val) => {
    onUpdate({ title: { es: val, en: val } });
  };

  const increaseSize = (e) => {
    e.stopPropagation();
    const sizes = [12, 14, 18, 24, 30, 36, 48, 60];
    const idx = sizes.indexOf(titleSize);
    const next = idx < sizes.length - 1 ? sizes[idx + 1] : sizes[sizes.length - 1];
    onUpdate({ titleSize: next });
  };

  const decreaseSize = (e) => {
    e.stopPropagation();
    const sizes = [12, 14, 18, 24, 30, 36, 48, 60];
    const idx = sizes.indexOf(titleSize);
    const next = idx > 0 ? sizes[idx - 1] : sizes[0];
    onUpdate({ titleSize: next });
  };

  return (
    <div className={`frame-card c-${cls}`} style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', ...(colorLibre ? { background: conAlfa(colorLibre, 0.20) } : null) }}>
      <div 
        className="frame-header" 
        style={{ 
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'flex-start',
          padding: '6px 12px', 
          borderBottom: '1px dashed var(--line-soft, #E5E1DD)',
          // La cabecera, algo menos transparente que el cuerpo: es la parte que
          // se agarra para mover el marco y la que lleva el nombre, asi que
          // conviene que se lea sobre lo que haya debajo.
          ...(colorLibre ? { background: conAlfa(colorLibre, 0.42) } : null),
          pointerEvents: 'auto',
          cursor: 'move',
          gap: '8px'
        }}
        onDoubleClick={(e) => {
          e.stopPropagation();
          if (callbacks && callbacks.startEdit) {
            callbacks.startEdit(item.id);
          }
        }}
      >
        <div style={{ flex: 1, textAlign: align }}>
          {editing ? (
            <input
              type="text"
              value={text}
              onChange={(e) => handleTitleChange(e.target.value)}
              className="frame-title-input"
              style={{ 
                width: '100%', 
                background: 'none', 
                border: 'none', 
                outline: 'none', 
                fontWeight: 800,
                fontSize: `var(--vista-titulo, ${titleSize}px)`,
                fontFamily: 'var(--font-display)',
                textAlign: align,
                color: titleColor === 'inherit' ? 'var(--frame-title-color, var(--ink))' : titleColor
              }}
              onClick={(e)=>e.stopPropagation()}
              onMouseDown={(e)=>e.stopPropagation()}
              onBlur={(e) => {
                const v = e.target.value.trim();
                onUpdate({ title: { es: v, en: v } });
                if (callbacks && callbacks.endEdit) {
                  callbacks.endEdit();
                }
              }}
              onKeyDown={(e) => {
                if (e.key === 'Enter' || e.key === 'Escape') {
                  e.target.blur();
                }
              }}
              placeholder={window.t('Nombre del marco', 'Frame name')}
              autoFocus
            />
          ) : (
            <div 
              className="frame-title" 
              style={{ 
                fontWeight: 800, 
                fontSize: `var(--vista-titulo, ${titleSize}px)`, 
                fontFamily: 'var(--font-display)',
                color: titleColor === 'inherit' ? 'var(--frame-title-color, var(--ink))' : titleColor,
                textAlign: align
              }}
            >
              {text || window.t('Sin título', 'Untitled')}
            </div>
          )}
        </div>
      </div>
      <div className="frame-body" style={{ flex: 1, pointerEvents: 'none' }}>
        {/* Cuerpo vacío transparente para agrupamiento */}
      </div>
    </div>
  );
}

// ──────────────── BIG TITLE ( head heading ) ────────────────
// ══════════════════════════════════════════════════════════
// FIGURAS — con texto dentro
// ══════════════════════════════════════════════════════════
//
// Ocho formas: las cuatro de diagramar de siempre (círculo, rombo, píldora,
// hexágono), el rectángulo y el triángulo que faltaban, el paralelogramo —que
// en un diagrama es "entrada de datos"— y la estrella, que no diagrama nada
// pero marca lo importante, que es para lo que se usa de verdad.
//
// Dibujadas en SVG y no con `clip-path`: un recorte no deja borde, y en esta
// aplicación todo lleva su línea de 1,5 px. Además el trazo no se deforma al
// estirar el nodo, porque el viewBox va en píxeles reales del nodo.
//
// Ninguna forma es "la redonda" y "la ovalada" por separado: es la misma, y se
// vuelve redonda con el botón de igualar de la barra de la derecha. Dos
// entradas para la misma figura solo obligan a elegir dos veces.
// Las ocho de siempre, y detras las que se piden a diario en un diagrama:
// flechas para el sentido, cilindro para "datos", nube para "algo de fuera",
// trapecio y galon para los pasos, cruz para "suma" y documento para "papel".
const FIGURAS = [
  'circulo', 'rectangulo', 'rombo', 'pildora', 'hexagono', 'triangulo',
  'paralelogramo', 'estrella',
  'flechaDerecha', 'flechaIzquierda', 'flechaDoble',
  'pentagono', 'octogono', 'cruz', 'nube',
  'cilindro', 'trapecio', 'galon', 'documento', 'escudo',
];

// Cuánto hay que meter el texto por cada lado, en porcentaje, para que no se
// salga por donde la figura se estrecha. Un triángulo no tiene sitio arriba —
// ahí acaba en punta—, así que su texto empieza más abajo.
const HUECO_FIGURA = {
  circulo:       { i: 15, d: 15, a: 13, b: 13 },
  rectangulo:    { i: 8,  d: 8,  a: 8,  b: 8 },
  rombo:         { i: 25, d: 25, a: 22, b: 22 },
  pildora:       { i: 11, d: 11, a: 8,  b: 8 },
  hexagono:      { i: 17, d: 17, a: 9,  b: 9 },
  triangulo:     { i: 22, d: 22, a: 38, b: 8 },
  paralelogramo: { i: 18, d: 18, a: 10, b: 10 },
  estrella:      { i: 28, d: 28, a: 30, b: 24 },
  flechaDerecha:   { i: 10, d: 26, a: 14, b: 14 },
  flechaIzquierda: { i: 26, d: 10, a: 14, b: 14 },
  flechaDoble:     { i: 24, d: 24, a: 14, b: 14 },
  pentagono:       { i: 16, d: 16, a: 22, b: 10 },
  octogono:        { i: 15, d: 15, a: 15, b: 15 },
  cruz:            { i: 26, d: 26, a: 26, b: 26 },
  nube:            { i: 18, d: 18, a: 24, b: 20 },
  cilindro:        { i: 10, d: 10, a: 22, b: 18 },
  trapecio:        { i: 20, d: 20, a: 12, b: 8 },
  galon:           { i: 18, d: 22, a: 12, b: 12 },
  documento:       { i: 9,  d: 9,  a: 9,  b: 18 },
  escudo:          { i: 14, d: 14, a: 10, b: 22 },
};

// Negro o blanco, según lo oscuro que sea el relleno. Y negro de verdad, no
// `var(--ink)`: el relleno de una figura es el mismo en tema claro y en oscuro,
// así que el color del texto tiene que serlo también. Con la variable, una
// figura blanca en tema oscuro salía con el texto blanco encima.
function tintaLegible(hex) {
  const m = /^#([0-9a-fA-F]{6})$/.exec(String(hex || '').trim());
  if (!m) return '#1A1A1A';
  const n = parseInt(m[1], 16);
  const luz = 0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255);
  return luz < 140 ? '#FFFFFF' : '#1A1A1A';
}

// Los diez puntos de una estrella de cinco: cinco fuera y cinco dentro,
// alternados. Se calcula y no se escribe a mano para que siga siendo una
// estrella cuando el nodo deja de ser cuadrado.
function puntosEstrella(w, h, m) {
  const cx = w / 2, cy = h / 2;
  const rx = w / 2 - m, ry = h / 2 - m;
  const puntos = [];
  for (let i = 0; i < 10; i++) {
    const dentro = i % 2 === 1;
    const f = dentro ? 0.42 : 1;
    // Se empieza arriba: -90 grados.
    const ang = (Math.PI / 5) * i - Math.PI / 2;
    puntos.push((cx + Math.cos(ang) * rx * f).toFixed(1) + ',' + (cy + Math.sin(ang) * ry * f).toFixed(1));
  }
  return puntos.join(' ');
}

window.tintaLegible = tintaLegible;

function FiguraSVG({ figura, w, h, relleno, grosor = 1.5 }) {
  // El trazo se pinta centrado en el borde, así que sin este margen se vería la
  // mitad de la línea cortada por el canto del nodo.
  const m = grosor / 2 + 0.5;
  const pintura = { fill: relleno, stroke: 'var(--line)', strokeWidth: grosor, strokeLinejoin: 'round' };
  const an = Math.max(2 * m + 1, w);
  const al = Math.max(2 * m + 1, h);

  let dibujo;
  if (figura === 'rectangulo') {
    dibujo = <rect x={m} y={m} width={an - 2 * m} height={al - 2 * m} rx={6} style={pintura} />;
  } else if (figura === 'rombo') {
    dibujo = <polygon points={`${an / 2},${m} ${an - m},${al / 2} ${an / 2},${al - m} ${m},${al / 2}`} style={pintura} />;
  } else if (figura === 'pildora') {
    dibujo = <rect x={m} y={m} width={an - 2 * m} height={al - 2 * m} rx={Math.min(an, al) / 2} style={pintura} />;
  } else if (figura === 'hexagono') {
    const q = Math.min(an * 0.24, al * 0.5);
    dibujo = <polygon points={`${q},${m} ${an - q},${m} ${an - m},${al / 2} ${an - q},${al - m} ${q},${al - m} ${m},${al / 2}`} style={pintura} />;
  } else if (figura === 'triangulo') {
    dibujo = <polygon points={`${an / 2},${m} ${an - m},${al - m} ${m},${al - m}`} style={pintura} />;
  } else if (figura === 'paralelogramo') {
    const k = Math.min(an * 0.2, 46);
    dibujo = <polygon points={`${k},${m} ${an - m},${m} ${an - k},${al - m} ${m},${al - m}`} style={pintura} />;
  } else if (figura === 'estrella') {
    dibujo = <polygon points={puntosEstrella(an, al, m)} style={pintura} />;
  } else if (figura === 'flechaDerecha' || figura === 'flechaIzquierda') {
    // El cuerpo ocupa dos tercios y la punta el resto; la punta no crece mas
    // de la cuenta en un nodo muy ancho, o deja de parecer una flecha.
    const punta = Math.min(an * 0.34, al * 0.9);
    const grueso = al * 0.28;
    const pts = figura === 'flechaDerecha'
      ? `${m},${grueso} ${an - punta},${grueso} ${an - punta},${m} ${an - m},${al / 2} ${an - punta},${al - m} ${an - punta},${al - grueso} ${m},${al - grueso}`
      : `${an - m},${grueso} ${punta},${grueso} ${punta},${m} ${m},${al / 2} ${punta},${al - m} ${punta},${al - grueso} ${an - m},${al - grueso}`;
    dibujo = <polygon points={pts} style={pintura} />;
  } else if (figura === 'flechaDoble') {
    const punta = Math.min(an * 0.22, al * 0.9);
    const grueso = al * 0.28;
    dibujo = <polygon points={`${m},${al / 2} ${punta},${m} ${punta},${grueso} ${an - punta},${grueso} ${an - punta},${m} ${an - m},${al / 2} ${an - punta},${al - m} ${an - punta},${al - grueso} ${punta},${al - grueso} ${punta},${al - m}`} style={pintura} />;
  } else if (figura === 'pentagono') {
    const pts = [];
    for (let i = 0; i < 5; i++) {
      const ang = (Math.PI * 2 / 5) * i - Math.PI / 2;
      pts.push(((an / 2) + Math.cos(ang) * (an / 2 - m)).toFixed(1) + ',' + ((al / 2) + Math.sin(ang) * (al / 2 - m)).toFixed(1));
    }
    dibujo = <polygon points={pts.join(' ')} style={pintura} />;
  } else if (figura === 'octogono') {
    const kx = an * 0.29, ky = al * 0.29;
    dibujo = <polygon points={`${kx},${m} ${an - kx},${m} ${an - m},${ky} ${an - m},${al - ky} ${an - kx},${al - m} ${kx},${al - m} ${m},${al - ky} ${m},${ky}`} style={pintura} />;
  } else if (figura === 'cruz') {
    const bx = an * 0.32, by = al * 0.32;
    dibujo = <polygon points={`${bx},${m} ${an - bx},${m} ${an - bx},${by} ${an - m},${by} ${an - m},${al - by} ${an - bx},${al - by} ${an - bx},${al - m} ${bx},${al - m} ${bx},${al - by} ${m},${al - by} ${m},${by} ${bx},${by}`} style={pintura} />;
  } else if (figura === 'nube') {
    // Cuatro bultos y una base recta: se dibuja en una caja de 100x60 y se
    // estira con el viewBox, asi la nube se deforma con el nodo como el resto.
    dibujo = (
      <path
        d="M25,52 A17,17 0 0,1 25,20 A15,15 0 0,1 50,10 A18,18 0 0,1 78,22 A16,16 0 0,1 78,52 Z"
        style={pintura}
        transform={`scale(${(an - 2 * m) / 100}, ${(al - 2 * m) / 60}) translate(${m}, ${m})`}
      />
    );
  } else if (figura === 'cilindro') {
    const rx = (an - 2 * m) / 2, ry = Math.min(al * 0.14, 22);
    dibujo = (
      <g style={pintura}>
        <path d={`M${m},${m + ry} A${rx},${ry} 0 0,1 ${an - m},${m + ry} L${an - m},${al - m - ry} A${rx},${ry} 0 0,1 ${m},${al - m - ry} Z`} />
        <ellipse cx={an / 2} cy={m + ry} rx={rx} ry={ry} style={{ ...pintura, fill: 'none' }} />
      </g>
    );
  } else if (figura === 'trapecio') {
    const k = Math.min(an * 0.18, 48);
    dibujo = <polygon points={`${k},${m} ${an - k},${m} ${an - m},${al - m} ${m},${al - m}`} style={pintura} />;
  } else if (figura === 'galon') {
    const k = Math.min(an * 0.16, 40);
    dibujo = <polygon points={`${m},${m} ${an - k},${m} ${an - m},${al / 2} ${an - k},${al - m} ${m},${al - m} ${k},${al / 2}`} style={pintura} />;
  } else if (figura === 'documento') {
    // El papel con la onda de abajo: el simbolo de "documento" de toda la vida.
    const onda = al * 0.14;
    dibujo = (
      <path
        d={`M${m},${m} L${an - m},${m} L${an - m},${al - m - onda} Q${an * 0.75},${al - m} ${an / 2},${al - m - onda * 0.6} T${m},${al - m - onda} Z`}
        style={pintura}
      />
    );
  } else if (figura === 'escudo') {
    dibujo = (
      <path
        d={`M${m},${m} L${an - m},${m} L${an - m},${al * 0.58} Q${an - m},${al - m} ${an / 2},${al - m} Q${m},${al - m} ${m},${al * 0.58} Z`}
        style={pintura}
      />
    );
  } else {
    dibujo = <ellipse cx={an / 2} cy={al / 2} rx={an / 2 - m} ry={al / 2 - m} style={pintura} />;
  }

  return (
    <svg viewBox={`0 0 ${an} ${al}`} width="100%" height="100%" preserveAspectRatio="none" style={{ display: 'block' }}>
      {dibujo}
    </svg>
  );
}

function ShapeItem({ item, lang, editing, onUpdate }) {
  const figura = FIGURAS.indexOf(item.figura) !== -1 ? item.figura : 'circulo';
  const w = Math.max(1, item.w || 200);
  const h = Math.max(1, item.h || 160);
  const relleno = window.resolveStickyColor ? window.resolveStickyColor(item.color || 'white') : '#FFFFFF';
  const texto = pickLang(item.content, lang);
  const ref = React.useRef(null);
  usaCursorAlEditar(ref, editing);
  const hueco = HUECO_FIGURA[figura] || HUECO_FIGURA.circulo;

  // Mismo trato que el título grande: se escribe en innerHTML y no en innerText,
  // para que un enlace a otro nodo no se borre solo al guardar.
  React.useEffect(() => {
    if (!ref.current) return;
    const sano = window.repairEscapedMarkup ? window.repairEscapedMarkup(texto || '') : (texto || '');
    if (ref.current.innerHTML !== sano) ref.current.innerHTML = sano;
  }, [item.id, texto]);

  // ── La figura crece con lo que se escribe ──
  //
  // Sin esto, una lista de quince líneas dentro de un círculo se comía sus
  // primeras líneas sin avisar: el texto se recorta por arriba y por abajo y no
  // hay barra de desplazamiento que lo insinúe.
  //
  // Se mide con la caja suelta —sin `bottom`, alto automático— porque encajada
  // entre los dos bordes su `scrollHeight` no puede ser menor que el hueco, y
  // entonces la figura crecería pero no volvería a encoger al borrar texto.
  // Y hay que devolver lo medido a tamaño de figura: el texto solo ocupa la
  // franja de en medio, así que un círculo necesita crecer más que un
  // rectángulo para el mismo párrafo.
  React.useEffect(() => {
    if (!ref.current) return;
    if (document.body.classList.contains('odi-busy')) return;
    const el = ref.current;
    const antesBottom = el.style.bottom;
    const antesHeight = el.style.height;
    el.style.bottom = 'auto';
    el.style.height = 'auto';
    const necesario = el.scrollHeight;
    el.style.bottom = antesBottom;
    el.style.height = antesHeight;

    const franja = Math.max(0.2, 1 - (hueco.a + hueco.b) / 100);
    const total = Math.max(90, Math.round(necesario / franja) + 4);
    // Crece, pero no encoge. Una nota sí puede encogerse porque es un
    // rectángulo y da igual su proporción; una figura no: al ajustarse a una
    // línea de texto, un círculo de 200x160 se convertía en una raja de 200x90
    // en cuanto se escribía dentro. Aquí el alto de partida es un suelo, y
    // quien quiera menos lo arrastra o pulsa Igualar.
    if (total > (item.h || 0)) onUpdate({ h: total });
  // eslint-disable-next-line
  }, [texto, editing, item.h, item.w, item.figura, item.textScale, item.manualH, lang]);

  const onInput = () => {
    if (!ref.current) return;
    onUpdate({ content: { es: ref.current.innerHTML, en: ref.current.innerHTML } });
  };

  const color = item.textColor && item.textColor !== 'inherit' ? item.textColor : tintaLegible(relleno);
  const decoracion = [item.underline && 'underline', item.strike && 'line-through'].filter(Boolean).join(' ') || 'none';

  return (
    <div style={{ position: 'relative', width: '100%', height: '100%' }}>
      <FiguraSVG figura={figura} w={w} h={h} relleno={relleno} />
      <div
        ref={ref}
        className="figura-texto"
        contentEditable={editing}
        suppressContentEditableWarning
        onInput={onInput}
        onMouseDown={(e) => editing && e.stopPropagation()}
        onClick={(e) => editing && e.stopPropagation()}
        data-placeholder={window.t('Escribe aquí…', 'Write here…')}
        style={{
          position: 'absolute',
          left: hueco.i + '%', right: hueco.d + '%',
          top: hueco.a + '%', bottom: hueco.b + '%',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          flexDirection: 'column',
          textAlign: item.align || 'center',
          color: color,
          fontWeight: item.bold === false ? 500 : 700,
          fontStyle: item.italic ? 'italic' : 'normal',
          textDecoration: decoracion,
          fontSize: `calc(15px * var(--vista-escala, ${item.textScale || 1}))`,
          lineHeight: 1.25,
          overflow: 'hidden',
          outline: 'none',
          cursor: editing ? 'text' : 'move',
          wordBreak: 'break-word',
        }}
      />
    </div>
  );
}

// ──────────────── SEPARADOR ────────────────
// Una línea con título para partir un lienzo grande en zonas. Hasta ahora la
// única forma de separar dos partes de un tablero era dejar hueco entre ellas y
// esperar que se entendiera.
//
// La línea ocupa lo que sobre del ancho, así que el separador se estira como
// cualquier nodo; y para ponerlo en vertical está el giro de siempre, que ya
// gira también el título. Sin título queda la línea sola, de lado a lado.
const ESTILOS_SEPARADOR = ['continua', 'discontinua', 'punteada', 'doble'];
const TEXTOS_EJEMPLO_SEPARADOR = ['Nueva sección', 'New section'];

// El borde de la línea, en un solo sitio: lo usan el nodo y las muestras de su
// menú, y así el menú enseña exactamente lo que se va a pintar.
function bordeSeparador(estilo, grosor, color) {
  const g = Math.max(1, grosor || 2);
  if (estilo === 'doble') return Math.max(3, g * 2 + 1) + 'px double ' + color;
  const tipo = estilo === 'discontinua' ? 'dashed' : estilo === 'punteada' ? 'dotted' : 'solid';
  return g + 'px ' + tipo + ' ' + color;
}
window.bordeSeparador = bordeSeparador;
window.ESTILOS_SEPARADOR = ESTILOS_SEPARADOR;

function SeparatorItem({ item, lang, editing, onUpdate }) {
  const texto = pickLang(item.content, lang) || '';
  const ref = React.useRef(null);
  // Uno recién puesto dice "Nueva sección": lo que se teclea lo sustituye.
  usaCursorAlEditar(ref, editing, TEXTOS_EJEMPLO_SEPARADOR.includes(texto.trim()));

  React.useEffect(() => {
    if (!ref.current) return;
    const sano = window.repairEscapedMarkup ? window.repairEscapedMarkup(texto) : texto;
    if (ref.current.innerHTML !== sano) ref.current.innerHTML = sano;
  }, [item.id, texto]);

  const onInput = () => {
    if (!ref.current) return;
    onUpdate({ content: { es: ref.current.innerHTML, en: ref.current.innerHTML } });
  };

  const align = ['left', 'center', 'right'].includes(item.align) ? item.align : 'left';
  const estilo = ESTILOS_SEPARADOR.includes(item.estiloLinea) ? item.estiloLinea : 'continua';
  // Sin color elegido, la tinta secundaria del tema: se ve en claro y en oscuro.
  const color = item.color ? (window.resolveStickyColor ? window.resolveStickyColor(item.color) : item.color) : 'var(--ink-2)';
  const sinTitulo = !editing && !texto.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').trim();
  const linea = { borderTop: bordeSeparador(estilo, item.grosor, color) };

  return (
    <div className={`separador sep-${align} ${sinTitulo ? 'sin-titulo' : ''}`}>
      <span className="sep-linea sep-antes" style={linea}/>
      <div
        ref={ref}
        className="sep-titulo"
        contentEditable={editing}
        suppressContentEditableWarning
        onInput={onInput}
        onMouseDown={(e) => editing && e.stopPropagation()}
        onClick={(e) => editing && e.stopPropagation()}
        // Es una línea de título: Intro no parte en dos, que el separador no
        // tiene alto para una segunda línea.
        onKeyDown={(e) => { if (e.key === 'Enter') e.preventDefault(); }}
        data-placeholder={window.t('Título de la sección', 'Section title')}
        style={{
          color: item.color ? color : 'var(--ink)',
          fontSize: `calc(18px * var(--vista-escala, ${item.textScale || 1}))`,
          cursor: editing ? 'text' : 'move',
        }}
      />
      <span className="sep-linea sep-despues" style={linea}/>
    </div>
  );
}

// ── El título se ajusta a su caja, como una imagen ──
//
// Antes la letra del título estaba clavada a 32 px, y los dos botones de tamaño
// que había no la tocaban. Hacer un título grande era imposible: se agrandaba la
// caja y la letra se quedaba igual, perdida en medio.
//
// Ahora es al revés: la caja manda. Se estira la esquina y la letra crece hasta
// llenarla, igual que una imagen. La cuenta es sencilla: se mide el texto una
// vez a 100 px y se escala por lo que quepa, en ancho y en alto, quedándose con
// el que antes se acabe. Los saltos de línea que escribas se respetan, pero el
// título NO parte las líneas por su cuenta: si lo hiciera, estirar la caja a lo
// ancho metería más palabras por línea en vez de agrandar la letra, que es justo
// lo contrario de lo que se espera de algo que funciona como una imagen.
const ALTO_LINEA_TITULO = 1.12;
// El texto con el que nace un título, en los dos idiomas en que lo guarda.
const TEXTOS_EJEMPLO_TITULO = ['Título Grande', 'Large Title'];
const LETRA_TITULO_ANTIGUA = 32;
// Un pelo menos de lo que cabría exactamente: el navegador redondea los anchos
// y, sin este margen, a veces la última letra rozaba el borde de la caja.
const HOLGURA_TITULO = 0.97;

const medidasTitulo = new Map();
let medidorTitulo = null;

function mideTitulo(html, peso, cursiva, fuente) {
  const clave = peso + '|' + (cursiva ? 1 : 0) + '|' + (fuente || '') + '|' + html;
  const guardada = medidasTitulo.get(clave);
  if (guardada) return guardada;
  if (!medidorTitulo) {
    medidorTitulo = document.createElement('div');
    medidorTitulo.setAttribute('aria-hidden', 'true');
    medidorTitulo.style.cssText = [
      'position:absolute', 'left:-99999px', 'top:0', 'visibility:hidden', 'pointer-events:none',
      'display:inline-block', 'white-space:pre', 'font-size:100px', 'padding:0', 'margin:0',
      'font-family:var(--fuente-nodo, var(--font-display))', 'line-height:' + ALTO_LINEA_TITULO,
    ].join(';');
    document.body.appendChild(medidorTitulo);
  }
  const css = window.cssDeFuente ? window.cssDeFuente(fuente) : null;
  if (css) medidorTitulo.style.setProperty('--fuente-nodo', css);
  else medidorTitulo.style.removeProperty('--fuente-nodo');
  medidorTitulo.style.fontWeight = String(peso);
  medidorTitulo.style.fontStyle = cursiva ? 'italic' : 'normal';
  medidorTitulo.innerHTML = html;
  const medida = {
    w: Math.max(1, medidorTitulo.offsetWidth),
    h: Math.max(1, medidorTitulo.offsetHeight),
  };
  // Tope por si alguien escribe sin parar: con esto no crece para siempre.
  if (medidasTitulo.size > 300) medidasTitulo.clear();
  medidasTitulo.set(clave, medida);
  return medida;
}

// Lo que necesita un título para saber su letra: el relleno que deja la caja
// (el de la tarjeta, solo si tiene color, más los 4 px del propio texto) y lo
// que mide su texto a 100 px. Si está vacío se mide el aviso que se ve en su
// lugar ("Escribe un título…"), para que un título recién puesto no salga con
// una letra gigantesca por no tener nada que medir.
function geometriaTitulo(item, text) {
  const conColor = item.color && item.color !== 'transparent';
  const html = (text && text.replace(/<br\s*\/?>/gi, '').trim())
    ? text
    : window.t('Escribe un título…', 'Write a title…');
  return {
    rellenoX: (conColor ? 12 * 2 : 0) + 4 * 2,
    rellenoY: (conColor ? 8 * 2 : 0) + 4 * 2,
    html,
    medida: mideTitulo(html, item.bold === false ? 500 : 800, !!item.italic, item.fuente),
  };
}

// La letra con la que se ve un título con su caja de ahora.
function letraDeTitulo(item) {
  const g = geometriaTitulo(item, pickLang(item.content, 'es'));
  const tieneCaja = typeof item.w === 'number' && typeof item.h === 'number';
  const anchoLibre = Math.max(10, (tieneCaja ? item.w : 300) - g.rellenoX);
  const altoLibre = Math.max(10, (tieneCaja ? item.h : 80) - g.rellenoY);
  return Math.max(6, 100 * HOLGURA_TITULO * Math.min(anchoLibre / g.medida.w, altoLibre / g.medida.h));
}
window.letraDeTitulo = letraDeTitulo;

// La caja con la que un título sale a `letra` px: la usan los tamaños H1–H4 y
// el cambio de tipografía (que así no le cambia el tamaño al título).
window.cajaDeTitulo = function (item, letra) {
  const g = geometriaTitulo(item, pickLang(item.content, 'es'));
  const f = letra / (100 * HOLGURA_TITULO);
  return { w: Math.ceil(g.medida.w * f + g.rellenoX), h: Math.ceil(g.medida.h * f + g.rellenoY) };
};

// Las medidas tomadas antes de que llegue la tipografía del título salen con la
// de repuesto, que es más estrecha: se tiran en cuanto la de verdad está lista.
if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
  document.fonts.ready.then(() => medidasTitulo.clear());
}

function BigTitleItem({ item, lang, editing, onUpdate }) {
  const text = pickLang(item.content, lang);
  const ref = React.useRef(null);
  usaCursorAlEditar(ref, editing, TEXTOS_EJEMPLO_TITULO.includes((text || '').trim()));
  const align = item.align || 'center';
  const color = item.textColor || 'inherit';
  // Estilos de nodo completo (B/I/S/U) del título
  const weight = item.bold === false ? 500 : 800;
  const fontStyle = item.italic ? 'italic' : 'normal';
  const textDecoration = [item.underline && 'underline', item.strike && 'line-through'].filter(Boolean).join(' ') || 'none';

  // innerHTML y no innerText: leer con innerText borraba cualquier etiqueta al
  // guardar, así que un enlace insertado en un título desaparecía en el mismo
  // instante en que se escribía. El título sigue sin tener formato propio —su
  // negrita, color y alineación son propiedades del nodo—, pero ahora puede
  // contener enlaces a otros nodos como las notas.
  React.useEffect(() => {
    if (!ref.current) return;
    const sano = window.repairEscapedMarkup ? window.repairEscapedMarkup(text || '') : (text || '');
    if (ref.current.innerHTML !== sano) {
      ref.current.innerHTML = sano;
    }
    // Si estaba escapado, se deja arreglado también en los datos, para que no
    // vuelva a salir el marcado a la vista en el próximo arranque.
    if (sano !== (text || '')) {
      onUpdate({ content: { es: sano, en: sano } });
    }
  }, [item.id, text]);

  const onInput = () => {
    if (!ref.current) return;
    onUpdate({ content: { es: ref.current.innerHTML, en: ref.current.innerHTML } });
  };

  const hasColor = item.color && item.color !== 'transparent';
  const bg = hasColor && window.resolveStickyColor ? window.resolveStickyColor(item.color) : 'transparent';

  // Se vuelve a medir cuando llega la tipografía del título (ver geometriaTitulo).
  const [, setFuentesListas] = React.useState(0);
  React.useEffect(() => {
    if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => setFuentesListas(v => v + 1));
  }, []);
  const { rellenoX, rellenoY, html: htmlParaMedir, medida } = geometriaTitulo(item, text);
  const tieneCaja = typeof item.w === 'number' && typeof item.h === 'number';
  const anchoLibre = Math.max(10, (tieneCaja ? item.w : 300) - rellenoX);
  const altoLibre = Math.max(10, (tieneCaja ? item.h : 80) - rellenoY);
  const letra = Math.max(6, 100 * HOLGURA_TITULO * Math.min(anchoLibre / medida.w, altoLibre / medida.h));

  // ── Los títulos que ya existían no cambian de aspecto ──
  //
  // Hasta ahora todos se pintaban a 32 px, con la caja que tuvieran alrededor
  // (normalmente más grande que el texto). Si se ajustaran sin más a esa caja,
  // al estrenar esta versión cambiarían de tamaño todos los títulos de todos los
  // tableros. Así que la primera vez que se pinta uno viejo, su caja se ciñe a
  // lo que ocupaba su texto a 32 px: la cuenta de arriba da entonces 32 px y se
  // ve exactamente igual que antes. La marca evita repetirlo.
  React.useEffect(() => {
    if (item.ajustaCaja || editing || !tieneCaja) return;
    const real = mideTitulo(htmlParaMedir, weight, !!item.italic, item.fuente);
    const factor = LETRA_TITULO_ANTIGUA / (100 * HOLGURA_TITULO);
    const w = Math.max(item.w, Math.ceil(real.w * factor + rellenoX));
    const h = Math.ceil(real.h * factor + rellenoY);
    onUpdate({ w, h, ajustaCaja: true });
  }, [item.id, item.ajustaCaja]);

  return (
    <div 
      className={`bigtitle-card ${hasColor ? 'has-bg' : ''}`}
      style={{
        ...window.varsDeFuente(item.fuente),
        width: '100%', 
        height: '100%', 
        display: 'flex', 
        alignItems: 'center', 
        justifyContent: align === 'center' ? 'center' : (align === 'right' ? 'flex-end' : 'flex-start'),
        background: bg,
        border: hasColor ? '1.5px solid var(--line)' : 'none',
        borderRadius: hasColor ? '8px' : '0px',
        boxShadow: hasColor ? 'var(--pop-sm)' : 'none',
        boxSizing: 'border-box',
        padding: hasColor ? '8px 12px' : '0px'
      }}
    >
      <div
        ref={ref}
        contentEditable={editing}
        suppressContentEditableWarning
        className="bigtitle-edit"
        onInput={onInput}
        onClick={(e)=>editing && e.stopPropagation()}
        onMouseDown={(e)=>editing && e.stopPropagation()}
        style={{
          fontSize: letra + 'px',
          lineHeight: ALTO_LINEA_TITULO,
          // Sin partir líneas: las pone quien escribe, no el ancho de la caja.
          whiteSpace: 'pre',
          fontWeight: weight,
          fontStyle: fontStyle,
          textDecoration: textDecoration,
          textAlign: align,
          color: color === 'inherit' ? 'var(--ink)' : color,
          background: 'none',
          border: 'none',
          outline: 'none',
          width: '100%',
          cursor: editing ? 'text' : 'move',
          padding: '4px'
        }}
        data-placeholder={window.t('Escribe un título…', 'Write a title…')}
      />
    </div>
  );
}

// ──────────────── GOOGLE MAPS PREVIEW ────────────────
const getMapsEmbedUrl = (url) => {
  if (!url) return '';
  let query = url.trim();

  // Si ya es un enlace de embed nativo de Google, usarlo tal cual
  if (query.includes('output=embed') || query.includes('/embed')) {
    return query;
  }

  // Si es un enlace normal de Google Maps del navegador
  if (query.includes('google.com/maps') || query.includes('google.es/maps') || query.includes('google.co/maps') || query.includes('google.com.co/maps')) {
    // 1. Intentar buscar /place/NOMBRE_LUGAR
    const placeMatch = query.match(/\/place\/([^\/]+)/);
    if (placeMatch && placeMatch[1]) {
      try {
        query = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
      } catch {
        query = placeMatch[1].replace(/\+/g, ' ');
      }
    } else {
      // 2. Intentar buscar coordenadas de tipo /@LAT,LON
      const coordMatch = query.match(/\/@(-?\d+\.\d+),(-?\d+\.\d+)/);
      if (coordMatch && coordMatch[1] && coordMatch[2]) {
        query = `${coordMatch[1]},${coordMatch[2]}`;
      }
    }
  }

  return `https://maps.google.com/maps?q=${encodeURIComponent(query)}&t=&z=14&ie=UTF8&iwloc=&output=embed`;
};

function MapItem({ item, lang, onUpdate, editing, onEndEdit, callbacks }) {
  const title = pickLang(item.title, lang);
  const caption = pickLang(item.caption, lang);
  const hasUrl = !!item.url;
  const embedUrl = getMapsEmbedUrl(item.url);
  const align = item.titleAlign || 'left';
  const titleColor = item.titleColor || 'inherit';

  if (!hasUrl || editing) {
    return (
      <div className="map-card empty" style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', boxSizing: 'border-box' }}>
        <div className="item-card" style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: '12px', justifyContent: 'center', boxSizing: 'border-box' }}>
          <div style={{ textAlign: 'center', marginBottom: '8px', color: 'var(--text-soft, #595459)' }}>
            <span className="material-symbols-rounded" style={{ fontSize: '24px' }}>map</span>
            <div style={{ fontSize: '11px', fontWeight: '800', marginTop: '2px' }}>
              {window.t('Configurar Mapa de Google', 'Configure Google Map')}
            </div>
          </div>
          <div style={{ display: 'flex', alignItems: 'center', gap: '6px', background: 'var(--paper-2)', padding: '6px 8px', borderRadius: '4px', border: '1.5px solid var(--line)' }}>
            <span className="material-symbols-rounded" style={{ fontSize: 16, color: 'var(--ink-3)' }}>pin_drop</span>
            <input
              className="link-input"
              autoFocus
              style={{ flex: 1, background: 'none', border: 'none', outline: 'none', fontSize: '12.5px', color: 'var(--ink)', width: '100%' }}
              placeholder={window.t('Pega dirección o enlace de Maps…', 'Paste address or Maps link…')}
              value={item.url || ''}
              onClick={(e)=>e.stopPropagation()}
              onMouseDown={(e)=>e.stopPropagation()}
              onChange={(e)=>onUpdate({ url: e.target.value })}
              onBlur={(e)=>{
                const v = e.target.value.trim();
                const patch = { url: v, _editing: false };
                if (v) {
                  patch.w = 340;
                  patch.h = 280;
                }
                onUpdate(patch);
                onEndEdit && onEndEdit();
              }}
              onKeyDown={(e)=>{
                if (e.key === 'Enter' || e.key === 'Escape') e.target.blur();
              }}
            />
          </div>
          <div style={{ fontSize: '9px', color: 'var(--text-soft, #595459)', textAlign: 'center', marginTop: '6px' }}>
            {window.t('Enter para confirmar · Esc para salir', 'Enter to confirm · Esc to exit')}
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="map-card" style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', boxSizing: 'border-box' }}>
      <div className="item-card" style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: '12px', boxSizing: 'border-box' }}>
        
        <div className="map-iframe-container" style={{ flex: 1, background: 'var(--bg-2, #ECEAE6)', borderRadius: '4px', overflow: 'hidden', border: '1.5px solid var(--line-soft, #E5E1DD)', minHeight: 0, position: 'relative' }}>
          {embedUrl ? (
            <>
              <iframe
                title="Google Maps Preview"
                src={embedUrl}
                width="100%"
                height="100%"
                style={{ border: 0, display: 'block' }}
                allowFullScreen=""
                loading="lazy"
                onMouseDown={(e)=>e.stopPropagation()}
              />
            </>
          ) : (
            <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '20px', textAlign: 'center', color: 'var(--text-soft, #595459)', boxSizing: 'border-box' }}>
              <span className="material-symbols-rounded" style={{ fontSize: '32px', marginBottom: '8px' }}>pin_drop</span>
              <div style={{ fontSize: '11.5px', fontWeight: '700' }}>
                {window.t('Sin dirección configurada', 'No address configured')}
              </div>
              <div style={{ fontSize: '10px', marginTop: '4px' }}>
                {window.t('Pega un enlace o dirección en la configuración lateral', 'Paste a link or address in the sidebar settings')}
              </div>
            </div>
          )}
        </div>

        <div className="map-info-row" style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '10px', color: 'var(--text-soft, #595459)', fontSize: '11px', fontFamily: 'var(--font-body)' }}>
          <span className="material-symbols-rounded" style={{ fontSize: '15px', color: '#EA4335' }}>pin_drop</span>
          <span style={{
            whiteSpace: 'nowrap',
            overflow: 'hidden',
            textOverflow: 'ellipsis',
            flex: 1
          }}>
            {item.url}
          </span>
        </div>

        {item.showTitle !== false && (
          item._editingTitle === true ? (
            <input
              type="text"
              className="map-title-input"
              value={title}
              placeholder={window.t('Google Maps', 'Google Maps')}
              onChange={(e) => onUpdate({ title: { es: e.target.value, en: e.target.value } })}
              onClick={(e)=>e.stopPropagation()}
              onMouseDown={(e)=>e.stopPropagation()}
              onBlur={(e) => {
                const v = e.target.value.trim();
                onUpdate({ title: { es: v, en: v }, _editingTitle: false });
              }}
              onKeyDown={(e)=>{
                if (e.key === 'Enter' || e.key === 'Escape') {
                  e.target.blur();
                }
              }}
              style={{
                fontWeight: 800,
                fontSize: '14px',
                fontFamily: 'var(--font-display)',
                marginTop: '8px',
                color: titleColor === 'inherit' ? 'var(--ink)' : titleColor,
                textAlign: align,
                width: '100%',
                background: 'none',
                border: 'none',
                outline: 'none',
                padding: 0
              }}
              autoFocus
            />
          ) : (
            <div 
              className="map-title" 
              style={{ 
                fontWeight: 800, 
                fontSize: '14px', 
                fontFamily: 'var(--font-display)', 
                marginTop: '8px', 
                color: titleColor === 'inherit' ? 'var(--ink)' : titleColor,
                textAlign: align,
                cursor: 'pointer',
                userSelect: 'none'
              }}
              onDoubleClick={(e) => {
                e.stopPropagation();
                onUpdate({ _editingTitle: true });
              }}
            >
              {title || window.t('Google Maps', 'Google Maps')}
            </div>
          )
        )}

        {item.showCaption && (
          <div className="map-caption-wrap" style={{ marginTop: '8px', borderTop: '1px dashed var(--line-soft, #E5E1DD)', paddingTop: '6px' }}>
            <NodeCaption item={item} lang={lang} onUpdate={onUpdate} className="map-caption-input" autoGrow/>
          </div>
        )}
      </div>
    </div>
  );
}

// ──────────────── TITLE (heading) ────────────────
function TitleItem({ item, lang }) {
  return (
    <div className="title-card" style={{ width: '100%', height: '100%' }}>
      <span className="title-text">{pickLang(item.content, lang)}</span>
    </div>
  );
}

// ──────────────── SWATCH (legacy palette) ────────────────
function SwatchItem({ item, lang }) {
  const colors = item.colors || [];
  return (
    <div className="swatch-card" style={{ width: '100%', height: '100%' }}>
      <div className="item-card">
        <div className="swatch-title">{pickLang(item.title, lang)}</div>
        <div className="swatch-row">
          {colors.map((c, i) => (
            <div key={i} className="swatch-chip" style={{ background: c.hex }} title={`${c.name || ''} ${c.hex}`}>
              <span className="swatch-hex">{c.hex}</span>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

// ──────────────── Dispatcher ────────────────
// ──────────────── DIBUJO ────────────────
//
// Los trazos se guardan como vectores en un espacio propio (vw × vh) y el nodo
// solo los estira a su caja. Por eso se puede mover, escalar y recolorear sin
// que pierdan un pelo de nitidez — y por eso ocupan una fracción de lo que
// ocuparía la misma raya guardada como imagen.
function DrawItem({ item }) {
  const D = window.OdiDraw;
  const strokes = item.strokes || [];
  const vw = item.vw || item.w || 300;
  const vh = item.vh || item.h || 200;

  if (!strokes.length) {
    return (
      <div className="draw-card draw-empty">
        <span className="material-symbols-rounded">gesture</span>
        <span>{window.t('Doble clic para dibujar', 'Double-click to draw')}</span>
      </div>
    );
  }

  return (
    <div className="draw-card" style={{ width: '100%', height: '100%' }}>
      <svg
        width="100%"
        height="100%"
        viewBox={`0 0 ${vw} ${vh}`}
        preserveAspectRatio="none"
        style={{ display: 'block', overflow: 'visible' }}
      >
        {strokes.map(s => {
          const g = D.strokeGeometry(s);
          if (!g) return null;
          // Cada trazo lleva detrás otro igual pero invisible y más gordo: es por
          // donde se agarra el dibujo (el nodo sin elegir solo responde en sus
          // trazos), y uno de 1 px no habría forma de atinarle.
          const agarre = <path key={s.id + '-agarre'} d={g.d} fill={g.mode === 'fill' ? 'transparent' : 'none'} stroke="transparent"
                    strokeWidth={Math.max(12, (g.width || 0) + 10)} strokeLinecap="round" strokeLinejoin="round"
                    style={{ pointerEvents: g.mode === 'fill' ? 'visible' : 'stroke', cursor: 'move' }}/>;
          return g.mode === 'fill'
            ? [agarre, <path key={s.id} d={g.d} fill={s.color} stroke="none" fillRule="nonzero"/>]
            : [agarre, <path key={s.id} d={g.d} fill="none" stroke={s.color} strokeWidth={g.width}
                    strokeLinecap="round" strokeLinejoin="round"/>];
        })}
      </svg>
    </div>
  );
}

// ── ¿De dónde salió este archivo? ──
//
// Cuando Oddinote guarda una imagen o un PDF se queda con el contenido metido
// dentro de la nota y se olvida de dónde vino, así que después no hay ninguna
// carpeta que abrir. Aquí se apunta la ruta, y solo eso: la ruta, no el
// archivo, que sigue viajando incrustado como siempre.
//
// En el escritorio, Electron pone `path` en los archivos que se eligen o se
// sueltan. En el navegador NO existe —y no puede existir, porque una página web
// no tiene por qué saber cómo se llaman tus carpetas—, así que ahí esto
// devuelve null y el botón de abrir la carpeta sencillamente no aparece.
//
// Solo sirve para los archivos que se añadan a partir de ahora: a los que ya
// están dentro de una nota no hay de dónde sacarles la ruta.
window.rutaDeArchivo = function (archivo) {
  try {
    const ruta = archivo && typeof archivo.path === 'string' && archivo.path ? archivo.path : null;
    // Queda escrito en el registro (%APPDATA%Odinoteodinote-debug.log) qué
    // llegó con cada archivo. Sin esto, cuando el botón de abrir la carpeta no
    // aparece no hay forma de saber si es que el archivo entró antes de que
    // esto existiera, o si el sistema no dio la ruta esta vez.
    console.log('[Oddinote] archivo añadido: ' + ((archivo && archivo.name) || '?') +
      ' · ruta: ' + (ruta || 'no llegó ninguna'));
    return ruta;
  } catch (e) {
    return null;
  }
};

// «lienzos» (y «tema») no los usa ItemRenderer: van para que React.memo vuelva
// a pintar el nodo cuando cambian. El progreso recibe todos los lienzos
// (cuenta casillas de todo el proyecto) y el tablero el suyo (enseña lo que
// tiene dentro); los demás, nada, y no se repintan cuando cambia otro nodo.
function ItemRenderer({ item, lang, editing, callbacks }) {
  const cb = callbacks || {};
  const onUpdate = (patch) => {
    if (cb.updateItem) {
      cb.updateItem(item.id, patch);
    } else {
      console.warn('ItemRenderer: falta callbacks.updateItem, el cambio no se guarda');
    }
  };
  switch (item.type) {
    case 'note':     return <NoteItem  item={item} lang={lang} editing={editing} onUpdate={onUpdate}/>;
    case 'image':    return <ImageItem item={item} lang={lang} onUpdate={onUpdate} callbacks={cb}/>;
    case 'link':     return <LinkItem  item={item} lang={lang} editing={editing} onUpdate={onUpdate} onEndEdit={cb.endEdit}/>;
    case 'todo':     return <TodoItem  item={item} lang={lang} editing={editing} onUpdate={onUpdate} callbacks={cb}/>;
    case 'column':   return <ColumnItem item={item} lang={lang} editing={editing} onUpdate={onUpdate} callbacks={cb}/>;
    case 'board':    return <BoardItem item={item} lang={lang} editing={editing} onUpdate={onUpdate} onOpenBoard={cb.openBoard} getNestedItems={cb.getNestedItems} onStartEdit={cb.startEdit}/>;
    case 'doc':      return <DocItem   item={item} lang={lang} onOpenDoc={cb.openDoc}/>;
    case 'calendar': return window.CalendarItem ? <window.CalendarItem item={item} lang={lang} editing={editing} onUpdate={onUpdate}/> : null;
    case 'table':    return <TableItem item={item} lang={lang} editing={editing} onUpdate={onUpdate} selected={cb.isSelectedItem ? cb.isSelectedItem(item.id) : false} onSelectItem={cb.selectItem} onResize={cb.resizeItemSilent}/>;
    case 'comment':  return <CommentItem item={item} lang={lang} editing={editing} onUpdate={onUpdate}/>;
    case 'audio':    return <AudioItem item={item} lang={lang} onUpdate={onUpdate}/>;
    case 'color':    return <ColorItem item={item} lang={lang} onUpdate={onUpdate}/>;
    case 'code':     return <CodeItem  item={item} lang={lang} editing={editing} onUpdate={onUpdate} callbacks={cb}/>;
    case 'timer':    return <TimerItem item={item} lang={lang} onUpdate={onUpdate}/>;
    case 'file':     return <FileItem  item={item} lang={lang} onUpdate={onUpdate} onOpenFile={cb.openFile}/>;
    case 'title':    return <TitleItem item={item} lang={lang}/>;
    case 'swatch':   return <SwatchItem item={item} lang={lang}/>;
    case 'frame':    return <FrameItem item={item} lang={lang} editing={editing} onUpdate={onUpdate} callbacks={cb}/>;
    case 'shape':    return <ShapeItem item={item} lang={lang} editing={editing} onUpdate={onUpdate}/>;
    case 'separator': return <SeparatorItem item={item} lang={lang} editing={editing} onUpdate={onUpdate}/>;
    case 'carpeta':  return window.CarpetaItem ? <window.CarpetaItem item={item} lang={lang} onUpdate={onUpdate}/> : null;
    case 'ruleta':   return window.RuletaItem ? <window.RuletaItem item={item} lang={lang} onUpdate={onUpdate}/> : null;
    case 'galeria':  return window.GaleriaItem ? <window.GaleriaItem item={item} lang={lang} onUpdate={onUpdate}/> : null;
    case 'progreso': return window.ProgresoItem ? <window.ProgresoItem item={item} lang={lang} callbacks={cb}/> : null;
    case 'bigtitle': return <BigTitleItem item={item} lang={lang} editing={editing} onUpdate={onUpdate}/>;
    case 'map':      return <MapItem item={item} lang={lang} editing={editing} onUpdate={onUpdate} onEndEdit={cb.endEdit} callbacks={cb}/>;
    case 'draw':     return <DrawItem item={item}/>;
    default:         return <NoteItem  item={item} lang={lang}/>;
  }
}
// Memorizado: al panear o hacer zoom el lienzo se vuelve a pintar entero, y
// con él TODOS los nodos, aunque ninguno haya cambiado (mismo item, mismo
// idioma, mismos callbacks). Con 500 nodos el paneo iba a 6 fps (lo midió el
// bot del lienzo). Lo que un nodo necesita saber del zoom no puede venir de
// pintarse otra vez: la galería lo escucha con el evento 'odi-zoom'.
const ItemRendererMemo = React.memo(ItemRenderer);


Object.assign(window, {
  ItemRenderer: ItemRendererMemo, COLOR_HEX_RESOLVED, STICKY_COLORS_NEW, BOARD_ICONS,
  // La barra de la derecha pinta las mismas figuras para elegirlas, así que el
  // dibujo vive en un solo sitio.
  FIGURAS, FiguraSVG,
  CATEGORY_COLORS, CATEGORY_HEX,
  colorClass, pickLang, parseLink,
  FileViewerModal,
});
