"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, Check, CloudOff, Coffee, ExternalLink, FileUp, FolderOpen, Image as IconeDeImagem, Layers, LayoutTemplate, Loader2, Maximize, Minus, Palette, Plus, Redo2, Ruler, Scaling, Shapes, Type, Undo2, X } from "lucide-react";
import { getSupabaseClient } from "@/lib/supabase";
import { encolherParaUpload } from "@/lib/fotos/encolher";
import {
  FUNDO_BASE,
  LIMITE_DE_PAGINAS,
  caixaDoTraco,
  cssDasFontesDaMarca,
  fontesDaMarca,
  fontesDosTrechos,
  registrarFontesDaMarca,
  CHAVES_DAS_FONTES_DE_BASE,
  fontesDoDocumento,
  garantirFontes,
  camadasDoFundo,
  corValida,
  documentoDasPaginas,
  documentoLimpo,
  luminancia,
  misturar,
  novoId,
  paginasDoDocumento,
  solida,
  type DocumentoDoBanner,
  type Peca,
  type PecaDeImagem,
  type PecaDeTexto,
  type PecaDeTraco,
  type PontoDoTraco,
} from "@/lib/banner-editor/documento";
import { redimensionarComInteligencia } from "@/lib/banner-editor/redimensionar";
import { MODELOS_DO_EDITOR, type PedidoDoModelo } from "@/lib/banner-editor/modelos";
import { forma as formaNova, imagem, texto as textoNovo } from "@/lib/banner-editor/pecas-novas";
import { registrarFontesComGlifos } from "@/lib/banner-editor/fontes-com-glifos";
import { aoChegarVetor, garantirVetores, vetoresATrazer } from "@/lib/banner-editor/vetores";
import { ehCaneca, formatoDaChave, formatoDoTamanho, medidaDeImpressao, type FormatoDaArte } from "@/lib/banner-editor/formatos";
import { GaleriaIndisponivel, abrirArte, criarArte, imagensEnviadas, listarArtes, salvarArte, type ResumoDaArte, type VinculoComOrcamento } from "@/lib/banner-editor/artes";
import { dadosDoVinculo, enviarParaOOrcamento, type DadosDoVinculo } from "@/lib/banner-editor/arte-no-orcamento";
import type { ProdutoDoEditor } from "../campos";
import { useEditorDoPainel } from "../contexto";
import { BarraDaPeca, BarraDoGrupo, type Alinhamento } from "./barra-da-peca";
import { BotaoDeBaixar } from "./baixar";
import { ContextoDasCores } from "./campos";
import { PainelDaBiblioteca, PainelDoAjuste, TITULOS_DOS_AJUSTES, type AbaDaBiblioteca, type AjusteDaPeca } from "./paineis";
import { Palco, ZOOM_MAXIMO, ZOOM_MINIMO, caixaDoGrupo, tracoJusto, type FerramentaDoPalco } from "./palco";
import type { TamanhoEscolhido } from "./tamanhos";
import { GradeDePaginas, TiraDePaginas, usePaginasDaTira, type AcaoNaPagina } from "./paginas";
import { MenuDaPeca, type AcaoDoMenu } from "./menu-da-peca";
import { PainelDeCamadas, type AlvoDaCamada } from "./camadas";
import { juntarGrupo, ordenarEscolhidas, soltarNaCamada } from "@/lib/banner-editor/camadas";
import { camposDoTexto, encolherTexto, fatorQueCabe } from "./texto-que-cabe";
import { ehPecaDoPainel, pegarArrasto } from "./arrastar-do-painel";
import { estiloPara } from "@/lib/banner-editor/estilo";
import { KIT_VAZIO, KitIndisponivel, lerKit, salvarKit, type KitDaMarca } from "@/lib/banner-editor/kit";
import { TEXTOS_PRONTOS } from "@/lib/banner-editor/pecas-novas";
import { PainelDaMarca } from "./painel-da-marca";
import { girarPaleta, trocarPaleta } from "@/lib/banner-editor/trocar-paleta";
import { BotaoDeAprovacao } from "./aprovacao";
import { useTelaCompacta } from "./tela-compacta";
import { CopiasEmOutrosTamanhos, EscolhaDeTamanho } from "./tamanhos";
import { encaixarForaDoPalco } from "./encaixe-fora";
import { BotaoDeVersoes } from "./versoes";
import { animarPagina, audiosDoProjeto, comAudios, criarRelogio, dividirAudio, dividirPagina, dividirUnidade, duracaoDaPagina, intervaloDoClipe, paginaNoTempo, projetoAnimado, semAnimacao, temposDoProjeto, type EstiloDaPagina } from "@/lib/banner-editor/cena";
import type { AudioDoProjeto } from "@/lib/banner-editor/documento";
import { FolhaDeAudios, type AbaDosSons, type FonteDaFala, type PedidoDeNarracao, type SomDoCatalogo } from "./folha-de-audios";
import { VOZES, alinharNoSom, comLegenda, type EstiloDaLegenda, type PalavraFalada } from "@/lib/banner-editor/narracao";
import { TocadorDeAudios } from "./tocador-de-audios";
import { ondaDoAudio } from "@/lib/banner-editor/onda-do-audio";
import { ondaJaMedida } from "@/lib/banner-editor/onda-do-video";
import { DURACAO_MAXIMA, animacaoNova, segundos, type AnimacaoDaPeca, type TransicaoDaPagina } from "@/lib/banner-editor/animacao";
import { LIMITE_DO_VIDEO, VideoQueNaoAbre, lerVideo } from "@/lib/banner-editor/video-enviado";
import { LinhaDoTempo, type AcaoDaLinha, type AcaoNoQuadro } from "./linha-do-tempo";
import { FolhaDeEfeitos, type AbaDosEfeitos } from "./folha-de-efeitos";
import { CenaNoPalco } from "./cena-no-palco";
import { BarraDeFerramentas, TECLAS_DAS_FERRAMENTAS } from "./barra-de-ferramentas";
import { apagarNos, comAlcas, mudarNos, type TipoDoNo } from "@/lib/banner-editor/curvas";
import { daParaConverter, formaEmCurvas } from "@/lib/banner-editor/forma-em-curvas";
import { daParaSeparar, entraNoRecorte, operarFormas, separarContornos, type OperacaoDeForma } from "@/lib/banner-editor/soldar-e-aparar";
import { ehFundoDaArte, numeracaoDosPosts, podeSerRecipiente, sincronizarFixas, velocidadeDo } from "@/lib/banner-editor/documento";
import { comFotoNova, fotoQueTroca } from "@/lib/banner-editor/trocar-foto";
import { alternarQuadro, comPoseNoTempo, comQuadrosAutomaticos, comQuadrosDa } from "@/lib/banner-editor/cena";
import { CarrosselContinuo } from "./carrossel-continuo";
import { PreviaDoInstagram } from "./previa-do-instagram";
import { trocarCores } from "@/lib/banner-editor/cmyk";
import { aquecerTirarFundo, fotoNaCaixa, tirarFundo } from "@/lib/banner-editor/tirar-fundo";
import { temTratamento, type MeioTom, type TratamentoDaFoto, type TrocaDeCor } from "@/lib/banner-editor/tratamento-da-foto";
import { gerarTratamento } from "./tratar-foto";
import { usePaletaDaFoto } from "./paineis-da-foto";
import type { ResumoDaVersao } from "@/lib/banner-editor/versoes";

/*
  O editor de artes (out/2026), no jeito do Canva: um documento de peças
  soltas, em que até o fundo e as texturas são camadas.

  Abre de dois jeitos:
   - de um campo de imagem da loja ("Criar banner"): o tamanho é o do lugar
     e "Usar este banner" põe a imagem na seção;
   - da tela Artes (galeria): tamanho pronto ou livre, que dá para mudar em
     "Redimensionar".
  Nos dois, a arte fica guardada sozinha a cada mudança (artes_do_editor,
  migration 222) e "Baixar" entrega PNG, JPG ou PDF na qualidade escolhida.

  Tela: barra de cima (voltar, nome, desfazer, refazer, tamanho, baixar,
  usar); trilho de ícones à esquerda (modelos, elementos, texto, imagens,
  camadas, suas artes) e UM painel por vez ao lado dele, que também abre os
  ajustes que a barra flutuante pede; no meio a arte, com a barra flutuante
  da peça (ou do grupo) em cima e o zoom no canto. A imagem final sai do
  mesmo HTML do palco, no servidor.

  Páginas (como no Canva, para carrossel e afins): o projeto tem uma ou mais
  páginas do mesmo tamanho, guardadas juntas (a 1 é o documento, as outras
  vão em `maisPaginas`). O palco mostra uma por vez e a tira de baixo troca,
  cria (em branco ou duplicada com o design), muda de lugar e apaga. O
  desfazer guarda o projeto inteiro: volta também página criada ou apagada.
  Redimensionar muda todas as páginas juntas.
*/

/* O projeto num instante: as páginas e qual está aberta (é o que o desfazer guarda). */
type Projeto = { paginas: DocumentoDoBanner[]; atual: number };

/** Uma página nova "só a página": o mesmo tamanho, com o fundo branco. */
function paginaEmBranco(largura: number, altura: number): DocumentoDoBanner {
  return { versao: 3, largura, altura, fundo: FUNDO_BASE, pecas: camadasDoFundo(largura, altura, solida("#FFFFFF")) };
}

/** A página copiada com o design, com peças de id novo (duas páginas no mesmo PDF não podem repetir id de degradê e filtro). */
/*
 * A página duplicada: cada peça ganha id novo (e o "dentro de" acompanha o
 * recipiente novo); as fixas mantêm o id, porque são a mesma peça em todas as
 * páginas.
 */
function copiaDaPagina(pagina: DocumentoDoBanner): DocumentoDoBanner {
  const novos = new Map(pagina.pecas.filter((p) => !p.fixa).map((p) => [p.id, novoId(p.tipo[0])]));
  return {
    ...pagina,
    pecas: pagina.pecas.map((peca) => ({ ...peca, id: novos.get(peca.id) ?? peca.id, ...(peca.dentroDe && novos.has(peca.dentroDe) ? { dentroDe: novos.get(peca.dentroDe) } : {}) }) as Peca),
  };
}

/** A página nova em branco já com as peças fixas (senão, aberta, ela apagaria as fixas das outras). */
function paginaNovaComAsFixas(base: DocumentoDoBanner): DocumentoDoBanner {
  /* Do carrossel contínuo, a página nova nasce do tamanho de um post. */
  const branca = paginaEmBranco(Math.round(base.largura / (base.fatias || 1)), base.altura);
  return { ...branca, pecas: [...branca.pecas, ...base.pecas.filter((p) => p.fixa)] };
}

type DadosDaLoja = { cor: string; logo: string | null; proporcaoDoLogo: number | null; nome: string | null; dono: string | null; produtos: ProdutoDoEditor[] };
type Painel = { tipo: "biblioteca"; aba: AbaDaBiblioteca } | { tipo: "ajuste"; ajuste: AjusteDaPeca } | null;
type Salvamento = "salvo" | "salvando" | "pendente" | "falhou" | "sem-galeria" | null;

const LIMITE_DO_HISTORICO = 80;
/* O "distorcendo" das várias escolhidas: a perspectiva do grupo inteiro (os cantos da caixa delas), presa àquelas peças. */
const perspectivaDoGrupo = (ids: string[]) => `__grupo:${[...ids].sort().join(",")}`;

const TRILHO: Array<{ aba: AbaDaBiblioteca; rotulo: string; Icone: typeof Type }> = [
  { aba: "modelos", rotulo: "Modelos", Icone: LayoutTemplate },
  { aba: "elementos", rotulo: "Elementos", Icone: Shapes },
  { aba: "texto", rotulo: "Texto", Icone: Type },
  { aba: "imagens", rotulo: "Imagens", Icone: IconeDeImagem },
  { aba: "camadas", rotulo: "Camadas", Icone: Layers },
  { aba: "marca", rotulo: "Marca", Icone: Palette },
  { aba: "artes", rotulo: "Suas artes", Icone: FolderOpen },
];

const TITULOS_DA_BIBLIOTECA: Record<AbaDaBiblioteca, string> = { modelos: "Modelos", elementos: "Elementos", texto: "Texto", imagens: "Imagens", camadas: "Camadas", artes: "Suas artes", marca: "Kit da marca" };

/** As chaves das fontes da marca que a página usa (texto, trechos, tabela, gráfico). */
function fontesDaMarcaUsadas(pagina: DocumentoDoBanner) {
  const chaves = pagina.pecas.flatMap((p) => (p.tipo === "texto" ? [p.fonte, ...fontesDosTrechos(p.html)] : p.tipo === "tabela" || p.tipo === "grafico" ? [p.fonte] : []));
  return new Set(chaves.filter((c) => c.startsWith("kit-")));
}

/** As cores que aparecem na arte (para o seletor oferecer de novo). */
function coresDoDocumento(doc: DocumentoDoBanner | null) {
  if (!doc) return [];
  const lista: string[] = [];
  for (const p of doc.pecas) {
    if (p.tipo === "texto") {
      lista.push(p.pintura.cor);
      if (p.fundo) lista.push(p.fundo.pintura.cor);
      if (p.contorno) lista.push(p.contorno.cor);
    } else if (p.tipo === "forma") {
      lista.push(p.pintura.cor);
      if (p.pintura.tipo !== "solida") lista.push(p.pintura.cor2);
      if (p.borda) lista.push(p.borda.cor);
    } else if (p.tipo === "textura") lista.push(p.cor);
    else if (p.tipo === "icone") lista.push(p.pintura.cor);
    else if (p.tipo === "traco") lista.push(p.cor, ...(p.fechado ? [p.preenchimento.cor] : []));
    else if (p.tipo === "qr") lista.push(p.cor);
    else if (p.tipo === "tabela") lista.push(p.corDoCabecalho, p.corDoTexto);
    else if (p.tipo === "grafico") lista.push(...p.cores);
    else if (p.borda) lista.push(p.borda.cor);
  }
  return [...new Set(lista.map((c) => corValida(c, "")).filter(Boolean))];
}

/* As peças que a animação das escolhidas pega: a de fora (quando a escolhida está dentro de outra) e o grupo inteiro. */
function idsDaAnimacao(doc: DocumentoDoBanner, escolhidas: Peca[]) {
  const todos = new Set<string>();
  for (const p of escolhidas) {
    const alvo = (p.dentroDe && doc.pecas.find((q) => q.id === p.dentroDe)) || p;
    todos.add(alvo.id);
    if (alvo.grupo) doc.pecas.filter((q) => q.grupo === alvo.grupo).forEach((q) => todos.add(q.id));
  }
  return [...todos];
}

function nomeDaArteNova(largura: number, altura: number) {
  const formato = formatoDoTamanho(largura, altura);
  const quando = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "2-digit" }).format(new Date());
  return `${formato?.nome || "Arte"} · ${quando}`;
}

/* A cor da letra que aparece sobre o fundo da arte (texto novo pelo atalho T). */
function corQueAparece(d: DocumentoDoBanner) {
  const fundo = d.pecas.find((p) => ehFundoDaArte(p, d.largura, d.altura));
  const pintura = fundo?.tipo === "forma" ? fundo.pintura : d.fundo.pintura;
  const cor = pintura.tipo === "nenhuma" ? "#FFFFFF" : pintura.cor;
  return /^#[0-9a-f]{6}$/i.test(cor) && luminancia(cor) < 0.4 ? "#FFFFFF" : "#141414";
}

export function EditorDeBanner({
  largura: larguraInicial,
  altura: alturaInicial,
  aoFechar,
  aoUsar,
  arteInicial = null,
  formatoInicial = null,
  modeloInicial = null,
  fotoInicial = null,
  nomeInicial = null,
  vinculoInicial = null,
}: {
  largura: number;
  altura: number;
  aoFechar: () => void;
  /* Só quando abre de um campo da loja: o tamanho fica preso ao do lugar. */
  aoUsar?: (url: string) => void;
  arteInicial?: ResumoDaArte | null;
  formatoInicial?: string | null;
  /* Arte nova a partir de um modelo escolhido na tela inicial (e com a foto da vitrine). */
  modeloInicial?: string | null;
  fotoInicial?: string | null;
  /* O nome da arte nova quando vem de fora (o "Criar arte" do orçamento). */
  nomeInicial?: string | null;
  /* A arte nova ligada a um item do orçamento (o "Criar no KtaloguDesign" do item). */
  vinculoInicial?: VinculoComOrcamento | null;
}) {
  const painelDoSite = useEditorDoPainel();
  const tamanhoFixo = Boolean(aoUsar);
  const [loja, setLoja] = useState<DadosDaLoja | null>(null);
  const [doc, setDoc] = useState<DocumentoDoBanner | null>(null);
  const docRef = useRef<DocumentoDoBanner | null>(null);
  const [selecionadas, setSelecionadas] = useState<string[]>([]);
  const [editando, setEditando] = useState<string | null>(null);
  /* No celular a arte aparece primeiro (a gaveta abre pela barra de baixo, como no Canva). */
  const [painel, setPainel] = useState<Painel>(() => (typeof window !== "undefined" && window.matchMedia("(max-width: 639px)").matches ? null : { tipo: "biblioteca", aba: arteInicial ? "elementos" : "modelos" }));
  const [zoom, setZoom] = useState<number | null>(null);
  /* Cada "Caber na tela" soma um: o palco traz a arte arrastada de volta para o meio. */
  const [voltaAoMeio, setVoltaAoMeio] = useState(0);
  const [escalaQueCabe, setEscalaQueCabe] = useState(0.5);
  const [fotoDosModelos, setFotoDosModelos] = useState<string | null>(null);
  const [gerando, setGerando] = useState(false);
  const [enviando, setEnviando] = useState(false);
  const [recado, setRecado] = useState<{ texto: string; tom: "bom" | "ruim" } | null>(null);
  const passado = useRef<Projeto[]>([]);
  const futuro = useRef<Projeto[]>([]);
  /* As páginas do projeto; a aberta fica velha aqui enquanto se mexe (a de verdade é o `doc`). */
  const paginasRef = useRef<DocumentoDoBanner[]>([]);
  const [paginas, setPaginas] = useState<DocumentoDoBanner[]>([]);
  const paginaRef = useRef(0);
  const [paginaAtual, setPaginaAtual] = useState(0);
  /* Páginas que acabaram de ser redimensionadas: o texto que passar da caixa encolhe quando ela abrir. */
  const paraEncaixar = useRef(new WeakSet<DocumentoDoBanner>());
  const [contagem, setContagem] = useState({ voltar: 0, avancar: 0 });
  const copiadas = useRef<Peca[]>([]);
  /* "Copiar estilo": a peça de origem e o pincel (próximo clique pinta). */
  const estiloCopiado = useRef<Peca | null>(null);
  const [temEstilo, setTemEstilo] = useState(false);
  const [pincel, setPincel] = useState(false);
  const pincelRef = useRef(false);
  const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
  /* Caneta ou desenho livre ligados; a foto em enquadre. */
  const [ferramenta, setFerramenta] = useState<FerramentaDoPalco>(null);
  /* A peça com os 4 cantos à mostra para distorcer. */
  const [distorcendo, setDistorcendo] = useState<string | null>(null);
  /* Ferramenta Forma: os nós escolhidos do traço escolhido. */
  const [nos, setNos] = useState<number[]>([]);
  const [enquadrando, setEnquadrando] = useState<string | null>(null);
  /* O kit da marca (sem a tabela no banco, vale só nesta visita). */
  const [kit, setKit] = useState<KitDaMarca>(KIT_VAZIO);
  const [kitSemTabela, setKitSemTabela] = useState(false);
  /* Arte de impressão: mostrar a área de segurança no palco. */
  const [mostrarMargens, setMostrarMargens] = useState(true);
  /* Na arte de caneca: as guias das faces e da alça. */
  const [mostrarGuiasDaCaneca, setMostrarGuiasDaCaneca] = useState(true);
  const kitCarregado = useRef(false);
  const [tamanhoAberto, setTamanhoAberto] = useState(false);
  const [arrastandoArquivo, setArrastandoArquivo] = useState(false);

  /* A arte guardada. */
  const [arteId, setArteId] = useState<string | null>(arteInicial?.id ?? null);
  /* Celular e tablet em pé (tela-compacta.ts): o mesmo editor, arrumado para o toque. No desktop é sempre null. */
  const compacto = useTelaCompacta();
  const compactoRef = useRef(compacto);
  useEffect(() => {
    compactoRef.current = compacto;
  });
  /* O orçamento desta arte: o da arte guardada (migration 231) ou o do link que abriu o editor. */
  const [vinculo] = useState<VinculoComOrcamento | null>(() => (arteInicial?.quote_id ? { orcamento: arteInicial.quote_id, item: arteInicial.quote_item_id ?? null } : vinculoInicial));
  const [doVinculo, setDoVinculo] = useState<DadosDoVinculo | null>(null);
  const [enviandoAoOrcamento, setEnviandoAoOrcamento] = useState<string | null>(null);
  const [enviadoAoOrcamento, setEnviadoAoOrcamento] = useState(false);
  useEffect(() => {
    if (!vinculo) return;
    let vivo = true;
    dadosDoVinculo(vinculo)
      .then((d) => {
        if (vivo) setDoVinculo(d);
      })
      .catch(() => undefined);
    return () => {
      vivo = false;
    };
  }, [vinculo]);
  const nomeDoModelo = modeloInicial && modeloInicial !== "branco" ? MODELOS_DO_EDITOR.find((m) => m.chave === modeloInicial)?.nome : null;
  const [nome, setNome] = useState(arteInicial?.nome || nomeInicial?.trim() || (nomeDoModelo ? `${nomeDoModelo} · ${nomeDaArteNova(larguraInicial, alturaInicial)}` : nomeDaArteNova(larguraInicial, alturaInicial)));
  const [formato, setFormato] = useState<string | null>(arteInicial?.formato ?? formatoInicial);
  const [salvamento, setSalvamento] = useState<Salvamento>(arteInicial ? "salvo" : null);
  const alterado = useRef(false);
  const [artes, setArtes] = useState<ResumoDaArte[] | null>(null);
  const [semGaleria, setSemGaleria] = useState(false);
  const [enviadasAgora, setEnviadasAgora] = useState<string[]>([]);

  const avisar = useCallback((texto: string, tom: "bom" | "ruim" = "bom") => setRecado({ texto, tom }), []);
  useEffect(() => {
    if (!recado) return;
    const relogio = window.setTimeout(() => setRecado(null), recado.tom === "ruim" ? 9000 : 4500);
    return () => window.clearTimeout(relogio);
  }, [recado]);

  /* A tela de trás (a galeria) não rola enquanto o editor está aberto. */
  useEffect(() => {
    const [html, corpo] = [document.documentElement, document.body];
    const antes = [html.style.overflow, corpo.style.overflow];
    html.style.overflow = "hidden";
    corpo.style.overflow = "hidden";
    return () => {
      html.style.overflow = antes[0];
      corpo.style.overflow = antes[1];
    };
  }, []);

  /* As fontes de sempre logo de cara; as da biblioteca grande quando a arte usa (ou o painel de fontes abre). */
  useEffect(() => garantirFontes(CHAVES_DAS_FONTES_DE_BASE), []);

  useEffect(() => {
    let vivo = true;
    fetch("/api/loja/editor/banner/documento", { cache: "no-store" })
      .then((r) => r.json())
      .catch(() => ({}))
      .then((dados: Partial<DadosDaLoja>) => {
        if (!vivo) return;
        const pronto: DadosDaLoja = {
          cor: dados.cor || painelDoSite?.corDaLoja || "#6D28D9",
          logo: dados.logo || null,
          proporcaoDoLogo: dados.proporcaoDoLogo || null,
          nome: dados.nome || null,
          dono: dados.dono || painelDoSite?.dono || null,
          produtos: painelDoSite?.produtos?.length ? painelDoSite.produtos : dados.produtos || [],
        };
        setLoja(pronto);
        /* Modelo aberto da galeria: a foto do primeiro produto com foto entra no lugar da foto do modelo. */
        const fotoDoModelo = fotoInicial ?? (modeloInicial && modeloInicial !== "branco" ? (pronto.produtos.find((p) => p.image_url)?.image_url ?? null) : null);
        const inicial = arteInicial
          ? documentoLimpo(arteInicial.documento, arteInicial.largura, arteInicial.altura)
          : (MODELOS_DO_EDITOR.find((m) => m.chave === modeloInicial) ?? MODELOS_DO_EDITOR[0]).montar({ largura: larguraInicial, altura: alturaInicial, cor: pronto.cor, logo: pronto.logo, proporcaoDoLogo: pronto.proporcaoDoLogo, foto: fotoDoModelo });
        if (!arteInicial && fotoDoModelo) setFotoDosModelos(fotoDoModelo);
        /* No banner da loja vale uma página só; abriu uma arte num lugar de outro tamanho: adapta. */
        /* Arte nova num formato de impressão: nasce com a resolução dele (medidas em mm, PDF para gráfica). */
        const dpiDoFormato = !arteInicial ? formatoDaChave(formatoInicial)?.dpi : undefined;
        const lista = (tamanhoFixo ? paginasDoDocumento(inicial).slice(0, 1) : paginasDoDocumento(inicial)).map((pagina) => (dpiDoFormato ? { ...pagina, impressao: { dpi: dpiDoFormato } } : pagina));
        const ajustadas = tamanhoFixo && (inicial.largura !== larguraInicial || inicial.altura !== alturaInicial) ? lista.map((pagina) => redimensionarComInteligencia(pagina, larguraInicial, alturaInicial, pronto.cor)) : lista;
        paginasRef.current = ajustadas;
        setPaginas(ajustadas);
        docRef.current = ajustadas[0];
        setDoc(ajustadas[0]);
      });
    return () => {
      vivo = false;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- abre uma vez
  }, []);

  const largura = doc?.largura ?? larguraInicial;
  const altura = doc?.altura ?? alturaInicial;

  const pedidoDoModelo = useMemo<PedidoDoModelo | null>(
    () => (loja ? { largura, altura, cor: loja.cor, logo: loja.logo, proporcaoDoLogo: loja.proporcaoDoLogo, foto: fotoDosModelos } : null),
    [loja, largura, altura, fotoDosModelos],
  );

  const cores = useMemo(() => {
    const daLoja = [...new Set([...kit.cores, ...(loja ? [corValida(loja.cor, "#6D28D9"), misturar(loja.cor, "#000000", 0.35), misturar(loja.cor, "#FFFFFF", 0.45)] : [])])];
    return { daLoja, usadas: coresDoDocumento(doc).filter((c) => !daLoja.includes(c)) };
  }, [loja, doc, kit.cores]);

  /* O kit da marca: abre com a loja (vazio, começa com a cor e o logo dela). */
  useEffect(() => {
    if (!loja?.dono || kitCarregado.current) return;
    kitCarregado.current = true;
    const inicial = { ...KIT_VAZIO, cores: [corValida(loja.cor, "#6D28D9")], logos: loja.logo ? [loja.logo] : [] };
    lerKit(loja.dono)
      .then((guardado) => setKit(guardado.cores.length || guardado.fontes.length || guardado.logos.length ? guardado : inicial))
      .catch((erro) => {
        if (erro instanceof KitIndisponivel) setKitSemTabela(true);
        setKit(inicial);
      });
  }, [loja]);

  /* A cor original do elemento em SVG aberta no painel "Cores do elemento". */
  const [corDoVetor, setCorDoVetor] = useState<string | null>(null);
  const abrirCorDoVetor = useCallback((original: string) => {
    setCorDoVetor(original);
    setPainel({ tipo: "ajuste", ajuste: "vetor" });
  }, []);

  /* Os SVGs com cor trocada: o texto deles vem do depósito uma vez; quando chega, o palco redesenha. */
  const [, setVetoresChegaram] = useState(0);
  useEffect(() => aoChegarVetor(() => setVetoresChegaram((n) => n + 1)), []);
  useEffect(() => {
    void garantirVetores([...(doc ? vetoresATrazer(doc.pecas) : []), ...paginas.flatMap((p) => vetoresATrazer(p.pecas))]);
  }, [doc, paginas]);

  /* As fontes da marca passam a valer no editor (no palco, nas miniaturas e no painel de fontes). */
  useEffect(() => {
    registrarFontesComGlifos();
    registrarFontesDaMarca(kit.fontes);
    for (const pagina of paginas) registrarFontesDaMarca(pagina.fontesProprias);
    let estilo = document.getElementById("fontes-da-marca-do-editor") as HTMLStyleElement | null;
    if (!estilo) {
      estilo = document.createElement("style");
      estilo.id = "fontes-da-marca-do-editor";
      document.head.appendChild(estilo);
    }
    const css = cssDasFontesDaMarca(fontesDaMarca().map((f) => f.chave));
    if (estilo.textContent !== css) estilo.textContent = css;
  }, [kit.fontes, paginas]);

  const mudarKit = useCallback(
    (novo: KitDaMarca) => {
      setKit(novo);
      if (kitSemTabela || !loja?.dono) return;
      salvarKit(loja.dono, novo).catch((erro) => {
        if (erro instanceof KitIndisponivel) setKitSemTabela(true);
        else avisar("Não deu para guardar o kit da marca agora.", "ruim");
      });
    },
    [avisar, kitSemTabela, loja?.dono],
  );

  /* A página com as fontes da marca que ela usa (vão junto para o banco e para o download). */
  const kitRef = useRef(kit);
  useEffect(() => {
    kitRef.current = kit;
  });
  const comFontesDaMarca = useCallback((pagina: DocumentoDoBanner): DocumentoDoBanner => {
    const usadas = fontesDaMarcaUsadas(pagina);
    const conhecidas = new Map([...(pagina.fontesProprias || []), ...fontesDaMarca(), ...kitRef.current.fontes].map((f) => [f.chave, f]));
    const lista = [...usadas].map((c) => conhecidas.get(c)).filter((f): f is NonNullable<typeof f> => Boolean(f));
    const { fontesProprias: _antigas, ...resto } = pagina;
    void _antigas;
    return lista.length ? { ...resto, fontesProprias: lista } : resto;
  }, []);

  /* ------------------------------------------------------------ histórico */
  /* O documento de agora mora num ref (lido e trocado na hora) e no estado (para desenhar). O histórico fica fora do atualizador do setState (no modo estrito ele roda duas vezes). */

  const pintar = useCallback((proximo: DocumentoDoBanner) => {
    docRef.current = proximo;
    setDoc(proximo);
    setContagem({ voltar: passado.current.length, avancar: futuro.current.length });
    alterado.current = true;
    setSalvamento((s) => (s === "sem-galeria" ? s : "pendente"));
  }, []);

  /* Todas as páginas como estão agora (a aberta vem do `doc`). */
  /* As páginas como estão (a aberta com o que está na tela), com as peças fixas iguais em todas. */
  const todasAsPaginas = useCallback(() => {
    const atual = docRef.current;
    return sincronizarFixas(
      paginasRef.current.map((pagina, i) => (i === paginaRef.current && atual ? atual : pagina)),
      paginaRef.current,
    );
  }, []);
  const projetoAgora = useCallback((): Projeto => ({ paginas: todasAsPaginas(), atual: paginaRef.current }), [todasAsPaginas]);

  /* Põe um projeto inteiro na tela (desfazer, operações de página, abrir outra arte). */
  const mostrarProjeto = useCallback(
    (projeto: Projeto) => {
      const atual = Math.max(0, Math.min(projeto.paginas.length - 1, projeto.atual));
      const mudouDePagina = atual !== paginaRef.current;
      paginasRef.current = projeto.paginas;
      paginaRef.current = atual;
      setPaginas(projeto.paginas);
      setPaginaAtual(atual);
      pintar(projeto.paginas[atual]);
      if (mudouDePagina) {
        setSelecionadas([]);
        setEditando(null);
      }
    },
    [pintar],
  );

  const registrar = useCallback(() => {
    if (!docRef.current) return;
    passado.current = [...passado.current.slice(-LIMITE_DO_HISTORICO + 1), projetoAgora()];
    futuro.current = [];
  }, [projetoAgora]);

  const guardar = useCallback(() => {
    registrar();
    setContagem({ voltar: passado.current.length, avancar: 0 });
  }, [registrar]);

  /*
   * Quadros-chave: o instante da agulha dentro da cena aberta (null tocando,
   * fora da cena ou sem linha do tempo). Mexer numa peça com quadros com a
   * agulha parada grava o quadro desse instante (como no CapCut).
   */
  const agulhaNaCena = useRef<() => number | null>(() => null);
  const trocar = useCallback(
    (novo: (d: DocumentoDoBanner) => DocumentoDoBanner, guardarNoHistorico = true) => {
      const atual = docRef.current;
      if (!atual) return;
      if (guardarNoHistorico) registrar();
      const proximo = novo(atual);
      const t = agulhaNaCena.current();
      pintar(t === null ? proximo : comQuadrosAutomaticos(atual, proximo, t));
    },
    [pintar, registrar],
  );

  const desfazer = useCallback(() => {
    const anterior = passado.current.at(-1);
    if (!anterior || !docRef.current) return;
    const agora = projetoAgora();
    passado.current = passado.current.slice(0, -1);
    futuro.current = [agora, ...futuro.current];
    mostrarProjeto(anterior);
  }, [mostrarProjeto, projetoAgora]);

  const refazer = useCallback(() => {
    const proximo = futuro.current[0];
    if (!proximo || !docRef.current) return;
    const agora = projetoAgora();
    futuro.current = futuro.current.slice(1);
    passado.current = [...passado.current, agora];
    mostrarProjeto(proximo);
  }, [mostrarProjeto, projetoAgora]);

  /* ------------------------------------------------------------- guardar */

  /*
   * Guardar é uma FILA: cada pedido espera o anterior terminar e grava o que
   * estiver na tela naquela hora. Assim nada se perde quando a pessoa mexe
   * enquanto guarda, e quem fecha o editor espera a última gravação (antes,
   * o pedido de fechar voltava na hora se já havia uma gravação andando, e a
   * galeria podia reabrir a versão velha). A arte e o nome são lidos de refs:
   * logo depois de criar a arte, o próximo pedido já atualiza em vez de criar
   * outra.
   */
  const arteIdRef = useRef<string | null>(arteInicial?.id ?? null);
  const nomeRef = useRef(nome);
  const formatoRef = useRef(formato);
  const donoRef = useRef<string | null>(null);
  const todasAsPaginasRef = useRef(todasAsPaginas);
  const comFontesDaMarcaRef = useRef((p: DocumentoDoBanner) => p);
  const vinculoRef = useRef(vinculo);
  useEffect(() => {
    todasAsPaginasRef.current = todasAsPaginas;
    comFontesDaMarcaRef.current = comFontesDaMarca;
    nomeRef.current = nome;
    formatoRef.current = formato;
    donoRef.current = loja?.dono ?? null;
  });
  const fila = useRef<Promise<void>>(Promise.resolve());
  const gravarUmaVez = useCallback(async () => {
    const atual = docRef.current;
    const dono = donoRef.current;
    if (!atual || !dono || !alterado.current) return;
    alterado.current = false;
    setSalvamento("salvando");
    try {
      const campos = { documento: documentoDasPaginas(todasAsPaginasRef.current().map(comFontesDaMarcaRef.current)), largura: atual.largura, altura: atual.altura, nome: nomeRef.current, formato: formatoRef.current };
      if (arteIdRef.current) await salvarArte(arteIdRef.current, campos);
      else {
        const id = await criarArte(dono, { ...campos, vinculo: vinculoRef.current });
        arteIdRef.current = id;
        setArteId(id);
      }
      setSalvamento(alterado.current ? "pendente" : "salvo");
    } catch (erro) {
      if (erro instanceof GaleriaIndisponivel) setSalvamento("sem-galeria");
      else {
        alterado.current = true;
        setSalvamento("falhou");
      }
    }
  }, []);
  const salvarJa = useCallback(() => {
    fila.current = fila.current.then(gravarUmaVez, gravarUmaVez);
    return fila.current;
  }, [gravarUmaVez]);

  /* "Enviar para o orçamento": guarda a arte e manda cada página como imagem da arte do item. */
  const enviarAoOrcamento = useCallback(async () => {
    if (!vinculo || enviandoAoOrcamento) return;
    setEnviandoAoOrcamento("Preparando…");
    try {
      alterado.current = true;
      await salvarJa();
      const { imagens, numero } = await enviarParaOOrcamento({
        vinculo,
        paginas: todasAsPaginas().map(comFontesDaMarca),
        nome,
        aoAndar: (feitas, total) => setEnviandoAoOrcamento(total > 1 ? `Enviando ${feitas}/${total}…` : "Enviando…"),
      });
      setEnviadoAoOrcamento(true);
      avisar(`${imagens === 1 ? "A arte foi" : `${imagens} imagens foram`} para o orçamento${numero ? ` #${numero}` : ""}${doVinculo?.item ? `, no item ${doVinculo.item}` : ""}.`);
    } catch (erro) {
      avisar(erro instanceof Error ? erro.message : "Não deu para enviar para o orçamento.", "ruim");
    } finally {
      setEnviandoAoOrcamento(null);
    }
  }, [avisar, comFontesDaMarca, doVinculo, enviandoAoOrcamento, nome, salvarJa, todasAsPaginas, vinculo]);

  /* Guarda um pouco depois da última mudança (e de novo se mudou enquanto guardava). */
  useEffect(() => {
    if (salvamento !== "pendente" && salvamento !== "falhou") return;
    const relogio = window.setTimeout(() => void salvarJa(), salvamento === "falhou" ? 8000 : 1500);
    return () => window.clearTimeout(relogio);
  }, [doc, salvamento, salvarJa]);

  const renomear = (novo: string) => {
    setNome(novo);
    alterado.current = true;
    setSalvamento((s) => (s === "sem-galeria" ? s : "pendente"));
  };

  /* Sair: espera a gravação em andamento e grava o que faltar, antes de fechar. */
  const fechar = useCallback(async () => {
    if (salvamento !== "sem-galeria") {
      await salvarJa();
      if (alterado.current) await salvarJa();
    }
    aoFechar();
  }, [aoFechar, salvamento, salvarJa]);

  /* As artes guardadas (aba "Suas artes" e as imagens já enviadas). */
  const carregarArtes = useCallback(async () => {
    if (!loja?.dono) return;
    try {
      setArtes(await listarArtes(loja.dono));
    } catch (erro) {
      if (erro instanceof GaleriaIndisponivel) setSemGaleria(true);
      setArtes([]);
    }
  }, [loja?.dono]);
  const abaAberta = painel?.tipo === "biblioteca" ? painel.aba : null;
  useEffect(() => {
    /* "Suas artes" sempre mostra o que está no banco agora (as miniaturas e o que abre). */
    if (abaAberta === "artes") void carregarArtes();
    // eslint-disable-next-line react-hooks/exhaustive-deps -- recarrega só ao abrir a aba
  }, [abaAberta]);
  useEffect(() => {
    if (abaAberta === "imagens" && artes === null) void carregarArtes();
  }, [abaAberta, artes, carregarArtes]);

  const imagensDaPessoa = useMemo(() => [...new Set([...enviadasAgora, ...imagensEnviadas(artes || [])])], [artes, enviadasAgora]);

  /* ---------------------------------------------------------------- peças */


  const mudarPeca = useCallback(
    (id: string, campos: Partial<Peca>, registrar = true) => {
      /* A pessoa mudou o tamanho da letra: deixa de ser encaixe automático. */
      const proprios = "tamanho" in campos ? ({ ...campos, encaixe: undefined } as Partial<Peca>) : campos;
      trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (p.id === id ? ({ ...p, ...proprios } as Peca) : p)) }), registrar);
    },
    [trocar],
  );
  const mexer = useCallback((id: string, campos: Partial<Peca>) => mudarPeca(id, campos, false), [mudarPeca]);
  const mexerVarias = useCallback(
    (mudancas: Array<{ id: string; campos: Partial<Peca> }>) => {
      const porId = new Map(mudancas.map((m) => [m.id, m.campos]));
      trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (porId.has(p.id) ? ({ ...p, ...porId.get(p.id) } as Peca) : p)) }), false);
    },
    [trocar],
  );

  /* Uma régua arrastada (ou um texto digitado) vira um passo só do desfazer. */
  const ultimaMudanca = useRef<{ id: string; chaves: string; em: number } | null>(null);
  const mudarDoPainel = useCallback(
    (id: string, campos: Partial<Peca>) => {
      const chaves = Object.keys(campos).sort().join(",");
      const agora = Date.now();
      const u = ultimaMudanca.current;
      const mesmoGesto = u && u.id === id && u.chaves === chaves && agora - u.em < 800;
      ultimaMudanca.current = { id, chaves, em: agora };
      mudarPeca(id, campos, !mesmoGesto);
    },
    [mudarPeca],
  );

  /* Pinta as peças com o estilo copiado (e as do grupo delas). */
  const colarEstilo = useCallback(
    (ids: string[]) => {
      const origem = estiloCopiado.current;
      if (!origem || !ids.length) return;
      trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (ids.includes(p.id) && p.id !== origem.id ? ({ ...p, ...estiloPara(origem, p) } as Peca) : p)) }));
    },
    [trocar],
  );

  /* Escolher: peça de grupo leva o grupo inteiro (sozinha = só ela, ao entrar no grupo com dois cliques). */
  const escolher = useCallback(
    (id: string | null, somar = false, sozinha = false) => {
      const d = docRef.current;
      if (pincelRef.current && id) {
        const peca = d?.pecas.find((p) => p.id === id);
        colarEstilo(peca?.grupo ? d!.pecas.filter((p) => p.grupo === peca.grupo).map((p) => p.id) : [id]);
        pincelRef.current = false;
        setPincel(false);
        setRecado(null);
        return;
      }
      const peca = id ? d?.pecas.find((p) => p.id === id) : null;
      const membros = !id ? [] : peca?.grupo && !sozinha ? d!.pecas.filter((p) => p.grupo === peca.grupo && !p.oculta).map((p) => p.id) : [id];
      setSelecionadas((atuais) => {
        const proximas = !id ? [] : somar ? (atuais.includes(id) ? atuais.filter((x) => !membros.includes(x)) : [...new Set([...atuais, ...membros])]) : membros;
        if (proximas.length !== 1 || proximas[0] !== atuais[0] || atuais.length !== 1) setPainel((p) => (p?.tipo === "ajuste" ? null : p));
        return proximas;
      });
      /* Celular e tablet: escolher uma peça fecha a gaveta da biblioteca (ela cobre a arte e a barra da peça); as camadas ficam. */
      if (id && compactoRef.current) setPainel((p) => (p?.tipo === "biblioteca" && p.aba !== "camadas" ? null : p));
    },
    [colarEstilo],
  );
  /* Pelas camadas: a linha do grupo escolhe o grupo inteiro; a peça de grupo, só ela; Shift soma. */
  const escolherDasCamadas = useCallback(
    (ids: string[], sozinha: boolean, somar: boolean) => {
      if (!ids.length) return;
      if (ids.length === 1) {
        escolher(ids[0], somar, sozinha);
        return;
      }
      setSelecionadas((atuais) => (somar ? (ids.every((id) => atuais.includes(id)) ? atuais.filter((id) => !ids.includes(id)) : [...new Set([...atuais, ...ids])]) : ids));
      setPainel((p) => (p?.tipo === "ajuste" ? null : p));
    },
    [escolher],
  );
  const mudarVariasDoPainel = useCallback(
    (ids: string[], campos: Partial<Peca>) => {
      trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (ids.includes(p.id) ? ({ ...p, ...campos } as Peca) : p)) }));
      if (campos.oculta) setSelecionadas((atuais) => atuais.filter((id) => !ids.includes(id)));
    },
    [trocar],
  );
  const escolherVarias = useCallback((ids: string[]) => {
    setSelecionadas(ids);
    setPainel((p) => (p?.tipo === "ajuste" ? null : p));
  }, []);

  /* Texto recém-posto: a letra encolhe até caber na caixa (a conta fica mais abaixo, junto do encaixe). */
  const encaixarNovo = useRef<((id: string) => void) | null>(null);
  const por = useCallback(
    (peca: Peca) => {
      /* Fundo novo entra por baixo de tudo; o resto, por cima. */
      const ehFundo = peca.tipo === "forma" && peca.nome === "Fundo";
      trocar((d) => ({ ...d, pecas: ehFundo ? [peca, ...d.pecas] : [...d.pecas, peca] }));
      setSelecionadas([peca.id]);
      setEditando(null);
      if (peca.tipo === "texto" && !peca.curva) encaixarNovo.current?.(peca.id);
      /* Celular e tablet: a peça nova aparece (a gaveta da biblioteca cobria a arte); as camadas ficam. */
      if (compactoRef.current) setPainel((p) => (p?.tipo === "biblioteca" && p.aba !== "camadas" ? null : p));
    },
    [trocar],
  );
  const porVarias = useCallback(
    (pecas: Peca[]) => {
      trocar((d) => ({ ...d, pecas: [...d.pecas, ...pecas] }));
      setSelecionadas(pecas.map((p) => p.id));
      setEditando(null);
      /* Celular e tablet: a peça nova aparece (a gaveta da biblioteca cobria a arte); as camadas ficam. */
      if (compactoRef.current) setPainel((p) => (p?.tipo === "biblioteca" && p.aba !== "camadas" ? null : p));
    },
    [trocar],
  );

  const apagar = useCallback(
    (ids: string[]) => {
      if (!ids.length) return;
      trocar((d) => ({ ...d, pecas: d.pecas.filter((p) => !ids.includes(p.id)) }));
      setSelecionadas([]);
      setEditando(null);
      setPainel((p) => (p?.tipo === "ajuste" ? null : p));
    },
    [trocar],
  );

  const colarCopias = useCallback(
    (pecas: Peca[], depoisDe?: string, deslocar = 24) => {
      const grupos = new Map<string, string>();
      const grupoNovo = (g?: string) => {
        if (!g) return undefined;
        if (!grupos.has(g)) grupos.set(g, novoId("g"));
        return grupos.get(g);
      };
      const copias = pecas.map((peca) => ({ ...peca, id: novoId(peca.tipo[0]), x: peca.x + deslocar, y: peca.y + deslocar, bloqueada: false, grupo: grupoNovo(peca.grupo) }) as Peca);
      trocar((d) => {
        const lista = [...d.pecas];
        const i = depoisDe ? lista.findIndex((p) => p.id === depoisDe) : -1;
        if (i >= 0) lista.splice(i + 1, 0, ...copias);
        else lista.push(...copias);
        return { ...d, pecas: lista };
      });
      setSelecionadas(copias.map((c) => c.id));
      return copias;
    },
    [trocar],
  );

  const duplicar = useCallback(
    (ids: string[]) => {
      const pecas = (docRef.current?.pecas || []).filter((p) => ids.includes(p.id));
      if (pecas.length) colarCopias(pecas, pecas.length === 1 ? pecas[0].id : undefined);
    },
    [colarCopias],
  );

  /* Alt + arrastar: cópias no mesmo lugar, logo acima das originais (o palco arrasta as cópias). */
  const clonar = useCallback(
    (ids: string[]) => {
      const d = docRef.current;
      if (!d) return [];
      const pecas = ids.map((id) => d.pecas.find((p) => p.id === id)).filter((p): p is Peca => Boolean(p));
      if (pecas.length !== ids.length) return [];
      const ultima = d.pecas.reduce((maior, p, i) => (ids.includes(p.id) ? i : maior), -1);
      return colarCopias(pecas, d.pecas[ultima]?.id, 0).map((c) => c.id);
    },
    [colarCopias],
  );

  /* O que a caneta ou o desenho livre desenharam vira um traço (a ferramenta segue ligada, como no Corel). */
  const desenhar = useCallback(
    (pontos: PontoDoTraco[], fechado: boolean, suave: boolean) => {
      const d = docRef.current;
      if (!d || pontos.length < 2) return;
      const espessura = Math.max(3, Math.min(d.largura, d.altura) * 0.006);
      const caixa = caixaDoTraco(pontos, espessura, fechado, suave);
      const traco: PecaDeTraco = {
        id: novoId("tr"),
        tipo: "traco",
        nome: fechado ? "Forma desenhada" : suave ? "Desenho" : "Linha",
        x: caixa.x,
        y: caixa.y,
        w: caixa.w,
        h: caixa.h,
        rot: 0,
        opacidade: 1,
        pontos: caixa.pontos,
        fechado,
        suave,
        cor: "#141414",
        espessura,
        tracejado: "solido",
        inicio: "nada",
        fim: "nada",
        preenchimento: solida("#FFD400"),
        sombra: null,
      };
      trocar((doc) => ({ ...doc, pecas: [...doc.pecas, traco] }));
      setSelecionadas([traco.id]);
    },
    [trocar],
  );

  /*
   * A foto que está no ponto da tela, para receber uma foto solta em cima
   * (como no Canva: qualquer foto troca; a forma ou o texto com foto dentro
   * troca a de dentro). Travada não troca.
   */
  const molduraNoPonto = useCallback((telaX: number, telaY: number) => {
    const d = docRef.current;
    if (!d) return null;
    const ids = document
      .elementsFromPoint(telaX, telaY)
      .map((el) => (el as HTMLElement).closest?.("[data-palco] [data-peca]")?.getAttribute("data-peca"))
      .filter((id): id is string => Boolean(id));
    for (const id of ids) {
      const peca = d.pecas.find((p) => p.id === id);
      const foto = peca ? fotoQueTroca(d.pecas, peca) : null;
      if (foto && !foto.bloqueada && !foto.fixa) return foto;
    }
    return null;
  }, []);

  /* Cor do kit na peça escolhida (a cor principal de cada tipo). */
  const usarCorDoKit = useCallback(
    (cor: string) => {
      if (!selecionadas.length) {
        avisar("Escolha uma peça na arte para usar a cor.");
        return;
      }
      trocar((doc) => ({
        ...doc,
        pecas: doc.pecas.map((p) => {
          if (!selecionadas.includes(p.id)) return p;
          if (p.tipo === "texto" || p.tipo === "forma" || p.tipo === "icone") return { ...p, pintura: solida(cor), ...(p.tipo === "texto" ? { html: p.html ? p.html.replace(/color:#[0-9A-F]{6};-webkit-text-fill-color:#[0-9A-F]{6};?/gi, "") : p.html } : {}) } as Peca;
          if (p.tipo === "traco" || p.tipo === "qr" || p.tipo === "textura") return { ...p, cor } as Peca;
          if (p.tipo === "tabela") return { ...p, corDoCabecalho: cor } as Peca;
          if (p.tipo === "grafico") return { ...p, cores: [cor, ...p.cores.filter((c) => c !== cor)] } as Peca;
          return p;
        }),
      }));
    },
    [avisar, selecionadas, trocar],
  );

  const usarFonteDoKit = useCallback(
    (chave: string) => {
      const d = docRef.current;
      const alvo = d?.pecas.filter((p) => selecionadas.includes(p.id) && (p.tipo === "texto" || p.tipo === "tabela" || p.tipo === "grafico")) || [];
      if (alvo.length) {
        trocar((doc) => ({ ...doc, pecas: doc.pecas.map((p) => (alvo.some((a) => a.id === p.id) ? ({ ...p, fonte: chave } as Peca) : p)) }));
        return;
      }
      if (!d) return;
      const novo = { ...TEXTOS_PRONTOS[1].criar({ largura: d.largura, altura: d.altura }), fonte: chave } as Peca;
      por(novo);
    },
    [por, selecionadas, trocar],
  );

  const usarLogoDoKit = useCallback(
    (url: string) => {
      const d = docRef.current;
      if (!d) return;
      const foto = new window.Image();
      foto.onload = () => por(imagem({ largura: d.largura, altura: d.altura }, url, "logo", foto.naturalWidth / Math.max(1, foto.naturalHeight)));
      foto.onerror = () => por(imagem({ largura: d.largura, altura: d.altura }, url, "logo", 3));
      foto.src = url;
    },
    [por],
  );

  /* Camada (ou grupo) arrastada no painel e solta acima ou abaixo de outra. */
  const soltarCamada = useCallback(
    (ids: string[], alvo: AlvoDaCamada, lugar: "acima" | "abaixo") =>
      trocar((d) => {
        const pecas = soltarNaCamada(d.pecas, ids, alvo, lugar);
        return pecas === d.pecas ? d : { ...d, pecas };
      }),
    [trocar],
  );

  const agruparOuDesagrupar = useCallback(() => {
    const d = docRef.current;
    if (!d || !selecionadas.length) return;
    const escolhidasAgora = d.pecas.filter((p) => selecionadas.includes(p.id));
    const grupo = escolhidasAgora[0]?.grupo;
    const jaAgrupadas = Boolean(grupo) && escolhidasAgora.every((p) => p.grupo === grupo);
    if (jaAgrupadas) {
      trocar((doc) => ({ ...doc, pecas: doc.pecas.map((p) => (p.grupo === grupo ? ({ ...p, grupo: undefined } as Peca) : p)) }));
      avisar("Grupo desfeito: cada peça anda sozinha de novo.");
    } else if (selecionadas.length > 1) {
      const novo = novoId("g");
      /* As do grupo ficam juntas nas camadas (no lugar da de cima): o grupo vira uma camada só. */
      trocar((doc) => ({ ...doc, pecas: juntarGrupo(doc.pecas.map((p) => (selecionadas.includes(p.id) ? ({ ...p, grupo: novo } as Peca) : p)), novo) }));
      avisar("Agrupadas: um clique escolhe o grupo, dois cliques entram numa peça.");
    }
  }, [avisar, selecionadas, trocar]);

  const copiarEstilo = useCallback(
    (comPincel: boolean) => {
      const d = docRef.current;
      const peca = d?.pecas.find((p) => p.id === selecionadas[0]);
      if (!peca) return;
      estiloCopiado.current = peca;
      setTemEstilo(true);
      if (comPincel) {
        pincelRef.current = true;
        setPincel(true);
        avisar("Clique na peça que vai receber o estilo (Esc cancela).");
      } else avisar("Estilo copiado. Escolha outra peça e use Ctrl+Alt+V.");
    },
    [avisar, selecionadas],
  );

  /* Frente, trás, subir, descer: o grupo anda inteiro; a peça escolhida sozinha anda só entre as do grupo dela. */
  const ordenar = useCallback(
    (ids: string[], para: "frente" | "tras" | "subir" | "descer") =>
      trocar((d) => {
        const pecas = ordenarEscolhidas(d.pecas, ids, para);
        return pecas.every((p, i) => p === d.pecas[i]) ? d : { ...d, pecas };
      }),
    [trocar],
  );

  /* Alinhar e distribuir as escolhidas (entre elas; uma só alinha na arte). */
  const alinhar = useCallback(
    (lado: Alinhamento) =>
      trocar((d) => {
        const escolhidas = d.pecas.filter((p) => selecionadas.includes(p.id) && !p.bloqueada);
        if (!escolhidas.length) return d;
        const caixa = escolhidas.length > 1 ? caixaDoGrupo(escolhidas) : { x: 0, y: 0, w: d.largura, h: d.altura };
        const mexidas = new Map(
          escolhidas.map((p) => {
            const campos: Partial<Peca> =
              lado === "esquerda" ? { x: caixa.x } : lado === "direita" ? { x: caixa.x + caixa.w - p.w } : lado === "centro-h" ? { x: caixa.x + (caixa.w - p.w) / 2 } : lado === "topo" ? { y: caixa.y } : lado === "base" ? { y: caixa.y + caixa.h - p.h } : { y: caixa.y + (caixa.h - p.h) / 2 };
            return [p.id, campos];
          }),
        );
        return { ...d, pecas: d.pecas.map((p) => (mexidas.has(p.id) ? ({ ...p, ...mexidas.get(p.id) } as Peca) : p)) };
      }),
    [selecionadas, trocar],
  );
  const distribuir = useCallback(
    (eixo: "h" | "v") =>
      trocar((d) => {
        const escolhidas = d.pecas.filter((p) => selecionadas.includes(p.id) && !p.bloqueada);
        if (escolhidas.length < 3) return d;
        const ordem = [...escolhidas].sort((a, b) => (eixo === "h" ? a.x - b.x : a.y - b.y));
        const caixa = caixaDoGrupo(ordem);
        const soma = ordem.reduce((s, p) => s + (eixo === "h" ? p.w : p.h), 0);
        const vao = ((eixo === "h" ? caixa.w : caixa.h) - soma) / (ordem.length - 1);
        let pos = eixo === "h" ? caixa.x : caixa.y;
        const mexidas = new Map<string, Partial<Peca>>();
        for (const p of ordem) {
          mexidas.set(p.id, eixo === "h" ? { x: pos } : { y: pos });
          pos += (eixo === "h" ? p.w : p.h) + vao;
        }
        return { ...d, pecas: d.pecas.map((p) => (mexidas.has(p.id) ? ({ ...p, ...mexidas.get(p.id) } as Peca) : p)) };
      }),
    [selecionadas, trocar],
  );

  const escrever = useCallback((id: string, texto: string, html: string | null) => mudarDoPainel(id, { texto, html } as Partial<PecaDeTexto>), [mudarDoPainel]);

  const agirPeloMenu = useCallback(
    (acao: AcaoDoMenu) => {
      const d = docRef.current;
      if (!d) return;
      const pecas = d.pecas.filter((p) => selecionadas.includes(p.id));
      if (acao === "copiar") copiadas.current = pecas;
      else if (acao === "colar" && copiadas.current.length) copiadas.current = colarCopias(copiadas.current);
      else if (acao === "duplicar") duplicar(selecionadas);
      else if (acao === "apagar") apagar(selecionadas);
      else if (acao === "frente" || acao === "tras") ordenar(selecionadas, acao);
      else if (acao === "agrupar" || acao === "desagrupar") agruparOuDesagrupar();
      else if (acao === "travar") {
        const travar = !pecas.every((p) => p.bloqueada);
        trocar((doc) => ({ ...doc, pecas: doc.pecas.map((p) => (selecionadas.includes(p.id) ? ({ ...p, bloqueada: travar } as Peca) : p)) }));
      } else if (acao === "esconder") {
        const esconder = !pecas.every((p) => p.oculta);
        trocar((doc) => ({ ...doc, pecas: doc.pecas.map((p) => (selecionadas.includes(p.id) ? ({ ...p, oculta: esconder } as Peca) : p)) }));
        if (esconder) setSelecionadas([]);
      } else if (acao === "copiar-estilo") copiarEstilo(false);
      else if (acao === "colar-estilo") colarEstilo(selecionadas);
      else if (acao === "escolher-tudo") setSelecionadas(d.pecas.filter((p) => !p.oculta && !p.bloqueada).map((p) => p.id));
    },
    [agruparOuDesagrupar, apagar, colarCopias, colarEstilo, copiarEstilo, duplicar, ordenar, selecionadas, trocar],
  );

  /* Peça arrastada do painel e solta na arte: entra centrada onde soltou. */
  const soltarDoPainel = useCallback(
    (pecas: Peca[], telaX: number, telaY: number) => {
      const palcoNaTela = document.querySelector<HTMLElement>('[data-editor="gerador"] [data-palco]');
      const k = Number(palcoNaTela?.dataset.palco) || 1;
      const caixa = palcoNaTela?.getBoundingClientRect();
      if (!caixa || !pecas.length) return;
      const x = (telaX - caixa.left) / k;
      const y = (telaY - caixa.top) / k;
      /* Uma foto solta em cima de uma moldura entra nela (a forma e o tamanho ficam). */
      const unica = pecas.length === 1 ? pecas[0] : null;
      if (unica && unica.tipo === "imagem") {
        const alvo = molduraNoPonto(telaX, telaY);
        if (alvo) {
          trocar((doc) => ({ ...doc, pecas: doc.pecas.map((p) => (p.id === alvo.id && p.tipo === "imagem" ? comFotoNova(p, unica.src) : p)) }));
          setSelecionadas([alvo.id]);
          return;
        }
      }
      const juntas = caixaDoGrupo(pecas);
      const dx = x - (juntas.x + juntas.w / 2);
      const dy = y - (juntas.y + juntas.h / 2);
      const postas = pecas.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy }) as Peca);
      if (postas.length === 1) por(postas[0]);
      else porVarias(postas);
    },
    [molduraNoPonto, por, porVarias, trocar],
  );

  /* --------------------------------------------------------- redimensionar */

  /*
   * Texto que passou da caixa (depois de reorganizar, ou com um texto maior
   * que o do modelo) diminui até caber. A conta é feita no próprio palco: o
   * navegador diz quanto o texto ocupa de verdade com aquela fonte.
   */
  const encaixarTextos = useCallback(
    (vezes = 3) => {
      const atual = docRef.current;
      if (!atual) return;
      const mudancas: Array<{ id: string; campos: Partial<PecaDeTexto> }> = [];
      for (const p of atual.pecas) {
        /* Decoração (letra gigante apagada ou só contorno) passa da caixa de propósito. */
        if (p.tipo !== "texto" || p.oculta || p.opacidade < 0.35 || p.pintura.tipo === "nenhuma" || p.curva) continue;
        const caixa = document.querySelector<HTMLElement>(`[data-editor="gerador"] [data-peca="${CSS.escape(p.id)}"] [data-texto]`)?.parentElement;
        if (!caixa) continue;
        const sobraY = caixa.scrollHeight / Math.max(1, caixa.clientHeight);
        const sobraX = caixa.scrollWidth / Math.max(1, caixa.clientWidth);
        const excesso = Math.max(sobraY, sobraX);
        if (excesso <= 1.08) continue;
        mudancas.push({ id: p.id, campos: encolherTexto(p, Math.max(0.3, 0.97 / excesso)) });
      }
      if (!mudancas.length) return;
      const porId = new Map(mudancas.map((m) => [m.id, m.campos]));
      trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (porId.has(p.id) ? ({ ...p, ...porId.get(p.id) } as Peca) : p)) }), false);
      if (vezes > 1) window.requestAnimationFrame(() => window.requestAnimationFrame(() => encaixarTextos(vezes - 1)));
    },
    [trocar],
  );

  /*
   * A letra no maior tamanho que cabe na caixa (a conta procura o tamanho no
   * próprio palco, sem redesenhar): o "Diminuir a letra" do aviso e o texto
   * recém-posto (o título pronto nascia maior que a caixa dele).
   */
  const encaixarExato = useCallback(
    (ids: string[]) => {
      const atual = docRef.current;
      if (!atual) return;
      const raiz = document.querySelector('[data-editor="gerador"] [data-palco]');
      const porId = new Map<string, Partial<PecaDeTexto>>();
      for (const p of atual.pecas) {
        if (p.tipo !== "texto" || !ids.includes(p.id) || p.curva) continue;
        const achado = camposDoTexto(raiz, p.id);
        if (!achado) continue;
        const f = fatorQueCabe(p, achado.caixa, achado.campo);
        if (f < 0.999) porId.set(p.id, encolherTexto(p, f));
      }
      if (porId.size) trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (porId.has(p.id) ? ({ ...p, ...porId.get(p.id) } as Peca) : p)) }), false);
    },
    [trocar],
  );
  encaixarNovo.current = (id: string) => {
    const medir = () => window.requestAnimationFrame(() => encaixarExato([id]));
    window.requestAnimationFrame(() => window.requestAnimationFrame(() => (document.fonts?.ready ? void document.fonts.ready.then(medir) : medir())));
  };

  /* "Diminuir a letra" do aviso de texto que não cabe: guarda para o desfazer e encaixa só esse. */
  const diminuirTexto = useCallback(
    (id: string) => {
      guardar();
      encaixarExato([id]);
    },
    [encaixarExato, guardar],
  );

  /* Espera o desenho novo pedir as fontes, as fontes chegarem, e só então mede. */
  const encaixarDepois = useCallback(() => {
    const medir = () => window.setTimeout(() => window.requestAnimationFrame(() => encaixarTextos()), 150);
    window.requestAnimationFrame(() =>
      window.requestAnimationFrame(() => {
        if (document.fonts?.ready) void document.fonts.ready.then(medir);
        else window.setTimeout(medir, 400);
      }),
    );
  }, [encaixarTextos]);

  /* Com várias páginas, o tamanho escolhido espera a pessoa dizer: só esta página ou todas. */
  const [tamanhoPendente, setTamanhoPendente] = useState<TamanhoEscolhido | null>(null);
  const redimensionar = useCallback(
    (t: TamanhoEscolhido, alcance?: "pagina" | "todas") => {
      const lista = todasAsPaginas();
      if (lista.length > 1 && !alcance) {
        setTamanhoPendente(t);
        return;
      }
      guardar();
      const atual = paginaRef.current;
      /* O tamanho novo leva a resolução de impressão dele (ou deixa de ser de impressão). */
      const comResolucao = (pagina: DocumentoDoBanner): DocumentoDoBanner => {
        const { impressao: _antiga, ...resto } = pagina;
        void _antiga;
        return t.dpi ? { ...resto, impressao: { dpi: t.dpi } } : resto;
      };
      const novas = lista.map((pagina, i) => (alcance === "pagina" && i !== atual ? pagina : comResolucao(redimensionarComInteligencia(pagina, t.largura, t.altura, loja?.cor))));
      novas.forEach((pagina, i) => (alcance !== "pagina" || i === atual) && paraEncaixar.current.add(pagina));
      mostrarProjeto({ paginas: novas, atual });
      /* O formato da arte (o da galeria) é o da primeira página. */
      if (alcance !== "pagina" || atual === 0) setFormato(t.formato);
      setTamanhoPendente(null);
      setTamanhoAberto(false);
      setSelecionadas([]);
      setZoom(null);
      encaixarDepois();
      const quais = alcance === "pagina" ? `A página ${atual + 1} foi reorganizada` : novas.length > 1 ? `As ${novas.length} páginas foram reorganizadas` : "Arte reorganizada";
      avisar(`${quais} em ${t.largura} × ${t.altura}. Se quiser a de antes, Ctrl+Z.`);
    },
    [avisar, encaixarDepois, guardar, loja?.cor, mostrarProjeto, todasAsPaginas],
  );

  /*
   * Carrossel contínuo: a página aberta vira uma tela larga com N posts
   * (cada um de até 1080 px; a arte não passa de 6000 px de largura). O que
   * já está nela fica no primeiro post; o fundo da arte estica para a tela
   * toda. "Uma página só" volta ao primeiro post.
   */
  const virarCarrossel = useCallback(
    (posts: number | null, quadrado = false) => {
      const lista = todasAsPaginas();
      const atual = paginaRef.current;
      const pagina = lista[atual];
      if (!pagina) return;
      guardar();
      let nova: DocumentoDoBanner;
      if (!posts) {
        const largura = Math.round(pagina.largura / (pagina.fatias || 1));
        nova = { ...pagina, largura, fatias: undefined, pecas: pagina.pecas.map((p) => (ehFundoDaArte(p, pagina.largura, pagina.altura) ? ({ ...p, w: largura } as Peca) : p)) };
      } else {
        const lado = Math.min(1080, Math.floor(6000 / posts));
        const altura = quadrado ? lado : Math.round(lado * 1.25);
        const larguraDoPost = pagina.fatias ? pagina.largura / pagina.fatias : pagina.largura;
        const base = Math.round(larguraDoPost) === lado && pagina.altura === altura ? { ...pagina, largura: lado } : redimensionarComInteligencia({ ...pagina, largura: Math.round(larguraDoPost), fatias: undefined }, lado, altura, loja?.cor);
        nova = { ...base, largura: lado * posts, altura, fatias: posts, pecas: base.pecas.map((p) => (ehFundoDaArte(p, lado, altura) ? ({ ...p, x: 0, w: lado * posts } as Peca) : p)) };
      }
      mostrarProjeto({ paginas: lista.map((p, i) => (i === atual ? nova : p)), atual });
      setTamanhoAberto(false);
      setSelecionadas([]);
      setZoom(null);
      avisar(posts ? `Carrossel contínuo de ${posts} posts: o que atravessar a linha continua no próximo. Ao baixar, sai um arquivo por post.` : "Voltou a ser uma página só (o primeiro post).");
    },
    [avisar, guardar, loja?.cor, mostrarProjeto, todasAsPaginas],
  );

  /*
   * Trocar a paleta da arte inteira (Marca → Paleta da arte): a página aberta
   * ou todas, com a mesma troca de cor em todas; Ctrl+Z desfaz.
   */
  const trocarAPaleta = useCallback(
    (cores: string[], todas: boolean, vezes: number) => {
      const lista = todasAsPaginas();
      const atual = paginaRef.current;
      if (!lista[atual]) return;
      guardar();
      const paleta = girarPaleta(cores, vezes);
      const novas = todas ? trocarPaleta(lista, paleta) : lista.map((p, i) => (i === atual ? trocarPaleta([p], paleta)[0] : p));
      mostrarProjeto({ paginas: novas, atual });
      avisar(vezes ? "Embaralhado. Clique de novo para outra combinação; Ctrl+Z desfaz." : "Paleta trocada. Clique de novo na mesma para embaralhar; Ctrl+Z desfaz.");
    },
    [avisar, guardar, mostrarProjeto, todasAsPaginas],
  );

  /*
   * "Copiar em outros tamanhos": cada tamanho marcado vira uma arte nova em
   * Suas artes, com todas as páginas reorganizadas (o mesmo do Redimensionar)
   * e os textos já encaixados fora do palco. A arte aberta não muda.
   */
  const [copiando, setCopiando] = useState(false);
  const criarCopiasEmTamanhos = useCallback(
    async (formatos: FormatoDaArte[]) => {
      const dono = donoRef.current;
      if (!dono || !formatos.length) {
        avisar("Não deu para criar as cópias agora (a galeria ainda não abriu).", "ruim");
        return;
      }
      setCopiando(true);
      try {
        const lista = todasAsPaginas().map(comFontesDaMarcaRef.current);
        for (const f of formatos) {
          const paginasDoTamanho: DocumentoDoBanner[] = [];
          for (const pagina of lista) {
            const { impressao: _antiga, ...resto } = redimensionarComInteligencia(pagina, f.largura, f.altura, loja?.cor);
            void _antiga;
            paginasDoTamanho.push(await encaixarForaDoPalco(f.dpi ? { ...resto, impressao: { dpi: f.dpi } } : resto));
          }
          await criarArte(dono, { nome: `${nomeRef.current} · ${f.nome}`.slice(0, 120), largura: f.largura, altura: f.altura, formato: f.chave, documento: documentoDasPaginas(paginasDoTamanho) });
        }
        setTamanhoAberto(false);
        setPainel({ tipo: "biblioteca", aba: "artes" });
        void carregarArtes();
        avisar(formatos.length === 1 ? `Cópia em ${formatos[0].nome} criada em Suas artes.` : `${formatos.length} cópias criadas em Suas artes: ${formatos.map((f) => f.nome).join(", ")}.`);
      } catch (erro) {
        avisar(erro instanceof Error ? erro.message : "Não deu para criar as cópias.", "ruim");
      } finally {
        setCopiando(false);
      }
    },
    [avisar, carregarArtes, loja?.cor, todasAsPaginas],
  );

  /* -------------------------------------------------------------- páginas */

  const irParaPagina = useCallback(
    (i: number) => {
      const lista = todasAsPaginas();
      if (i === paginaRef.current || i < 0 || i >= lista.length) return;
      paginasRef.current = lista;
      paginaRef.current = i;
      setPaginas(lista);
      setPaginaAtual(i);
      docRef.current = lista[i];
      setDoc(lista[i]);
      setSelecionadas([]);
      setEditando(null);
      setPainel((p) => (p?.tipo === "ajuste" ? null : p));
      if (paraEncaixar.current.has(lista[i])) encaixarDepois();
    },
    [encaixarDepois, todasAsPaginas],
  );

  /* Toda operação de página entra no desfazer. */
  const mudarPaginas = useCallback(
    (fazer: (lista: DocumentoDoBanner[], atual: number) => Projeto | null, mexeNosAudios = false) => {
      const lista = todasAsPaginas();
      const feito = fazer(lista, paginaRef.current);
      if (!feito) return;
      /* A trilha de áudio é do projeto: mora sempre na primeira página (mudar a ordem, duplicar ou dividir não leva nem copia). */
      const proximo = mexeNosAudios ? feito : { ...feito, paginas: comAudios(feito.paginas, audiosDoProjeto(lista)) };
      guardar();
      mostrarProjeto(proximo);
      setSelecionadas([]);
      setEditando(null);
      setPainel((p) => (p?.tipo === "ajuste" ? null : p));
    },
    [guardar, mostrarProjeto, todasAsPaginas],
  );

  const adicionarPagina = useCallback(
    (jeito: "branca" | "duplicar") =>
      mudarPaginas((lista, atual) => {
        if (lista.length >= LIMITE_DE_PAGINAS) return null;
        const base = lista[atual];
        const nova = jeito === "branca" ? paginaNovaComAsFixas(base) : copiaDaPagina(base);
        return { paginas: [...lista.slice(0, atual + 1), nova, ...lista.slice(atual + 1)], atual: atual + 1 };
      }),
    [mudarPaginas],
  );

  const agirNaPagina = useCallback(
    (i: number, acao: AcaoNaPagina) => {
      mudarPaginas((lista, atual) => {
        if (acao === "duplicar") {
          if (lista.length >= LIMITE_DE_PAGINAS) return null;
          return { paginas: [...lista.slice(0, i + 1), copiaDaPagina(lista[i]), ...lista.slice(i + 1)], atual: i + 1 };
        }
        if (acao === "apagar") {
          if (lista.length < 2) return null;
          const resto = lista.filter((_, j) => j !== i);
          return { paginas: resto, atual: atual > i ? atual - 1 : Math.min(atual, resto.length - 1) };
        }
        const j = acao === "esquerda" ? i - 1 : i + 1;
        if (j < 0 || j >= lista.length) return null;
        const trocadas = [...lista];
        [trocadas[i], trocadas[j]] = [trocadas[j], trocadas[i]];
        return { paginas: trocadas, atual: atual === i ? j : atual === j ? i : atual };
      });
      if (acao === "apagar") avisar("Página apagada. Ctrl+Z traz de volta.");
    },
    [avisar, mudarPaginas],
  );

  /* Arrastar na tira ou na grade: a página sai de "de" e fica na posição "para" (a aberta continua aberta). */
  const moverPagina = useCallback(
    (de: number, para: number) =>
      mudarPaginas((lista, atual) => {
        if (de === para || de < 0 || para < 0 || de >= lista.length || para >= lista.length) return null;
        const nova = [...lista];
        const [movida] = nova.splice(de, 1);
        nova.splice(para, 0, movida);
        return { paginas: nova, atual: nova.indexOf(lista[atual]) };
      }),
    [mudarPaginas],
  );

  const [gradeAberta, setGradeAberta] = useState(false);
  const fecharGrade = useCallback(() => setGradeAberta(false), []);

  /* ----------------------------------------------------------------- zoom */

  const escala = zoom ?? escalaQueCabe;
  const aproximar = useCallback((fator: number) => setZoom((z) => Math.min(ZOOM_MAXIMO, Math.max(ZOOM_MINIMO, Math.round((z ?? escalaQueCabe) * fator * 100) / 100))), [escalaQueCabe]);
  /* A roda do mouse e a pinça: o palco escala ao vivo e manda a escala quando o gesto para (sem arredondar, senão o touchpad não sai do lugar). */
  const fixarZoomDoPalco = useCallback((k: number) => setZoom(Math.min(ZOOM_MAXIMO, Math.max(ZOOM_MINIMO, k))), []);
  /* Durante o gesto, o número e a faixa do zoom acompanham direto na página (sem redesenhar o editor). */
  const numeroDoZoom = useRef<HTMLSpanElement | null>(null);
  const faixaDoZoom = useRef<HTMLInputElement | null>(null);
  const zoomVivoDoPalco = useCallback((k: number) => {
    if (numeroDoZoom.current) numeroDoZoom.current.textContent = `${Math.round(k * 100)}%`;
    if (faixaDoZoom.current) faixaDoZoom.current.value = String(Math.round(k * 100));
  }, []);

  /* --------------------------------------------------------------- imagem */

  const enviarImagem = useCallback(
    async (lista: FileList | File[] | null, naMoldura: string | null = null) => {
      const escolhidos = lista ? Array.from(lista).filter((f) => /^image\/(png|jpe?g|webp)$/.test(f.type)).slice(0, 20) : [];
      const atual = docRef.current;
      if (!escolhidos.length || !atual) {
        if (lista && Array.from(lista).length) avisar("Use imagens PNG, JPG ou WebP.", "ruim");
        return;
      }
      setEnviando(true);
      /* Várias de uma vez: sobem juntas e entram na arte uma deslocada da outra (na moldura, só a primeira). */
      const supabase = getSupabaseClient();
      const subir = async (arquivo: File) => {
        const pronto = await encolherParaUpload(arquivo);
        const extensao = pronto.name.split(".").pop() || "webp";
        const caminho = `catalog/sections/pagina-inicial/editor/imagens/${Math.random().toString(36).slice(2, 12)}_${Date.now()}.${extensao}`;
        const { error } = await supabase.storage.from("products").upload(caminho, pronto);
        if (error) throw error;
        const bitmap = await createImageBitmap(pronto).catch(() => null);
        return { url: supabase.storage.from("products").getPublicUrl(caminho).data.publicUrl, proporcao: bitmap ? bitmap.width / bitmap.height : 1 };
      };
      const resultados = await Promise.allSettled((naMoldura ? escolhidos.slice(0, 1) : escolhidos).map(subir));
      const subidas = resultados.flatMap((r) => (r.status === "fulfilled" ? [r.value] : []));
      try {
        if (!subidas.length) throw new Error("nenhuma");
        if (naMoldura && docRef.current?.pecas.some((p) => p.id === naMoldura)) {
          const url = subidas[0].url;
          trocar((doc) => ({ ...doc, pecas: doc.pecas.map((p) => (p.id === naMoldura && p.tipo === "imagem" ? comFotoNova(p, url) : p)) }));
          setSelecionadas([naMoldura]);
        } else if (subidas.length === 1) por(imagem({ largura: atual.largura, altura: atual.altura }, subidas[0].url, "foto", subidas[0].proporcao));
        else {
          const passo = Math.min(atual.largura, atual.altura) * 0.04;
          porVarias(
            subidas.map((s, i) => {
              const peca = imagem({ largura: atual.largura, altura: atual.altura }, s.url, "foto", s.proporcao);
              return { ...peca, x: peca.x + passo * (i - (subidas.length - 1) / 2), y: peca.y + passo * (i - (subidas.length - 1) / 2) };
            }),
          );
        }
        setEnviadasAgora((l) => [...subidas.map((s) => s.url), ...l]);
        const falharam = resultados.length - subidas.length;
        if (falharam) avisar(`${falharam} ${falharam === 1 ? "imagem não subiu" : "imagens não subiram"}. As outras entraram na arte.`, "ruim");
        else if (subidas.length > 1) avisar(`${subidas.length} imagens na arte.`);
      } catch {
        avisar("Não deu para enviar essas imagens. Tente outras (PNG, JPG ou WebP).", "ruim");
      } finally {
        setEnviando(false);
      }
    },
    [avisar, por, porVarias, trocar],
  );

  /* Tirar o fundo da foto (no navegador): a foto nova vai para o depósito e troca a da peça (o Ctrl+Z volta). */
  const [fundoSaindo, setFundoSaindo] = useState<{ id: string; texto: string; saindo?: boolean } | null>(null);
  /* A foto sem fundo, ainda só no navegador: o palco mostra ela até a do depósito carregar (o documento já guarda a do depósito). */
  const [previaDoFundo, setPreviaDoFundo] = useState<{ id: string; url: string; caixa: { x: number; y: number; w: number; h: number } } | null>(null);
  const docComFundo = useMemo(() => (doc && previaDoFundo ? { ...doc, pecas: doc.pecas.map((p) => (p.id === previaDoFundo.id && p.tipo === "imagem" ? ({ ...p, ...previaDoFundo.caixa, src: previaDoFundo.url, ajuste: "conter", enquadre: null, mascara: null } as PecaDeImagem) : p)) } : doc), [doc, previaDoFundo]);
  const tirarFundoDaFoto = useCallback(
    async (id: string) => {
      const peca = docRef.current?.pecas.find((p) => p.id === id);
      /* Uma de cada vez (a prévia ainda subindo também conta). */
      if (!peca || peca.tipo !== "imagem" || fundoSaindo || previaDoFundo) return;
      setFundoSaindo({ id, texto: "Analisando a foto…" });
      let deuCerto = false;
      let previa: string | null = null;
      try {
        /* A parte da foto que aparece na peça (a moldura pode esconder pedaços). */
        const natural = await new Promise<{ w: number; h: number }>((r) => {
          const i = new Image();
          i.crossOrigin = "anonymous";
          i.onload = () => r({ w: i.naturalWidth, h: i.naturalHeight });
          i.onerror = () => r({ w: peca.w, h: peca.h });
          i.src = peca.src;
        });
        const naCaixa = fotoNaCaixa(peca, natural);
        const visivel = { x0: Math.max(0, -naCaixa.x / naCaixa.w), y0: Math.max(0, -naCaixa.y / naCaixa.h), x1: Math.min(1, (peca.w - naCaixa.x) / naCaixa.w), y1: Math.min(1, (peca.h - naCaixa.y) / naCaixa.h) };
        const { arquivo, corte, pelaCor } = await tirarFundo(peca.src, (e) => setFundoSaindo({ id, texto: e.etapa === "baixando" ? `Baixando a ferramenta ${Math.round(e.progresso * 100)}%` : "Tirando o fundo…" }), visivel);
        /* A peça encolhe para o objeto, no mesmo lugar da arte (o giro e o espelho continuam valendo). */
        const local = { x: naCaixa.x + corte.x0 * naCaixa.w, y: naCaixa.y + corte.y0 * naCaixa.h, w: (corte.x1 - corte.x0) * naCaixa.w, h: (corte.y1 - corte.y0) * naCaixa.h };
        if (peca.espelharX) local.x = peca.w - local.x - local.w;
        if (peca.espelharY) local.y = peca.h - local.y - local.h;
        const rad = (peca.rot * Math.PI) / 180;
        const dx = local.x + local.w / 2 - peca.w / 2;
        const dy = local.y + local.h / 2 - peca.h / 2;
        const centro = { x: peca.x + peca.w / 2 + dx * Math.cos(rad) - dy * Math.sin(rad), y: peca.y + peca.h / 2 + dx * Math.sin(rad) + dy * Math.cos(rad) };
        const caixaNova = { x: centro.x - local.w / 2, y: centro.y - local.h / 2, w: Math.max(4, local.w), h: Math.max(4, local.h) };
        /* O resultado aparece na hora (a foto montada aqui mesmo), enquanto sobe para o depósito por trás. */
        previa = URL.createObjectURL(arquivo);
        setPreviaDoFundo({ id, url: previa, caixa: caixaNova });
        setFundoSaindo({ id, texto: "Pronto", saindo: true });
        window.setTimeout(() => setFundoSaindo((f) => (f?.id === id && f.saindo ? null : f)), 700);
        avisar(pelaCor ? "Fundo de cor lisa tirado (como o de logo), sem sobrar a cor dele na borda. Ctrl+Z volta a imagem como era." : "Fundo tirado. Ctrl+Z volta a foto como era.");
        const supabase = getSupabaseClient();
        const caminho = `catalog/sections/pagina-inicial/editor/imagens/${Math.random().toString(36).slice(2, 12)}_${Date.now()}.${arquivo.type === "image/webp" ? "webp" : "png"}`;
        const { error } = await supabase.storage.from("products").upload(caminho, arquivo, { contentType: arquivo.type });
        if (error) throw new Error("Não deu para guardar a foto sem fundo. Tente de novo.");
        const url = supabase.storage.from("products").getPublicUrl(caminho).data.publicUrl;
        performance.mark("fundo:enviada");
        /* A foto nova já carregada antes de trocar: o efeito some e ela aparece sem piscar. */
        const pronta = new Image();
        pronta.src = url;
        await pronta.decode().catch(() => undefined);
        /* Guarda a foto de antes (o "Restaurar" da borracha pinta com ela). */
        if (docRef.current?.pecas.some((p) => p.id === id))
          trocar((doc) => ({
            ...doc,
            pecas: doc.pecas.map((p) => {
              if (p.id !== id || p.tipo !== "imagem") return p;
              /* O corte em relação à foto original (se já era um corte, o novo fica dentro dele). */
              const antes = p.semFundoDe ? p.corteDoFundo ?? { x0: 0, y0: 0, x1: 1, y1: 1 } : { x0: 0, y0: 0, x1: 1, y1: 1 };
              const lx = antes.x1 - antes.x0;
              const ly = antes.y1 - antes.y0;
              const corteDoFundo = { x0: antes.x0 + corte.x0 * lx, y0: antes.y0 + corte.y0 * ly, x1: antes.x0 + corte.x1 * lx, y1: antes.y0 + corte.y1 * ly };
              return { ...p, ...caixaNova, src: url, ajuste: "conter", enquadre: null, mascara: null, semFundoDe: p.semFundoDe || p.src, corteDoFundo } as PecaDeImagem;
            }),
          }));
        setEnviadasAgora((l) => [url, ...l]);
        performance.mark("fundo:trocada");
        deuCerto = true;
      } catch (erro) {
        avisar(erro instanceof Error ? erro.message : "Não deu para tirar o fundo agora.", "ruim");
      } finally {
        /* A prévia sai quando a foto do depósito já está na peça (ou quando deu errado). */
        setPreviaDoFundo((p) => (p?.id === id ? null : p));
        if (previa) URL.revokeObjectURL(previa);
        if (!deuCerto) setFundoSaindo(null);
      }
    },
    [avisar, fundoSaindo, previaDoFundo, trocar],
  );

  /* A ferramenta da barra do lado: desenhar limpa a escolha; a Forma mantém (mexe no desenho escolhido). */
  const escolherFerramenta = useCallback((f: FerramentaDoPalco) => {
    setFerramenta(f);
    setNos([]);
    if (f === "caneta" || f === "lapis") {
      setSelecionadas([]);
      setEditando(null);
    }
  }, []);

  /* Trocou a peça escolhida: os nós escolhidos eram da outra. */
  const tracoEscolhido = selecionadas.length === 1 ? selecionadas[0] : null;
  useEffect(() => setNos([]), [tracoEscolhido]);

  /* Os botões da Forma na barra de cima: curva, reta, canto, suave, apagar nó, fechar ou abrir. */
  const mexerNosDoTraco = useCallback(
    (acao: TipoDoNo | "apagar" | "fechar") => {
      const traco = docRef.current?.pecas.find((p) => p.id === tracoEscolhido);
      if (!traco || traco.tipo !== "traco") return;
      const pontos = comAlcas(traco.pontos, traco.fechado, traco.suave);
      const escolhidos = nos.length ? nos.filter((i) => i < pontos.length) : pontos.map((_, i) => i);
      let campos: Partial<PecaDeTraco>;
      if (acao === "apagar") {
        const resto = apagarNos(pontos, escolhidos, traco.fechado);
        if (!resto) {
          avisar(traco.fechado ? "A forma precisa de pelo menos 3 nós." : "A linha precisa de pelo menos 2 nós.", "ruim");
          return;
        }
        campos = { pontos: resto };
        setNos([]);
      } else if (acao === "fechar") campos = { fechado: !traco.fechado, ...(traco.fechado ? {} : { inicio: "nada", fim: "nada" }) } as Partial<PecaDeTraco>;
      else campos = { pontos: mudarNos(pontos, escolhidos, acao, traco.fechado) };
      const novo = { ...traco, ...campos, suave: false } as PecaDeTraco;
      trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (p.id === traco.id ? ({ ...novo, ...(tracoJusto(novo) || {}) } as Peca) : p)) }));
    },
    [avisar, nos, tracoEscolhido, trocar],
  );

  /*
   * "Colocar dentro" (máscara de recorte): das duas escolhidas, o recipiente é o
   * texto (a outra, foto, vira o recheio) ou a que serve de silhueta; com as duas
   * servindo, a de baixo recebe a de cima. A de dentro passa para logo acima do
   * recipiente.
   */
  const paraColocarDentro = useCallback((pecas: Peca[]) => {
    if (pecas.length !== 2) return null;
    const [a, b] = pecas;
    const ordem = docRef.current?.pecas || [];
    const [debaixo, decima] = ordem.indexOf(a) < ordem.indexOf(b) ? [a, b] : [b, a];
    if (debaixo.tipo === "texto" && decima.tipo === "imagem") return { recipiente: debaixo, dentro: decima };
    if (decima.tipo === "texto" && debaixo.tipo === "imagem") return { recipiente: decima, dentro: debaixo };
    if (podeSerRecipiente(debaixo, decima) && debaixo.tipo !== "texto") return { recipiente: debaixo, dentro: decima };
    if (podeSerRecipiente(decima, debaixo) && decima.tipo !== "texto") return { recipiente: decima, dentro: debaixo };
    return null;
  }, []);
  const colocarDentro = useCallback(() => {
    const pecas = (docRef.current?.pecas || []).filter((p) => selecionadas.includes(p.id));
    const par = paraColocarDentro(pecas);
    if (!par) return;
    trocar((d) => {
      const resto = d.pecas.filter((p) => p.id !== par.dentro.id);
      const onde = resto.findIndex((p) => p.id === par.recipiente.id);
      const dentro = { ...par.dentro, dentroDe: par.recipiente.id, grupo: undefined } as Peca;
      return { ...d, pecas: [...resto.slice(0, onde + 1), dentro, ...resto.slice(onde + 1)] };
    });
    setSelecionadas([par.dentro.id]);
    avisar(par.recipiente.tipo === "texto" ? "A foto virou o recheio das letras. Arraste a foto para escolher a parte que aparece." : "Pronto: aparece só dentro da outra. Arraste para mudar o que aparece; Ctrl+clique pega a de baixo.");
  }, [avisar, paraColocarDentro, selecionadas, trocar]);
  const tirarDeDentro = useCallback(
    (id: string) => trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (p.id === id ? ({ ...p, dentroDe: undefined } as Peca) : p)) })),
    [trocar],
  );

  /*
   * Soldar, Aparar, Interseção e Frente menos trás (como no Corel): as formas
   * escolhidas viram um desenho só, no lugar da que recebe o corte (a de baixo;
   * na "frente menos trás", a de cima). As outras saem da arte.
   */
  const [operando, setOperando] = useState(false);
  const operarNasFormas = useCallback(
    async (operacao: OperacaoDeForma) => {
      const d = docRef.current;
      if (!d || operando) return;
      const pecas = d.pecas.filter((p) => selecionadas.includes(p.id));
      if (pecas.some((p) => p.bloqueada)) {
        avisar("Destrave as peças antes.", "ruim");
        return;
      }
      setOperando(true);
      try {
        const r = await operarFormas(pecas, operacao);
        if (!r.ok) {
          avisar(r.erro, "ruim");
          return;
        }
        trocar((doc) => ({
          ...doc,
          pecas: doc.pecas
            .filter((p) => !r.saem.includes(p.id))
            .map((p) => (p.id === r.peca.id ? r.peca : p.dentroDe && r.saem.includes(p.dentroDe) ? ({ ...p, dentroDe: undefined } as Peca) : p)),
        }));
        setSelecionadas([r.peca.id]);
        avisar(operacao === "aparar" ? "Aparado: as de cima recortaram a de baixo. Ctrl+Z desfaz." : operacao === "soldar" ? "Soldadas num desenho só. Ctrl+Z desfaz." : "Pronto. Ctrl+Z desfaz.");
      } catch {
        avisar("Não deu para fazer o corte dessas formas.", "ruim");
      } finally {
        setOperando(false);
      }
    },
    [avisar, operando, selecionadas, trocar],
  );
  const separarDesenho = useCallback(
    async (id: string) => {
      const traco = docRef.current?.pecas.find((p) => p.id === id);
      if (!traco || !daParaSeparar(traco)) return;
      const pedacos = await separarContornos(traco, () => novoId("tr"));
      trocar((doc) => {
        const onde = doc.pecas.findIndex((p) => p.id === id);
        if (onde < 0) return doc;
        return { ...doc, pecas: [...doc.pecas.slice(0, onde), ...pedacos, ...doc.pecas.slice(onde + 1)] };
      });
      setSelecionadas(pedacos.map((p) => p.id));
      avisar(`Separado em ${pedacos.length} ${pedacos.length === 1 ? "peça" : "peças"}.`);
    },
    [avisar, trocar],
  );

  /* "Converter em curvas" (Ctrl+Q): a forma pronta vira desenho com nós e a Forma liga. */
  const converterEmCurvas = useCallback(
    (id: string) => {
      const forma = docRef.current?.pecas.find((p) => p.id === id);
      if (!forma || forma.tipo !== "forma") return;
      const traco = formaEmCurvas(forma);
      if (!traco) {
        avisar("Essa forma não dá para converter em curvas.", "ruim");
        return;
      }
      trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (p.id === id ? traco : p)) }));
      setSelecionadas([id]);
      setFerramenta("forma");
      setNos([]);
      avisar("Virou curvas: arraste os nós, as alças ou a linha.");
    },
    [avisar, trocar],
  );

  /* As páginas da tira e da grade (as fixas já iguais em todas). */
  const paginasVistas = useMemo(() => (doc ? sincronizarFixas(paginas.map((pagina, i) => (i === paginaAtual ? doc : pagina)), paginaAtual) : paginas), [doc, paginas, paginaAtual]);
  /* A arte usa uma fonte da biblioteca grande: o CSS dela entra agora (cada família uma vez só). */
  useEffect(() => garantirFontes(fontesDoDocumento(paginasVistas)), [paginasVistas]);
  const paginasDaTira = usePaginasDaTira(paginasVistas, paginaAtual);

  /* Fixar em todas as páginas (a página mestre): liga e desliga; ao soltar, as cópias das outras páginas saem. */
  const fixarEmTodas = useCallback(
    (id: string) => {
      const peca = docRef.current?.pecas.find((p) => p.id === id);
      if (!peca) return;
      const fixar = !peca.fixa;
      trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (p.id === id ? ({ ...p, fixa: fixar || undefined } as Peca) : p)) }));
      avisar(fixar ? "Fixa em todas as páginas: mude aqui e muda em todas." : "Agora só nesta página.");
    },
    [avisar, trocar],
  );


  /* A borracha da foto (limpar restos): a foto nova sobe e troca a da peça (Ctrl+Z volta). */
  const [apagando, setApagando] = useState<string | null>(null);
  const terminarBorracha = useCallback(
    async (id: string, arquivo: File) => {
      try {
        const supabase = getSupabaseClient();
        const caminho = `catalog/sections/pagina-inicial/editor/imagens/${Math.random().toString(36).slice(2, 12)}_${Date.now()}.${arquivo.type === "image/webp" ? "webp" : "png"}`;
        const { error } = await supabase.storage.from("products").upload(caminho, arquivo, { contentType: arquivo.type });
        if (error) throw error;
        const url = supabase.storage.from("products").getPublicUrl(caminho).data.publicUrl;
        const pronta = new Image();
        pronta.src = url;
        await pronta.decode().catch(() => undefined);
        trocar((doc) => ({ ...doc, pecas: doc.pecas.map((p) => (p.id === id && p.tipo === "imagem" ? ({ ...p, src: url } as PecaDeImagem) : p)) }));
        setApagando(null);
        avisar("Foto limpa. Ctrl+Z volta como estava.");
      } catch {
        avisar("Não deu para guardar a foto limpa. Tente de novo.", "ruim");
      }
    },
    [avisar, trocar],
  );

  /*
   * O tratamento da foto (trocar cores e retícula): cada mudança mostra a
   * prévia no palco na hora (um arquivo leve, só na tela) e, quando a pessoa
   * para de mexer, o arquivo de verdade sobe e entra na peça junto com o
   * tratamento (um passo do Ctrl+Z). Até lá, o painel lê o pendente.
   */
  const [tratamentosPendentes, setTratamentosPendentes] = useState<Record<string, TratamentoDaFoto>>({});
  const pendentesRef = useRef<Record<string, TratamentoDaFoto>>({});
  const [previasDasFotos, setPreviasDasFotos] = useState<Record<string, string>>({});
  const [aplicandoTratamento, setAplicandoTratamento] = useState<string | null>(null);
  const vezDoTratamento = useRef<Record<string, number>>({});
  const guardarDepois = useRef<Record<string, number>>({});
  /* A cor da foto escolhida nas bolinhas (abre "Trocar cores" nela); vale só para aquela peça. */
  const [corDaFoto, setCorDaFoto] = useState<{ id: string; cor: string | null } | null>(null);
  const porPrevia = useCallback((id: string, url: string | null) => {
    setPreviasDasFotos((atuais) => {
      const velha = atuais[id];
      if (velha?.startsWith("blob:") && velha !== url) URL.revokeObjectURL(velha);
      const novas = { ...atuais };
      if (url) novas[id] = url;
      else delete novas[id];
      return novas;
    });
  }, []);
  /* O tratamento guardado vale se o src da peça ainda é o arquivo que ele gerou (tirar fundo, borracha e trocar a foto fazem outro). */
  const tratamentoGuardado = (p: PecaDeImagem) => (p.tratamento && p.tratamento.feito === p.src ? p.tratamento : null);
  const tratamentoDaFoto = (p: PecaDeImagem): TratamentoDaFoto | null => tratamentosPendentes[p.id] ?? tratamentoGuardado(p);
  const origemDaFoto = (p: PecaDeImagem) => tratamentosPendentes[p.id]?.de ?? tratamentoGuardado(p)?.de ?? p.src;
  const guardarTratamento = useCallback(
    async (id: string, novo: TratamentoDaFoto, vez: number) => {
      const atual = () => vezDoTratamento.current[id] === vez;
      setAplicandoTratamento(id);
      try {
        let src = novo.de;
        let tratamento: TratamentoDaFoto | null = null;
        if (temTratamento(novo)) {
          const arquivo = await gerarTratamento(novo, true);
          if (!atual()) return;
          const extensao = arquivo.type === "image/svg+xml" ? "svg" : arquivo.type === "image/webp" ? "webp" : "png";
          const supabase = getSupabaseClient();
          const caminho = `catalog/sections/pagina-inicial/editor/imagens/${Math.random().toString(36).slice(2, 12)}_${Date.now()}.${extensao}`;
          const { error } = await supabase.storage.from("products").upload(caminho, arquivo, { contentType: arquivo.type, cacheControl: "31536000" });
          if (error) throw error;
          src = supabase.storage.from("products").getPublicUrl(caminho).data.publicUrl;
          const pronta = new Image();
          pronta.src = src;
          await pronta.decode().catch(() => undefined);
          /* Mexeu de novo enquanto subia: este arquivo não vai para a arte (sai do depósito). */
          if (!atual()) {
            void supabase.storage.from("products").remove([caminho]);
            return;
          }
          tratamento = { ...novo, feito: src };
        }
        trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (p.id === id && p.tipo === "imagem" ? ({ ...p, src, tratamento } as PecaDeImagem) : p)) }));
        delete pendentesRef.current[id];
        setTratamentosPendentes({ ...pendentesRef.current });
        porPrevia(id, null);
      } catch {
        if (atual()) avisar("Não deu para aplicar na foto agora. Mexa de novo para tentar outra vez.", "ruim");
      } finally {
        if (atual()) setAplicandoTratamento((a) => (a === id ? null : a));
      }
    },
    [avisar, porPrevia, trocar],
  );
  const tratarFoto = useCallback(
    (id: string, campos: { trocas?: TrocaDeCor[]; meioTom?: MeioTom | null }) => {
      const p = docRef.current?.pecas.find((q) => q.id === id);
      if (!p || p.tipo !== "imagem") return;
      const atual = pendentesRef.current[id] ?? (p.tratamento && p.tratamento.feito === p.src ? p.tratamento : null);
      const de = atual?.de ?? p.src;
      const trocas = campos.trocas !== undefined ? campos.trocas : atual?.trocas;
      const meioTom = campos.meioTom !== undefined ? campos.meioTom : atual?.meioTom;
      const novo: TratamentoDaFoto = { de, ...(trocas?.length ? { trocas } : {}), ...(meioTom ? { meioTom } : {}) };
      const vez = (vezDoTratamento.current[id] ?? 0) + 1;
      vezDoTratamento.current[id] = vez;
      pendentesRef.current[id] = novo;
      setTratamentosPendentes({ ...pendentesRef.current });
      if (!temTratamento(novo)) porPrevia(id, de);
      else
        void gerarTratamento(novo, false)
          .then((arquivo) => {
            if (vezDoTratamento.current[id] === vez) porPrevia(id, URL.createObjectURL(arquivo));
          })
          .catch(() => {
            if (vezDoTratamento.current[id] === vez) avisar("Essa imagem vem de outro site e o navegador não deixa mexer nas cores dela. Envie a imagem pelo botão Enviar imagem.", "ruim");
          });
      window.clearTimeout(guardarDepois.current[id]);
      guardarDepois.current[id] = window.setTimeout(() => void guardarTratamento(id, novo, vez), 700);
    },
    [avisar, guardarTratamento, porPrevia],
  );
  useEffect(() => {
    const timers = guardarDepois.current;
    return () => Object.values(timers).forEach((t) => window.clearTimeout(t));
  }, []);
  /* A prévia do tratamento no palco (só na tela; a peça guardada não muda até o arquivo subir). */
  const docNoPalco = useMemo(
    () => (docComFundo && Object.keys(previasDasFotos).length ? { ...docComFundo, pecas: docComFundo.pecas.map((p) => (p.tipo === "imagem" && previasDasFotos[p.id] ? ({ ...p, src: previasDasFotos[p.id] } as PecaDeImagem) : p)) } : docComFundo),
    [docComFundo, previasDasFotos],
  );

  /* Escolheu uma foto: deixa a ferramenta de tirar o fundo pronta (só se já estiver no cache). */
  const fotoEscolhida = docRef.current?.pecas.find((p) => selecionadas.length === 1 && p.id === selecionadas[0] && p.tipo === "imagem")?.id ?? null;
  useEffect(() => {
    if (fotoEscolhida) void aquecerTirarFundo();
  }, [fotoEscolhida]);

  /* Colar uma imagem (Ctrl+V com imagem copiada) põe na arte. */
  useEffect(() => {
    const aoColar = (e: ClipboardEvent) => {
      const alvo = e.target as HTMLElement | null;
      if (alvo && (alvo.tagName === "INPUT" || alvo.tagName === "TEXTAREA" || alvo.isContentEditable)) return;
      const arquivos = Array.from(e.clipboardData?.files || []).filter((f) => f.type.startsWith("image/"));
      if (!arquivos.length) return;
      e.preventDefault();
      void enviarImagem(arquivos);
    };
    window.addEventListener("paste", aoColar);
    return () => window.removeEventListener("paste", aoColar);
  }, [enviarImagem]);

  /* ------------------------------------------------------------- teclado */

  useEffect(() => {
    const aoTeclar = (e: KeyboardEvent) => {
      const alvo = e.target as HTMLElement | null;
      const digitando = alvo && (alvo.tagName === "INPUT" || alvo.tagName === "TEXTAREA" || alvo.tagName === "SELECT" || alvo.isContentEditable);
      const ctrl = e.ctrlKey || e.metaKey;
      const tecla = e.key.toLowerCase();
      if (ctrl && tecla === "z" && !digitando) {
        e.preventDefault();
        if (e.shiftKey) refazer();
        else desfazer();
        return;
      }
      if (ctrl && tecla === "y" && !digitando) {
        e.preventDefault();
        refazer();
        return;
      }
      if (ctrl && (tecla === "=" || tecla === "+")) {
        e.preventDefault();
        aproximar(1.2);
        return;
      }
      if (ctrl && tecla === "-") {
        e.preventDefault();
        aproximar(1 / 1.2);
        return;
      }
      if (ctrl && tecla === "0") {
        e.preventDefault();
        setZoom(null);
        setVoltaAoMeio((n) => n + 1);
        return;
      }
      if (ctrl && tecla === "s") {
        e.preventDefault();
        void salvarJa();
        return;
      }
      /* Escrevendo um texto (mesmo com o foco num botão da barra), as teclas são do texto. */
      if (digitando || editando) return;
      if (e.key === "PageDown" || e.key === "PageUp") {
        e.preventDefault();
        irParaPagina(paginaRef.current + (e.key === "PageDown" ? 1 : -1));
        return;
      }
      if ((e.key === "Escape" || e.key === "Enter") && enquadrando) {
        e.preventDefault();
        setEnquadrando(null);
        return;
      }
      if (e.key === "Escape" && distorcendo) {
        setDistorcendo(null);
        return;
      }
      if (e.key === "Escape" && apagando) {
        setApagando(null);
        return;
      }
      if (e.key === "Escape" && ferramenta) {
        /* Na Forma, o primeiro Esc solta os nós; o segundo volta para a de escolher. */
        if (ferramenta === "forma" && nos.length) setNos([]);
        else escolherFerramenta(null);
        return;
      }
      /* Como no Canva: T põe um texto, R um retângulo e C um círculo (no meio da arte, já escolhidos). */
      if (!ctrl && !e.altKey && !e.shiftKey && (tecla === "t" || tecla === "r" || tecla === "c") && docRef.current) {
        e.preventDefault();
        const d = docRef.current;
        const m = { largura: d.largura, altura: d.altura };
        const lado = Math.min(d.largura, d.altura);
        if (tecla === "t") por(textoNovo(m, { texto: "Seu texto aqui", tamanho: lado * 0.07, peso: 600, linha: 1.15, pintura: solida(corQueAparece(d)) }, d.largura * 0.5, lado * 0.12));
        else por(formaNova(m, tecla === "r" ? "retangulo" : "circulo", lado * 0.36, lado * (tecla === "r" ? 0.24 : 0.36), "#C4C4C4", tecla === "r" ? { raio: 0 } : {}));
        return;
      }
      if (!ctrl && !e.altKey && tecla in TECLAS_DAS_FERRAMENTAS) {
        e.preventDefault();
        escolherFerramenta(TECLAS_DAS_FERRAMENTAS[tecla]);
        return;
      }
      if (ctrl && tecla === "q" && selecionadas.length === 1) {
        e.preventDefault();
        converterEmCurvas(selecionadas[0]);
        return;
      }
      if (e.key === "Escape") {
        if (pincelRef.current) {
          pincelRef.current = false;
          setPincel(false);
          setRecado(null);
        } else if (selecionadas.length) escolher(null);
        /* Esc nunca fecha o editor (assustava: parecia que a arte tinha sumido). Fechar é pelo "Voltar". */
        return;
      }
      if (ctrl && tecla === "g") {
        e.preventDefault();
        agruparOuDesagrupar();
        return;
      }
      if (ctrl && e.altKey && tecla === "c") {
        e.preventDefault();
        copiarEstilo(false);
        return;
      }
      if (ctrl && e.altKey && tecla === "v") {
        e.preventDefault();
        colarEstilo(selecionadas);
        return;
      }
      if (ctrl && (e.key === "]" || e.key === "[")) {
        e.preventDefault();
        agirPeloMenu(e.key === "]" ? "frente" : "tras");
        return;
      }
      if (ctrl && tecla === "a") {
        e.preventDefault();
        const d = docRef.current;
        if (d) escolherVarias(d.pecas.filter((p) => !p.oculta && !p.bloqueada).map((p) => p.id));
        return;
      }
      if (ctrl && tecla === "v" && copiadas.current.length) {
        e.preventDefault();
        copiadas.current = colarCopias(copiadas.current);
        return;
      }
      if (!selecionadas.length) return;
      const pecas = (docRef.current?.pecas || []).filter((p) => selecionadas.includes(p.id));
      if (!pecas.length) return;
      if ((e.key === "Delete" || e.key === "Backspace") && ferramenta === "forma" && nos.length && pecas.length === 1 && pecas[0].tipo === "traco") {
        e.preventDefault();
        mexerNosDoTraco("apagar");
      } else if (e.key === "Delete" || e.key === "Backspace") {
        e.preventDefault();
        apagar(selecionadas);
      } else if (ctrl && tecla === "d") {
        e.preventDefault();
        duplicar(selecionadas);
      } else if (ctrl && tecla === "c") {
        copiadas.current = pecas;
      } else if (e.key === "Enter" && pecas.length === 1 && pecas[0].tipo === "texto" && !pecas[0].bloqueada) {
        e.preventDefault();
        setEditando(pecas[0].id);
      } else if (e.key.startsWith("Arrow")) {
        e.preventDefault();
        const passo = e.shiftKey ? 10 : 1;
        const dx = e.key === "ArrowLeft" ? -passo : e.key === "ArrowRight" ? passo : 0;
        const dy = e.key === "ArrowUp" ? -passo : e.key === "ArrowDown" ? passo : 0;
        const soltas = pecas.filter((p) => !p.bloqueada);
        if (soltas.length === 1) mudarDoPainel(soltas[0].id, { x: soltas[0].x + dx, y: soltas[0].y + dy });
        else if (soltas.length) {
          guardar();
          mexerVarias(soltas.map((p) => ({ id: p.id, campos: { x: p.x + dx, y: p.y + dy } })));
        }
      }
    };
    window.addEventListener("keydown", aoTeclar);
    return () => window.removeEventListener("keydown", aoTeclar);
  }, [selecionadas, editando, desfazer, refazer, apagar, duplicar, colarCopias, mudarDoPainel, mexerVarias, guardar, fechar, gerando, aproximar, escolher, escolherVarias, salvarJa, irParaPagina, por, agruparOuDesagrupar, copiarEstilo, colarEstilo, agirPeloMenu, enquadrando, ferramenta, nos, escolherFerramenta, converterEmCurvas, mexerNosDoTraco, distorcendo, apagando]);

  /* --------------------------------------------------------- linha do tempo */

  /*
   * O projeto como vídeo (a linha do tempo embaixo da arte). O relógio guarda
   * o instante do projeto inteiro (as cenas uma depois da outra) e anda fora
   * do React; a cena no palco e a agulha assinam. "tocando" e "pausada" põem
   * a cena por cima das peças (a prévia); clicar na arte volta a editar.
   */
  const comLinhaDoTempo = Boolean(doc && !tamanhoFixo && !doc.impressao);
  const [cena, setCena] = useState<"tocando" | "pausada" | null>(null);
  const [trilhasAbertas, setTrilhasAbertas] = useState(true);
  const [folha, setFolhaDeEfeitos] = useState<{ aba: AbaDosEfeitos; indice: number } | null>(null);
  /* A folha de sons (músicas, efeitos sonoros, enviar); uma folha aberta por vez. */
  const [folhaDeSons, setFolhaDeSonsCru] = useState<AbaDosSons | null>(null);
  const setFolha = useCallback((f: Parameters<typeof setFolhaDeEfeitos>[0]) => {
    setFolhaDeSonsCru(null);
    setFolhaDeEfeitos(f);
  }, []);
  const setFolhaDeSons = useCallback((aba: AbaDosSons | null) => {
    setFolhaDeEfeitos(null);
    setFolhaDeSonsCru(aba);
  }, []);
  /* O clipe de áudio escolhido na linha do tempo (escolher uma peça tira a escolha dele). */
  const [audioEscolhidoCru, setAudioEscolhido] = useState<string | null>(null);
  const audioEscolhido = selecionadas.length ? null : audioEscolhidoCru;
  const [enviandoAudio, setEnviandoAudio] = useState(false);
  /*
   * A linha do tempo só aparece com a arte animada (ou depois de clicar em
   * Animar). Arte parada (post, carrossel, story sem movimento) fica como
   * antes: a tira de páginas embaixo, com o + para mais páginas. Clicou em
   * Animar e fechou sem animar nada: volta a ser arte parada.
   */
  const [modoAnimacao, setModoAnimacao] = useState(false);
  const animadaAgora = useMemo(() => projetoAnimado(paginasVistas), [paginasVistas]);
  const usaLinhaDoTempo = comLinhaDoTempo && (animadaAgora || modoAnimacao);
  useEffect(() => {
    if (modoAnimacao && !animadaAgora && !folha && !folhaDeSons) setModoAnimacao(false);
  }, [modoAnimacao, animadaAgora, folha, folhaDeSons]);
  const relogio = useMemo(() => criarRelogio(), []);
  const quadroDaCena = useRef(0);
  agulhaNaCena.current = () => {
    if (relogio.tocando || !usaLinhaDoTempo) return null;
    const { inicios, duracoes } = temposDoProjeto(todasAsPaginas());
    const i = paginaRef.current;
    const t = relogio.tempo() - (inicios[i] ?? 0);
    return t >= -0.001 && t <= (duracoes[i] ?? 0) + 0.001 ? Math.round(Math.max(0, t) * 100) / 100 : null;
  };
  /* A agulha parou: as peças com quadros-chave vão para a pose do instante (sem passo no desfazer e sem gravar quadro). */
  const seguirAgulha = useCallback(() => {
    const t = agulhaNaCena.current();
    const d = docRef.current;
    if (t === null || !d) return;
    const nova = comPoseNoTempo(d, t);
    if (nova !== d) pintar(nova);
  }, [pintar]);
  const pararRelogio = useCallback(() => {
    cancelAnimationFrame(quadroDaCena.current);
    quadroDaCena.current = 0;
    if (relogio.tocando) {
      relogio.tocando = false;
      relogio.mudar(relogio.tempo());
    }
  }, [relogio]);
  useEffect(() => () => cancelAnimationFrame(quadroDaCena.current), []);
  /* A cena aberta no editor acompanha a agulha (parou noutra cena, abre ela). */
  const abrirCenaDaAgulha = useCallback(() => {
    const { indice } = paginaNoTempo(todasAsPaginas(), relogio.tempo());
    if (indice !== paginaRef.current) irParaPagina(indice);
  }, [todasAsPaginas, relogio, irParaPagina]);
  const sairDaCena = useCallback(() => {
    pararRelogio();
    setCena(null);
  }, [pararRelogio]);
  /*
   * Toca de `de` até `ate` (sem eles: da agulha até o fim; do fim, volta ao
   * começo). `voltar`: no fim, a agulha volta para onde estava e o editor
   * volta a editar (a prévia de um efeito escolhido).
   */
  const tocar = useCallback(
    (de?: number, ate?: number, voltar = false) => {
      const lista = todasAsPaginas();
      const { total } = temposDoProjeto(lista);
      if (!total) return;
      pararRelogio();
      const antes = relogio.tempo();
      const fim = Math.min(total, ate ?? total);
      let comecoDaCena = de ?? antes;
      if (comecoDaCena >= fim - 0.05) comecoDaCena = 0;
      setEditando(null);
      relogio.tocando = true;
      setCena("tocando");
      const comeco = performance.now() - comecoDaCena * 1000;
      const passo = () => {
        const t = (performance.now() - comeco) / 1000;
        if (t >= fim) {
          pararRelogio();
          relogio.mudar(voltar ? antes : fim);
          setCena(null);
          if (!voltar) abrirCenaDaAgulha();
          seguirAgulha();
          return;
        }
        relogio.mudar(t);
        quadroDaCena.current = requestAnimationFrame(passo);
      };
      relogio.mudar(comecoDaCena);
      quadroDaCena.current = requestAnimationFrame(passo);
    },
    [todasAsPaginas, pararRelogio, relogio, abrirCenaDaAgulha, seguirAgulha],
  );
  const pausar = useCallback(() => {
    pararRelogio();
    setCena("pausada");
    abrirCenaDaAgulha();
    seguirAgulha();
  }, [pararRelogio, abrirCenaDaAgulha, seguirAgulha]);
  /* A régua: mostra a arte no instante (prévia). */
  const buscarNoTempo = useCallback(
    (t: number) => {
      pararRelogio();
      setEditando(null);
      relogio.mudar(t);
      setCena("pausada");
      abrirCenaDaAgulha();
      seguirAgulha();
    },
    [pararRelogio, relogio, abrirCenaDaAgulha, seguirAgulha],
  );
  /* Só leva a agulha (sem prévia). */
  const moverAgulha = useCallback(
    (t: number) => {
      pararRelogio();
      relogio.mudar(t);
      setCena(null);
      abrirCenaDaAgulha();
      seguirAgulha();
    },
    [pararRelogio, relogio, abrirCenaDaAgulha, seguirAgulha],
  );
  /* Os quadros-chave pela linha do tempo: marcar/tirar na agulha, ir até um, arrastar, trocar a curva, apagar. */
  const agirNoQuadro = useCallback(
    (acao: AcaoNoQuadro) => {
      const d = docRef.current;
      if (!d) return;
      const { inicios } = temposDoProjeto(todasAsPaginas());
      const comeco = inicios[paginaRef.current] ?? 0;
      if (acao.tipo === "marcar") {
        const t = agulhaNaCena.current();
        const alvo = idsDaAnimacao(d, d.pecas.filter((p) => selecionadas.includes(p.id)));
        if (!alvo.length) return avisar("Escolha uma peça para marcar o quadro-chave.", "ruim");
        if (t === null) return avisar("Leve a agulha para dentro da cena aberta.", "ruim");
        const tinha = d.pecas.some((p) => alvo.includes(p.id) && p.animacao?.quadros?.length);
        trocar((doc) => alternarQuadro(doc, alvo, t));
        if (!tinha) avisar("Quadro-chave marcado. Leve a agulha para outro instante e mexa na peça: o próximo quadro é gravado sozinho.");
        return;
      }
      if (acao.tipo === "ir") {
        setSelecionadas([acao.id]);
        sairDaCena();
        relogio.mudar(comeco + acao.t);
        seguirAgulha();
        return;
      }
      if (acao.tipo === "mover") trocar((doc) => comQuadrosDa(doc, acao.id, (q) => q.map((x) => (Math.abs(x.t - acao.de) < 0.001 ? { ...x, t: Math.round(acao.para * 100) / 100 } : x))));
      else if (acao.tipo === "apagar") trocar((doc) => comQuadrosDa(doc, acao.id, (q) => q.filter((x) => Math.abs(x.t - acao.t) >= 0.001)));
    },
    [todasAsPaginas, selecionadas, avisar, trocar, sairDaCena, relogio, seguirAgulha],
  );
  /* Clicar numa cena: abre ela e, se a agulha está fora, leva a agulha para o começo dela. */
  const irParaCena = useCallback(
    (i: number) => {
      sairDaCena();
      const lista = todasAsPaginas();
      const { inicios, duracoes } = temposDoProjeto(lista);
      const t = relogio.tempo();
      if (t < inicios[i] || t >= inicios[i] + duracoes[i]) relogio.mudar(inicios[i]);
      irParaPagina(i);
    },
    [sairDaCena, todasAsPaginas, relogio, irParaPagina],
  );

  /* Muda uma página (a aberta pelo histórico comum, sem perder a escolha). */
  const mudarUmaPagina = useCallback(
    (i: number, mudar: (p: DocumentoDoBanner) => DocumentoDoBanner, chave?: string) => {
      if (i === paginaRef.current) trocar(mudar, chave ? passoDaAnimacao(chave) : true);
      else mudarPaginas((lista, atual) => ({ paginas: lista.map((p, j) => (j === i ? mudar(p) : p)), atual }));
    },
    [trocar, mudarPaginas],
  );
  /* Uma régua arrastada vira um passo só do desfazer (como no painel das peças). */
  const ultimaDaAnimacao = useRef<{ chave: string; em: number } | null>(null);
  const passoDaAnimacao = (chave: string) => {
    const agora = Date.now();
    const u = ultimaDaAnimacao.current;
    ultimaDaAnimacao.current = { chave, em: agora };
    return !(u && u.chave === chave && agora - u.em < 800);
  };

  /* O clipe mudou na linha do tempo (o tempo da peça e, no vídeo, de onde o arquivo começa). */
  const mudarClipe = useCallback(
    (ids: string[], a: AnimacaoDaPeca | null, video?: { id: string; corte: number } | null) => {
      trocar((d) => ({
        ...d,
        pecas: d.pecas.map((p) => {
          let nova = ids.includes(p.id) ? ({ ...p, animacao: a ?? undefined } as Peca) : p;
          if (video && p.id === video.id && nova.tipo === "imagem" && nova.video) nova = { ...nova, video: { ...nova.video, corte: video.corte } };
          return nova;
        }),
      }));
    },
    [trocar],
  );
  /* O efeito escolhido na folha: muda as peças e mostra o trecho da entrada (como o CapCut faz ao escolher). */
  const animarPecas = useCallback(
    (a: AnimacaoDaPeca | null) => {
      const d = docRef.current;
      if (!d) return;
      const alvo = idsDaAnimacao(d, d.pecas.filter((p) => selecionadas.includes(p.id)));
      if (!alvo.length) return;
      const antes = d.pecas.find((p) => alvo.includes(p.id) && p.animacao)?.animacao ?? null;
      trocar((doc) => ({ ...doc, pecas: doc.pecas.map((p) => (alvo.includes(p.id) ? ({ ...p, animacao: a ?? undefined } as Peca) : p)) }), passoDaAnimacao(`p:${alvo.join(",")}`));
      const inicio = temposDoProjeto(todasAsPaginas()).inicios[paginaRef.current] ?? 0;
      if (a?.entrada && a.entrada !== antes?.entrada) tocar(inicio + Math.max(0, a.inicio - 0.15), inicio + a.inicio + a.duracao + 0.5, true);
      else if (a?.saida && a.saida !== antes?.saida && a.fim !== null) tocar(inicio + Math.max(0, a.fim - 0.4), inicio + a.fim + a.duracaoDaSaida + 0.3, true);
      else if (a?.enfase && a.enfase !== antes?.enfase) tocar(inicio + a.inicio + (a.entrada ? a.duracao : 0), inicio + a.inicio + (a.entrada ? a.duracao : 0) + a.ritmo * 2, true);
    },
    [selecionadas, trocar, tocar, todasAsPaginas],
  );
  const animarACena = useCallback(
    (estilo: EstiloDaPagina) => {
      const i = paginaRef.current;
      mudarUmaPagina(i, (d) => animarPagina(d, estilo, d.animacao?.fundo && d.animacao.fundo !== "parado" ? d.animacao.fundo : "aproximar"));
      const { inicios } = temposDoProjeto(todasAsPaginas());
      tocar(inicios[i], inicios[i] + duracaoDaPagina(todasAsPaginas()[i]), true);
      avisar("Cena animada. Clique numa peça para mudar só ela. Ctrl+Z volta.");
    },
    [mudarUmaPagina, tocar, todasAsPaginas, avisar],
  );
  const tirarAnimacaoDaCena = useCallback(() => {
    sairDaCena();
    mudarUmaPagina(paginaRef.current, (d) => semAnimacao(d));
    avisar("A cena ficou sem animação. Ctrl+Z volta.");
  }, [sairDaCena, mudarUmaPagina, avisar]);
  const mudarTransicao = useCallback(
    (i: number, t: TransicaoDaPagina | null) => {
      mudarUmaPagina(i, (p) => ({ ...p, transicao: t }), `t:${i}`);
      const { inicios } = temposDoProjeto(todasAsPaginas());
      if (t) tocar(Math.max(0, inicios[i] - 0.6), inicios[i] + t.duracao + 0.5, true);
    },
    [mudarUmaPagina, todasAsPaginas, tocar],
  );
  const mudarDuracaoDaCena = useCallback((i: number, duracao: number) => mudarUmaPagina(i, (p) => ({ ...p, animacao: { fundo: p.animacao?.fundo ?? "parado", duracao } }), `d:${i}`), [mudarUmaPagina]);

  /* ---- a trilha de áudio */
  const mudarAudios = useCallback((mudar: (lista: AudioDoProjeto[]) => AudioDoProjeto[]) => mudarPaginas((lista, atual) => ({ paginas: comAudios(lista, mudar(audiosDoProjeto(lista))), atual }), true), [mudarPaginas]);
  const mudarAudio = useCallback((id: string, campos: Partial<AudioDoProjeto>) => mudarAudios((lista) => lista.map((a) => (a.id === id ? { ...a, ...campos } : a))), [mudarAudios]);
  /* Um som entra na agulha (a música, do começo se a agulha está no fim) e toca até o fim do projeto ou do arquivo. */
  const porSom = useCallback(
    (som: { nome: string; src: string; tipo: AudioDoProjeto["tipo"]; duracao: number; onda?: number[] }) => {
      const { total } = temposDoProjeto(todasAsPaginas());
      let inicio = Math.round(relogio.tempo() * 10) / 10;
      if (inicio >= total - 0.3) inicio = 0;
      const resta = Math.max(0.5, total - inicio);
      const dura = Math.round(Math.min(som.duracao, som.tipo === "efeito" ? som.duracao : resta) * 100) / 100;
      const novo: AudioDoProjeto = { id: novoId("a"), nome: som.nome, src: som.src, tipo: som.tipo, duracao: som.duracao, inicio, corte: 0, dura, volume: som.tipo === "musica" ? 0.8 : 1, suave: som.tipo !== "efeito", ...(som.onda ? { onda: som.onda } : {}) };
      mudarAudios((lista) => [...lista, novo]);
      setAudioEscolhido(novo.id);
      setTrilhasAbertas(true);
      avisar(`${som.nome} na trilha de áudio, em ${segundos(inicio)}. Arraste o clipe ou as pontas para ajustar.`);
    },
    [todasAsPaginas, relogio, mudarAudios, avisar],
  );
  const enviarAudio = useCallback(
    async (arquivos: FileList | null) => {
      const arquivo = arquivos ? Array.from(arquivos).find((f) => f.type.startsWith("audio/") || /\.(mp3|wav|m4a|ogg|aac)$/i.test(f.name)) : null;
      if (!arquivo) {
        avisar("Use áudio MP3, WAV, M4A ou OGG.", "ruim");
        return;
      }
      if (arquivo.size > 50 * 1024 * 1024) {
        avisar("O áudio pode ter até 50 MB.", "ruim");
        return;
      }
      setEnviandoAudio(true);
      try {
        /* A duração e a forma de onda saem do próprio arquivo (o navegador abre). */
        const som = await ondaDoAudio(await arquivo.arrayBuffer());
        if (!som) throw new Error("Este áudio não abre no navegador. Converta para MP3 e tente de novo.");
        const extensao = (arquivo.name.split(".").pop() || "mp3").toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 4) || "mp3";
        const caminho = `catalog/sections/pagina-inicial/editor/audios/${Math.random().toString(36).slice(2, 12)}_${Date.now()}.${extensao}`;
        const supabase = getSupabaseClient();
        const { error } = await supabase.storage.from("products").upload(caminho, arquivo, { contentType: arquivo.type || "audio/mpeg", cacheControl: "31536000" });
        if (error) throw new Error("Não deu para enviar o áudio. Confira a internet e tente de novo.");
        porSom({ nome: arquivo.name.replace(/\.[^.]+$/, "").slice(0, 40) || "Meu áudio", src: supabase.storage.from("products").getPublicUrl(caminho).data.publicUrl, tipo: "enviado", duracao: som.duracao, onda: som.onda });
        setFolhaDeSons(null);
      } catch (erro) {
        avisar(erro instanceof Error ? erro.message : "Não deu para enviar o áudio.", "ruim");
      } finally {
        setEnviandoAudio(false);
      }
    },
    [avisar, porSom, setFolhaDeSons],
  );

  /* ------------------------------------------------ narração e legenda */

  const [trabalhandoNaFala, setTrabalhandoNaFala] = useState<string | null>(null);
  /* As falas do projeto que a legenda automática ouve: os áudios da trilha e os vídeos das cenas. */
  const fontesDaFala = useMemo<FonteDaFala[]>(() => {
    const lista = paginasVistas;
    return [
      ...audiosDoProjeto(lista).map((a) => ({ id: a.id, nome: a.nome, tipo: "audio" as const })),
      ...lista.flatMap((p, i) => p.pecas.filter((x) => x.tipo === "imagem" && x.video).map((x) => ({ id: x.id, nome: `Vídeo da cena ${i + 1}`, tipo: "video" as const }))),
    ];
  }, [paginasVistas]);
  const pedirLegenda = useCallback(async (src: string): Promise<PalavraFalada[]> => {
    const r = await fetch("/api/artes/legenda", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ src }) });
    const d = (await r.json().catch(() => ({}))) as { palavras?: PalavraFalada[]; aproximado?: boolean; error?: string };
    if (!r.ok || !d.palavras) throw new Error(d.error || "A legenda não saiu agora. Tente de novo.");
    if (!d.aproximado) return d.palavras;
    /* Tempos aproximados (a transcrição de reserva): acerta os começos no próprio som. */
    try {
      const contexto = new OfflineAudioContext(1, 1, 44100);
      const som = await contexto.decodeAudioData(await (await fetch(src)).arrayBuffer());
      return alinharNoSom(d.palavras.map((p) => p.texto), som.getChannelData(0), som.sampleRate);
    } catch {
      return d.palavras;
    }
  }, []);
  const legendar = useCallback(
    async (fonteId: string, estilo: EstiloDaLegenda) => {
      const lista = todasAsPaginas();
      const { inicios } = temposDoProjeto(lista);
      const audio = audiosDoProjeto(lista).find((a) => a.id === fonteId);
      let alvo: { src: string; comeco: number; corte: number; velocidade: number } | null = audio ? { src: audio.src, comeco: audio.inicio, corte: audio.corte, velocidade: 1 } : null;
      lista.forEach((p, i) => {
        const v = p.pecas.find((x) => x.id === fonteId);
        if (!alvo && v?.tipo === "imagem" && v.video) alvo = { src: v.video.src, comeco: inicios[i] + intervaloDoClipe(v.animacao, duracaoDaPagina(p)).comeco, corte: v.video.corte, velocidade: velocidadeDo(v.video) || 1 };
      });
      if (!alvo) return;
      const { src, comeco, corte, velocidade } = alvo;
      setTrabalhandoNaFala("Ouvindo a fala…");
      try {
        const palavras = await pedirLegenda(src);
        mudarPaginas((l, atual) => ({ paginas: comLegenda(l, palavras, comeco, corte, estilo, velocidade), atual }));
        setFolhaDeSons(null);
        avisar(`Legenda pronta: ${palavras.length} palavras no tempo da fala. Dá para corrigir como qualquer texto.`);
      } catch (e) {
        avisar(e instanceof Error ? e.message : "A legenda não saiu agora.", "ruim");
      } finally {
        setTrabalhandoNaFala(null);
      }
    },
    [todasAsPaginas, pedirLegenda, mudarPaginas, setFolhaDeSons, avisar],
  );
  const narrar = useCallback(
    async (pedido: PedidoDeNarracao) => {
      setTrabalhandoNaFala("Gerando a voz…");
      try {
        const r = await fetch("/api/artes/narracao", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ texto: pedido.texto, voz: pedido.voz, jeito: pedido.jeito }) });
        if (!r.ok) throw new Error(((await r.json().catch(() => ({}))) as { error?: string }).error || "A voz não saiu agora.");
        const mp3 = await r.blob();
        const som = await ondaDoAudio(await mp3.arrayBuffer());
        if (!som) throw new Error("A voz veio com defeito. Tente de novo.");
        const tipo = r.headers.get("content-type")?.includes("wav") ? "audio/wav" : "audio/mpeg";
        const caminho = `catalog/sections/pagina-inicial/editor/audios/narracao_${Math.random().toString(36).slice(2, 12)}_${Date.now()}.${tipo === "audio/wav" ? "wav" : "mp3"}`;
        const supabase = getSupabaseClient();
        const { error } = await supabase.storage.from("products").upload(caminho, mp3, { contentType: tipo, cacheControl: "31536000" });
        if (error) throw new Error("Não deu para guardar a voz. Confira a internet e tente de novo.");
        const src = supabase.storage.from("products").getPublicUrl(caminho).data.publicUrl;
        const lista = todasAsPaginas();
        const { total } = temposDoProjeto(lista);
        let inicio = Math.round(relogio.tempo() * 10) / 10;
        if (inicio >= total - 0.3) inicio = 0;
        const voz = VOZES.find((v) => v.chave === pedido.voz)?.nome ?? "Voz";
        const novo: AudioDoProjeto = { id: novoId("a"), nome: `Narração (${voz})`, src, tipo: "enviado", duracao: som.duracao, inicio, corte: 0, dura: Math.round(som.duracao * 100) / 100, volume: 1, suave: false, fadeDeEntrada: 0, fadeDeSaida: 0.2, onda: som.onda };
        /* A fala inteira cabe: a última cena estica se precisar. */
        const falta = inicio + som.duracao + 0.4 - total;
        mudarPaginas((l, atual) => {
          const esticadas = falta > 0 ? l.map((p, i) => (i === l.length - 1 ? { ...p, animacao: { fundo: p.animacao?.fundo ?? "parado", duracao: Math.min(60, Math.round((duracaoDaPagina(p) + falta) * 10) / 10) } } : p)) : l;
          return { paginas: comAudios(esticadas, [...audiosDoProjeto(l), novo]), atual };
        }, true);
        setAudioEscolhido(novo.id);
        setTrilhasAbertas(true);
        if (pedido.legenda) {
          /* O texto é o que a pessoa escreveu: as palavras dele no tempo do próprio som (sem transcrever). */
          setTrabalhandoNaFala("Fazendo a legenda…");
          const audio = await new OfflineAudioContext(1, 1, 44100).decodeAudioData(await mp3.arrayBuffer());
          const palavras = alinharNoSom(pedido.texto.trim().split(/\s+/), audio.getChannelData(0), audio.sampleRate);
          mudarPaginas((l, atual) => ({ paginas: comLegenda(l, palavras, inicio, 0, pedido.legenda!), atual }));
        }
        setFolhaDeSons(null);
        avisar(`Narração na trilha de áudio (${segundos(som.duracao)})${pedido.legenda ? ", com a legenda no tempo da fala" : ""}.`);
      } catch (e) {
        avisar(e instanceof Error ? e.message : "A voz não saiu agora.", "ruim");
      } finally {
        setTrabalhandoNaFala(null);
      }
    },
    [todasAsPaginas, relogio, mudarPaginas, setFolhaDeSons, avisar],
  );

  /* Dividir no ponto da agulha: o clipe escolhido (se a agulha está nele) ou a cena. */
  const dividir = useCallback(() => {
    const lista = todasAsPaginas();
    const { indice, local } = paginaNoTempo(lista, relogio.tempo());
    sairDaCena();
    if (audioEscolhido) {
      const a = audiosDoProjeto(lista).find((x) => x.id === audioEscolhido);
      const partes = a ? dividirAudio(a, relogio.tempo()) : null;
      if (!partes) {
        avisar("Ponha a agulha dentro do áudio escolhido para dividir.", "ruim");
        return;
      }
      mudarAudios((l) => l.flatMap((x) => (x.id === audioEscolhido ? partes : [x])));
      setAudioEscolhido(partes[1].id);
      avisar("Áudio dividido em dois. Ctrl+Z volta.");
      return;
    }
    const d = docRef.current;
    if (d && selecionadas.length && indice === paginaRef.current) {
      const r = dividirUnidade(d, idsDaAnimacao(d, d.pecas.filter((p) => selecionadas.includes(p.id))), local);
      if (!r) {
        avisar(d.pecas.some((p) => selecionadas.includes(p.id) && p.fixa) ? "Peça fixa em todas as páginas não divide." : "Ponha a agulha dentro do clipe escolhido para dividir.", "ruim");
        return;
      }
      trocar(() => r.pagina);
      setSelecionadas(r.novos);
      avisar("Clipe dividido em dois. Ctrl+Z volta.");
      return;
    }
    if (lista.length >= LIMITE_DE_PAGINAS) {
      avisar(`O projeto já tem ${LIMITE_DE_PAGINAS} cenas.`, "ruim");
      return;
    }
    const partes = dividirPagina(lista[indice], local);
    if (!partes) {
      avisar("Ponha a agulha no meio da cena para dividir.", "ruim");
      return;
    }
    mudarPaginas((l) => ({ paginas: [...l.slice(0, indice), partes[0], partes[1], ...l.slice(indice + 1)], atual: indice + 1 }));
    avisar("Cena dividida em duas. Ctrl+Z volta.");
  }, [todasAsPaginas, relogio, sairDaCena, selecionadas, trocar, avisar, mudarPaginas, audioEscolhido, mudarAudios]);

  /* Vídeo: sobe o arquivo e a capa (o primeiro quadro), entra na cena a partir da agulha. */
  const entradaDeVideo = useRef<HTMLInputElement | null>(null);
  /* "Substituir" da foto escolhida (ou da foto de dentro da forma/texto). */
  const entradaDaTroca = useRef<HTMLInputElement | null>(null);
  const fotoATrocar = useRef<string | null>(null);
  const enviarVideo = useCallback(
    async (lista: FileList | File[] | null) => {
      const arquivo = lista ? Array.from(lista).find((f) => f.type.startsWith("video/")) : null;
      const atual = docRef.current;
      if (!arquivo || !atual) return;
      if (!/^video\/(mp4|webm|quicktime)$/.test(arquivo.type)) {
        avisar("Use vídeo MP4, WebM ou MOV.", "ruim");
        return;
      }
      if (arquivo.size > LIMITE_DO_VIDEO) {
        avisar("O vídeo pode ter até 200 MB.", "ruim");
        return;
      }
      setEnviando(true);
      try {
        const lido = await lerVideo(arquivo);
        const supabase = getSupabaseClient();
        const nomeBase = `${Math.random().toString(36).slice(2, 12)}_${Date.now()}`;
        const extensao = arquivo.type === "video/webm" ? "webm" : arquivo.type === "video/quicktime" ? "mov" : "mp4";
        const pasta = "catalog/sections/pagina-inicial/editor/videos";
        const [envio, capa] = await Promise.all([
          supabase.storage.from("products").upload(`${pasta}/${nomeBase}.${extensao}`, arquivo, { contentType: arquivo.type, cacheControl: "31536000" }),
          supabase.storage.from("products").upload(`${pasta}/${nomeBase}-capa.webp`, lido.capa, { contentType: "image/webp", cacheControl: "31536000" }),
        ]);
        if (envio.error || capa.error) throw envio.error || capa.error;
        const url = supabase.storage.from("products").getPublicUrl(`${pasta}/${nomeBase}.${extensao}`).data.publicUrl;
        const urlDaCapa = supabase.storage.from("products").getPublicUrl(`${pasta}/${nomeBase}-capa.webp`).data.publicUrl;
        /* Entra onde a agulha está (se ela está nesta cena); a cena cresce para caber o vídeo (até 60 s). */
        const { inicios } = temposDoProjeto(todasAsPaginas());
        const duracao = duracaoDaPagina(atual);
        const agora = relogio.tempo() - (inicios[paginaRef.current] ?? 0);
        const inicio = agora > 0 && agora < duracao - 0.5 ? Math.round(agora * 10) / 10 : 0;
        const precisa = Math.min(DURACAO_MAXIMA, Math.ceil((inicio + lido.duracao) * 10) / 10);
        const novaDuracao = Math.max(duracao, precisa);
        const base = imagem({ largura: atual.largura, altura: atual.altura }, urlDaCapa, "foto", lido.largura / lido.altura);
        const peca: PecaDeImagem = { ...base, nome: "Vídeo", video: { src: url, duracao: lido.duracao, corte: 0, mudo: false, volume: 1 }, animacao: animacaoNova(null, { inicio, fim: inicio + lido.duracao < novaDuracao - 0.05 ? Math.round((inicio + lido.duracao) * 10) / 10 : null }) };
        por(peca);
        if (novaDuracao > duracao) {
          trocar((d) => ({ ...d, animacao: { fundo: d.animacao?.fundo ?? "parado", duracao: novaDuracao } }), false);
          avisar(`Vídeo na cena. A cena passou para ${segundos(novaDuracao)} para caber o vídeo${lido.duracao > DURACAO_MAXIMA ? " (o resto do arquivo dá para usar dividindo a cena)" : ""}.`);
        } else avisar("Vídeo na cena. Arraste as pontas do clipe para aparar.");
        setTrilhasAbertas(true);
      } catch (erro) {
        avisar(erro instanceof VideoQueNaoAbre ? erro.message : "Não deu para enviar o vídeo. Confira a internet e tente de novo.", "ruim");
      } finally {
        setEnviando(false);
        if (entradaDeVideo.current) entradaDeVideo.current.value = "";
      }
    },
    [avisar, por, trocar, relogio, todasAsPaginas],
  );
  const ligarSom = useCallback(() => {
    const p = docRef.current?.pecas.find((x) => selecionadas.includes(x.id) && x.tipo === "imagem" && x.video) as PecaDeImagem | undefined;
    if (!p?.video) return;
    mudarDoPainel(p.id, { video: { ...p.video, mudo: !p.video.mudo } } as Partial<Peca>);
    avisar(p.video.mudo ? "Som do vídeo ligado." : "Vídeo sem som.");
  }, [selecionadas, mudarDoPainel, avisar]);
  /* O som de um vídeo da cena: volume e mudo (subir o volume de um vídeo mudo liga o som). */
  const mudarSomDoVideo = useCallback(
    (id: string, campos: { volume?: number; mudo?: boolean }) => {
      const p = docRef.current?.pecas.find((x) => x.id === id && x.tipo === "imagem" && x.video) as PecaDeImagem | undefined;
      if (!p?.video) return;
      const volume = campos.volume === undefined ? p.video.volume : Math.max(0, Math.min(1, campos.volume));
      const mudo = campos.mudo !== undefined ? campos.mudo : campos.volume !== undefined && campos.volume > 0 ? false : p.video.mudo;
      mudarDoPainel(p.id, { video: { ...p.video, volume, mudo } } as Partial<Peca>);
    },
    [mudarDoPainel],
  );

  /*
   * "Separar áudio" (o "Extrair áudio" do CapCut): o som do vídeo vira uma
   * faixa própria na trilha de áudio, no mesmo instante e com o mesmo pedaço
   * do arquivo, e o vídeo fica mudo. A faixa apara, anda, divide, tem volume
   * e fade como qualquer áudio. Um passo só no desfazer.
   */
  const separarAudio = useCallback((id: string) => {
    const lista = todasAsPaginas();
    const i = paginaRef.current;
    const pagina = lista[i];
    const p = pagina?.pecas.find((x) => x.id === id && x.tipo === "imagem" && x.video) as PecaDeImagem | undefined;
    if (!pagina || !p?.video) return;
    const onda = ondaJaMedida(p.video.src);
    if (onda === null) {
      avisar("Este vídeo não tem som para separar.", "ruim");
      return;
    }
    if (p.video.mudo) {
      avisar("O vídeo está sem som: ligue o som antes de separar.", "ruim");
      return;
    }
    if (velocidadeDo(p.video) !== 1) {
      avisar("Para separar o áudio, deixe o vídeo na velocidade normal (1×).", "ruim");
      return;
    }
    if (audiosDoProjeto(lista).length >= 30) {
      avisar("A trilha de áudio já tem 30 sons.", "ruim");
      return;
    }
    const { inicios } = temposDoProjeto(lista);
    const { comeco, fim } = intervaloDoClipe(p.animacao, duracaoDaPagina(pagina));
    const video = p.video;
    const novo: AudioDoProjeto = {
      id: novoId("a"),
      nome: "Som do vídeo",
      src: video.src,
      tipo: "enviado",
      duracao: video.duracao,
      inicio: Math.round((inicios[i] + comeco) * 100) / 100,
      corte: video.corte,
      dura: Math.round(Math.max(0.1, Math.min(fim - comeco, video.duracao - video.corte)) * 100) / 100,
      volume: video.volume,
      suave: false,
      ...(onda ? { onda } : {}),
    };
    mudarPaginas((todas, atual) => {
      const mudas = todas.map((pg, j) => (j === i ? { ...pg, pecas: pg.pecas.map((x) => (x.id === p.id && x.tipo === "imagem" && x.video ? { ...x, video: { ...x.video, mudo: true } } : x)) } : pg));
      return { paginas: comAudios(mudas, [...audiosDoProjeto(todas), novo]), atual };
    }, true);
    setAudioEscolhido(novo.id);
    setTrilhasAbertas(true);
    avisar("Áudio separado: agora é uma faixa própria na trilha de áudio, e o vídeo ficou sem som. Ctrl+Z volta.");
  }, [todasAsPaginas, avisar, mudarPaginas]);

  /* A velocidade do vídeo escolhido (0 = congela no quadro do corte). */
  const mudarVelocidade = useCallback(
    (v: number) => {
      const p = docRef.current?.pecas.find((x) => selecionadas.includes(x.id) && x.tipo === "imagem" && x.video) as PecaDeImagem | undefined;
      if (!p?.video) return;
      const { velocidade: _antiga, ...resto } = p.video;
      void _antiga;
      mudarDoPainel(p.id, { video: v === 1 ? resto : { ...resto, velocidade: v } } as Partial<Peca>);
      avisar(v === 0 ? "Quadro congelado: o vídeo fica parado no ponto do corte." : v === 1 ? "Velocidade normal." : `Vídeo em ${String(v).replace(".", ",")}×.`);
    },
    [selecionadas, mudarDoPainel, avisar],
  );

  const agirNaLinha = useCallback(
    (acao: AcaoDaLinha) => {
      if (acao === "dividir") dividir();
      else if (acao === "audios") setFolhaDeSons(folhaDeSons ? null : "musica");
      else if (audioEscolhido && (acao === "apagar" || acao === "duplicar")) {
        if (acao === "apagar") {
          mudarAudios((l) => l.filter((x) => x.id !== audioEscolhido));
          setAudioEscolhido(null);
          avisar("Áudio tirado da trilha. Ctrl+Z volta.");
        } else {
          const copia = (a: AudioDoProjeto): AudioDoProjeto => ({ ...a, id: novoId("a"), inicio: Math.round((a.inicio + a.dura) * 100) / 100 });
          mudarAudios((l) => l.flatMap((x) => (x.id === audioEscolhido ? [x, copia(x)] : [x])));
        }
      } else if (acao === "duplicar") {
        if (selecionadas.length) duplicar(selecionadas);
        else agirNaPagina(paginaRef.current, "duplicar");
      } else if (acao === "apagar") {
        if (selecionadas.length) apagar(selecionadas);
        else agirNaPagina(paginaRef.current, "apagar");
      } else if (acao === "video") entradaDeVideo.current?.click();
      else if (acao === "som") ligarSom();
    },
    [dividir, selecionadas, duplicar, apagar, agirNaPagina, ligarSom, folhaDeSons, setFolhaDeSons, audioEscolhido, mudarAudios, avisar],
  );
  const abrirEfeitos = useCallback(() => {
    setEditando(null);
    setFolha((f) => (f && f.aba !== "transicao" ? null : { aba: selecionadas.length ? "entrada" : "estilo", indice: paginaRef.current }));
  }, [selecionadas.length, setFolha]);
  /* "Animar" numa arte parada: abre a linha do tempo junto com a folha de efeitos. */
  const animar = useCallback(() => {
    setModoAnimacao(true);
    abrirEfeitos();
  }, [abrirEfeitos]);

  /* Teclas da linha do tempo: Espaço (um toque) toca e pausa (segurado, continua arrastando a vista), S ou Ctrl+B divide, Esc sai da prévia. */
  useEffect(() => {
    let espacoEm = 0;
    let arrastou = false;
    const digitando = (e: KeyboardEvent) => {
      const alvo = e.target as HTMLElement | null;
      return Boolean(alvo && (alvo.tagName === "INPUT" || alvo.tagName === "TEXTAREA" || alvo.tagName === "SELECT" || alvo.isContentEditable));
    };
    const desce = (e: KeyboardEvent) => {
      if (!usaLinhaDoTempo || digitando(e) || editando) return;
      if (e.code === "Space" && !e.repeat) {
        espacoEm = performance.now();
        arrastou = false;
      } else if (e.key === "Escape" && cena) sairDaCena();
      else if ((e.key === "Delete" || e.key === "Backspace") && audioEscolhido) {
        e.preventDefault();
        mudarAudios((l) => l.filter((x) => x.id !== audioEscolhido));
        setAudioEscolhido(null);
      }
      else if ((e.key.toLowerCase() === "s" && !e.ctrlKey && !e.metaKey && !e.altKey) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b")) {
        e.preventDefault();
        dividir();
      } else if (e.key.toLowerCase() === "k" && !e.ctrlKey && !e.metaKey && !e.altKey && selecionadas.length) {
        e.preventDefault();
        agirNoQuadro({ tipo: "marcar" });
      }
    };
    const sobe = (e: KeyboardEvent) => {
      if (!usaLinhaDoTempo || e.code !== "Space" || digitando(e) || editando) return;
      if (performance.now() - espacoEm < 280 && !arrastou) {
        if (relogio.tocando) pausar();
        else tocar();
      }
    };
    const moveu = (e: PointerEvent) => {
      if (e.buttons) arrastou = true;
    };
    window.addEventListener("keydown", desce);
    window.addEventListener("keyup", sobe);
    window.addEventListener("pointermove", moveu);
    return () => {
      window.removeEventListener("keydown", desce);
      window.removeEventListener("keyup", sobe);
      window.removeEventListener("pointermove", moveu);
    };
  }, [usaLinhaDoTempo, editando, cena, sairDaCena, dividir, relogio, pausar, tocar, audioEscolhido, mudarAudios, selecionadas.length, agirNoQuadro]);

  /* A altura da linha do tempo: a arte encolhe para ficar acima dela. */
  const caixaDaLinha = useRef<HTMLDivElement | null>(null);
  const [alturaDaLinha, setAlturaDaLinha] = useState(150);
  useEffect(() => {
    const el = caixaDaLinha.current;
    if (!el) return;
    const medir = () => setAlturaDaLinha(el.querySelector<HTMLElement>("[data-linha-do-tempo]")?.offsetHeight ?? 150);
    medir();
    const obs = new ResizeObserver(medir);
    obs.observe(el);
    return () => obs.disconnect();
  }, [usaLinhaDoTempo, paginas.length]);

  /* ---------------------------------------------------------------- usar */

  const usar = async () => {
    if (!docRef.current || !aoUsar) return;
    setEditando(null);
    setGerando(true);
    setRecado(null);
    try {
      const resposta = await fetch("/api/loja/editor/banner/documento", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ largura, altura, documento: comFontesDaMarca(docRef.current) }),
      });
      const corpo = (await resposta.json().catch(() => ({}))) as { url?: string; error?: string };
      if (!resposta.ok || !corpo.url) throw new Error(corpo.error || "Não deu para gerar o banner agora. Tente de novo em instantes.");
      if (alterado.current) await salvarJa();
      aoUsar(corpo.url);
    } catch (erro) {
      avisar(erro instanceof Error && erro.message !== "Failed to fetch" ? erro.message : "Sem conexão com o painel. Confira a internet e tente de novo.", "ruim");
    } finally {
      setGerando(false);
    }
  };

  const abrirArteGuardada = async (resumo: ResumoDaArte) => {
    /* A que já está aberta não volta para trás. */
    if (resumo.id === arteIdRef.current) {
      avisar("Esta arte já está aberta aqui.");
      return;
    }
    /* Grava a de agora e busca a outra como está no banco agora (nunca a da lista). */
    await salvarJa();
    let arte = resumo;
    try {
      arte = (await abrirArte(resumo.id)) ?? resumo;
    } catch {
      /* sem rede: abre a da lista */
    }
    const limpo = paginasDoDocumento(documentoLimpo(arte.documento, arte.largura, arte.altura));
    const mesmoTamanho = arte.largura === largura && arte.altura === altura;
    const lista = tamanhoFixo ? limpo.slice(0, 1) : limpo;
    guardar();
    mostrarProjeto({ paginas: tamanhoFixo && !mesmoTamanho ? lista.map((pagina) => redimensionarComInteligencia(pagina, largura, altura, loja?.cor)) : lista, atual: 0 });
    arteIdRef.current = tamanhoFixo && !mesmoTamanho ? null : arte.id;
    setArteId(arteIdRef.current);
    setNome(tamanhoFixo && !mesmoTamanho ? `${arte.nome} (${largura} × ${altura})` : arte.nome);
    setFormato(tamanhoFixo && !mesmoTamanho ? formatoDoTamanho(largura, altura)?.chave ?? null : arte.formato);
    /* Abrir não é mudar: só guarda quando mexer. */
    alterado.current = !(mesmoTamanho || !tamanhoFixo) ? true : false;
    setSalvamento(alterado.current ? "pendente" : "salvo");
    setSelecionadas([]);
    setEditando(null);
    setZoom(null);
  };

  /* Volta a arte para uma versão guardada (a de agora já virou versão; o Desfazer também traz de volta). */
  const voltarParaVersao = (versao: ResumoDaVersao) => {
    const limpo = paginasDoDocumento(documentoLimpo(versao.documento, versao.largura, versao.altura));
    const lista = tamanhoFixo ? limpo.slice(0, 1).map((pagina) => (pagina.largura === largura && pagina.altura === altura ? pagina : redimensionarComInteligencia(pagina, largura, altura, loja?.cor))) : limpo;
    guardar();
    mostrarProjeto({ paginas: lista, atual: 0 });
    alterado.current = true;
    setSalvamento("pendente");
    setSelecionadas([]);
    setEditando(null);
  };

  const escolhidas = doc?.pecas.filter((p) => selecionadas.includes(p.id)) || [];
  const escolhida = escolhidas.length === 1 ? escolhidas[0] : null;
  /* O enquadre só vale enquanto a foto é a única escolhida. */
  const enquadrandoAgora = enquadrando && escolhida?.id === enquadrando ? enquadrando : null;
  const ajusteAberto = painel?.tipo === "ajuste" ? painel.ajuste : null;
  /* Foto (não SVG, não vídeo): dá para trocar cores e fazer a retícula; as cores viram bolinhas na barra quando ela é elemento (recorte ou cores chapadas). */
  const fotoTratavel = escolhida?.tipo === "imagem" && !escolhida.vetor && !escolhida.video && !escolhida.moldura ? escolhida : null;
  const paletaDaBarra = usePaletaDaFoto(fotoTratavel ? origemDaFoto(fotoTratavel) : null);
  const tratamentoDaBarra = fotoTratavel ? tratamentoDaFoto(fotoTratavel) : null;
  const coresDaBarra = paletaDaBarra && (paletaDaBarra.transparente || paletaDaBarra.cobertura >= 0.8) ? paletaDaBarra.cores.map((de) => ({ de, agora: tratamentoDaBarra?.trocas?.find((t) => t.de === de)?.para ?? de })) : null;
  const corDaFotoAberta = corDaFoto && escolhida && corDaFoto.id === escolhida.id ? corDaFoto.cor : null;
  const tituloDoPainel = painel ? (painel.tipo === "biblioteca" ? TITULOS_DA_BIBLIOTECA[painel.aba] : TITULOS_DOS_AJUSTES[painel.ajuste]) : "";
  const idsDaFolha = folha && doc && folha.indice === paginaAtual ? idsDaAnimacao(doc, escolhidas) : [];
  const botaoDeCima = "flex h-9 w-9 items-center justify-center rounded-[8px] text-white/90 transition hover:bg-white/15 disabled:opacity-35 disabled:hover:bg-transparent";

  const textoDoSalvamento =
    salvamento === "salvando" ? "Guardando…" : salvamento === "salvo" ? "Guardada" : salvamento === "pendente" ? "Mudanças a guardar" : salvamento === "falhou" ? "Não guardou, tentando de novo" : salvamento === "sem-galeria" ? "Galeria desligada" : "";

  return (
    <ContextoDasCores.Provider value={cores}>
      <div
        className="fixed inset-0 z-[95] flex flex-col bg-[#EEEDF3] [font-feature-settings:'cv11']"
        role="dialog"
        aria-modal="true"
        aria-label="Editor de artes"
        data-editor="gerador"
        data-compacto={compacto ?? undefined}
        data-celular={compacto === "celular" ? "" : undefined}
        data-com-peca={compacto && escolhidas.length ? "" : undefined}
        data-painel-aberto={compacto && painel && (painel.tipo === "biblioteca" || escolhida) ? "" : undefined}
      >
        {/* ---------------------------------------------------- barra de cima */}
        <header className="flex h-[56px] shrink-0 items-center gap-2 bg-[linear-gradient(90deg,#4C1D95_0%,#6D28D9_45%,#7C3AED_70%,#6366F1_100%)] px-3 text-white">
          <button type="button" onClick={() => void fechar()} disabled={gerando} aria-label="Voltar" className="flex h-9 items-center gap-1.5 rounded-[8px] px-2.5 text-[13.5px] font-semibold text-white/95 transition hover:bg-white/15" data-topo-voltar>
            <ArrowLeft size={17} /> Voltar
          </button>
          <span className="mx-1.5 h-6 w-px bg-white/25" data-topo-divisor />
          <input
            value={nome}
            onChange={(e) => renomear(e.target.value.slice(0, 120))}
            aria-label="Nome da arte"
            className="h-9 w-[220px] min-w-0 rounded-[8px] border border-transparent bg-transparent px-2 text-[14px] font-bold text-white outline-none transition placeholder:text-white/60 hover:border-white/30 focus:border-white/60 focus:bg-white/10"
            data-topo-nome
          />
          {textoDoSalvamento ? (
            <span className={`flex items-center gap-1 text-[11.5px] font-semibold ${salvamento === "falhou" || salvamento === "sem-galeria" ? "text-[#FDE68A]" : "text-white/75"}`} title={salvamento === "sem-galeria" ? "A tabela da galeria ainda não existe no banco: a arte não fica guardada, mas dá para baixar." : textoDoSalvamento} data-topo-salvo>
              {salvamento === "salvando" ? <Loader2 size={12} className="animate-spin" /> : salvamento === "salvo" ? <Check size={12} strokeWidth={3} /> : salvamento === "sem-galeria" || salvamento === "falhou" ? <CloudOff size={12} /> : null}
              {textoDoSalvamento}
            </span>
          ) : null}
          <span className="mx-1.5 h-6 w-px bg-white/25" data-topo-divisor />
          <button type="button" onClick={desfazer} disabled={!contagem.voltar} title="Desfazer (Ctrl+Z)" className={botaoDeCima}>
            <Undo2 size={18} />
          </button>
          <button type="button" onClick={refazer} disabled={!contagem.avancar} title="Refazer (Ctrl+Y)" className={botaoDeCima}>
            <Redo2 size={18} />
          </button>
          <BotaoDeVersoes
            dono={loja?.dono ?? null}
            arteId={arteId}
            atual={() => (docRef.current ? { nome, largura: docRef.current.largura, altura: docRef.current.altura, documento: documentoDasPaginas(todasAsPaginas().map(comFontesDaMarca)) } : null)}
            antesDeGuardar={async () => {
              alterado.current = true;
              await salvarJa();
              return arteIdRef.current;
            }}
            aoVoltar={voltarParaVersao}
            aoAvisar={avisar}
            classe={botaoDeCima}
          />
          <div className="relative">
            <button
              type="button"
              onClick={() => !tamanhoFixo && setTamanhoAberto((v) => !v)}
              title={tamanhoFixo ? "O tamanho é o do lugar do banner na loja" : "Redimensionar a arte"}
              className={`flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-white/15 px-3 text-[11.5px] font-semibold tabular-nums text-white/90 ${tamanhoFixo ? "cursor-default" : "transition hover:bg-white/25"}`}
              data-topo-tamanho
            >
              {tamanhoFixo ? null : <Scaling size={13} />}
              {doc?.impressao ? `${medidaDeImpressao(largura, altura, doc.impressao.dpi)} · ${doc.impressao.dpi} ppp` : `${largura} × ${altura}`}
            </button>
            {tamanhoAberto ? (
              <>
                <button
                  type="button"
                  aria-label="Fechar"
                  className="fixed inset-0 z-[55] cursor-default"
                  onClick={() => {
                    setTamanhoAberto(false);
                    setTamanhoPendente(null);
                  }}
                />
                <div className="absolute left-0 top-[calc(100%+10px)] z-[60] max-h-[70vh] w-[380px] overflow-y-auto rounded-[16px] bg-white p-4 text-[#1F1B2E] shadow-[0_18px_50px_rgba(20,11,43,0.28)] ring-1 ring-black/5" data-janela-do-topo>
                  <p className="mb-3 text-[14px] font-bold">Redimensionar</p>
                  {tamanhoPendente ? (
                    <div>
                      <p className="mb-3 text-[12.5px] leading-snug text-[#4B4760]">
                        O projeto tem {paginas.length} páginas. Levar para <b className="tabular-nums">{tamanhoPendente.largura} × {tamanhoPendente.altura}</b>:
                      </p>
                      <div className="space-y-2">
                        <button type="button" onClick={() => redimensionar(tamanhoPendente, "pagina")} className="flex w-full flex-col items-start rounded-[12px] bg-[#F6F4FB] px-3.5 py-2.5 text-left transition hover:bg-[#EEEAF8]">
                          <span className="text-[13px] font-bold">Só a página {paginaAtual + 1}</span>
                          <span className="text-[11.5px] text-[#8A849E]">As outras ficam no tamanho em que estão</span>
                        </button>
                        <button type="button" onClick={() => redimensionar(tamanhoPendente, "todas")} className="flex w-full flex-col items-start rounded-[12px] bg-[#7C3AED] px-3.5 py-2.5 text-left text-white transition hover:bg-[#6D28D9]">
                          <span className="text-[13px] font-bold">Todas as {paginas.length} páginas</span>
                          <span className="text-[11.5px] text-white/80">Cada uma se reorganiza no tamanho novo</span>
                        </button>
                        <button type="button" onClick={() => setTamanhoPendente(null)} className="w-full py-1.5 text-[12px] font-semibold text-[#8A849E] hover:text-[#1F1B2E]">
                          Voltar
                        </button>
                      </div>
                    </div>
                  ) : (
                    <>
                      <CopiasEmOutrosTamanhos atual={{ largura: doc?.fatias ? Math.round(largura / doc.fatias) : largura, altura }} criando={copiando} aoCriar={(formatos) => void criarCopiasEmTamanhos(formatos)} />
                      <EscolhaDeTamanho atual={{ largura, altura }} aoEscolher={(t) => redimensionar(t)} textoDoBotao="Redimensionar" />
                      <CarrosselContinuo fatias={doc?.fatias ?? null} aoVirar={virarCarrossel} />
                    </>
                  )}
                </div>
              </>
            ) : null}
          </div>
          <div className="ml-auto flex items-center gap-2.5" data-topo-direita>
            {recado ? (
              <span className={`min-w-0 max-w-[320px] truncate rounded-[8px] px-3 py-1.5 text-[12px] font-semibold text-white ${recado.tom === "ruim" ? "bg-[#BE123C]/90" : "bg-white/15"}`} title={recado.texto} data-recado={recado.tom}>
                {recado.texto}
              </span>
            ) : null}
            {aoUsar ? null : <PreviaDoInstagram paginas={() => todasAsPaginas().map(comFontesDaMarca)} nome={loja?.nome || nome} logo={loja?.logo || null} />}
            {vinculo && !aoUsar ? (
              <>
                <button
                  type="button"
                  onClick={() => void enviarAoOrcamento()}
                  disabled={Boolean(enviandoAoOrcamento)}
                  title={`As páginas viram as imagens da arte do item${doVinculo?.item ? ` "${doVinculo.item}"` : ""} no orçamento${doVinculo?.numero ? ` #${doVinculo.numero}` : ""}`}
                  className="flex h-10 shrink-0 items-center gap-2 whitespace-nowrap rounded-[10px] bg-white px-4 text-[13.5px] font-bold text-[#5B21B6] shadow-sm transition hover:bg-[#F5F3FF] disabled:opacity-70"
                  aria-label="Enviar para o orçamento"
                  data-enviar-ao-orcamento
                  data-botao-do-topo
                >
                  {enviandoAoOrcamento ? <Loader2 size={16} className="animate-spin" /> : <FileUp size={16} />}
                  {enviandoAoOrcamento || `Enviar para o orçamento${doVinculo?.numero ? ` #${doVinculo.numero}` : ""}`}
                </button>
                {enviadoAoOrcamento ? (
                  <a href={`/dashboard/quotes/${vinculo.orcamento}`} target="_blank" rel="noreferrer" title="Abrir o orçamento" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] bg-white/15 text-white ring-1 ring-white/30 transition hover:bg-white/25" data-abrir-orcamento>
                    <ExternalLink size={16} />
                  </a>
                ) : null}
              </>
            ) : null}
            {aoUsar ? null : (
              <BotaoDeAprovacao
                vinculo={vinculo ? { orcamento: vinculo.orcamento, item: vinculo.item, rotulo: doVinculo?.numero ? `Orçamento #${doVinculo.numero}` : "Este orçamento" } : null}
                paginas={() => todasAsPaginas().map(comFontesDaMarca)}
                dono={loja?.dono ?? null}
                arteId={arteId}
                nome={nome}
                documento={() => (docRef.current ? documentoDasPaginas(todasAsPaginas().map(comFontesDaMarca)) : null)}
                antesDeCriar={async () => {
                  /* A arte vai guardada antes (a prova fica ligada a ela). */
                  alterado.current = true;
                  await salvarJa();
                  return arteIdRef.current;
                }}
                aoAvisar={avisar}
              />
            )}
            <BotaoDeBaixar
              documento={() => (docRef.current ? comFontesDaMarca(docRef.current) : null)}
              paginas={() => todasAsPaginas().map(comFontesDaMarca)}
              paginaAtual={paginaAtual}
              nome={nome}
              aoAvisar={avisar}
              aoAnimar={
                comLinhaDoTempo
                  ? () => {
                      setModoAnimacao(true);
                      setFolha({ aba: "estilo", indice: paginaRef.current });
                    }
                  : undefined
              }
              aoAjustarCores={(mapa) => {
                mudarPaginas((lista, atual) => ({ paginas: lista.map((p) => trocarCores(p, mapa)), atual }));
                avisar(`${Object.keys(mapa).length} cores trocadas pelas que a gráfica imprime. Ctrl+Z volta.`);
              }}
            />
            {aoUsar ? (
              <button
                type="button"
                onClick={usar}
                disabled={gerando || !doc}
                className="flex h-10 items-center gap-2 rounded-[10px] bg-white px-5 text-[13.5px] font-bold text-[#5B21B6] shadow-[0_2px_10px_rgba(0,0,0,0.15)] transition hover:bg-[#F5F3FF] disabled:opacity-70"
              >
                {gerando ? <Loader2 size={16} className="animate-spin" /> : null}
                {gerando ? "Gerando o banner…" : "Usar este banner"}
              </button>
            ) : null}
          </div>
        </header>

        <div className="flex min-h-0 flex-1" data-conteudo>
          {/* ------------------------------------------------------ trilho */}
          <nav className="flex w-[76px] shrink-0 flex-col items-center gap-1 border-r border-[#E6E3EE] bg-white py-3" data-trilho>
            {TRILHO.map(({ aba, rotulo, Icone }) => {
              const ativa = painel?.tipo === "biblioteca" && painel.aba === aba;
              return (
                <button key={aba} type="button" onClick={() => setPainel(ativa ? null : { tipo: "biblioteca", aba })} className="group flex w-[68px] flex-col items-center gap-1 py-1.5 text-[10.5px] font-semibold">
                  <span className={`flex h-10 w-10 items-center justify-center rounded-[12px] transition ${ativa ? "bg-[#EDE7FF] text-[#6D28D9]" : "text-[#5A5470] group-hover:bg-[#F3F1F9] group-hover:text-[#1F1B2E]"}`}>
                    <Icone size={21} strokeWidth={ativa ? 2.3 : 1.9} />
                  </span>
                  <span className={`text-center leading-tight ${ativa ? "text-[#6D28D9]" : "text-[#5A5470] group-hover:text-[#1F1B2E]"}`}>{rotulo}</span>
                </button>
              );
            })}
          </nav>

          {/* ------------------------------------------------------ painel */}
          {painel && doc && pedidoDoModelo && (painel.tipo === "biblioteca" || escolhida) ? (
            <aside
              className="flex w-[340px] shrink-0 flex-col border-r border-[#E6E3EE] bg-white"
              data-painel-lateral
              onMouseDown={(e) => {
                /* Escrevendo um texto, clicar num botão do painel não tira a seleção do trecho. */
                if (editando && (e.target as HTMLElement).closest("button")) e.preventDefault();
              }}
            >
              <div className="flex h-14 shrink-0 items-center justify-between px-5">
                <h2 className="text-[15px] font-bold text-[#1F1B2E]">{tituloDoPainel}</h2>
                <button type="button" onClick={() => setPainel(null)} title="Fechar" className="flex h-8 w-8 items-center justify-center rounded-[8px] text-[#8A849E] transition hover:bg-[#F3F1F9] hover:text-[#1F1B2E]">
                  <X size={17} />
                </button>
              </div>
              <div className="min-h-0 flex-1 overflow-y-auto px-5 pb-6">
                {painel.tipo === "biblioteca" && painel.aba === "marca" ? (
                  <PainelDaMarca kit={kit} aoMudar={mudarKit} semTabela={kitSemTabela} temEscolhida={selecionadas.length > 0} aoUsarCor={usarCorDoKit} aoUsarFonte={usarFonteDoKit} aoUsarLogo={usarLogoDoKit} aoAvisar={avisar} aoTrocarPaleta={trocarAPaleta} paginas={paginas.length || 1} />
                ) : painel.tipo === "biblioteca" && painel.aba === "camadas" ? (
                  <PainelDeCamadas
                    pecas={doc.pecas}
                    selecionadas={selecionadas}
                    aoEscolher={escolherDasCamadas}
                    aoMenu={(ids, sozinha, x, y) => {
                      if (!ids.every((id) => selecionadas.includes(id)) || (!sozinha && selecionadas.length !== ids.length)) escolherDasCamadas(ids, sozinha, false);
                      setMenu({ x, y });
                    }}
                    aoMudar={mudarVariasDoPainel}
                    aoOrdenar={ordenar}
                    aoSoltar={soltarCamada}
                  />
                ) : painel.tipo === "biblioteca" ? (
                  <PainelDaBiblioteca
                    aba={painel.aba}
                    doc={doc}
                    pedidoDoModelo={pedidoDoModelo}
                    logo={loja?.logo || null}
                    proporcaoDoLogo={loja?.proporcaoDoLogo || null}
                    produtos={loja?.produtos || []}
                    fotoDosModelos={fotoDosModelos}
                    enviando={enviando}
                    artes={artes}
                    arteAberta={arteId}
                    galeriaIndisponivel={semGaleria}
                    imagensDaPessoa={imagensDaPessoa}
                    aoTrocarDocumento={(novo) => {
                      /* Modelo de carrossel: as folhas dele entram no lugar desta página, uma depois da outra. */
                      const folhas = paginasDoDocumento(novo);
                      if (folhas.length > 1 && !tamanhoFixo) {
                        mudarPaginas((lista, atual) => ({ paginas: [...lista.slice(0, atual), ...folhas, ...lista.slice(atual + 1)].slice(0, LIMITE_DE_PAGINAS), atual }));
                        avisar(`Carrossel com ${folhas.length} páginas. Ctrl+Z volta como estava.`);
                        return;
                      }
                      trocar(() => folhas[0]);
                      escolher(null);
                      setEditando(null);
                    }}
                    aoPor={por}
                    aoPorVarias={porVarias}
                    aoEnviarImagem={(lista) => void enviarImagem(lista)}
                    aoEscolherFotoDosModelos={setFotoDosModelos}
                    aoAbrirArte={(arte) => void abrirArteGuardada(arte)}
                  />
                ) : escolhida ? (
                  <PainelDoAjuste
                    key={`${escolhida.id}-${painel.ajuste}`}
                    ajuste={painel.ajuste}
                    peca={escolhida}
                    corDoVetor={corDoVetor}
                    aoCorDoVetor={setCorDoVetor}
                    medida={{ largura, altura }}
                    mudar={(campos) => mudarDoPainel(escolhida.id, campos)}
                    aoOrdem={(para) => ordenar([escolhida.id], para)}
                    aoEnquadrar={() => setEnquadrando(escolhida.id)}
                    aoDistorcer={() => setDistorcendo((d) => (d === escolhida.id ? null : escolhida.id))}
                    distorcendo={distorcendo === escolhida.id}
                    foto={
                      fotoTratavel
                        ? { tratamento: tratamentoDaBarra, origem: origemDaFoto(fotoTratavel), aoTratar: (campos) => tratarFoto(fotoTratavel.id, campos), aplicando: aplicandoTratamento === fotoTratavel.id, corInicial: corDaFotoAberta, dpi: doc.impressao?.dpi ?? null }
                        : null
                    }
                  />
                ) : null}
              </div>
            </aside>
          ) : null}

          {/* ------------------------------------------------------ arte */}
          <main
            className="relative min-w-0 flex-1 overflow-hidden"
            data-area-da-arte
            onDragOver={(e) => {
              if (ehPecaDoPainel(e)) {
                e.preventDefault();
                e.dataTransfer.dropEffect = "copy";
                return;
              }
              if (Array.from(e.dataTransfer.types).includes("Files")) {
                e.preventDefault();
                setArrastandoArquivo(true);
              }
            }}
            onDragLeave={(e) => {
              if (e.currentTarget === e.target) setArrastandoArquivo(false);
            }}
            onDrop={(e) => {
              e.preventDefault();
              setArrastandoArquivo(false);
              if (ehPecaDoPainel(e)) {
                const pecas = pegarArrasto();
                if (pecas) soltarDoPainel(pecas, e.clientX, e.clientY);
                return;
              }
              if (Array.from(e.dataTransfer.files).some((f) => f.type.startsWith("video/"))) {
                /* Vídeo solto numa arte parada: ela vira animada (o vídeo precisa da linha do tempo). */
                if (comLinhaDoTempo) {
                  setModoAnimacao(true);
                  void enviarVideo(e.dataTransfer.files);
                }
                else avisar("Vídeo entra nas artes de redes sociais (post, story, reels), não no banner da loja nem na impressão.", "ruim");
                return;
              }
              void enviarImagem(e.dataTransfer.files, molduraNoPonto(e.clientX, e.clientY)?.id ?? null);
            }}
          >
            {doc ? (
              <>
                <Palco
                  doc={docNoPalco ?? doc}
                  selecionadas={cena ? [] : selecionadas}
                  zoom={zoom}
                  editando={cena ? null : editando}
                  aoSelecionar={escolher}
                  aoSelecionarVarias={escolherVarias}
                  aoMexer={mexer}
                  aoMexerVarias={mexerVarias}
                  aoComecarGesto={guardar}
                  aoEditarTexto={setEditando}
                  aoEscrever={escrever}
                  aoAjustarEscala={setEscalaQueCabe}
                  aoDuplicar={(id) => duplicar([id])}
                  aoApagar={(id) => apagar([id])}
                  alcas={ajusteAberto === "transparencia" ? "transparencia" : ajusteAberto === "cor" ? "pintura" : null}
                  aoZoom={fixarZoomDoPalco}
                  aoZoomVivo={zoomVivoDoPalco}
                  reservaEmbaixo={usaLinhaDoTempo ? alturaDaLinha + (folhaDeSons ? 272 : folha ? 176 : 0) - 40 : tamanhoFixo ? 0 : 70}
                  cena={cena ? <CenaNoPalco paginas={paginasVistas} relogio={relogio} largura={doc.largura} altura={doc.altura} /> : null}
                  aoClonar={clonar}
                  aoMenu={(x, y) => setMenu({ x, y })}
                  pincel={pincel}
                  ferramenta={ferramenta}
                  aoDesenhar={desenhar}
                  enquadrando={enquadrandoAgora}
                  aoEnquadrar={setEnquadrando}
                  margemDeSeguranca={doc.impressao && mostrarMargens ? (3 / 25.4) * doc.impressao.dpi : null}
                  guiasDaCaneca={mostrarGuiasDaCaneca && ehCaneca(doc)}
                  analisando={fundoSaindo ? { id: fundoSaindo.id, saindo: Boolean(fundoSaindo.saindo) } : null}
                  nos={nos}
                  aoNos={setNos}
                  distorcendo={distorcendo && selecionadas.includes(distorcendo) ? distorcendo : null}
                  perspectivaDoGrupo={selecionadas.length > 1 && distorcendo === perspectivaDoGrupo(selecionadas)}
                  apagando={apagando && selecionadas.includes(apagando) ? apagando : null}
                  contagem={numeracaoDosPosts(paginasVistas)[paginaAtual] ?? { pagina: 1, total: 1 }}
                  aoTerminarBorracha={(id, arquivo) => terminarBorracha(id, arquivo)}
                  aoCancelarBorracha={() => setApagando(null)}
                  aoEditarNos={() => escolherFerramenta("forma")}
                  aoDiminuirTexto={diminuirTexto}
                  compacto={compacto}
                  voltaAoMeio={voltaAoMeio}
                />
                {menu ? (
                  <MenuDaPeca
                    x={menu.x}
                    y={menu.y}
                    quantas={escolhidas.length}
                    travadas={escolhidas.length > 0 && escolhidas.every((p) => p.bloqueada)}
                    agrupadas={escolhidas.length > 0 && Boolean(escolhidas[0].grupo) && escolhidas.every((p) => p.grupo === escolhidas[0].grupo)}
                    ocultas={escolhidas.length > 0 && escolhidas.every((p) => p.oculta)}
                    temCopia={copiadas.current.length > 0}
                    temEstilo={temEstilo}
                    aoEscolher={agirPeloMenu}
                    aoFechar={() => setMenu(null)}
                  />
                ) : null}
                {/* Tocando ou parada num instante: clicar na arte volta a editar. */}
                {cena ? <button type="button" aria-label="Voltar a editar" className="absolute inset-0 z-30 cursor-default" onClick={sairDaCena} data-sair-da-cena /> : null}
                {/* A linha do tempo no lugar da tira de páginas (com a folha de efeitos por cima quando aberta). */}
                {usaLinhaDoTempo && paginas.length ? (
                  <div ref={caixaDaLinha} className="pointer-events-none absolute inset-x-0 bottom-0 z-40 flex flex-col" data-baixo-da-arte>
                    {folhaDeSons ? (
                      <div className="px-3">
                        <FolhaDeAudios
                          aba={folhaDeSons}
                          enviando={enviandoAudio}
                          fontes={fontesDaFala}
                          trabalhando={trabalhandoNaFala}
                          aoNarrar={(p) => void narrar(p)}
                          aoLegendar={(id, estilo) => void legendar(id, estilo)}
                          aoUsar={(som: SomDoCatalogo) => {
                            /* A onda fina (a do catálogo é resumida): mede do arquivo; sem rede, fica a do catálogo. */
                            void fetch(som.src)
                              .then((r) => r.arrayBuffer())
                              .then(ondaDoAudio)
                              .catch(() => null)
                              .then((medida) => porSom({ ...som, onda: medida?.onda ?? som.onda }));
                          }} aoEnviar={(l) => void enviarAudio(l)} aoFechar={() => setFolhaDeSons(null)} />
                      </div>
                    ) : null}
                    {folha && paginasVistas[folha.indice] ? (
                      <div className="px-3">
                        <FolhaDeEfeitos
                          key={`${folha.indice}-${idsDaFolha.join(",")}`}
                          paginas={paginasVistas}
                          indice={folha.indice}
                          ids={idsDaFolha}
                          aba={folha.aba}
                          aoMudarPeca={animarPecas}
                          aoTransicao={mudarTransicao}
                          aoEstilo={animarACena}
                          aoMudarPagina={(a) => mudarUmaPagina(folha.indice, (p) => ({ ...p, animacao: a }), `a:${folha.indice}`)}
                          aoTirarTudo={tirarAnimacaoDaCena}
                          aoFechar={() => setFolha(null)}
                        />
                      </div>
                    ) : null}
                    <LinhaDoTempo
                      paginas={paginasVistas}
                      atual={paginaAtual}
                      relogio={relogio}
                      tocando={cena === "tocando"}
                      aberta={trilhasAbertas}
                      aoAbrir={setTrilhasAbertas}
                      selecionadas={cena ? [] : selecionadas}
                      aoTocar={() => tocar()}
                      aoPausar={pausar}
                      aoBuscar={buscarNoTempo}
                      aoMoverAgulha={moverAgulha}
                      aoIrPagina={irParaCena}
                      aoEscolher={(id) => {
                        sairDaCena();
                        escolher(id);
                      }}
                      aoMudarClipe={mudarClipe}
                      aoQuadro={agirNoQuadro}
                      aoVelocidade={mudarVelocidade}
                      aoSomDoVideo={mudarSomDoVideo}
                      aoSepararAudio={(id) => {
                        sairDaCena();
                        separarAudio(id);
                      }}
                      aoMudarDuracao={mudarDuracaoDaCena}
                      aoAgir={agirNaLinha}
                      aoAgirNaPagina={agirNaPagina}
                      aoAdicionarPagina={() => adicionarPagina("duplicar")}
                      aoAbrirEfeitos={abrirEfeitos}
                      aoAbrirTransicao={(i) => setFolha({ aba: "transicao", indice: i })}
                      audios={audiosDoProjeto(paginasVistas)}
                      audioEscolhido={audioEscolhido}
                      aoEscolherAudio={(id) => {
                        sairDaCena();
                        if (id) setSelecionadas([]);
                        setAudioEscolhido(id);
                      }}
                      aoMudarAudio={mudarAudio}
                    />
                    <TocadorDeAudios audios={audiosDoProjeto(paginasVistas)} relogio={relogio} />
                    <input ref={entradaDeVideo} type="file" accept="video/mp4,video/webm,video/quicktime" className="hidden" onChange={(e) => void enviarVideo(e.target.files)} data-entrada-de-video />
                  </div>
                                ) : tamanhoFixo || !paginas.length ? null : (
                  <div className="pointer-events-none absolute bottom-3 left-4 right-[310px] z-40 flex justify-center" data-tira>
                    <TiraDePaginas
                      paginas={paginasDaTira}
                      atual={paginaAtual}
                      aoIr={irParaPagina}
                      aoAdicionar={adicionarPagina}
                      aoAgir={agirNaPagina}
                      aoMover={moverPagina}
                      aoVerTodas={() => setGradeAberta(true)}
                    />
                  </div>
                )}
                {gradeAberta && !tamanhoFixo && paginas.length ? (
                  <GradeDePaginas
                    paginas={paginasDaTira}
                    atual={paginaAtual}
                    aoIr={irParaPagina}
                    aoAgir={agirNaPagina}
                    aoMover={moverPagina}
                    aoAdicionar={adicionarPagina}
                    aoFechar={fecharGrade}
                  />
                ) : null}
                <div className="pointer-events-none absolute inset-x-0 top-4 z-40 flex justify-center px-4" data-barra-de-cima>
                  {cena ? (
                    <div className="pointer-events-auto flex h-12 items-center gap-2 rounded-[12px] bg-white px-4 text-[12.5px] text-[#6B6584] shadow-[0_2px_6px_rgba(20,11,43,0.06),0_10px_30px_rgba(20,11,43,0.10)] ring-1 ring-black/5" data-aviso-da-cena>
                      <span className={`h-2 w-2 rounded-full ${cena === "tocando" ? "animate-pulse bg-[#E11D48]" : "bg-[#8A849E]"}`} />
                      {cena === "tocando" ? "Reproduzindo" : "Prévia do instante"} · <b className="font-semibold text-[#2B2640]">clique na arte</b> ou Esc para editar
                    </div>
                  ) : escolhidas.length > 1 ? (
                    <BarraDoGrupo
                      quantas={escolhidas.length}
                      travadas={escolhidas.every((p) => p.bloqueada)}
                      agrupadas={Boolean(escolhidas[0].grupo) && escolhidas.every((p) => p.grupo === escolhidas[0].grupo)}
                      aoAgrupar={agruparOuDesagrupar}
                      aoColocarDentro={paraColocarDentro(escolhidas) ? colocarDentro : undefined}
                      aoOperar={escolhidas.every(entraNoRecorte) ? (operacao) => void operarNasFormas(operacao) : undefined}
                      operando={operando}
                      aoAlinhar={alinhar}
                      aoDistribuir={distribuir}
                      aoDuplicar={() => duplicar(selecionadas)}
                      aoTravar={() => {
                        const travar = !escolhidas.every((p) => p.bloqueada);
                        trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (selecionadas.includes(p.id) ? ({ ...p, bloqueada: travar } as Peca) : p)) }));
                      }}
                      aoApagar={() => apagar(selecionadas)}
                      aoAnimar={comLinhaDoTempo ? animar : undefined}
                      animando={Boolean(folha)}
                      aoPerspectiva={escolhidas.some((p) => p.bloqueada) ? undefined : () => setDistorcendo((d) => (d === perspectivaDoGrupo(selecionadas) ? null : perspectivaDoGrupo(selecionadas)))}
                      perspectivaLigada={distorcendo === perspectivaDoGrupo(selecionadas)}
                      aoTirarPerspectiva={
                        escolhidas.some((p) => p.perspectiva || p.distorcao)
                          ? () => {
                              setDistorcendo(null);
                              trocar((d) => ({ ...d, pecas: d.pecas.map((p) => (selecionadas.includes(p.id) ? ({ ...p, distorcao: null, perspectiva: null } as Peca) : p)) }));
                            }
                          : undefined
                      }
                      aoTrocarFoto={(() => {
                        const fotos = escolhidas.filter((p) => p.tipo === "imagem" && !p.bloqueada && !p.fixa);
                        return fotos.length === 1 && escolhidas.every((p) => p.grupo && p.grupo === escolhidas[0].grupo)
                          ? () => {
                              fotoATrocar.current = fotos[0].id;
                              entradaDaTroca.current?.click();
                            }
                          : undefined;
                      })()}
                    />
                  ) : (
                    <BarraDaPeca
                      peca={escolhida}
                      emEscrita={Boolean(escolhida && editando === escolhida.id)}
                      ajusteAberto={ajusteAberto}
                      mudar={(campos) => escolhida && mudarDoPainel(escolhida.id, campos)}
                      aoAbrir={(ajuste) => setPainel((p) => (p?.tipo === "ajuste" && p.ajuste === ajuste ? null : { tipo: "ajuste", ajuste }))}
                      corDoVetor={corDoVetor}
                      aoCorDoVetor={abrirCorDoVetor}
                      coresDaFoto={coresDaBarra}
                      corDaFoto={corDaFotoAberta}
                      aoCorDaFoto={
                        fotoTratavel
                          ? (cor) => {
                              setCorDaFoto({ id: fotoTratavel.id, cor });
                              setPainel({ tipo: "ajuste", ajuste: "cores-da-foto" });
                            }
                          : undefined
                      }
                      aoReticula={fotoTratavel ? () => setPainel((p) => (p?.tipo === "ajuste" && p.ajuste === "reticula" ? null : { tipo: "ajuste", ajuste: "reticula" })) : undefined}
                      comReticula={Boolean(tratamentoDaBarra?.meioTom)}
                      aoPerspectiva={escolhida && !escolhida.bloqueada ? () => setDistorcendo((d) => (d === escolhida.id ? null : escolhida.id)) : undefined}
                      perspectivaLigada={Boolean(escolhida && distorcendo === escolhida.id)}
                      pincel={pincel}
                      aoEnquadrar={escolhida ? () => setEnquadrando(escolhida.id) : undefined}
                      nosDoTraco={ferramenta === "forma" && escolhida?.tipo === "traco" ? { escolhidos: nos.length, fechado: escolhida.fechado } : null}
                      aoMexerNos={mexerNosDoTraco}
                      aoTirarDeDentro={escolhida?.dentroDe ? () => tirarDeDentro(escolhida.id) : undefined}
                      aoSubstituir={escolhida && fotoQueTroca(doc.pecas, escolhida) && !fotoQueTroca(doc.pecas, escolhida)!.bloqueada ? () => {
                        fotoATrocar.current = fotoQueTroca(doc.pecas, escolhida)!.id;
                        entradaDaTroca.current?.click();
                      } : undefined}
                      fotoDeDentro={Boolean(escolhida && escolhida.tipo !== "imagem" && fotoQueTroca(doc.pecas, escolhida))}
                      aoConverterEmCurvas={escolhida?.tipo === "forma" && daParaConverter(escolhida) ? () => converterEmCurvas(escolhida.id) : undefined}
                      aoSeparar={escolhida && daParaSeparar(escolhida) ? () => void separarDesenho(escolhida.id) : undefined}
                      dpiDeImpressao={doc.impressao?.dpi ?? null}
                      aoTirarFundo={escolhida?.tipo === "imagem" && !escolhida.moldura ? () => void tirarFundoDaFoto(escolhida.id) : undefined}
                      aoAnimar={comLinhaDoTempo ? animar : undefined}
                      animando={Boolean(folha)}
                      animada={Boolean(escolhida?.animacao && (escolhida.animacao.entrada || escolhida.animacao.enfase || escolhida.animacao.saida))}
                      fixa={Boolean(escolhida?.fixa)}
                      aoFixar={escolhida && !tamanhoFixo ? () => fixarEmTodas(escolhida.id) : undefined}
                      aoBorracha={escolhida?.tipo === "imagem" && !escolhida.moldura ? () => setApagando((a) => (a === escolhida.id ? null : escolhida.id)) : undefined}
                      tirandoFundo={fundoSaindo && !fundoSaindo.saindo ? fundoSaindo.texto : previaDoFundo ? "Guardando a foto…" : null}
                      aoPincel={() => {
                        if (pincelRef.current) {
                          pincelRef.current = false;
                          setPincel(false);
                          setRecado(null);
                        } else copiarEstilo(true);
                      }}
                    />
                  )}
                </div>
                <input
                      ref={entradaDaTroca}
                      type="file"
                      accept="image/png,image/jpeg,image/webp"
                      className="hidden"
                      onChange={(e) => {
                        const alvo = fotoATrocar.current;
                        void enviarImagem(e.target.files, alvo).finally(() => {
                          if (entradaDaTroca.current) entradaDaTroca.current.value = "";
                        });
                      }}
                      data-entrada-da-troca
                    />
                {arrastandoArquivo ? (
                  <div className="pointer-events-none absolute inset-3 z-50 flex items-center justify-center rounded-[20px] border-[3px] border-dashed border-[#8B5CF6] bg-[#7C3AED]/10">
                    <span className="rounded-full bg-white px-5 py-2.5 text-[14px] font-bold text-[#5B21B6] shadow-lg">Solte a imagem para pôr na arte</span>
                  </div>
                ) : null}
                {enviando ? (
                  <div style={usaLinhaDoTempo ? { bottom: alturaDaLinha + (folhaDeSons ? 272 : folha ? 176 : 0) + 12 } : undefined} className="pointer-events-none absolute bottom-4 left-4 z-40 flex items-center gap-2 rounded-full bg-white px-4 py-2 text-[12.5px] font-semibold text-[#4B4760] shadow-lg">
                    <Loader2 size={14} className="animate-spin text-[#7C3AED]" /> Enviando a imagem…
                  </div>
                ) : null}
                <div className="pointer-events-none absolute right-4 top-1/2 z-40 -translate-y-1/2" data-ferramentas style={usaLinhaDoTempo ? { top: `calc((100% - ${alturaDaLinha + (folhaDeSons ? 272 : folha ? 176 : 0)}px) / 2)` } : undefined}>
                  <BarraDeFerramentas ferramenta={ferramenta} aoEscolher={escolherFerramenta} />
                </div>
                <div style={usaLinhaDoTempo ? { bottom: alturaDaLinha + (folhaDeSons ? 272 : folha ? 176 : 0) + 12 } : undefined} className="absolute bottom-4 right-4 z-40 flex items-center gap-1 rounded-full bg-white px-1.5 py-1 shadow-[0_2px_6px_rgba(20,11,43,0.08),0_8px_24px_rgba(20,11,43,0.10)] ring-1 ring-black/5" data-zoom>
                  <button type="button" onClick={() => aproximar(1 / 1.2)} title="Afastar (Ctrl −)" className="flex h-8 w-8 items-center justify-center rounded-full text-[#4B4760] hover:bg-[#F1EFF6]">
                    <Minus size={15} />
                  </button>
                  <input ref={faixaDoZoom} type="range" min={Math.round(ZOOM_MINIMO * 100)} max={Math.round(ZOOM_MAXIMO * 100)} value={Math.round(escala * 100)} onChange={(e) => setZoom(Number(e.target.value) / 100)} className="w-28 accent-[#7C3AED]" aria-label="Zoom" />
                  <button type="button" onClick={() => aproximar(1.2)} title="Aproximar (Ctrl +)" className="flex h-8 w-8 items-center justify-center rounded-full text-[#4B4760] hover:bg-[#F1EFF6]">
                    <Plus size={15} />
                  </button>
                  <span ref={numeroDoZoom} className="min-w-11 text-center text-[12px] font-semibold tabular-nums text-[#2B2640]">{`${Math.round(escala * 100)}%`}</span>
                  <button type="button" onClick={() => { setZoom(null); setVoltaAoMeio((n) => n + 1); }} title="Caber na tela (Ctrl 0)" className="flex h-8 w-8 items-center justify-center rounded-full text-[#4B4760] hover:bg-[#F1EFF6]">
                    <Maximize size={14} />
                  </button>
                </div>
                {(doc.impressao || ehCaneca(doc)) ? (
                  <div style={usaLinhaDoTempo ? { bottom: alturaDaLinha + (folhaDeSons ? 272 : folha ? 176 : 0) + 12 } : undefined} className="absolute bottom-4 left-4 z-40 flex items-center gap-1 rounded-full bg-white px-1.5 py-1 shadow-[0_2px_6px_rgba(20,11,43,0.08),0_8px_24px_rgba(20,11,43,0.10)] ring-1 ring-black/5" data-guias>
                    {doc.impressao ? (
                      <button type="button" onClick={() => setMostrarMargens((v) => !v)} title={mostrarMargens ? "Esconder a área de segurança" : "Mostrar a área de segurança (3 mm da borda)"} className={`flex h-8 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] font-semibold transition-colors ${mostrarMargens ? "bg-[#0891B2]/10 text-[#0891B2]" : "text-[#4B4760] hover:bg-[#F1EFF6]"}`}>
                        <Ruler size={13} />
                        <span>Segurança</span>
                      </button>
                    ) : null}
                    {ehCaneca(doc) ? (
                      <button type="button" data-botao-guias-da-caneca onClick={() => setMostrarGuiasDaCaneca((v) => !v)} title={mostrarGuiasDaCaneca ? "Esconder as guias da caneca" : "Mostrar as guias da caneca (lados e alça)"} className={`flex h-8 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] font-semibold transition-colors ${mostrarGuiasDaCaneca ? "bg-[#DB2777]/10 text-[#DB2777]" : "text-[#4B4760] hover:bg-[#F1EFF6]"}`}>
                        <Coffee size={13} />
                        <span>Guias</span>
                      </button>
                    ) : null}
                  </div>
                ) : null}
              </>
            ) : (
              <div className="flex h-full items-center justify-center text-[13px] text-[#6B6584]">
                <Loader2 size={18} className="mr-2 animate-spin" /> Preparando o editor…
              </div>
            )}
          </main>
        </div>
      </div>
    </ContextoDasCores.Provider>
  );
}
