"use client";

import { EfeitoDeAnalise } from "./efeito-de-analise";
import { Borracha } from "./borracha";
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Copy, Lock, MoreHorizontal, RotateCw, Trash2, TriangleAlert } from "lucide-react";
import {
  anguloDosPontos,
  caixaDaPeca,
  caixaDoTraco,
  conteudoDaPeca,
  ehFundoDaArte,
  escalarPeca,
  htmlDoTextoLimpo,
  matrizDaDistorcao,
  pontosDoAngulo,
  temTrechos,
  type DocumentoDoBanner,
  type Peca,
  type PecaDeTexto,
  type Pintura,
  type PontosDoDegrade,
  type PecaDeImagem,
  type PecaDeTraco,
  type ContagemDaPagina,
  type PontoDoTraco,
} from "@/lib/banner-editor/documento";
import { registrarCampo } from "./texto-rico";
import { apagarNos, caminhoDasCurvas, comAlcas, dividirTrecho, entortarTrecho, maisPerto, moverAlca, moverNos } from "@/lib/banner-editor/curvas";
import { exibicaoGuardada, htmlDaPeca, htmlDoFundo } from "./desenho-guardado";
import { comConteudosJuntos, conteudosDe, porQueNaoRecebe, recipienteAberto, recipienteDe } from "@/lib/banner-editor/conteudo-dentro";
import { cantosDoGrupo, distorcoesDoGrupo, gradeDaPerspectiva, homografiaDaCaixa, pelaHomografia, type CaixaReta, type Ponto, type Quadrilatero } from "@/lib/banner-editor/perspectiva-do-grupo";
import { camposDoTexto, faltaNoTexto, naoCabe, textoDeEnfeite } from "./texto-que-cabe";
import { PontosDaReticulaViva } from "./pontos-da-reticula-viva";

/*
  O palco do editor de artes: a arte no tamanho real, encolhida (ou
  ampliada) para a tela, com as peças desenhadas pelo MESMO HTML que o
  servidor fotografa (lib/banner-editor/documento.ts). O fundo e as texturas
  são peças como as outras (a primeira camada é o fundo).

  Por cima, em pixels de tela (não encolhem com o zoom):
   - o contorno da peça sob o mouse;
   - a moldura da escolhida, como no Canva: cantos redondos brancos
     (redimensionam mantendo a proporção; Shift solta), laterais em pílula
     (esticam um lado), o botão de girar embaixo e, em cima, duplicar e
     apagar. Peça travada mostra o cadeado e não sai do lugar;
   - várias escolhidas (Shift + clique, ou o laço arrastado no vazio, no
     fundo travado ou com Shift em cima de qualquer peça, que
     pega só o que ficar TODO dentro dele, como nos outros editores): cada
     uma contornada e a caixa do grupo tracejada; arrastar qualquer uma move
     todas;
   - o número ao vivo (tamanho, posição ou giro) enquanto mexe;
   - as guias rosa quando encaixa no centro ou na borda da arte ou de outra
     peça (Alt desliga).
  O canto puxado para fora sempre aumenta e para dentro sempre diminui (a
  conta é a projeção do arrasto na diagonal, nos eixos da peça, mesmo girada).

  Arrasto ao vivo: mover, aumentar e girar (uma peça ou várias) mudam só o
  palco enquanto o mouse anda (`gestoAoVivo`); ao soltar, a arte recebe tudo de uma
  vez (um passo do Ctrl+Z). Antes, cada movimento redesenhava o editor
  inteiro (barras, painéis, páginas) e o arrasto pesava.

  Sem barras de rolagem: a roda do mouse (e a pinça do touchpad) dá zoom no
  ponto onde está o cursor, e a vista se arrasta segurando a barra de espaço
  ou com o botão do meio do mouse, solta como no Figma: com ou sem zoom, para
  qualquer lado (um pedaço da arte sempre fica na tela; "Caber na tela"
  volta ela para o meio).

  Agilidade: grupo (Ctrl+G) é escolhido inteiro com um clique e dois cliques
  entram numa peça dele; várias escolhidas ganham cantos para crescer juntas
  e o botão de girar o conjunto; Alt + arrastar leva uma cópia (a original
  fica); o botão direito abre o menu da peça; ao mover, as guias rosa também
  mostram quando a peça fica com o mesmo espaço dos dois lados. Com o
  "copiar estilo" ligado, o cursor vira o pincel e o clique pinta a peça.

  Criação: linha e seta mostram os PONTOS (arrastar muda o caminho; Shift
  trava a direção); a caneta põe pontos a cada clique (dois cliques ou Enter
  terminam, clicar no primeiro ponto fecha a forma, Esc desiste) e o desenho
  livre segue o mouse; dois cliques numa foto que preenche a moldura entram
  no enquadre (arrastar move a foto dentro dela, a roda aproxima, Enter ou
  clicar fora termina).

  Escrever: dois cliques num texto (ou um clique num texto já escolhido) e o
  PRÓPRIO texto da arte vira campo de escrita, com a mesma letra, cor e
  efeitos; a caixa cresce sozinha quando o texto passa dela. Selecionar um
  trecho e usar a barra (negrito, cor, tamanho...) muda só o trecho
  (texto-rico.ts). A escrita termina com Esc ou clicando fora, na arte.

  Colocar dentro (como o PowerClip do Corel): o que está dentro de outra peça
  é parte dela. O clique pega a de fora (ela anda, cresce e gira com o
  conteúdo junto); dois cliques entram no ajuste do que vai dentro: o resto
  da arte fica apagado, a foto aparece inteira e fraquinha fora da forma
  para ser arrastada, os cantos aumentam e Esc (ou "Pronto") termina. No
  "Colocar dentro…", o cursor vira a seta e o clique escolhe quem recebe.

  Fora da página: o que passa da borda da arte continua à vista em volta
  dela, apagadinho e com o contorno (o aramado do Corel), e dá para pegar
  e arrastar de volta.

  Alças do degradê (como a transparência interativa do Corel): a peça
  escolhida que tem degradê na cor ou transparência em degradê mostra uma
  seta do começo ao fim (com as duas, vale a do painel aberto; sem painel, a
  transparência). Arrastar uma ponta muda a direção e onde o sumiço (ou a
  troca de cor) começa e termina; Shift trava de 15 em 15 graus e as pontas
  encaixam no meio e nas bordas (Alt solta). O tracinho no meio da seta
  corre por ela e muda onde fica o meio a meio. Clicar numa ponta abre o
  "quanto cobre" dela.
*/

/** As ferramentas da barra do lado: desenhar com a caneta (Bézier) ou à mão, e a Forma (mexer nos nós). */
export type FerramentaDoPalco = "caneta" | "lapis" | "forma" | null;

/** O ponto da tela (cliente) no traço, em fração da caixa dele (com o giro e o espelho desfeitos). */
function fracaoNoTraco(traco: Peca, ponto: { x: number; y: number }) {
  const rad = (-traco.rot * Math.PI) / 180;
  const dx = ponto.x - (traco.x + traco.w / 2);
  const dy = ponto.y - (traco.y + traco.h / 2);
  let fx = (dx * Math.cos(rad) - dy * Math.sin(rad)) / traco.w + 0.5;
  let fy = (dx * Math.sin(rad) + dy * Math.cos(rad)) / traco.h + 0.5;
  if (traco.espelharX) fx = 1 - fx;
  if (traco.espelharY) fy = 1 - fy;
  return { x: fx, y: fy };
}

/** O traço com a caixa justa de novo em volta da curva (depois de mexer nos nós; com giro, a caixa fica). */
export function tracoJusto(traco: PecaDeTraco): Partial<Peca> | null {
  /* Com giro ou com outros contornos (Soldar/Aparar), a caixa fica: os contornos estão em fração dela. */
  if (traco.rot || traco.contornos?.length) return null;
  const naArte = (q: { x: number; y: number }) => ({ x: traco.x + (traco.espelharX ? 1 - q.x : q.x) * traco.w, y: traco.y + (traco.espelharY ? 1 - q.y : q.y) * traco.h });
  const pontos = traco.pontos.map((q) => ({ ...q, ...naArte(q), e: q.e ? naArte(q.e) : q.e, s: q.s ? naArte(q.s) : q.s }));
  const caixa = caixaDoTraco(pontos, traco.espessura, traco.fechado, traco.suave);
  const voltar = (q: { x: number; y: number }) => ({ x: traco.espelharX ? 1 - q.x : q.x, y: traco.espelharY ? 1 - q.y : q.y });
  return { ...caixa, pontos: caixa.pontos.map((q) => ({ ...q, ...voltar(q), ...(q.e ? { e: voltar(q.e) } : {}), ...(q.s ? { s: voltar(q.s) } : {}) })) } as Partial<Peca>;
}

/** O que as alças mexem: a transparência da peça ou o degradê da pintura. */
export type ModoDasAlcas = "transparencia" | "pintura";

type Gesto =
  /* clonar: Alt segurado no clique (a cópia nasce no primeiro movimento); fundo: Ctrl no clique (pega a peça de baixo). */
  | { tipo: "mover"; id: string; ids: string[]; inicioX: number; inicioY: number; posicoes: Map<string, { x: number; y: number }>; jaEscolhida: boolean; mexeu: boolean; clonar?: boolean; clonou?: boolean; fundo?: boolean }
  | { tipo: "redimensionar"; id: string; hx: number; hy: number; inicioX: number; inicioY: number; peca: Peca }
  | { tipo: "girar"; id: string; cx: number; cy: number }
  /* clique: o laço que começou em cima de uma peça (Shift, ou o fundo travado); soltar sem arrastar vale como clique nela. */
  | { tipo: "laco"; inicioX: number; inicioY: number; somar: boolean; antes: string[]; mexeu?: boolean; clique?: { id: string; alternar: boolean } }
  | { tipo: "vista"; x: number; y: number }
  | { tipo: "redimensionarGrupo"; hx: number; hy: number; inicioX: number; inicioY: number; caixa: { x: number; y: number; w: number; h: number }; pecas: Peca[] }
  | { tipo: "girarGrupo"; cx: number; cy: number; inicio: number; pecas: Peca[] }
  /* ultimo: os pontos do último movimento (a caixa justa ao soltar usa eles; o documento pode ainda não ter redesenhado). */
  | { tipo: "ponto"; id: string; indice: number; mexeu: boolean; ultimo?: PontoDoTraco[] }
  | { tipo: "enquadre"; id: string; inicioX: number; inicioY: number; base: { zoom: number; x: number; y: number }; excesso: { x: number; y: number } }
  | { tipo: "lapis" }
  /* Ferramenta Forma: mexer nos nós escolhidos, numa alça, ou entortar o trecho arrastando a linha (pontos de antes guardados). */
  | { tipo: "no"; id: string; indices: number[]; inicio: { x: number; y: number }; original: PontoDoTraco[]; mexeu: boolean; ultimo?: PontoDoTraco[] }
  | { tipo: "alcaDoNo"; id: string; indice: number; qual: "e" | "s"; original: PontoDoTraco[]; mexeu: boolean; ultimo?: PontoDoTraco[] }
  | { tipo: "entortar"; id: string; trecho: number; t: number; inicio: { x: number; y: number }; original: PontoDoTraco[]; mexeu: boolean; ultimo?: PontoDoTraco[] }
  /* Distorcer: um dos 4 cantos sendo arrastado (fração da caixa, sem o espelho). */
  | { tipo: "canto"; id: string; indice: number }
  /* Um canto da perspectiva do grupo: os cantos e a caixa de quando começou (as peças entortam todas juntas). */
  | { tipo: "cantoDoGrupo"; indice: number; ids: string[]; caixa: CaixaReta; cantos: Quadrilatero }
  /* Caneta Bézier: o nó acabou de entrar; arrastar puxa as alças dele (Alt: só a de saída, nó em canto). */
  | { tipo: "caneta"; inicioX: number; inicioY: number; mexeu: boolean }
  /* qual: 1 = começo, 2 = fim, 3 = o tracinho do meio. */
  | { tipo: "alca"; id: string; modo: ModoDasAlcas; qual: 1 | 2 | 3; inicioX: number; inicioY: number; mexeu: boolean }
  /* Retícula Viva: arrastar um ponto de cor ou a alça do alcance dele. */
  | { tipo: "pontoVivo"; id: string; indice: number; inicioX: number; inicioY: number; mexeu: boolean }
  | { tipo: "alcanceVivo"; id: string; indice: number };

const ALCAS: Array<[number, number]> = [
  [-1, -1],
  [0, -1],
  [1, -1],
  [1, 0],
  [1, 1],
  [0, 1],
  [-1, 1],
  [-1, 0],
];

const CURSORES: Record<string, string> = {
  "-1,-1": "nwse-resize",
  "1,1": "nwse-resize",
  "1,-1": "nesw-resize",
  "-1,1": "nesw-resize",
  "0,-1": "ns-resize",
  "0,1": "ns-resize",
  "-1,0": "ew-resize",
  "1,0": "ew-resize",
};

const ROXO = "#8B3DFF";
/* Os limites do zoom (3% a 2000%): o editor usa os mesmos nos botões e na faixa. */
export const ZOOM_MINIMO = 0.03;
export const ZOOM_MAXIMO = 20;

/* A caixa que contém as peças (sem giro). */
export function caixaDoGrupo(pecas: Array<Pick<Peca, "x" | "y" | "w" | "h">>) {
  const x = Math.min(...pecas.map((p) => p.x));
  const y = Math.min(...pecas.map((p) => p.y));
  const x2 = Math.max(...pecas.map((p) => p.x + p.w));
  const y2 = Math.max(...pecas.map((p) => p.y + p.h));
  return { x, y, w: x2 - x, h: y2 - y };
}

/* A pintura que as alças mexem (forma e texto pintam com `pintura`). */
function pinturaDa(p: Peca): Pintura | null {
  return p.tipo === "forma" || p.tipo === "texto" || p.tipo === "icone" ? p.pintura : null;
}

/** Os pontos e o "quanto cobre" de cada ponta, no modo das alças (null = a peça não tem o que mexer). */
function alcasDa(p: Peca, modo: ModoDasAlcas | null): { pontos: PontosDoDegrade; alfa: number; alfa2: number; meio: number; cor: string; cor2: string; radial: boolean } | null {
  if (modo === "transparencia" && p.transparencia) {
    const t = p.transparencia;
    return { pontos: t, alfa: t.alfa, alfa2: t.alfa2, meio: t.meio ?? 0.5, cor: "#141414", cor2: "#141414", radial: Boolean(t.radial) };
  }
  const pintura = modo === "pintura" ? pinturaDa(p) : null;
  if (!pintura || (pintura.tipo !== "linear" && pintura.tipo !== "radial")) return null;
  const radial = pintura.tipo === "radial";
  /* Radial: a do começo é o centro, a do fim é a borda do círculo (sem pontos, o círculo do meio até a borda). */
  let pontos = pintura.pontos ?? (radial ? { x1: 0.5, y1: 0.5, x2: 1, y2: 0.5 } : pontosDoAngulo(pintura.angulo, p.w, p.h));
  /* A pintura vira junto com a peça espelhada; as alças mostram onde a cor está de verdade. */
  if (p.espelharX) pontos = { ...pontos, x1: 1 - pontos.x1, x2: 1 - pontos.x2 };
  if (p.espelharY) pontos = { ...pontos, y1: 1 - pontos.y1, y2: 1 - pontos.y2 };
  return { pontos, alfa: pintura.alfa ?? 1, alfa2: pintura.alfa2 ?? pintura.alfa ?? 1, meio: pintura.meio ?? 0.5, cor: pintura.cor, cor2: pintura.cor2, radial };
}

/** Que alças a peça mostra: as do painel aberto, se ela tiver; senão a transparência, senão o degradê da cor. */
function modoDasAlcas(p: Peca, preferido: ModoDasAlcas | null): ModoDasAlcas | null {
  if (preferido && alcasDa(p, preferido)) return preferido;
  if (alcasDa(p, "transparencia")) return "transparencia";
  if (alcasDa(p, "pintura")) return "pintura";
  return null;
}

/** Os campos da peça com os pontos novos (e o "quanto cobre"), no modo das alças. */
function camposDasAlcas(p: Peca, modo: ModoDasAlcas, pontos: PontosDoDegrade, alfas?: { alfa?: number; alfa2?: number; meio?: number }): Partial<Peca> | null {
  if (modo === "transparencia") {
    if (!p.transparencia) return null;
    return { transparencia: { ...p.transparencia, ...pontos, ...alfas } };
  }
  const pintura = pinturaDa(p);
  if (!pintura) return null;
  let pt = pontos;
  if (p.espelharX) pt = { ...pt, x1: 1 - pt.x1, x2: 1 - pt.x2 };
  if (p.espelharY) pt = { ...pt, y1: 1 - pt.y1, y2: 1 - pt.y2 };
  return { pintura: { ...pintura, ...alfas, pontos: pt, ...(pintura.tipo === "radial" ? {} : { angulo: anguloDosPontos(pt, p.w, p.h) }) } } as Partial<Peca>;
}

/* A caixa que a peça ocupa de verdade (girada, os quatro cantos). */
function caixaGirada(p: Peca) {
  if (!p.rot) return { x: p.x, y: p.y, w: p.w, h: p.h };
  const r = (p.rot * Math.PI) / 180;
  const [cos, sen] = [Math.abs(Math.cos(r)), Math.abs(Math.sin(r))];
  const w = p.w * cos + p.h * sen;
  const h = p.w * sen + p.h * cos;
  return { x: p.x + p.w / 2 - w / 2, y: p.y + p.h / 2 - h / 2, w, h };
}

/* O cursor do "Colocar dentro…": a seta grossa do PowerClip do Corel (a ponta é o clique). */
const CURSOR_DE_COLOCAR_DENTRO = `url("data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="32" height="24" viewBox="0 0 32 24"><path d="M2 8.5h15V3l13 9-13 9v-5.5H2z" fill="#141414" stroke="#FFFFFF" stroke-width="1.6" stroke-linejoin="round"/></svg>')}") 30 12, crosshair`;

/* A peça passa da borda da arte (girada, pelos quatro cantos). */
function saiDaPagina(p: Peca, d: DocumentoDoBanner) {
  const c = caixaGirada(p);
  return c.x < -1 || c.y < -1 || c.x + c.w > d.largura + 1 || c.y + c.h > d.altura + 1;
}

/* O que o palco faz com o mouse numa peça (o mesmo objeto sempre: chama a versão mais nova). */
type AcoesDaPeca = {
  apertar: (e: React.PointerEvent, p: Peca) => void;
  entrar: (id: string) => void;
  sair: (id: string) => void;
  doisCliques: (e: React.MouseEvent, p: Peca) => void;
  menu: (e: React.MouseEvent, p: Peca) => void;
};

/*
 * Uma peça no palco. Só redesenha quando ela muda (o desenho vem guardado de
 * desenho-guardado.ts): arrastar uma peça numa arte de 400 peças redesenhava
 * as 400 a cada quadro.
 */
const PecaNoPalco = memo(function PecaNoPalco({
  peca,
  estilo,
  html,
  escolhida,
  apagada,
  cursor,
  acoes,
  atravessa,
}: {
  peca: Peca;
  /* null: a foto que é recheio de texto (uma caixa invisível no lugar dela, que dá para arrastar quando escolhida). */
  estilo: Record<string, string> | null;
  html: { __html: string } | null;
  escolhida: boolean;
  /* No ajuste do que vai dentro, o que fica por cima do recipiente apaga e não pega o clique. */
  apagada: boolean;
  cursor: string;
  acoes: AcoesDaPeca;
  /* Cobrindo a arte inteira por cima de outras, travada ou textura (a vinheta, o grão): o clique atravessa para a de baixo. */
  atravessa: boolean;
}) {
  return (
    <div
      data-peca={peca.id}
      onPointerDown={(e) => acoes.apertar(e, peca)}
      onPointerEnter={() => acoes.entrar(peca.id)}
      onPointerLeave={() => acoes.sair(peca.id)}
      onDoubleClick={(e) => acoes.doisCliques(e, peca)}
      onContextMenu={(e) => acoes.menu(e, peca)}
      className="select-none"
      style={{
        position: "absolute",
        ...(estilo ?? { ...caixaDaPeca(peca), opacity: "0", pointerEvents: escolhida ? "auto" : "none" }),
        ...(apagada ? { opacity: String(0.12 * Math.max(0, Math.min(1, peca.opacidade))), pointerEvents: "none" } : {}),
        ...(atravessa ? { pointerEvents: "none" } : {}),
        cursor,
        touchAction: "none",
      }}
      dangerouslySetInnerHTML={html ?? { __html: "" }}
    />
  );
});

/* O que fica à vista no zoom ao vivo (o resto da caixa some até o zoom fixar). */
const ACOMPANHAM_O_ZOOM = "[data-palco],[data-sombra-da-arte],[data-fatia],[data-margem-de-seguranca],[data-guias-da-caneca]";

/*
 * O "fora da página" repete o desenho das peças com os mesmos nomes de
 * filtro, máscara e degradê (id="..."). Com dois elementos de mesmo nome,
 * mexer num (esconder no zoom) fazia o Chrome refazer o da arte: as cópias
 * ganham nomes próprios.
 */
/*
 * O objeto { __html } é o MESMO enquanto o desenho não muda: o React compara
 * o objeto, não o texto, e com um novo a cada desenho ele refazia o HTML de
 * tudo que passa da borda a cada mudança na arte (filtros e fotos de novo,
 * segundos de redesenho por toque).
 */
const htmlsDeFora = new WeakMap<{ __html: string }, { __html: string }>();
function htmlDeFora(original: { __html: string }) {
  let pronto = htmlsDeFora.get(original);
  if (!pronto) {
    pronto = { __html: original.__html.replace(/\bid="/g, 'id="fora-').replace(/url\(#/g, "url(#fora-").replace(/href="#/g, 'href="#fora-') };
    htmlsDeFora.set(original, pronto);
  }
  return pronto;
}

/*
 * O elemento de cada peça, guardado pela própria peça: a que não mudou volta
 * com o MESMO elemento e o React nem olha para ela (no arrasto, só a peça que
 * anda é refeita, mesmo numa arte de 400 peças).
 */
type ElementoGuardado = { ex: ReturnType<typeof exibicaoGuardada>; escolhida: boolean; apagada: boolean; cursor: string; acoes: AcoesDaPeca; atravessa: boolean; el: React.ReactElement };
const elementoDaPeca = new WeakMap<Peca, ElementoGuardado>();
/*
 * A peça arrastada não ganha mais camada própria (era will-change no arrasto,
 * de quando o contorno era um filtro caro): dentro da camada da arte, o
 * Chrome desenha camada de peça no mínimo no tamanho da ARTE, e as peças por
 * cima dela viram camadas também; no "caber na tela" o arrasto custava 7x
 * mais (medido em 02/11: 2,4 s contra 0,45 s em 40 passos no Órbita).
 */
function pecaNoPalcoGuardada(peca: Peca, ex: ReturnType<typeof exibicaoGuardada>, escolhida: boolean, apagada: boolean, cursor: string, acoes: AcoesDaPeca, atravessa: boolean) {
  const g = elementoDaPeca.get(peca);
  if (g && g.ex === ex && g.escolhida === escolhida && g.apagada === apagada && g.cursor === cursor && g.acoes === acoes && g.atravessa === atravessa) return g.el;
  const el = <PecaNoPalco key={peca.id} peca={peca} estilo={ex?.estilo ?? null} html={ex?.html ?? null} escolhida={escolhida} apagada={apagada} cursor={cursor} acoes={acoes} atravessa={atravessa} />;
  elementoDaPeca.set(peca, { ex, escolhida, apagada, cursor, acoes, atravessa, el });
  return el;
}

/* O laço não pega o fundo nem o que cobre a arte inteira (senão tudo vira grupo). */
function cobreAArte(p: Peca, d: DocumentoDoBanner) {
  return p.x <= 1 && p.y <= 1 && p.x + p.w >= d.largura - 1 && p.y + p.h >= d.altura - 1;
}
/* A caixa da peça cobre 60% da arte ou mais (o grão arrastado um pouco para o lado continua sendo o grão). */
function cobreQuaseTudo(p: Peca, d: DocumentoDoBanner) {
  const c = caixaGirada(p);
  const w = Math.max(0, Math.min(d.largura, c.x + c.w) - Math.max(0, c.x));
  const h = Math.max(0, Math.min(d.altura, c.y + c.h) - Math.max(0, c.y));
  return w * h >= 0.6 * d.largura * d.altura;
}

export function Palco({
  doc: docDaArte,
  selecionadas,
  zoom,
  editando,
  aoSelecionar,
  aoSelecionarVarias,
  aoMexer,
  aoMexerVarias,
  aoComecarGesto,
  aoEditarTexto,
  aoEscrever,
  aoAjustarEscala,
  aoDuplicar,
  aoApagar,
  aoZoom,
  aoZoomVivo,
  alcas = null,
  reservaEmbaixo = 0,
  aoClonar,
  aoMenu,
  pincel = false,
  ferramenta = null,
  aoDesenhar,
  enquadrando = null,
  aoEnquadrar,
  margemDeSeguranca = null,
  guiasDaCaneca = false,
  analisando = null,
  nos = [],
  aoNos,
  aoEditarNos,
  distorcendo = null,
  perspectivaDoGrupo = false,
  apagando = null,
  contagem = null,
  aoTerminarBorracha,
  aoCancelarBorracha,
  aoDiminuirTexto,
  cena = null,
  compacto = null,
  voltaAoMeio = 0,
  escolhendoRecipiente = null,
  aoEscolherRecipiente,
  aoAjustarConteudo,
  reticulaViva = null,
  aoEscolherPontoVivo,
}: {
  doc: DocumentoDoBanner;
  selecionadas: string[];
  /* null = caber na tela. */
  zoom: number | null;
  editando: string | null;
  /* somar = Shift: põe ou tira da escolha, sem desfazer as outras; sozinha = só ela, mesmo estando num grupo. */
  aoSelecionar: (id: string | null, somar?: boolean, sozinha?: boolean) => void;
  aoSelecionarVarias: (ids: string[]) => void;
  /* Muda a peça sem guardar no histórico (o gesto já guardou no começo). */
  aoMexer: (id: string, campos: Partial<Peca>) => void;
  aoMexerVarias: (mudancas: Array<{ id: string; campos: Partial<Peca> }>) => void;
  aoComecarGesto: () => void;
  aoEditarTexto: (id: string | null) => void;
  aoEscrever: (id: string, texto: string, html: string | null) => void;
  aoAjustarEscala: (escala: number) => void;
  aoDuplicar: (id: string) => void;
  aoApagar: (id: string) => void;
  /* Fixa o zoom (no fim do giro da roda ou da pinça; durante o gesto o palco escala sozinho). */
  aoZoom: (escala: number) => void;
  /* O zoom durante o gesto, antes de fixar (para o número do zoom acompanhar). */
  aoZoomVivo?: (escala: number) => void;
  /* Espaço a mais embaixo (a tira de páginas), para a arte não ficar atrás dela. */
  reservaEmbaixo?: number;
  /* Alt + arrastar: cria cópias no mesmo lugar e devolve os ids delas (na mesma ordem). */
  aoClonar: (ids: string[]) => string[];
  /* Botão direito: o menu da peça (ou do vazio) no ponto da tela. */
  aoMenu: (x: number, y: number) => void;
  /* "Copiar estilo" ligado: o cursor vira o pincel. */
  pincel?: boolean;
  /* Caneta ou desenho livre ligados: os pontos (na arte) viram um traço. */
  ferramenta?: FerramentaDoPalco;
  aoDesenhar: (pontos: PontoDoTraco[], fechado: boolean, suave: boolean) => void;
  /* A foto em enquadre (arrastar move a foto dentro da moldura). */
  enquadrando?: string | null;
  aoEnquadrar: (id: string | null) => void;
  /* Arte de impressão: a linha da área de segurança (px da arte da borda até ela). */
  margemDeSeguranca?: number | null;
  /* Arte de caneca: as guias das faces (metade de cada lado da alça) e das pontas que encostam na alça. */
  guiasDaCaneca?: boolean;
  /* As alças do degradê na peça escolhida (o painel aberto decide). */
  alcas?: ModoDasAlcas | null;
  /* A foto que está perdendo o fundo: o efeito de "analisando" por cima (saindo = terminou, some). */
  analisando?: { id: string; saindo: boolean } | null;
  /* Ferramenta Forma: os nós escolhidos do traço escolhido (índices) e quem muda a escolha. */
  nos?: number[];
  aoNos?: (indices: number[]) => void;
  /* Dois cliques num desenho com a ferramenta de escolher: liga a Forma (como no Corel). */
  aoEditarNos?: () => void;
  /* A peça com os 4 cantos à mostra para distorcer (perspectiva). */
  distorcendo?: string | null;
  /* Perspectiva das várias escolhidas (o grupo): os 4 cantos da caixa delas e a grade, na arte. */
  perspectivaDoGrupo?: boolean;
  /* A foto na borracha (limpar restos): a tela de pintura fica por cima dela. */
  apagando?: string | null;
  aoTerminarBorracha?: (id: string, arquivo: File) => Promise<void>;
  aoCancelarBorracha?: () => void;
  /* A página aberta e o total (o contador "01/05" das peças fixas). */
  contagem?: ContagemDaPagina | null;
  /* Texto que não cabe: diminuir a letra até caber (a conta, com o redesenho a cada passo, é do editor). */
  aoDiminuirTexto?: (id: string) => void;
  /* Tocando a animação (ou parado num instante da linha do tempo): a cena cobre as peças. */
  cena?: React.ReactNode;
  /* Celular ou tablet em pé (tela-compacta.ts): menos margem em volta da arte. No desktop, null (como sempre). */
  compacto?: "celular" | "tablet" | null;
  /* Muda a cada "Caber na tela": a arte arrastada volta para o meio (mesmo se o zoom já estava no caber). */
  voltaAoMeio?: number;
  /* "Colocar dentro…": as peças que vão entrar; o clique numa peça escolhe quem recebe (null = desistiu). */
  escolhendoRecipiente?: string[] | null;
  aoEscolherRecipiente?: (id: string | null) => void;
  /* Dois cliques numa peça com coisa dentro: ajustar o que vai dentro (o editor escolhe o conteúdo). */
  aoAjustarConteudo?: (id: string) => void;
  /* Retícula Viva aberta no painel: os pontos de cor na peça (arrastar, puxar o alcance, clicar na peça põe outro). */
  reticulaViva?: { id: string; ponto: number | null } | null;
  aoEscolherPontoVivo?: (i: number | null) => void;
}) {
  /*
   * O arrasto ao vivo: as mudanças do gesto em curso (por peça), só no palco.
   * O palco desenha a arte com elas (e com o que está dentro das peças indo
   * junto); as contas do gesto usam a arte do editor (docRef), parada.
   */
  const [gestoAoVivo, setGestoAoVivo] = useState<Map<string, Partial<Peca>> | null>(null);
  const gestoAoVivoRef = useRef<Map<string, Partial<Peca>> | null>(null);
  const doc = useMemo(
    () => (gestoAoVivo ? comConteudosJuntos(docDaArte, { ...docDaArte, pecas: docDaArte.pecas.map((p) => (gestoAoVivo.has(p.id) ? ({ ...p, ...gestoAoVivo.get(p.id) } as Peca) : p)) }) : docDaArte),
    [docDaArte, gestoAoVivo],
  );
  const mexerAoVivo = (mudancas: Array<{ id: string; campos: Partial<Peca> }>) => {
    const novo = new Map(mudancas.map((m) => [m.id, m.campos]));
    gestoAoVivoRef.current = novo;
    setGestoAoVivo(novo);
  };
  /* Soltou: o que mudou ao vivo vai para a arte (uma peça pelo aoMexer, que cuida da letra que deixa de ser automática). */
  const fixarVivo = () => {
    const final = gestoAoVivoRef.current;
    gestoAoVivoRef.current = null;
    if (!final) return;
    setGestoAoVivo(null);
    const mudancas = [...final].map(([id, campos]) => ({ id, campos }));
    if (mudancas.length === 1) aoMexer(mudancas[0].id, mudancas[0].campos);
    else if (mudancas.length) aoMexerVarias(mudancas);
  };
  const area = useRef<HTMLDivElement | null>(null);
  const palco = useRef<HTMLDivElement | null>(null);
  /*
   * A vista: o quanto a arte foi arrastada do meio, em pixels da tela. Vai
   * direto no estilo do miolo (sem redesenhar as peças a cada movimento) e
   * não depende de rolagem, por isso anda também sem zoom.
   */
  const miolo = useRef<HTMLDivElement | null>(null);
  const vista = useRef({ x: 0, y: 0 });
  /*
   * Zoom ao vivo (a roda e a pinça): durante o gesto só o miolo escala (um
   * transform, sem redesenhar o editor a cada giro); parado um instante, o
   * zoom é fixado e a arte redesenha nítida no mesmo lugar. `vivo` é a arte
   * na tela (escala e canto); `base`, onde ela fica no miolo na escala fixada.
   */
  const vivo = useRef<{ k: number; x: number; y: number } | null>(null);
  const base = useRef<{ k: number; ox: number; oy: number; tela: DOMRect } | null>(null);
  const fixarDepois = useRef(0);
  /*
   * Durante o giro só a arte escala: a moldura, as alças, os contornos e o
   * que passa da página somem até o zoom fixar e voltam no tamanho certo (a
   * sombra, as divisas, a margem e as guias da caneca acompanham a arte).
   * Escondidos um a um, no estilo de cada um: com um atributo no pai e uma
   * regra de CSS, o Chrome recalculava o estilo da arte inteira, refazia os
   * filtros das peças e redesenhava a arte toda a cada zoom (num PC sem placa
   * de vídeo, segundos de tela parada e o aviso de "página sem resposta").
   */
  const esconderEnfeites = (esconder: boolean) => {
    const caixa = palco.current?.parentElement;
    if (!caixa) return;
    for (const filho of Array.from(caixa.children) as HTMLElement[]) {
      if (filho.matches(ACOMPANHAM_O_ZOOM)) continue;
      if (esconder && filho.style.visibility !== "hidden") {
        filho.dataset.someNoZoom = "";
        filho.style.visibility = "hidden";
      } else if (!esconder && filho.dataset.someNoZoom !== undefined) {
        delete filho.dataset.someNoZoom;
        filho.style.visibility = "";
      }
    }
  };
  /*
   * A arte é uma camada própria do tamanho que ela tem NA TELA (o palco; a
   * escala fica no de dentro), pelo translateZ: o Chrome a desenha pixel a
   * pixel na escala da tela, e quando o zoom fixa o palco muda de tamanho e
   * ela é refeita, nítida, uma vez só. No zoom ao vivo ela ganha a dica de
   * camada (will-change) já no primeiro giro: como a camada já existe, a dica
   * só prende o desenho pronto e os giros esticam, sem redesenhar a arte
   * (sem isso, segundos por giro num PC sem placa de vídeo e "página sem
   * resposta"). A dica sai quando o zoom fixa: com ela parada, o Chrome
   * desenha as imagens SVG (ornamentos, vetores, grão, texturas) sem a
   * resolução da tela (medido em 02/11: a 180%, 2/3 da nitidez) e, depois
   * de afastar, a arte inteira borrada.
   */
  const dicaDoZoom = (ligada: boolean) => {
    if (palco.current) palco.current.style.willChange = ligada ? "transform" : "";
  };

  /* O miolo no lugar: com o zoom ao vivo, o que leva a arte fixada até a da tela; sem ele, só a vista. */
  const pintarMiolo = () => {
    const m = miolo.current;
    if (!m) return;
    const v = vivo.current;
    const b = base.current;
    if (v && b) {
      const s = v.k / b.k;
      m.style.transform = `translate(${v.x - b.tela.left - s * b.ox}px, ${v.y - b.tela.top - s * b.oy}px) scale(${s})`;
      return;
    }
    const { x, y } = vista.current;
    m.style.transform = x || y ? `translate(${x}px, ${y}px)` : "";
  };
  /* Onde a arte fica no miolo sem a vista, na escala k (com o miolo só na vista). */
  const medirBase = (k: number) => {
    const caixa = palco.current?.getBoundingClientRect();
    const tela = area.current?.getBoundingClientRect();
    return caixa && tela ? { k, ox: caixa.left - vista.current.x - tela.left, oy: caixa.top - vista.current.y - tela.top, tela } : null;
  };
  /* Um pedaço da arte sempre fica na tela, para ela não se perder: o canto dela preso pela tela. */
  const prender = (esquerda: number, topo: number, largura: number, altura: number, tela: DOMRect) => {
    const sobra = Math.min(64, largura / 2, altura / 2);
    return { esquerda: Math.min(tela.right - sobra, Math.max(tela.left + sobra - largura, esquerda)), topo: Math.min(tela.bottom - sobra, Math.max(tela.top + sobra - altura, topo)) };
  };
  const porVista = (x: number, y: number) => {
    const caixa = palco.current?.getBoundingClientRect();
    const tela = area.current?.getBoundingClientRect();
    if (caixa && tela) {
      const esquerda = caixa.left - vista.current.x;
      const topo = caixa.top - vista.current.y;
      const preso = prender(esquerda + x, topo + y, caixa.width, caixa.height, tela);
      x = preso.esquerda - esquerda;
      y = preso.topo - topo;
    }
    vista.current = { x, y };
    pintarMiolo();
  };
  /* Arrastar a vista (a mão, o botão do meio, os dois dedos); no meio do zoom ao vivo, anda a arte da tela. */
  const andar = (dx: number, dy: number) => {
    const v = vivo.current;
    const b = base.current;
    if (!v || !b) return porVista(vista.current.x + dx, vista.current.y + dy);
    const preso = prender(v.x + dx, v.y + dy, docRef.current.largura * v.k, docRef.current.altura * v.k, b.tela);
    vivo.current = { k: v.k, x: preso.esquerda, y: preso.topo };
    pintarMiolo();
  };
  const acabarVivo = () => {
    window.clearTimeout(fixarDepois.current);
    fixarDepois.current = 0;
    vivo.current = null;
    base.current = null;
    area.current?.removeAttribute("data-zoom-vivo");
    esconderEnfeites(false);
    pintarMiolo();
    dicaDoZoom(false);
  };
  /* Fixa o zoom ao vivo: o editor guarda a escala e, no redesenho, a vista acerta (o efeito de [escala]). */
  const fixarZoom = () => {
    window.clearTimeout(fixarDepois.current);
    fixarDepois.current = 0;
    const v = vivo.current;
    const b = base.current;
    if (!v || !b) return;
    if (v.k !== b.k) return aoZoomRef.current(v.k);
    /* A escala não mudou (já estava no limite): só a vista. */
    acabarVivo();
    porVista(v.x - b.tela.left - b.ox, v.y - b.tela.top - b.oy);
  };
  /* Um passo do zoom ao vivo no ponto da tela (o ponto da arte embaixo dele fica no lugar). */
  const zoomVivo = (fator: number, telaX: number, telaY: number) => {
    if (!vivo.current || !base.current) {
      const b = medirBase(escalaRef.current);
      if (!b) return;
      base.current = b;
      vivo.current = { k: b.k, x: b.tela.left + b.ox + vista.current.x, y: b.tela.top + b.oy + vista.current.y };
      area.current?.setAttribute("data-zoom-vivo", "");
      dicaDoZoom(true);
    }
    esconderEnfeites(true);
    const v = vivo.current;
    const k = Math.min(ZOOM_MAXIMO, Math.max(ZOOM_MINIMO, v.k * fator));
    const f = k / v.k;
    const preso = prender(telaX - (telaX - v.x) * f, telaY - (telaY - v.y) * f, docRef.current.largura * k, docRef.current.altura * k, base.current.tela);
    vivo.current = { k, x: preso.esquerda, y: preso.topo };
    pintarMiolo();
    aoZoomVivoRef.current?.(k);
    window.clearTimeout(fixarDepois.current);
    fixarDepois.current = window.setTimeout(fixarZoom, 180);
  };
  const [caber, setCaber] = useState(0.5);
  const [guias, setGuias] = useState<{ v: number[]; h: number[] }>({ v: [], h: [] });
  /* Espaços iguais achados ao mover (segmentos em pixels da arte, com a medida). */
  const [espacos, setEspacos] = useState<Array<{ x1: number; y1: number; x2: number; y2: number; medida: number }>>([]);
  const [medida, setMedida] = useState<string | null>(null);
  const [sobre, setSobre] = useState<string | null>(null);
  const [laco, setLaco] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
  /* A ponta clicada (mostra o "quanto cobre" dela); vale só para a mesma peça e o mesmo modo. */
  const [ponta, setPonta] = useState<{ id: string; modo: ModoDasAlcas; qual: 1 | 2 } | null>(null);
  /* O desenho em andamento (caneta ou lápis), em pixels da arte, e onde está o mouse. */
  const [rascunho, setRascunho] = useState<PontoDoTraco[]>([]);
  const [cursorDoDesenho, setCursorDoDesenho] = useState<PontoDoTraco | null>(null);
  const rascunhoRef = useRef<PontoDoTraco[]>([]);
  /* Barra de espaço segurada (e o arrasto da vista com ela ou com o botão do meio). */
  const [espaco, setEspaco] = useState(false);
  const [arrastandoVista, setArrastandoVista] = useState(false);
  const espacoRef = useRef(false);
  const gesto = useRef<Gesto | null>(null);
  const escala = zoom ?? caber;
  const docRef = useRef(docDaArte);
  const escalaRef = useRef(escala);
  const selecionadasRef = useRef(selecionadas);
  const ferramentaRef = useRef(ferramenta);
  const enquadrandoRef = useRef(enquadrando);
  const reticulaVivaRef = useRef(reticulaViva);
  const aoComecarGestoRef = useRef(aoComecarGesto);
  const aoEscolherPontoVivoRef = useRef(aoEscolherPontoVivo);
  useEffect(() => {
    reticulaVivaRef.current = reticulaViva;
    aoComecarGestoRef.current = aoComecarGesto;
    aoEscolherPontoVivoRef.current = aoEscolherPontoVivo;
    docRef.current = docDaArte;
    escalaRef.current = escala;
    selecionadasRef.current = selecionadas;
    ferramentaRef.current = ferramenta;
    enquadrandoRef.current = enquadrando;
  });

  useLayoutEffect(() => {
    const el = area.current;
    if (!el) return;
    const medir = () => {
      const { width, height } = el.getBoundingClientRect();
      const sobraX = compacto ? 32 : 120;
      const sobraY = compacto === "celular" ? 48 : compacto === "tablet" ? 120 : 200;
      const nova = Math.max(0.02, Math.min((width - sobraX) / doc.largura, (height - sobraY - reservaEmbaixo) / doc.altura, 1));
      setCaber(nova);
      aoAjustarEscala(nova);
    };
    medir();
    const observador = new ResizeObserver(medir);
    observador.observe(el);
    return () => observador.disconnect();
  }, [doc.largura, doc.altura, aoAjustarEscala, reservaEmbaixo, compacto]);

  useEffect(() => {
    /* Ponto da tela → ponto da arte. */
    const naArte = (clienteX: number, clienteY: number) => {
      const caixa = palco.current?.getBoundingClientRect();
      const k = escalaRef.current;
      return caixa ? { x: (clienteX - caixa.left) / k, y: (clienteY - caixa.top) / k } : { x: 0, y: 0 };
    };

    const mover = (e: PointerEvent) => {
      const g = gesto.current;
      if (!g) return;
      const d = docRef.current;
      const k = escalaRef.current;

      if (g.tipo === "lapis") {
        const a = naArte(e.clientX, e.clientY);
        const ultimo = rascunhoRef.current.at(-1);
        if (!ultimo || Math.hypot(a.x - ultimo.x, a.y - ultimo.y) > 2 / k) {
          rascunhoRef.current = [...rascunhoRef.current, a];
          setRascunho(rascunhoRef.current);
        }
        return;
      }

      if (g.tipo === "ponto") {
        const traco = d.pecas.find((p) => p.id === g.id);
        if (!traco || traco.tipo !== "traco") return;
        g.mexeu = true;
        const a = naArte(e.clientX, e.clientY);
        const rad = (-traco.rot * Math.PI) / 180;
        const dx = a.x - (traco.x + traco.w / 2);
        const dy = a.y - (traco.y + traco.h / 2);
        let fx = (dx * Math.cos(rad) - dy * Math.sin(rad)) / traco.w + 0.5;
        let fy = (dx * Math.sin(rad) + dy * Math.cos(rad)) / traco.h + 0.5;
        /* Shift: de 15 em 15 graus em volta do ponto vizinho. */
        const vizinho = traco.pontos[g.indice === 0 ? 1 : g.indice - 1];
        if (e.shiftKey && vizinho) {
          const vx = (fx - vizinho.x) * traco.w;
          const vy = (fy - vizinho.y) * traco.h;
          const dist = Math.hypot(vx, vy);
          const ang = Math.round(Math.atan2(vy, vx) / (Math.PI / 12)) * (Math.PI / 12);
          fx = vizinho.x + (Math.cos(ang) * dist) / traco.w;
          fy = vizinho.y + (Math.sin(ang) * dist) / traco.h;
        }
        const atual = traco.pontos[g.indice];
        const pontos = moverNos(traco.pontos, [g.indice], { x: fx - atual.x, y: fy - atual.y });
        g.ultimo = pontos;
        aoMexer(traco.id, { pontos } as Partial<Peca>);
        return;
      }

      if (g.tipo === "caneta") {
        if (!g.mexeu && Math.hypot(e.clientX - g.inicioX, e.clientY - g.inicioY) < 4) return;
        g.mexeu = true;
        const a = naArte(e.clientX, e.clientY);
        const lista = rascunhoRef.current;
        const no = lista.at(-1);
        if (!no) return;
        /* Arrastar puxa a alça de saída; a de entrada espelha (Alt: só a de saída, nó em canto). */
        const espelhada = { x: 2 * no.x - a.x, y: 2 * no.y - a.y };
        rascunhoRef.current = [...lista.slice(0, -1), e.altKey ? { ...no, s: a, e: no.e ?? null, no: "canto" } : { ...no, s: a, e: espelhada, no: "suave" }];
        setRascunho(rascunhoRef.current);
        return;
      }

      if (g.tipo === "canto") {
        const peca = d.pecas.find((p) => p.id === g.id);
        if (!peca) return;
        /* O canto vai para onde o mouse está, em fração da caixa (o giro desfeito; o espelho fica dentro da distorção). */
        const a = naArte(e.clientX, e.clientY);
        const rad = (-peca.rot * Math.PI) / 180;
        const dx = a.x - (peca.x + peca.w / 2);
        const dy = a.y - (peca.y + peca.h / 2);
        const fx = (dx * Math.cos(rad) - dy * Math.sin(rad)) / peca.w + 0.5;
        const fy = (dx * Math.sin(rad) + dy * Math.cos(rad)) / peca.h + 0.5;
        const cantos = (peca.distorcao?.cantos ?? [[0, 0], [1, 0], [1, 1], [0, 1]]).map((c, i) => (i === g.indice ? [fx, fy] : c)) as NonNullable<Peca["distorcao"]>["cantos"];
        aoMexer(peca.id, { distorcao: { cantos } } as Partial<Peca>);
        return;
      }

      if (g.tipo === "cantoDoGrupo") {
        const a = naArte(e.clientX, e.clientY);
        const cantos = g.cantos.map((c, i) => (i === g.indice ? [a.x, a.y] : c)) as Quadrilatero;
        const pecas = d.pecas.filter((p) => g.ids.includes(p.id));
        aoMexerVarias(distorcoesDoGrupo(pecas, g.caixa, cantos).map(({ id, distorcao, perspectiva }) => ({ id, campos: { distorcao, perspectiva } as Partial<Peca> })));
        return;
      }

      if (g.tipo === "no" || g.tipo === "alcaDoNo" || g.tipo === "entortar") {
        const traco = d.pecas.find((p) => p.id === g.id);
        if (!traco || traco.tipo !== "traco") return;
        const f = fracaoNoTraco(traco, naArte(e.clientX, e.clientY));
        g.mexeu = true;
        let pontos: PontoDoTraco[];
        if (g.tipo === "no") pontos = moverNos(g.original, g.indices, { x: f.x - g.inicio.x, y: f.y - g.inicio.y });
        else if (g.tipo === "alcaDoNo") pontos = moverAlca(g.original, g.indice, g.qual, f, e.altKey);
        else pontos = entortarTrecho(g.original, traco.fechado, false, g.trecho, g.t, { x: f.x - g.inicio.x, y: f.y - g.inicio.y });
        g.ultimo = pontos;
        aoMexer(traco.id, { pontos, suave: false } as Partial<Peca>);
        return;
      }

      if (g.tipo === "enquadre") {
        const foto = d.pecas.find((p) => p.id === g.id);
        if (!foto || foto.tipo !== "imagem") return;
        /* Foto espelhada: o desenho está virado, então o ponto da foto anda ao contrário do mouse. */
        const dx = ((e.clientX - g.inicioX) / k) * (foto.espelharX ? -1 : 1);
        const dy = ((e.clientY - g.inicioY) / k) * (foto.espelharY ? -1 : 1);
        /* A foto anda junto com o mouse: x% muda o quanto a sobra da foto permite. */
        const x = g.excesso.x > 0.5 ? Math.max(0, Math.min(100, g.base.x - (dx / g.excesso.x) * 100)) : g.base.x;
        const y = g.excesso.y > 0.5 ? Math.max(0, Math.min(100, g.base.y - (dy / g.excesso.y) * 100)) : g.base.y;
        aoMexer(foto.id, { enquadre: { ...g.base, x, y } } as Partial<Peca>);
        return;
      }

      if (g.tipo === "vista") {
        andar(e.clientX - g.x, e.clientY - g.y);
        g.x = e.clientX;
        g.y = e.clientY;
        return;
      }

      if (g.tipo === "laco") {
        const a = naArte(g.inicioX, g.inicioY);
        const b = naArte(e.clientX, e.clientY);
        if (!g.mexeu && Math.hypot(e.clientX - g.inicioX, e.clientY - g.inicioY) < 4) return;
        g.mexeu = true;
        const caixa = { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
        setLaco(caixa);
        const dentro = d.pecas
          .filter((p) => !p.oculta && !p.bloqueada && !cobreAArte(p, d) && !(p.dentroDe && recipienteDe(p, d.pecas)))
          .filter((p) => {
            const c = caixaGirada(p);
            return c.x >= caixa.x && c.y >= caixa.y && c.x + c.w <= caixa.x + caixa.w && c.y + c.h <= caixa.y + caixa.h;
          })
          .map((p) => p.id);
        aoSelecionarVarias(g.somar ? [...new Set([...g.antes, ...dentro])] : dentro);
        return;
      }

      /* A peça do gesto (o do grupo e o mover de várias não dependem de uma só). */
      const peca = "id" in g ? d.pecas.find((p) => p.id === g.id) : undefined;

      /* Retícula Viva: o ponto anda (em fração da caixa, pela peça girada) ou o alcance cresce (a distância ao ponto). */
      if (g.tipo === "pontoVivo" || g.tipo === "alcanceVivo") {
        const r = peca?.reticulaViva;
        if (!peca || !r || !r.pontos[g.indice]) return;
        if (g.tipo === "pontoVivo" && !g.mexeu && Math.hypot(e.clientX - g.inicioX, e.clientY - g.inicioY) < 3) return;
        if (g.tipo === "pontoVivo") g.mexeu = true;
        const a = naArte(e.clientX, e.clientY);
        const rad = (-peca.rot * Math.PI) / 180;
        const dx = a.x - (peca.x + peca.w / 2);
        const dy = a.y - (peca.y + peca.h / 2);
        const fx = (dx * Math.cos(rad) - dy * Math.sin(rad)) / peca.w + 0.5;
        const fy = (dx * Math.sin(rad) + dy * Math.cos(rad)) / peca.h + 0.5;
        const q = r.pontos[g.indice];
        const novo =
          g.tipo === "pontoVivo"
            ? { ...q, x: Math.max(-0.5, Math.min(1.5, fx)), y: Math.max(-0.5, Math.min(1.5, fy)) }
            : { ...q, alcance: Math.max(0.05, Math.min(2, Math.hypot((fx - q.x) * peca.w, (fy - q.y) * peca.h) / Math.max(1, Math.min(peca.w, peca.h)))) };
        setMedida(g.tipo === "pontoVivo" ? `Ponto ${g.indice + 1}` : `Alcance ${Math.round(novo.alcance * 100)}%`);
        aoMexer(peca.id, { reticulaViva: { ...r, pontos: r.pontos.map((p, i) => (i === g.indice ? novo : p)) } } as Partial<Peca>);
        return;
      }

      if (g.tipo === "alca") {
        if (!peca) return;
        const modo = g.modo;
        const atual = alcasDa(peca, modo);
        if (!atual) return;
        if (!g.mexeu && Math.hypot(e.clientX - g.inicioX, e.clientY - g.inicioY) < 3) return;
        g.mexeu = true;
        /* O ponto do mouse nos eixos da peça (ela pode estar girada), em fração da caixa. */
        const a = naArte(e.clientX, e.clientY);
        const rad = (-peca.rot * Math.PI) / 180;
        const dx = a.x - (peca.x + peca.w / 2);
        const dy = a.y - (peca.y + peca.h / 2);
        let fx = (dx * Math.cos(rad) - dy * Math.sin(rad)) / peca.w + 0.5;
        let fy = (dx * Math.sin(rad) + dy * Math.cos(rad)) / peca.h + 0.5;
        if (g.qual === 3) {
          /* O tracinho corre só pela seta: a projeção do mouse nela (em pixels) vira o meio. */
          const { x1, y1, x2, y2 } = atual.pontos;
          const vx = (x2 - x1) * peca.w;
          const vy = (y2 - y1) * peca.h;
          const ida = ((fx - x1) * peca.w * vx + (fy - y1) * peca.h * vy) / Math.max(1, vx * vx + vy * vy);
          let meio = Math.max(0.05, Math.min(0.95, ida));
          if (!e.altKey && Math.abs(meio - 0.5) < 0.03) meio = 0.5;
          const campos = camposDasAlcas(peca, modo, atual.pontos, { meio: Math.abs(meio - 0.5) < 0.005 ? undefined : meio });
          if (campos) {
            setMedida(`Meio ${Math.round(meio * 100)}%`);
            aoMexer(peca.id, campos);
          }
          return;
        }
        const outro = g.qual === 1 ? { x: atual.pontos.x2, y: atual.pontos.y2 } : { x: atual.pontos.x1, y: atual.pontos.y1 };
        if (e.shiftKey) {
          /* De 15 em 15 graus em volta da outra ponta (medido em pixels, não em fração). */
          const vx = (fx - outro.x) * peca.w;
          const vy = (fy - outro.y) * peca.h;
          const dist = Math.hypot(vx, vy);
          const ang = Math.round(Math.atan2(vy, vx) / (Math.PI / 12)) * (Math.PI / 12);
          fx = outro.x + (Math.cos(ang) * dist) / peca.w;
          fy = outro.y + (Math.sin(ang) * dist) / peca.h;
        } else if (!e.altKey) {
          const perto = 8 / (k * Math.min(peca.w, peca.h));
          for (const alvo of [0, 0.5, 1]) {
            if (Math.abs(fx - alvo) < perto) fx = alvo;
            if (Math.abs(fy - alvo) < perto) fy = alvo;
          }
        }
        fx = Math.max(-2, Math.min(3, fx));
        fy = Math.max(-2, Math.min(3, fy));
        const pontos = g.qual === 1 ? { ...atual.pontos, x1: fx, y1: fy } : { ...atual.pontos, x2: fx, y2: fy };
        if (Math.hypot(pontos.x2 - pontos.x1, pontos.y2 - pontos.y1) < 0.01) return;
        const campos = camposDasAlcas(peca, modo, pontos);
        if (campos) {
          setMedida(`${anguloDosPontos(pontos, peca.w, peca.h)}°`);
          aoMexer(peca.id, campos);
        }
        return;
      }

      if (g.tipo === "mover") {
        if (!g.mexeu && Math.hypot(e.clientX - g.inicioX, e.clientY - g.inicioY) < 3) return;
        /* Alt + arrastar: a cópia nasce agora (Alt + clique sozinho não duplica nada); o que anda é a cópia. */
        if (g.clonar && !g.clonou) {
          g.clonou = true;
          const copias = aoClonar(g.ids);
          if (copias.length === g.ids.length) {
            g.posicoes = new Map(copias.map((id, i) => [id, g.posicoes.get(g.ids[i])!]));
            g.ids = copias;
            g.id = copias[0];
          }
        }
        /* As cópias ainda não chegaram ao desenho: espera o próximo movimento. */
        if (!d.pecas.some((p) => g.ids.includes(p.id))) return;
        g.mexeu = true;
        const movidas = d.pecas.filter((p) => g.ids.includes(p.id) && !p.bloqueada);
        if (!movidas.length) return;
        let dx = (e.clientX - g.inicioX) / k;
        let dy = (e.clientY - g.inicioY) / k;
        const v: number[] = [];
        const h: number[] = [];
        /* O encaixe vale para a caixa do grupo (uma peça só é um grupo de uma). */
        const original = caixaDoGrupo(movidas.map((p) => ({ ...p, ...g.posicoes.get(p.id)! })));
        const marcas: Array<{ x1: number; y1: number; x2: number; y2: number; medida: number }> = [];
        if (!e.altKey || g.clonou) {
          const perto = 7 / k;
          const alvosX = [0, d.largura / 2, d.largura];
          const alvosY = [0, d.altura / 2, d.altura];
          for (const outra of d.pecas) {
            if (g.ids.includes(outra.id) || outra.oculta || outra.rot || cobreAArte(outra, d)) continue;
            alvosX.push(outra.x, outra.x + outra.w / 2, outra.x + outra.w);
            alvosY.push(outra.y, outra.y + outra.h / 2, outra.y + outra.h);
          }
          const encaixe = (pos: number, tam: number, alvos: number[], guiasDoEixo: number[]) => {
            let melhor: { delta: number; alvo: number } | null = null;
            for (const ponto of [0, tam / 2, tam]) {
              for (const alvo of alvos) {
                const delta = alvo - (pos + ponto);
                if (Math.abs(delta) < perto && (!melhor || Math.abs(delta) < Math.abs(melhor.delta))) melhor = { delta, alvo };
              }
            }
            if (!melhor) return 0;
            guiasDoEixo.push(melhor.alvo);
            return melhor.delta;
          };
          dx += encaixe(original.x + dx, original.w, alvosX, v);
          dy += encaixe(original.y + dy, original.h, alvosY, h);

          /* Mesmo espaço dos dois lados: a peça entre duas vizinhas da mesma linha (ou coluna). */
          const outras = d.pecas.filter((o) => !g.ids.includes(o.id) && !o.oculta && !o.rot && !cobreAArte(o, d));
          const caixa = { x: original.x + dx, y: original.y + dy, w: original.w, h: original.h };
          if (!v.length) {
            const linha = outras.filter((o) => o.y < caixa.y + caixa.h && o.y + o.h > caixa.y);
            const esquerda = linha.filter((o) => o.x + o.w <= caixa.x + perto).sort((a, b) => b.x + b.w - (a.x + a.w))[0];
            const direita = linha.filter((o) => o.x >= caixa.x + caixa.w - perto).sort((a, b) => a.x - b.x)[0];
            if (esquerda && direita) {
              const alvo = (esquerda.x + esquerda.w + direita.x - caixa.w) / 2;
              if (Math.abs(alvo - caixa.x) < perto && alvo > esquerda.x + esquerda.w) {
                dx += alvo - caixa.x;
                const meioY = caixa.y + caixa.h / 2;
                const vao = alvo - (esquerda.x + esquerda.w);
                marcas.push({ x1: esquerda.x + esquerda.w, y1: meioY, x2: alvo, y2: meioY, medida: vao }, { x1: alvo + caixa.w, y1: meioY, x2: direita.x, y2: meioY, medida: vao });
              }
            }
          }
          if (!h.length) {
            const coluna = outras.filter((o) => o.x < caixa.x + caixa.w && o.x + o.w > caixa.x);
            const cima = coluna.filter((o) => o.y + o.h <= caixa.y + perto).sort((a, b) => b.y + b.h - (a.y + a.h))[0];
            const baixo = coluna.filter((o) => o.y >= caixa.y + caixa.h - perto).sort((a, b) => a.y - b.y)[0];
            if (cima && baixo) {
              const alvo = (cima.y + cima.h + baixo.y - caixa.h) / 2;
              if (Math.abs(alvo - caixa.y) < perto && alvo > cima.y + cima.h) {
                dy += alvo - caixa.y;
                const meioX = original.x + dx + caixa.w / 2;
                const vao = alvo - (cima.y + cima.h);
                marcas.push({ x1: meioX, y1: cima.y + cima.h, x2: meioX, y2: alvo, medida: vao }, { x1: meioX, y1: alvo + caixa.h, x2: meioX, y2: baixo.y, medida: vao });
              }
            }
          }
        }
        setEspacos(marcas);
        setGuias({ v, h });
        setMedida(movidas.length > 1 ? `${movidas.length} peças` : `X ${Math.round(original.x + dx)}  Y ${Math.round(original.y + dy)}`);
        mexerAoVivo(movidas.map((p) => ({ id: p.id, campos: { x: g.posicoes.get(p.id)!.x + dx, y: g.posicoes.get(p.id)!.y + dy } })));
        return;
      }

      if (g.tipo === "redimensionarGrupo") {
        const dx = (e.clientX - g.inicioX) / k;
        const dy = (e.clientY - g.inicioY) / k;
        const c = g.caixa;
        const diagonal = Math.hypot(c.w, c.h);
        const projecao = (g.hx * dx * c.w + g.hy * dy * c.h) / diagonal;
        const f = Math.max(16 / Math.min(c.w, c.h), (diagonal + projecao) / diagonal);
        /* O canto oposto fica parado. */
        const ax = g.hx > 0 ? c.x : c.x + c.w;
        const ay = g.hy > 0 ? c.y : c.y + c.h;
        setMedida(`${Math.round(c.w * f)} × ${Math.round(c.h * f)}`);
        mexerAoVivo(g.pecas.map((p) => ({ id: p.id, campos: { ...escalarPeca(p, f), x: ax + (p.x - ax) * f, y: ay + (p.y - ay) * f } as Partial<Peca> })));
        return;
      }

      if (g.tipo === "girarGrupo") {
        const caixa = palco.current?.getBoundingClientRect();
        if (!caixa) return;
        let delta = (Math.atan2(e.clientY - (caixa.top + g.cy * k), e.clientX - (caixa.left + g.cx * k)) * 180) / Math.PI - g.inicio;
        if (e.shiftKey) delta = Math.round(delta / 15) * 15;
        const rad = (delta * Math.PI) / 180;
        setMedida(`${Math.round(((delta % 360) + 540) % 360 - 180)}°`);
        mexerAoVivo(
          g.pecas.map((p) => {
            const px = p.x + p.w / 2 - g.cx;
            const py = p.y + p.h / 2 - g.cy;
            const nx = g.cx + px * Math.cos(rad) - py * Math.sin(rad);
            const ny = g.cy + px * Math.sin(rad) + py * Math.cos(rad);
            let rot = p.rot + delta;
            while (rot > 180) rot -= 360;
            while (rot < -180) rot += 360;
            return { id: p.id, campos: { x: nx - p.w / 2, y: ny - p.h / 2, rot: Math.round(rot * 10) / 10 } };
          }),
        );
        return;
      }

      if (g.tipo === "girar") {
        const caixa = palco.current?.getBoundingClientRect();
        if (!caixa) return;
        const cx = caixa.left + g.cx * k;
        const cy = caixa.top + g.cy * k;
        /* O botão fica embaixo: o ângulo conta a partir dele. */
        let angulo = (Math.atan2(e.clientY - cy, e.clientX - cx) * 180) / Math.PI - 90;
        while (angulo > 180) angulo -= 360;
        while (angulo < -180) angulo += 360;
        if (e.shiftKey) angulo = Math.round(angulo / 15) * 15;
        else for (const alvo of [-180, -135, -90, -45, 0, 45, 90, 135, 180]) if (Math.abs(angulo - alvo) < 3) angulo = alvo;
        angulo = Math.round(angulo);
        setMedida(`${angulo}°`);
        mexerAoVivo([{ id: g.id, campos: { rot: angulo } }]);
        return;
      }

      if (g.tipo !== "redimensionar") return;
      /* redimensionar: o arrasto nos eixos da peça (ela pode estar girada) */
      const p0 = g.peca;
      const dx = (e.clientX - g.inicioX) / k;
      const dy = (e.clientY - g.inicioY) / k;
      const rad = (p0.rot * Math.PI) / 180;
      const cos = Math.cos(rad);
      const sin = Math.sin(rad);
      const lx = dx * cos + dy * sin;
      const ly = -dx * sin + dy * cos;
      const minimo = 16;
      let w = p0.w;
      let hh = p0.h;
      const canto = g.hx !== 0 && g.hy !== 0;
      const manterProporcao = canto && !e.shiftKey;
      if (manterProporcao) {
        const diagonal = Math.hypot(p0.w, p0.h);
        const projecao = (g.hx * lx * p0.w + g.hy * ly * p0.h) / diagonal;
        const f = Math.max(minimo / Math.min(p0.w, p0.h), (diagonal + projecao) / diagonal);
        w = p0.w * f;
        hh = p0.h * f;
      } else {
        if (g.hx) w = Math.max(minimo, p0.w + g.hx * lx);
        if (g.hy) hh = Math.max(minimo, p0.h + g.hy * ly);
      }
      const mx = (g.hx * (w - p0.w)) / 2;
      const my = (g.hy * (hh - p0.h)) / 2;
      const cx = p0.x + p0.w / 2 + mx * cos - my * sin;
      const cy = p0.y + p0.h / 2 + mx * sin + my * cos;
      const campos: Partial<Peca> = { x: cx - w / 2, y: cy - hh / 2, w, h: hh };
      if (p0.tipo === "texto" && manterProporcao) {
        const f = w / p0.w;
        const t = campos as Partial<PecaDeTexto>;
        t.tamanho = Math.max(6, Math.round(p0.tamanho * f * 10) / 10);
        /* Os trechos com tamanho próprio crescem junto. */
        if (p0.html) t.html = p0.html.replace(/font-size:([\d.]+)px/g, (_, v) => `font-size:${Math.max(4, Math.round(Number(v) * f * 10) / 10)}px`);
      }
      setMedida(`${Math.round(w)} × ${Math.round(hh)}`);
      mexerAoVivo([{ id: g.id, campos }]);
    };
    const soltar = (e?: PointerEvent) => {
      /* Nós e alças: vale o lugar onde o mouse soltou (o último movimento pode não ter chegado). */
      const emCurso = gesto.current;
      if (e && emCurso && (emCurso.tipo === "no" || emCurso.tipo === "alcaDoNo" || emCurso.tipo === "entortar") && emCurso.mexeu) mover(e);
      const g = gesto.current;
      gesto.current = null;
      fixarVivo();
      /* Desenho livre: termina ao soltar (os pontos simplificados viram curva suave). */
      if (g?.tipo === "lapis") {
        const pontos = simplificar(rascunhoRef.current, 1.4 / escalaRef.current);
        rascunhoRef.current = [];
        setRascunho([]);
        if (pontos.length >= 2) aoDesenhar(pontos, false, true);
        return;
      }
      /* Ponto do traço solto: a caixa volta a ficar justa em volta do desenho (sem giro). */
      if (g?.tipo === "ponto" || g?.tipo === "no" || g?.tipo === "alcaDoNo" || g?.tipo === "entortar") {
        const traco = docRef.current.pecas.find((p) => p.id === g.id);
        const justo = g.mexeu && traco && traco.tipo === "traco" ? tracoJusto(g.ultimo ? { ...traco, pontos: g.ultimo, ...(g.tipo === "ponto" ? {} : { suave: false }) } : traco) : null;
        if (justo && traco) aoMexer(traco.id, justo);
        return;
      }
      if (g?.tipo === "caneta" || g?.tipo === "canto" || g?.tipo === "cantoDoGrupo") return;
      if (g?.tipo === "enquadre") return;
      setGuias({ v: [], h: [] });
      setEspacos([]);
      setMedida(null);
      if (g?.tipo === "laco") {
        setLaco(null);
        /* Não arrastou: era um clique na peça (Shift liga e desliga ela entre as escolhidas). */
        if (!g.mexeu && g.clique) aoSelecionar(g.clique.id, g.clique.alternar);
        return;
      }
      if (g?.tipo === "vista") {
        setArrastandoVista(false);
        return;
      }
      if (g?.tipo === "pontoVivo" || g?.tipo === "alcanceVivo") return;
      /* Clique sem arrastar numa alça: abre (ou fecha) o "quanto cobre" daquela ponta. */
      if (g?.tipo === "alca") {
        const qual = g.qual;
        if (!g.mexeu && qual !== 3) setPonta((atual) => (atual?.id === g.id && atual.qual === qual && atual.modo === g.modo ? null : { id: g.id, modo: g.modo, qual }));
        return;
      }
      /* Ctrl + clique: pega a peça de baixo daquele ponto (chega no fundo por baixo da textura sem abrir as camadas). */
      if (g?.tipo === "mover" && !g.mexeu && g.fundo) {
        const ali = document
          .elementsFromPoint(g.inicioX, g.inicioY)
          .map((el) => (el as HTMLElement).closest?.("[data-peca]")?.getAttribute("data-peca"))
          .filter((id, i, todos): id is string => Boolean(id) && todos.indexOf(id) === i);
        const i = ali.indexOf(g.id);
        if (ali.length > 1) aoSelecionar(ali[(i + 1) % ali.length], false, true);
        return;
      }
      /* Retícula Viva aberta: o clique (sem arrastar) na peça põe outro ponto de cor ali (até 8). */
      const viva = reticulaVivaRef.current;
      if (g?.tipo === "mover" && !g.mexeu && g.jaEscolhida && viva && viva.id === g.id) {
        const peca = docRef.current.pecas.find((p) => p.id === g.id);
        const r = peca?.reticulaViva;
        if (peca && r && r.pontos.length < 8) {
          const a = naArte(g.inicioX, g.inicioY);
          const rad = (-peca.rot * Math.PI) / 180;
          const dx = a.x - (peca.x + peca.w / 2);
          const dy = a.y - (peca.y + peca.h / 2);
          const x = (dx * Math.cos(rad) - dy * Math.sin(rad)) / peca.w + 0.5;
          const y = (dx * Math.sin(rad) + dy * Math.cos(rad)) / peca.h + 0.5;
          const usadas = new Set(r.pontos.map((q) => q.cor));
          const cor = ["#E40FD3", "#00E5FF", "#FFD400", "#FF3D6E", "#7C3AED", "#22C55E", "#FF8A00", "#111111"].find((c) => !usadas.has(c)) ?? "#E40FD3";
          aoComecarGestoRef.current?.();
          aoMexer(peca.id, { reticulaViva: { ...r, pontos: [...r.pontos, { x, y, cor, alcance: 0.4 }] } } as Partial<Peca>);
          aoEscolherPontoVivoRef.current?.(r.pontos.length);
        }
        return;
      }
      /* Um clique (sem arrastar) numa peça já escolhida: entre várias, fica só ela (ou o grupo dela); texto abre a escrita. */
      if (g?.tipo === "mover" && !g.mexeu && g.jaEscolhida) {
        const peca = docRef.current.pecas.find((p) => p.id === g.id);
        const doGrupo = peca?.grupo ? docRef.current.pecas.filter((p) => p.grupo === peca.grupo).map((p) => p.id) : [g.id];
        if (g.ids.length > 1 && !(g.ids.length === doGrupo.length && g.ids.every((id) => doGrupo.includes(id)))) {
          aoSelecionar(g.id);
          return;
        }
        /* Texto com foto dentro: os dois cliques ajustam a foto (Enter escreve). */
        if (g.ids.length === 1 && peca?.tipo === "texto" && !peca.bloqueada && !conteudosDe(peca.id, docRef.current.pecas).length) aoEditarTexto(peca.id);
      }
    };
    window.addEventListener("pointermove", mover);
    window.addEventListener("pointerup", soltar);
    window.addEventListener("pointercancel", soltar);
    return () => {
      window.removeEventListener("pointermove", mover);
      window.removeEventListener("pointerup", soltar);
      window.removeEventListener("pointercancel", soltar);
    };
  }, [aoMexer, aoMexerVarias, aoEditarTexto, aoSelecionar, aoSelecionarVarias, aoClonar, aoDesenhar]);

  /* Roda do mouse (e pinça do touchpad): zoom ao vivo no ponto do cursor. */
  const aoZoomRef = useRef(aoZoom);
  const aoZoomVivoRef = useRef(aoZoomVivo);
  const aoMexerRef = useRef(aoMexer);
  useEffect(() => {
    aoZoomRef.current = aoZoom;
    aoZoomVivoRef.current = aoZoomVivo;
    aoMexerRef.current = aoMexer;
  });
  useEffect(() => () => window.clearTimeout(fixarDepois.current), []);
  useEffect(() => {
    const el = area.current;
    if (!el) return;
    const roda = (e: WheelEvent) => {
      e.preventDefault();
      const passo = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * 400 : e.deltaY;
      if (!passo) return;
      const fator = Math.exp(-Math.max(-300, Math.min(300, passo)) * (e.ctrlKey ? 0.01 : 0.0018));
      /* No enquadre, a roda aproxima a FOTO dentro da moldura (não a arte). */
      const emEnquadre = enquadrandoRef.current ? docRef.current.pecas.find((p) => p.id === enquadrandoRef.current) : null;
      if (emEnquadre && emEnquadre.tipo === "imagem") {
        const atual = emEnquadre.enquadre || { zoom: 1, x: 50, y: 50 };
        aoMexerRef.current(emEnquadre.id, { enquadre: { ...atual, zoom: Math.max(1, Math.min(5, atual.zoom * fator)) } } as Partial<Peca>);
        return;
      }
      zoomVivo(fator, e.clientX, e.clientY);
    };
    el.addEventListener("wheel", roda, { passive: false });
    return () => el.removeEventListener("wheel", roda);
  }, []);
  /*
   * Toque (celular e tablet): dois dedos aproximam ou afastam a arte no ponto
   * entre eles e arrastam a vista, como no Canva. O primeiro dedo já pode ter
   * começado um gesto (mover a peça, o laço): o segundo dedo desfaz.
   */
  useEffect(() => {
    const el = area.current;
    if (!el) return;
    let pinca: { distancia: number; meioX: number; meioY: number } | null = null;
    const medir = (e: TouchEvent) => {
      const [a, b] = [e.touches[0], e.touches[1]];
      return { distancia: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), meioX: (a.clientX + b.clientX) / 2, meioY: (a.clientY + b.clientY) / 2 };
    };
    const comecar = (e: TouchEvent) => {
      if (e.touches.length !== 2) return;
      e.preventDefault();
      gesto.current = null;
      pinca = medir(e);
    };
    const mexer = (e: TouchEvent) => {
      if (!pinca || e.touches.length !== 2) return;
      e.preventDefault();
      const agora = medir(e);
      andar(agora.meioX - pinca.meioX, agora.meioY - pinca.meioY);
      zoomVivo(agora.distancia / Math.max(1, pinca.distancia), agora.meioX, agora.meioY);
      pinca = agora;
    };
    /* Soltou um dedo: o zoom fixa na hora. */
    const terminar = (e: TouchEvent) => {
      if (e.touches.length >= 2 || !pinca) return;
      pinca = null;
      fixarZoom();
    };
    el.addEventListener("touchstart", comecar, { passive: false });
    el.addEventListener("touchmove", mexer, { passive: false });
    el.addEventListener("touchend", terminar);
    el.addEventListener("touchcancel", terminar);
    return () => {
      el.removeEventListener("touchstart", comecar);
      el.removeEventListener("touchmove", mexer);
      el.removeEventListener("touchend", terminar);
      el.removeEventListener("touchcancel", terminar);
    };
  }, []);
  /*
   * Depois do zoom fixado. Do zoom ao vivo: a arte fica onde estava na tela e
   * o miolo volta só para a vista (se a roda ainda gira, o ao vivo segue em
   * cima da escala nova). Dos botões, do Ctrl + e da faixa: a arte fica
   * centrada no miolo, então o meio dela na tela não muda com a escala, e o
   * ponto da arte no meio do miolo fica no lugar (sem arrastar, a arte
   * continua no meio).
   */
  const escalaAntes = useRef(escala);
  useLayoutEffect(() => {
    const antes = escalaAntes.current;
    escalaAntes.current = escala;
    /* Já aqui (o efeito comum só roda depois de pintar): o próximo giro da roda parte da escala nova. */
    escalaRef.current = escala;
    if (antes === escala) return;
    const v = vivo.current;
    if (v) {
      vivo.current = null;
      pintarMiolo();
      if (fixarDepois.current) {
        base.current = medirBase(escala);
        vivo.current = v;
        pintarMiolo();
        return;
      }
      acabarVivo();
      const chegou = palco.current?.getBoundingClientRect();
      if (chegou && v.k === escala) {
        porVista(vista.current.x + v.x - chegou.left, vista.current.y + v.y - chegou.top);
        return;
      }
    }
    const caixa = palco.current?.getBoundingClientRect();
    if (!caixa) return;
    const meioX = caixa.left + caixa.width / 2;
    const meioY = caixa.top + caixa.height / 2;
    const telaX = meioX - vista.current.x;
    const telaY = meioY - vista.current.y;
    const esquerdaAntes = meioX - (doc.largura * antes) / 2;
    const topoAntes = meioY - (doc.altura * antes) / 2;
    const esquerda = telaX - ((telaX - esquerdaAntes) / antes) * escala;
    const topo = telaY - ((telaY - topoAntes) / antes) * escala;
    porVista(vista.current.x + esquerda - caixa.left, vista.current.y + topo - caixa.top);
    // eslint-disable-next-line react-hooks/exhaustive-deps -- só a troca de escala move a vista
  }, [escala]);
  /* "Caber na tela" (e trocar o tamanho da arte): a arte volta para o meio. */
  const zoomAntes = useRef(zoom);
  useLayoutEffect(() => {
    const voltou = zoom === null && zoomAntes.current !== null;
    zoomAntes.current = zoom;
    if (!voltou) return;
    acabarVivo();
    porVista(0, 0);
    // eslint-disable-next-line react-hooks/exhaustive-deps -- só a volta para o caber
  }, [zoom]);
  useLayoutEffect(() => {
    acabarVivo();
    porVista(0, 0);
    // eslint-disable-next-line react-hooks/exhaustive-deps -- porVista só lê refs
  }, [voltaAoMeio, doc.largura, doc.altura]);

  /* Caneta: Enter termina, Esc desiste (o editor não vê essas teclas enquanto há desenho em andamento). */
  useEffect(() => {
    if (!ferramenta) return;
    const tecla = (e: KeyboardEvent) => {
      if (!rascunhoRef.current.length) return;
      if (e.key === "Enter" || e.key === "Escape") {
        e.preventDefault();
        e.stopPropagation();
        const pontos = rascunhoRef.current;
        rascunhoRef.current = [];
        setRascunho([]);
        if (e.key === "Enter" && pontos.length >= 2) aoDesenhar(pontos, false, false);
      }
    };
    window.addEventListener("keydown", tecla, true);
    return () => window.removeEventListener("keydown", tecla, true);
  }, [ferramenta, aoDesenhar]);

  /* Barra de espaço segurada: o mouse arrasta a vista (como a mãozinha dos outros editores). */
  useEffect(() => {
    const desce = (e: KeyboardEvent) => {
      if (e.code !== "Space") return;
      const alvo = e.target as HTMLElement | null;
      if (alvo && (alvo.tagName === "INPUT" || alvo.tagName === "TEXTAREA" || alvo.tagName === "SELECT" || alvo.isContentEditable)) return;
      /* A repetição da tecla segurada também: solta, ela rolava a tela sozinha. */
      e.preventDefault();
      if (e.repeat) return;
      espacoRef.current = true;
      setEspaco(true);
    };
    const sobe = (e: KeyboardEvent) => {
      if (e.code !== "Space") return;
      espacoRef.current = false;
      setEspaco(false);
    };
    const saiu = () => {
      espacoRef.current = false;
      setEspaco(false);
    };
    window.addEventListener("keydown", desce);
    window.addEventListener("keyup", sobe);
    window.addEventListener("blur", saiu);
    return () => {
      window.removeEventListener("keydown", desce);
      window.removeEventListener("keyup", sobe);
      window.removeEventListener("blur", saiu);
    };
  }, []);

  /*
   * Os textos que não cabem na caixa (medidos um instante depois de a arte
   * parar de mudar, e de novo quando uma fonte termina de chegar). Escrevendo,
   * a caixa já cresce sozinha.
   */
  const [naoCabem, setNaoCabem] = useState<string[]>([]);
  useEffect(() => {
    if (editando) return;
    const medir = () => {
      const lista = docDaArte.pecas.filter((p): p is PecaDeTexto => p.tipo === "texto" && !p.oculta && !p.curva).filter((p) => {
        const achado = camposDoTexto(palco.current, p.id);
        return achado ? naoCabe(p, faltaNoTexto(achado.caixa, achado.campo)) : false;
      });
      const ids = lista.map((p) => p.id);
      setNaoCabem((atuais) => (atuais.join() === ids.join() ? atuais : ids));
    };
    const t = window.setTimeout(medir, 250);
    const fontes = document.fonts;
    fontes?.addEventListener?.("loadingdone", medir);
    return () => {
      window.clearTimeout(t);
      fontes?.removeEventListener?.("loadingdone", medir);
    };
  }, [docDaArte.pecas, editando]);

  /* Aumentar a caixa até o texto caber: primeiro a largura (a palavra mais comprida inteira), depois a altura com essa largura. */
  const aumentarCaixa = (p: PecaDeTexto) => {
    const achado = camposDoTexto(palco.current, p.id);
    const embrulho = achado?.caixa.closest<HTMLElement>("[data-peca]");
    if (!achado || !embrulho) return;
    const dw = Math.max(0, Math.ceil(faltaNoTexto(achado.caixa, achado.campo).largura + 2));
    const larguraAntes = embrulho.style.width;
    if (dw) embrulho.style.width = `${p.w + dw}px`;
    const dh = Math.max(0, Math.ceil(faltaNoTexto(achado.caixa, achado.campo).altura + 2));
    embrulho.style.width = larguraAntes;
    if (!dw && !dh) return;
    /* Cresce para o lado do alinhamento (girado, cresce pelo meio para não andar). */
    const fx = p.rot ? 0.5 : p.alinhamento === "centro" ? 0.5 : p.alinhamento === "direita" ? 1 : 0;
    const fy = p.rot ? 0.5 : p.vertical === "topo" ? 0 : p.vertical === "base" ? 1 : 0.5;
    aoComecarGesto();
    aoMexer(p.id, { x: p.x - dw * fx, y: p.y - dh * fy, w: p.w + dw, h: p.h + dh });
  };

  const escolhidas = doc.pecas.filter((p) => selecionadas.includes(p.id) && !p.oculta);
  const escolhida = escolhidas.length === 1 ? escolhidas[0] : null;
  /* Ajustando o que vai dentro: o recipiente aberto e as peças em foco (ele e o conteúdo); o resto apaga. */
  const emAjuste = recipienteAberto(selecionadas, doc.pecas);
  const conteudoEmAjuste = emAjuste ? conteudosDe(emAjuste.id, doc.pecas) : [];
  const emFoco = emAjuste ? new Set([emAjuste.id, ...conteudoEmAjuste.map((p) => p.id)]) : null;
  const indiceDoAjuste = emAjuste ? doc.pecas.indexOf(emAjuste) : -1;
  /* O clique numa peça que está dentro de outra pega a de fora (menos no ajuste dela). */
  const queOCliquePega = (p: Peca, pecas: Peca[], aberto: Peca | null) => {
    const r = p.dentroDe ? recipienteDe(p, pecas) : null;
    return r && aberto?.id !== r.id ? r : p;
  };
  const sobrePeca = sobre ? doc.pecas.find((p) => p.id === sobre && !p.oculta) : null;
  const sobreAlvo = sobrePeca ? queOCliquePega(sobrePeca, doc.pecas, emAjuste) : null;
  const debaixo = sobreAlvo && !selecionadas.includes(sobreAlvo.id) ? sobreAlvo : null;
  /* "Colocar dentro…": o contorno da peça debaixo do mouse fica vermelho quando ela não recebe. */
  const naoRecebe = escolhendoRecipiente && debaixo ? Boolean(porQueNaoRecebe(debaixo, doc.pecas.filter((p) => escolhendoRecipiente.includes(p.id)), doc.pecas)) : false;
  /* O que passa da borda da arte (o fundo e o que cobre a arte inteira não contam; no ajuste, só o que vai dentro, inteiro). */
  const foraDaPagina = (() => {
    if (cena) return [];
    if (emAjuste) return conteudoEmAjuste.filter((p) => !p.oculta && saiDaPagina(p, doc));
    const fora = new Set(doc.pecas.filter((p) => !p.oculta && !(p.dentroDe && recipienteDe(p, doc.pecas)) && !cobreAArte(p, doc) && !ehFundoDaArte(p, doc.largura, doc.altura) && saiDaPagina(p, doc)).map((p) => p.id));
    /* O que está dentro da peça que saiu vai junto (recortado por ela, como na arte). */
    return doc.pecas.filter((p) => fora.has(p.id) || (p.dentroDe && fora.has(p.dentroDe) && !p.oculta && recipienteDe(p, doc.pecas)));
  })();

  /*
   * Forma vazada (só a borda, sem preenchimento), como a moldura fina dos
   * modelos, e traço aberto (uma linha): o clique longe da linha é da peça de
   * baixo; elas só são escolhidas perto da linha (como no Corel). Sem isso a
   * moldura por cima tomava todos os cliques, e o fio de uma marcação pegava
   * o clique na caixa inteira dele.
   */
  const noVaoDaForma = (p: Peca, clienteX: number, clienteY: number) => {
    const vazada = p.tipo === "forma" && p.pintura.tipo === "nenhuma";
    const linha = p.tipo === "traco" && !p.contornos?.length && (!p.fechado || p.preenchimento.tipo === "nenhuma");
    if (!vazada && !linha) return false;
    const caixa = palco.current?.getBoundingClientRect();
    const k = escalaRef.current;
    if (!caixa || !k) return false;
    const ax = (clienteX - caixa.left) / k - (p.x + p.w / 2);
    const ay = (clienteY - caixa.top) / k - (p.y + p.h / 2);
    const r = (-(p.rot || 0) * Math.PI) / 180;
    const lx = ax * Math.cos(r) - ay * Math.sin(r);
    const ly = ax * Math.sin(r) + ay * Math.cos(r);
    if (p.tipo === "traco") return maisPerto(p.pontos, p.fechado, p.suave, p.w, p.h, lx + p.w / 2, ly + p.h / 2).distancia > Math.max(p.espessura / 2 + 4, 10 / k);
    if (p.tipo !== "forma") return false;
    /* Perto da borda (uma vez e meia a grossura, ou 10 px da tela) ainda é a forma. */
    const folga = Math.max((p.borda?.largura ?? 0) * 1.5, 10 / k);
    const rx = p.w / 2;
    const ry = p.h / 2;
    if (rx <= folga * 2 || ry <= folga * 2) return false;
    if (p.forma === "circulo" || p.forma === "anel") return Math.hypot(lx / rx, ly / ry) < 1 - folga / Math.min(rx, ry);
    return Math.abs(lx) < rx - folga && Math.abs(ly) < ry - folga;
  };
  /* A peça que o clique pega de verdade (a de baixo, quando ele cai no vão de uma forma vazada). */
  const pecaDeVerdade = (clicada: Peca, clienteX: number, clienteY: number): Peca => {
    if (!noVaoDaForma(clicada, clienteX, clienteY)) return clicada;
    const pecas = docRef.current.pecas;
    for (const el of document.elementsFromPoint(clienteX, clienteY)) {
      const id = el.closest("[data-palco] [data-peca]")?.getAttribute("data-peca");
      if (!id || id === clicada.id) continue;
      const outra = pecas.find((o) => o.id === id);
      if (outra && !noVaoDaForma(outra, clienteX, clienteY)) return outra;
    }
    return clicada;
  };

  const comecarMover = (e: React.PointerEvent, clicada: Peca) => {
    if (e.button !== 0 || espacoRef.current) return;
    e.stopPropagation();
    const peca = queOCliquePega(pecaDeVerdade(clicada, e.clientX, e.clientY), docRef.current.pecas, recipienteAberto(selecionadasRef.current, docRef.current.pecas));
    if (escolhendoRecipiente) {
      e.preventDefault();
      aoEscolherRecipiente?.(peca.id);
      return;
    }
    if (editando === peca.id) return;
    e.preventDefault();
    /* Pincel do "copiar estilo": o clique só pinta a peça. */
    if (pincel) {
      aoSelecionar(peca.id);
      return;
    }
    if (editando) aoEditarTexto(null);
    const atuais = selecionadasRef.current;
    const jaEscolhida = atuais.includes(peca.id);
    /* Peça de grupo: o clique leva o grupo inteiro (dois cliques entram nela sozinha). */
    const doGrupo = peca.grupo ? docRef.current.pecas.filter((p) => p.grupo === peca.grupo && !p.oculta).map((p) => p.id) : [peca.id];
    /*
     * Shift + arrastar faz o laço (soma às escolhidas) começando em cima de
     * qualquer peça, como no vazio; Shift + clique sem arrastar liga e desliga
     * a peça. O fundo travado (ou o "Fundo" da arte) também: arrastar nele é o
     * laço, porque quase toda arte tem um fundo cobrindo o "vazio".
     */
    const fundoParado = !jaEscolhida && !e.altKey && !(e.ctrlKey || e.metaKey) && cobreAArte(peca, docRef.current) && (peca.bloqueada || ehFundoDaArte(peca, docRef.current.largura, docRef.current.altura));
    if (e.shiftKey || fundoParado) {
      gesto.current = { tipo: "laco", inicioX: e.clientX, inicioY: e.clientY, somar: e.shiftKey, antes: atuais, clique: { id: peca.id, alternar: e.shiftKey } };
      return;
    }
    if (!jaEscolhida) aoSelecionar(peca.id);
    const ids = jaEscolhida ? atuais : doGrupo;
    const posicoes = new Map(docRef.current.pecas.filter((p) => ids.includes(p.id)).map((p) => [p.id, { x: p.x, y: p.y }]));
    const soltas = ids.every((id) => !docRef.current.pecas.find((p) => p.id === id)?.bloqueada);
    /* Alt: o clonar guarda no desfazer quando a cópia nascer; sem Alt, guarda já. */
    const clonar = e.altKey && soltas;
    if (!clonar && ids.some((id) => !docRef.current.pecas.find((p) => p.id === id)?.bloqueada)) aoComecarGesto();
    gesto.current = { tipo: "mover", id: peca.id, ids, inicioX: e.clientX, inicioY: e.clientY, posicoes, jaEscolhida, mexeu: false, clonar, fundo: e.ctrlKey || e.metaKey };
  };

  /* Dois cliques numa peça. */
  const doisCliquesNaPeca = (e: React.MouseEvent, clicada: Peca) => {
    e.stopPropagation();
    if (escolhendoRecipiente) return;
    const peca = pecaDeVerdade(clicada, e.clientX, e.clientY);
    /* Peça com coisa dentro (ou o que está dentro dela): os dois cliques entram no ajuste do que vai dentro. */
    const pecas = docRef.current.pecas;
    const aberto = recipienteAberto(selecionadasRef.current, pecas);
    const alvo = queOCliquePega(peca, pecas, aberto);
    if (aberto?.id !== alvo.id && conteudosDe(alvo.id, pecas).length) {
      aoAjustarConteudo?.(alvo.id);
      return;
    }
    if (peca.tipo === "texto" && !peca.bloqueada) {
      aoSelecionar(peca.id, false, true);
      aoEditarTexto(peca.id);
    } else if (peca.tipo === "imagem" && peca.ajuste === "cobrir" && !peca.bloqueada && !peca.dentroDe) {
      aoSelecionar(peca.id, false, true);
      aoEnquadrar(peca.id);
    } else if (peca.tipo === "traco" && !peca.bloqueada && ferramenta !== "forma") {
      aoSelecionar(peca.id, false, true);
      aoEditarNos?.();
    } else if (peca.grupo) aoSelecionar(peca.id, false, true);
  };
  const menuDaPeca = (e: React.MouseEvent, peca: Peca) => {
    e.preventDefault();
    e.stopPropagation();
    const alvo = queOCliquePega(pecaDeVerdade(peca, e.clientX, e.clientY), docRef.current.pecas, recipienteAberto(selecionadasRef.current, docRef.current.pecas));
    if (!selecionadasRef.current.includes(alvo.id)) aoSelecionar(alvo.id);
    aoMenu(e.clientX, e.clientY);
  };
  /* As ações das peças: o objeto fica o mesmo (a peça que não mudou não redesenha) e chama sempre a versão mais nova. */
  const acoesAgora = useRef<AcoesDaPeca | null>(null);
  useLayoutEffect(() => {
    acoesAgora.current = {
      apertar: comecarMover,
      entrar: (id) => setSobre(id),
      sair: (id) => setSobre((s) => (s === id ? null : s)),
      doisCliques: doisCliquesNaPeca,
      menu: menuDaPeca,
    };
  });
  const acoesDaPeca = useMemo<AcoesDaPeca>(
    () => ({
      apertar: (e, p) => acoesAgora.current?.apertar(e, p),
      entrar: (id) => acoesAgora.current?.entrar(id),
      sair: (id) => acoesAgora.current?.sair(id),
      doisCliques: (e, p) => acoesAgora.current?.doisCliques(e, p),
      menu: (e, p) => acoesAgora.current?.menu(e, p),
    }),
    [],
  );

  /* Já houve uma peça que não cobre a arte inteira (no laço das peças do palco). */
  let jaTemPecaEmbaixo = false;
  const caixaNaTela = (p: { x: number; y: number; w: number; h: number; rot?: number }): React.CSSProperties => ({
    left: p.x * escala,
    top: p.y * escala,
    width: p.w * escala,
    height: p.h * escala,
    transform: p.rot ? `rotate(${p.rot}deg)` : undefined,
  });

  /* A caixa do conjunto pelo que cada peça ocupa de verdade (girada, os quatro cantos). */
  const grupo = escolhidas.length > 1 ? caixaDoGrupo(escolhidas.map(caixaGirada)) : null;
  /* Um grupo inteiro escolhido aparece como uma peça só: uma caixa, sem o contorno de cada peça. */
  const idDoGrupo = escolhidas[0]?.grupo;
  const grupoInteiro = Boolean(grupo && idDoGrupo && escolhidas.every((p) => p.grupo === idDoGrupo) && escolhidas.length === doc.pecas.filter((p) => p.grupo === idDoGrupo && !p.oculta).length);
  /* Passando por cima de uma peça de grupo: acende o grupo inteiro (é ele que o clique pega). */
  const debaixoDoGrupo = debaixo?.grupo ? doc.pecas.filter((p) => p.grupo === debaixo.grupo && !p.oculta) : null;
  const caixaDebaixo = debaixo ? (debaixoDoGrupo && debaixoDoGrupo.length > 1 ? caixaDoGrupo(debaixoDoGrupo.map(caixaGirada)) : debaixo) : null;
  const modoAtual = escolhida && !escolhida.bloqueada && editando !== escolhida.id ? modoDasAlcas(escolhida, alcas) : null;
  /*
   * Peça entortada (perspectiva): a moldura de escolha segue o desenho, não a
   * caixa de antes (o "outro quadrado" que ficava sobrando). No modo
   * perspectiva, só o quadro com os 4 cantos e a grade; fora dele, o contorno
   * entortado, as alças no lugar delas no desenho e os botões em cima e embaixo
   * do desenho. `noDesenho` leva um ponto da caixa (0 a 1) para o desenho.
   */
  const emPerspectiva = Boolean(escolhida && distorcendo === escolhida.id && !escolhida.bloqueada);
  const entortada = escolhida?.distorcao && !emPerspectiva ? escolhida.distorcao : null;
  const homografiaDaEntortada = entortada ? homografiaDaCaixa({ x: 0, y: 0, w: 1, h: 1 }, entortada.cantos as Quadrilatero) : null;
  const noDesenho = (u: number, v: number): Ponto => (homografiaDaEntortada ? pelaHomografia(homografiaDaEntortada, u, v) : [u, v]);
  const limitesDoDesenho = entortada
    ? { x0: Math.min(...entortada.cantos.map((c) => c[0])), x1: Math.max(...entortada.cantos.map((c) => c[0])), y0: Math.min(...entortada.cantos.map((c) => c[1])), y1: Math.max(...entortada.cantos.map((c) => c[1])) }
    : { x0: 0, x1: 1, y0: 0, y1: 1 };
  const degrade = escolhida && modoAtual ? alcasDa(escolhida, modoAtual) : null;

  return (
    <div
      ref={area}
      className="relative h-full w-full overflow-hidden"
      style={{ ...(compacto ? { touchAction: "none" } : {}), ...(escolhendoRecipiente ? { cursor: CURSOR_DE_COLOCAR_DENTRO } : {}) }}
      /* O botão do meio arrasta a vista (sem a rolagem automática do Windows). */
      onMouseDown={(e) => {
        if (e.button === 1) e.preventDefault();
      }}
      /* Quem rolar a área por conta própria (o navegador levando um texto em foco para a tela) vira vista: a rolagem fica sempre no zero. */
      onScroll={(e) => {
        const el = e.currentTarget;
        if (!el.scrollLeft && !el.scrollTop) return;
        const { scrollLeft, scrollTop } = el;
        el.scrollLeft = 0;
        el.scrollTop = 0;
        andar(-scrollLeft, -scrollTop);
      }}
      /* Clicou logo depois de girar a roda: o zoom fixa antes (as contas do clique usam a escala fixada). */
      onPointerDownCapture={() => {
        if (fixarDepois.current) fixarZoom();
      }}
      onContextMenu={(e) => {
        e.preventDefault();
        aoMenu(e.clientX, e.clientY);
      }}
      onPointerDown={(e) => {
        if (e.button === 1 || (e.button === 0 && espacoRef.current)) {
          e.preventDefault();
          gesto.current = { tipo: "vista", x: e.clientX, y: e.clientY };
          setArrastandoVista(true);
          return;
        }
        if (e.button !== 0) return;
        if (escolhendoRecipiente) {
          aoEscolherRecipiente?.(null);
          return;
        }
        if (editando) aoEditarTexto(null);
        if (enquadrandoRef.current) aoEnquadrar(null);
        /* No vazio: começa o laço (Shift soma às escolhidas). */
        gesto.current = { tipo: "laco", inicioX: e.clientX, inicioY: e.clientY, somar: e.shiftKey, antes: selecionadasRef.current };
        if (!e.shiftKey) aoSelecionar(null);
      }}
    >
      {/* O miolo tem o tamanho da área: a arte fica no meio dele (maior que a
          tela, passa igual dos dois lados) e a vista anda com o translate.
          A arte é uma camada própria (ver o palco, abaixo): a vista e o zoom
          ao vivo andam sem redesenhar. */}
      <div ref={miolo} className={compacto ? `flex h-full w-full items-center justify-center px-4 ${compacto === "celular" ? "pt-4" : "pt-[72px]"}` : "flex h-full w-full items-center justify-center px-14 pt-24"} style={{ paddingBottom: (compacto ? 24 : 96) + reservaEmbaixo, transformOrigin: "0 0" }}>
        <div data-caixa-da-arte className="relative shrink-0" style={{ width: doc.largura * escala, height: doc.altura * escala }}>
          {/* A sombra da arte numa camada própria, parada: o contorno que segue o
              mouse, as alças e o aviso de texto que não cabe são redesenhados a
              cada movimento, e com a sombra junto ela piscava na borda da arte. */}
          <div aria-hidden data-sombra-da-arte className="pointer-events-none absolute inset-0 shadow-[0_1px_3px_rgba(20,11,43,0.12),0_18px_48px_-12px_rgba(20,11,43,0.28)]" style={{ transform: "translateZ(0)" }} />
          {/*
            A arte: o palco tem o tamanho dela NA TELA e é uma camada própria
            (translateZ; a dica do zoom ao vivo é do código, ver dicaDoZoom);
            a escala fica no de dentro. O fundo branco é do palco: ele pinta
            no espaço da tela e prende a camada nesse espaço.
          */}
          <div ref={palco} data-palco={escala} className="absolute left-0 top-0 overflow-hidden bg-white" style={{ width: doc.largura * escala, height: doc.altura * escala, transform: "translateZ(0)" }}>
            <div data-escala-da-arte className="absolute left-0 top-0" style={{ width: doc.largura, height: doc.altura, transform: `scale(${escala})`, transformOrigin: "0 0" }}>
              <div className="absolute inset-0" dangerouslySetInnerHTML={htmlDoFundo(doc.fundo, doc.largura, doc.altura)} />
              {doc.pecas.flatMap((peca, indice) => {
                if (peca.oculta) return [];
                /*
                 * Por cima de outras peças, não toma o clique (a peça de baixo é
                 * que é escolhida; ela se escolhe nas Camadas): a travada que
                 * cobre a arte inteira (a vinheta) e a textura que cobre quase
                 * toda (o grão, travado ou não, mesmo arrastado para o lado). O
                 * fundo, embaixo de tudo, continua pegando.
                 */
                const cobreTudo = cobreAArte(peca, doc);
                const atravessa = Boolean(jaTemPecaEmbaixo && ((peca.bloqueada && cobreTudo) || (peca.tipo === "textura" && cobreQuaseTudo(peca, doc))));
                if (!cobreTudo) jaTemPecaEmbaixo = true;
                if (editando === peca.id && peca.tipo === "texto") return [<TextoEmEscrita key={peca.id} peca={peca} aoEscrever={(texto, html) => aoEscrever(peca.id, texto, html)} aoCrescer={(h) => aoMexer(peca.id, { h })} aoSair={() => aoEditarTexto(null)} />];
                const el = pecaNoPalcoGuardada(
                  peca,
                  exibicaoGuardada(peca, doc.pecas, contagem),
                  selecionadas.includes(peca.id),
                  Boolean(emFoco && indice > indiceDoAjuste && !emFoco.has(peca.id)),
                  escolhendoRecipiente ? CURSOR_DE_COLOCAR_DENTRO : pincel ? "copy" : peca.bloqueada ? "default" : peca.tipo === "texto" && escolhida?.id === peca.id ? "text" : "move",
                  acoesDaPeca,
                  atravessa,
                );
                /* Ajustando o que vai dentro: um véu branco logo abaixo do recipiente apaga o que fica atrás; clicar nele termina. */
                if (!emAjuste || peca.id !== emAjuste.id) return [el];
                return [
                  <div
                    key="veu-do-ajuste"
                    data-veu-do-ajuste
                    className="absolute inset-0"
                    style={{ background: "rgba(255,255,255,0.74)" }}
                    onPointerDown={(e) => {
                      if (e.button !== 0 || espacoRef.current) return;
                      e.stopPropagation();
                      aoSelecionar(emAjuste.id);
                    }}
                  />,
                  el,
                ];
              })}
              {/* O que vai dentro, inteiro e fraquinho por cima (dá para ver a parte escondida e arrastar por ela). */}
              {conteudoEmAjuste.map((c) =>
                c.oculta ? null : (
                  <div
                    key={`inteiro-${c.id}`}
                    data-conteudo-inteiro={c.id}
                    onPointerDown={(e) => comecarMover(e, c)}
                    className="select-none"
                    style={{ position: "absolute", ...caixaDaPeca(c), opacity: String(0.38 * Math.max(0, Math.min(1, c.opacidade))), cursor: c.bloqueada ? "default" : "move", touchAction: "none" }}
                    dangerouslySetInnerHTML={htmlDaPeca(c)}
                  />
                ),
              )}
              {/* Carrossel contínuo: as fixas (logo, contador) se repetem em cada post, cada uma com o número do post (só para ver). */}
              {doc.fatias && doc.fatias >= 2
                ? Array.from({ length: doc.fatias - 1 }, (_, k) => k + 1).flatMap((k) =>
                    doc.pecas
                      .filter((p) => p.fixa && !p.oculta)
                      .map((p) => {
                        const ex = exibicaoGuardada(p, doc.pecas, { pagina: (contagem?.pagina ?? 1) + k, total: contagem?.total ?? doc.fatias! });
                        if (!ex) return null;
                        const passo = doc.largura / doc.fatias!;
                        return <div key={`fixa-${k}-${p.id}`} data-fixa-repetida={k + 1} className="pointer-events-none" style={{ position: "absolute", ...ex.estilo, left: `${p.x + passo * k}px` }} dangerouslySetInnerHTML={ex.html} />;
                      }),
                  )
                : null}
              {cena ?? null}
              {(() => {
                const alvo = analisando ? doc.pecas.find((p) => p.id === analisando.id && !p.oculta) : null;
                return alvo && analisando ? <EfeitoDeAnalise peca={alvo} escala={escala} saindo={analisando.saindo} /> : null;
              })()}
            </div>
          </div>

          {/* Fora da página (o aramado do Corel): o que passa da borda continua à vista, apagadinho e com o
              contorno, e dá para pegar e arrastar de volta. O recorte com furo deixa a parte de dentro da
              arte para as peças de verdade (e o clique também). */}
          {foraDaPagina.length ? (
            <div
              data-fora-da-pagina
              aria-hidden
              className="pointer-events-none absolute left-0 top-0"
              style={{ width: doc.largura, height: doc.altura, transform: `scale(${escala})`, transformOrigin: "0 0", clipPath: "polygon(evenodd, -1000000px -1000000px, 1000000px -1000000px, 1000000px 1000000px, -1000000px 1000000px, -1000000px -1000000px, 0 0, 0 100%, 100% 100%, 100% 0, 0 0)" }}
            >
              {foraDaPagina.map((p) => {
                /* No ajuste, o que vai dentro aparece inteiro; fora dele, como na arte (o de dentro recortado pela de fora). */
                const ex = emAjuste ? { estilo: caixaDaPeca(p) as Record<string, string>, html: htmlDaPeca(p) } : exibicaoGuardada(p, doc.pecas, contagem);
                if (!ex) return null;
                const html = htmlDeFora(ex.html);
                const deDentro = Boolean(p.dentroDe && !emAjuste);
                return (
                  <div
                    key={p.id}
                    data-peca-fora={p.id}
                    className="pointer-events-auto select-none"
                    onPointerDown={(e) => comecarMover(e, p)}
                    onPointerEnter={() => setSobre(p.id)}
                    onPointerLeave={() => setSobre((s) => (s === p.id ? null : s))}
                    onContextMenu={(e) => menuDaPeca(e, p)}
                    style={{
                      position: "absolute",
                      ...ex.estilo,
                      /* Só a transparência (filtro de cinza em foto custava a cada quadro do arrasto e do zoom). */
                      opacity: String(0.32 * Math.max(0.3, Math.min(1, p.opacidade))),
                      ...(deDentro ? {} : { outline: `${1.5 / escala}px solid #7C3AED`, outlineOffset: 0 }),
                      cursor: escolhendoRecipiente ? CURSOR_DE_COLOCAR_DENTRO : p.bloqueada ? "default" : "move",
                      touchAction: "none",
                    }}
                    dangerouslySetInnerHTML={html}
                  />
                );
              })}
            </div>
          ) : null}

          {/* Ajustando o que vai dentro: o contorno do recipiente (o "Pronto" fica na barra de cima). */}
          {emAjuste ? <div className="pointer-events-none absolute z-10 outline-dashed outline-2 outline-[#7C3AED]" style={caixaNaTela(emAjuste)} data-contorno-do-ajuste title="Arraste o que vai dentro · os cantos aumentam · Esc termina" /> : null}

          {/* As divisas dos posts do carrossel contínuo, com o número de cada um. */}
          {doc.fatias && doc.fatias >= 2
            ? Array.from({ length: doc.fatias }, (_, k) => (
                <div key={`fatia${k}`} data-fatia={k + 1} className="pointer-events-none absolute top-0 z-[16] h-full" style={{ left: (doc.largura / doc.fatias!) * k * escala, width: (doc.largura / doc.fatias!) * escala }}>
                  {k > 0 ? <span className="absolute left-0 top-0 h-full border-l-2 border-dashed border-[#06B6D4]" /> : null}
                  <span className="absolute left-2 top-2 rounded-[6px] bg-[#06B6D4] px-1.5 py-[1px] text-[11px] font-bold tabular-nums text-white shadow">{k + 1}/{doc.fatias}</span>
                </div>
              ))
            : null}

          {margemDeSeguranca ? (
            <div
              data-margem-de-seguranca
              className="pointer-events-none absolute z-[15] border border-dashed border-[#06B6D4]"
              style={{ left: margemDeSeguranca * escala, top: margemDeSeguranca * escala, right: margemDeSeguranca * escala, bottom: margemDeSeguranca * escala }}
              title="Área de segurança: textos e logos aqui dentro não correm risco no corte"
            >
              <span className="absolute -top-[18px] left-0 whitespace-nowrap rounded-[4px] bg-[#06B6D4] px-1.5 py-[1px] text-[9.5px] font-bold text-white">Área de segurança</span>
            </div>
          ) : null}

          {guiasDaCaneca ? (
            <div data-guias-da-caneca className="pointer-events-none absolute inset-0 z-[15]">
              {/* As pontas (1 cm de cada lado) ficam coladas na alça: aparecem pouco. */}
              {(["left", "right"] as const).map((lado) => (
                <div key={lado} className="absolute top-0 h-full bg-[repeating-linear-gradient(135deg,rgba(236,72,153,0.16)_0_6px,transparent_6px_12px)]" style={{ [lado]: 0, width: "5%" }} title="Encosta na alça: aparece pouco">
                  <span className={`absolute bottom-1 whitespace-nowrap rounded-[4px] bg-[#EC4899] px-1.5 py-[1px] text-[9.5px] font-bold text-white ${lado === "left" ? "left-1" : "right-1"}`}>Alça</span>
                </div>
              ))}
              {[
                { onde: 25, rotulo: "Lado A", dica: "Centro do que aparece com a alça à esquerda" },
                { onde: 50, rotulo: "Meio", dica: "Lado oposto à alça" },
                { onde: 75, rotulo: "Lado B", dica: "Centro do que aparece com a alça à direita" },
              ].map((g) => (
                <div key={g.onde} className="absolute top-0 h-full" style={{ left: `${g.onde}%` }} title={g.dica}>
                  <span className={`absolute top-0 h-full border-l ${g.onde === 50 ? "border-dotted border-[#EC4899]/60" : "border-dashed border-[#EC4899]"}`} />
                  <span className="absolute left-0 top-1 -translate-x-1/2 whitespace-nowrap rounded-[4px] bg-[#EC4899] px-1.5 py-[1px] text-[9.5px] font-bold text-white">{g.rotulo}</span>
                </div>
              ))}
            </div>
          ) : null}

          {ferramenta === "caneta" || ferramenta === "lapis" ? (
            <CamadaDeDesenho
              ferramenta={ferramenta}
              escala={escala}
              largura={doc.largura}
              altura={doc.altura}
              rascunho={rascunho}
              cursor={cursorDoDesenho}
              aoPonto={(e) => {
                const caixa = palco.current?.getBoundingClientRect();
                if (!caixa) return;
                const a = { x: (e.clientX - caixa.left) / escala, y: (e.clientY - caixa.top) / escala };
                if (ferramenta === "lapis") {
                  rascunhoRef.current = [a];
                  setRascunho([a]);
                  gesto.current = { tipo: "lapis" };
                  return;
                }
                const atual = rascunhoRef.current;
                const primeiro = atual[0];
                /* Clicou no primeiro ponto (com 3 ou mais): fecha a forma. */
                if (primeiro && atual.length >= 3 && Math.hypot(a.x - primeiro.x, a.y - primeiro.y) < 10 / escala) {
                  rascunhoRef.current = [];
                  setRascunho([]);
                  aoDesenhar(atual, true, false);
                  return;
                }
                const ultimo = atual.at(-1);
                let ponto = a;
                if (e.shiftKey && ultimo) {
                  const dist = Math.hypot(a.x - ultimo.x, a.y - ultimo.y);
                  const ang = Math.round(Math.atan2(a.y - ultimo.y, a.x - ultimo.x) / (Math.PI / 12)) * (Math.PI / 12);
                  ponto = { x: ultimo.x + Math.cos(ang) * dist, y: ultimo.y + Math.sin(ang) * dist };
                }
                /* O segundo clique do "dois cliques" não vira ponto repetido. */
                if (ultimo && Math.hypot(ponto.x - ultimo.x, ponto.y - ultimo.y) < 3 / escala) return;
                rascunhoRef.current = [...atual, ponto];
                setRascunho(rascunhoRef.current);
                /* Segurou e arrastou: puxa as alças do nó (vira curva). */
                gesto.current = { tipo: "caneta", inicioX: e.clientX, inicioY: e.clientY, mexeu: false };
              }}
              aoMover={(e) => {
                const caixa = palco.current?.getBoundingClientRect();
                if (caixa) setCursorDoDesenho({ x: (e.clientX - caixa.left) / escala, y: (e.clientY - caixa.top) / escala });
              }}
              aoTerminar={() => {
                const pontos = rascunhoRef.current;
                rascunhoRef.current = [];
                setRascunho([]);
                if (pontos.length >= 2) aoDesenhar(pontos, false, false);
              }}
            />
          ) : null}

          {guias.v.map((x, i) => (
            <div key={`v${i}`} className="pointer-events-none absolute -top-4 z-20 w-px bg-[#FF2D87]" style={{ left: x * escala, height: `calc(100% + 32px)` }} />
          ))}
          {guias.h.map((y, i) => (
            <div key={`h${i}`} className="pointer-events-none absolute -left-4 z-20 h-px bg-[#FF2D87]" style={{ top: y * escala, width: `calc(100% + 32px)` }} />
          ))}

          {espacos.map((m, i) => (
            <div key={`e${i}`} className="pointer-events-none absolute z-20" style={{ left: Math.min(m.x1, m.x2) * escala, top: Math.min(m.y1, m.y2) * escala, width: Math.max(1, Math.abs(m.x2 - m.x1) * escala), height: Math.max(1, Math.abs(m.y2 - m.y1) * escala) }}>
              <div className="absolute inset-0" style={{ background: "#FF2D87" }} />
              <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 whitespace-nowrap rounded-[6px] bg-[#FF2D87] px-1.5 py-[1px] text-[10.5px] font-bold tabular-nums text-white">{Math.round(m.medida)}</span>
            </div>
          ))}

          {/* A peça entortada (perspectiva) tem o contorno entortado junto: a mesma matriz, no tamanho da tela. */}
          {caixaDebaixo && !medida ? (
            debaixo && caixaDebaixo === debaixo && debaixo.distorcao ? (
              <div className="pointer-events-none absolute z-10" style={caixaNaTela(debaixo)}>
                <div className="absolute left-0 top-0 h-full w-full outline outline-2 outline-[#8B3DFF]/45" style={{ transformOrigin: "0 0", transform: matrizDaDistorcao(debaixo.distorcao, debaixo.w * escala, debaixo.h * escala) }} />
              </div>
            ) : (
              <div className={`pointer-events-none absolute z-10 rounded-[1px] outline outline-2 ${naoRecebe ? "outline-[#E11D48]/70" : escolhendoRecipiente ? "outline-[#7C3AED]" : "outline-[#8B3DFF]/45"}`} style={caixaNaTela(caixaDebaixo)} />
            )
          ) : null}

          {laco ? <div className="pointer-events-none absolute z-30 border border-[#8B3DFF] bg-[#8B3DFF]/10" style={caixaNaTela(laco)} /> : null}

          {/* Perspectiva do grupo (como a do Corel): o quadrilátero, a grade entortada e os 4 cantos para puxar. */}
          {perspectivaDoGrupo && escolhidas.length > 1
            ? (() => {
                const { caixa, cantos } = cantosDoGrupo(escolhidas);
                const naTela = ([x, y]: Ponto) => `${x * escala},${y * escala}`;
                return (
                  <>
                    <svg className="pointer-events-none absolute left-0 top-0 z-30 overflow-visible" width={doc.largura * escala} height={doc.altura * escala} aria-hidden="true" data-grade-da-perspectiva>
                      {gradeDaPerspectiva(caixa, cantos, 8).map(([a, b], i) => (
                        <line key={i} x1={a[0] * escala} y1={a[1] * escala} x2={b[0] * escala} y2={b[1] * escala} stroke={ROXO} strokeOpacity={0.35} strokeWidth={0.8} />
                      ))}
                      <polygon points={cantos.map(naTela).join(" ")} fill="none" stroke={ROXO} strokeWidth={1.5} strokeDasharray="6 4" />
                    </svg>
                    {cantos.map(([x, y], i) => (
                      <span
                        key={`pg${i}`}
                        data-canto-da-perspectiva={i}
                        title="Arraste o canto: o grupo inteiro entorta junto"
                        onPointerDown={(e) => {
                          e.stopPropagation();
                          e.preventDefault();
                          aoComecarGesto();
                          gesto.current = { tipo: "cantoDoGrupo", indice: i, ids: escolhidas.map((p) => p.id), caixa, cantos };
                        }}
                        className="pointer-events-auto absolute z-40 flex h-8 w-8 cursor-move items-center justify-center"
                        style={{ left: x * escala, top: y * escala, transform: "translate(-50%,-50%)", touchAction: "none" }}
                      >
                        <span className="block h-[13px] w-[13px] bg-white shadow-[0_1px_3px_rgba(0,0,0,0.35)] ring-2 ring-[#8B3DFF]" />
                      </span>
                    ))}
                  </>
                );
              })()
            : null}

          {/* Várias escolhidas: cada uma contornada e a caixa do conjunto tracejada. Grupo inteiro: uma caixa só, como uma peça. */}
          {grupo && !perspectivaDoGrupo ? (
            <>
              {grupoInteiro
                ? null
                : escolhidas.map((p) => <div key={p.id} className="pointer-events-none absolute z-20 rounded-[1px]" style={{ ...caixaNaTela(p), outline: `1.5px solid ${p.bloqueada ? "#A1A1AA" : ROXO}` }} />)}
              <div
                data-selecao={grupoInteiro ? "grupo" : "varias"}
                className={`pointer-events-none absolute z-30 ${grupoInteiro ? "rounded-[1px]" : "rounded-[2px] border-2 border-dashed border-[#8B3DFF]"}`}
                style={{
                  ...(grupoInteiro ? caixaNaTela(grupo) : caixaNaTela({ x: grupo.x - 4 / escala, y: grupo.y - 4 / escala, w: grupo.w + 8 / escala, h: grupo.h + 8 / escala })),
                  ...(grupoInteiro ? { outline: `2px solid ${escolhidas.some((p) => p.bloqueada) ? "#A1A1AA" : ROXO}` } : {}),
                }}
              >
                {escolhidas.some((p) => p.bloqueada)
                  ? null
                  : ALCAS.filter(([hx, hy]) => hx && hy).map(([hx, hy]) => (
                      <span
                        key={`g${hx},${hy}`}
                        title="Arraste para aumentar ou diminuir tudo junto"
                        onPointerDown={(e) => {
                          e.stopPropagation();
                          e.preventDefault();
                          aoComecarGesto();
                          gesto.current = { tipo: "redimensionarGrupo", hx, hy, inicioX: e.clientX, inicioY: e.clientY, caixa: { ...grupo }, pecas: escolhidas.map((p) => ({ ...p })) };
                        }}
                        className="pointer-events-auto absolute flex h-[30px] w-[30px] items-center justify-center"
                        style={{ left: `${((hx + 1) / 2) * 100}%`, top: `${((hy + 1) / 2) * 100}%`, transform: "translate(-50%,-50%)", cursor: CURSORES[`${hx},${hy}`], touchAction: "none" }}
                      >
                        <span className="block h-[14px] w-[14px] rounded-full bg-white shadow-[0_1px_3px_rgba(0,0,0,0.3)] ring-1 ring-black/10" />
                      </span>
                    ))}
                {escolhidas.some((p) => p.bloqueada) ? null : (
                  <span
                    title="Girar tudo junto (Shift: de 15 em 15)"
                    onPointerDown={(e) => {
                      e.stopPropagation();
                      e.preventDefault();
                      const caixa = palco.current?.getBoundingClientRect();
                      if (!caixa) return;
                      aoComecarGesto();
                      const cx = grupo.x + grupo.w / 2;
                      const cy = grupo.y + grupo.h / 2;
                      const inicio = (Math.atan2(e.clientY - (caixa.top + cy * escala), e.clientX - (caixa.left + cx * escala)) * 180) / Math.PI;
                      gesto.current = { tipo: "girarGrupo", cx, cy, inicio, pecas: escolhidas.map((p) => ({ ...p })) };
                    }}
                    className="pointer-events-auto absolute left-1/2 flex h-9 w-9 -translate-x-1/2 cursor-grab items-center justify-center"
                    style={{ bottom: -46, touchAction: "none" }}
                  >
                    <span className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-[#4B4760] shadow-[0_1px_4px_rgba(0,0,0,0.3)] ring-1 ring-black/10">
                      <RotateCw size={13} strokeWidth={2.4} />
                    </span>
                  </span>
                )}
                {medida ? (
                  <span className="absolute left-1/2 top-[calc(100%+54px)] -translate-x-1/2 whitespace-nowrap rounded-[8px] bg-[#1F1B2E] px-2.5 py-1 text-[11.5px] font-semibold tabular-nums text-white shadow-lg">{medida}</span>
                ) : null}
              </div>
            </>
          ) : null}

          {/* Texto que não cabe na caixa: contorno laranja tracejado (a escolhida mostra o aviso com as saídas). */}
          {naoCabem.map((id) => {
            const p = doc.pecas.find((q) => q.id === id);
            if (!p || p.tipo !== "texto" || p.id === escolhida?.id || textoDeEnfeite(p)) return null;
            return <div key={`nc${id}`} data-nao-cabe={id} title="O texto não cabe na caixa" className="pointer-events-none absolute z-[25] rounded-[1px] outline-dashed outline-2 outline-[#F59E0B]" style={caixaNaTela(p)} />;
          })}

          {escolhida ? (
            <div className="pointer-events-none absolute z-30" style={caixaNaTela(escolhida)}>
              {emPerspectiva ? null : entortada ? (
                <div className="absolute left-0 top-0 h-full w-full rounded-[1px]" style={{ transformOrigin: "0 0", transform: matrizDaDistorcao(entortada, escolhida.w * escala, escolhida.h * escala), outline: `2px solid ${escolhida.bloqueada ? "#A1A1AA" : ROXO}`, outlineOffset: 0 }} data-moldura-entortada />
              ) : (
                <div className="absolute inset-0 rounded-[1px]" style={{ outline: `2px solid ${escolhida.bloqueada ? "#A1A1AA" : ROXO}`, outlineOffset: 0 }} />
              )}

              {/* Duplicar e apagar, presos em cima da peça (some enquanto mexe, enquanto escreve e no modo perspectiva). */}
              {!medida && editando !== escolhida.id && !emPerspectiva ? (
                <div
                  className="pointer-events-auto absolute left-1/2 flex -translate-x-1/2 items-center gap-0.5 rounded-[10px] bg-white p-1 shadow-[0_4px_16px_rgba(20,11,43,0.18)] ring-1 ring-black/5"
                  style={{ top: limitesDoDesenho.y0 * escolhida.h * escala - 52, left: `${((limitesDoDesenho.x0 + limitesDoDesenho.x1) / 2) * 100}%`, transform: `translateX(-50%) rotate(${-escolhida.rot}deg)` }}
                  onPointerDown={(e) => e.stopPropagation()}
                >
                  {escolhida.bloqueada ? (
                    <span className="flex h-8 items-center gap-1 px-2 text-[11.5px] font-semibold text-[#6B6584]">
                      <Lock size={13} /> Travada
                    </span>
                  ) : null}
                  {escolhida.tipo === "texto" && !escolhida.bloqueada && naoCabem.includes(escolhida.id) ? (
                    <span data-aviso-nao-cabe className="flex items-center gap-0.5 border-r border-[#EEEAF5] pr-1">
                      <span className="flex h-8 items-center gap-1 whitespace-nowrap pl-1.5 pr-1 text-[11.5px] font-bold text-[#B45309]">
                        <TriangleAlert size={14} strokeWidth={2.4} /> Não cabe
                      </span>
                      <button type="button" data-caber="letra" title="Diminui a letra até o texto caber na caixa" onClick={() => aoDiminuirTexto?.(escolhida.id)} className="h-8 whitespace-nowrap rounded-[8px] px-2 text-[11.5px] font-bold text-[#4B4760] hover:bg-[#FEF3C7]">
                        {compacto ? "Letra menor" : "Diminuir a letra"}
                      </button>
                      <button type="button" data-caber="caixa" title="Aumenta a caixa até o texto caber" onClick={() => aumentarCaixa(escolhida)} className="h-8 whitespace-nowrap rounded-[8px] px-2 text-[11.5px] font-bold text-[#4B4760] hover:bg-[#FEF3C7]">
                        {compacto ? "Caixa maior" : "Aumentar a caixa"}
                      </button>
                    </span>
                  ) : null}
                  <button type="button" title="Duplicar (Ctrl+D)" onClick={() => aoDuplicar(escolhida.id)} className="flex h-8 w-8 items-center justify-center rounded-[8px] text-[#4B4760] hover:bg-[#F1EFF6]">
                    <Copy size={15} />
                  </button>
                  <button type="button" title="Apagar (Delete)" onClick={() => aoApagar(escolhida.id)} className="flex h-8 w-8 items-center justify-center rounded-[8px] text-[#4B4760] hover:bg-[#FFE4E6] hover:text-[#BE123C]">
                    <Trash2 size={15} />
                  </button>
                  {compacto ? (
                    <button
                      type="button"
                      title="Mais opções"
                      aria-label="Mais opções"
                      onClick={(e) => {
                        const r = e.currentTarget.getBoundingClientRect();
                        aoMenu(r.left, r.bottom + 6);
                      }}
                      className="flex h-8 w-8 items-center justify-center rounded-[8px] text-[#4B4760] hover:bg-[#F1EFF6]"
                      data-mais-da-peca
                    >
                      <MoreHorizontal size={16} />
                    </button>
                  ) : null}
                </div>
              ) : null}

              {escolhida.bloqueada || editando === escolhida.id || (escolhida.tipo === "traco" && (linhaSimples(escolhida) || ferramenta === "forma")) || enquadrando === escolhida.id || distorcendo === escolhida.id || apagando === escolhida.id
                ? null
                : ALCAS.map(([hx, hy]) => {
                    const canto = hx !== 0 && hy !== 0;
                    /* Pílula de esticar só quando a peça tem espaço: num texto baixinho, a de cima e a de baixo cobririam o meio dele (e o clique não chegaria ao texto). */
                    if (!canto && ((hx && (escolhida.w * escala < 48 || escolhida.h * escala < 30)) || (hy && (escolhida.h * escala < 48 || escolhida.w * escala < 30)))) return null;
                    return (
                      <span
                        key={`${hx},${hy}`}
                        onPointerDown={(e) => {
                          e.stopPropagation();
                          e.preventDefault();
                          aoComecarGesto();
                          gesto.current = { tipo: "redimensionar", id: escolhida.id, hx, hy, inicioX: e.clientX, inicioY: e.clientY, peca: { ...escolhida } };
                        }}
                        className="pointer-events-auto absolute flex items-center justify-center"
                        style={{ left: `${noDesenho((hx + 1) / 2, (hy + 1) / 2)[0] * 100}%`, top: `${noDesenho((hx + 1) / 2, (hy + 1) / 2)[1] * 100}%`, width: 30, height: 30, transform: "translate(-50%,-50%)", cursor: CURSORES[`${hx},${hy}`], touchAction: "none" }}
                      >
                        <span
                          className={`block bg-white shadow-[0_1px_3px_rgba(0,0,0,0.3)] ring-1 ring-black/10 ${canto ? "h-[14px] w-[14px] rounded-full" : hx ? "h-[22px] w-[8px] rounded-full" : "h-[8px] w-[22px] rounded-full"}`}
                        />
                      </span>
                    );
                  })}

              {apagando === escolhida.id && escolhida.tipo === "imagem" ? (
                <Borracha foto={escolhida} escala={escala} aoPronto={(arquivo) => aoTerminarBorracha?.(escolhida.id, arquivo) ?? Promise.resolve()} aoCancelar={() => aoCancelarBorracha?.()} />
              ) : null}

              {distorcendo === escolhida.id && !escolhida.bloqueada ? (
                <>
                  {/* O quadrilátero da perspectiva e os 4 cantos para arrastar. */}
                  <svg className="pointer-events-none absolute left-0 top-0 overflow-visible" width={escolhida.w * escala} height={escolhida.h * escala} aria-hidden="true">
                    {gradeDaPerspectiva({ x: 0, y: 0, w: escolhida.w * escala, h: escolhida.h * escala }, (escolhida.distorcao?.cantos ?? [[0, 0], [1, 0], [1, 1], [0, 1]]).map(([x, y]) => [x * escolhida.w * escala, y * escolhida.h * escala]) as Quadrilatero, 8).map(([a, b], i) => (
                      <line key={i} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]} stroke={ROXO} strokeOpacity={0.35} strokeWidth={0.8} />
                    ))}
                    <polygon points={(escolhida.distorcao?.cantos ?? [[0, 0], [1, 0], [1, 1], [0, 1]]).map(([x, y]) => `${x * escolhida.w * escala},${y * escolhida.h * escala}`).join(" ")} fill="none" stroke={ROXO} strokeWidth={1.5} strokeDasharray="5 4" />
                  </svg>
                  {(escolhida.distorcao?.cantos ?? [[0, 0], [1, 0], [1, 1], [0, 1]]).map(([x, y], i) => (
                    <span
                      key={`d${i}`}
                      data-canto-da-distorcao={i}
                      title="Arraste o canto (perspectiva)"
                      onPointerDown={(e) => {
                        e.stopPropagation();
                        e.preventDefault();
                        aoComecarGesto();
                        gesto.current = { tipo: "canto", id: escolhida.id, indice: i };
                      }}
                      className="pointer-events-auto absolute flex h-8 w-8 cursor-move items-center justify-center"
                      style={{ left: `${x * 100}%`, top: `${y * 100}%`, transform: "translate(-50%,-50%)", touchAction: "none" }}
                    >
                      <span className="block h-[14px] w-[14px] rotate-45 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.35)] ring-2 ring-[#8B3DFF]" />
                    </span>
                  ))}
                </>
              ) : null}

              {escolhida.tipo === "traco" && !escolhida.bloqueada && ferramenta === "forma" ? (
                <EditorDeNos
                  traco={escolhida}
                  escala={escala}
                  nos={nos}
                  aoNos={(indices) => aoNos?.(indices)}
                  fracao={(cx, cy) => {
                    const caixa = palco.current?.getBoundingClientRect();
                    return fracaoNoTraco(escolhida, caixa ? { x: (cx - caixa.left) / escala, y: (cy - caixa.top) / escala } : { x: 0, y: 0 });
                  }}
                  aoComecar={(g) => {
                    aoComecarGesto();
                    gesto.current = g;
                  }}
                  aoTrocar={(pontos, escolher) => {
                    aoComecarGesto();
                    const novo = { ...escolhida, pontos, suave: false };
                    aoMexer(escolhida.id, { pontos, suave: false, ...(tracoJusto(novo) || {}) } as Partial<Peca>);
                    aoNos?.(escolher);
                  }}
                />
              ) : null}

              {escolhida.tipo === "traco" && !escolhida.bloqueada && editando !== escolhida.id && ferramenta !== "forma" && linhaSimples(escolhida)
                ? escolhida.pontos.map((q, i) => (
                    <span
                      key={`p${i}`}
                      title="Arraste o ponto (Shift: de 15 em 15 graus)"
                      onPointerDown={(e) => {
                        e.stopPropagation();
                        e.preventDefault();
                        aoComecarGesto();
                        gesto.current = { tipo: "ponto", id: escolhida.id, indice: i, mexeu: false };
                      }}
                      className="pointer-events-auto absolute flex h-7 w-7 cursor-move items-center justify-center"
                      style={{ left: `${q.x * 100}%`, top: `${q.y * 100}%`, transform: "translate(-50%,-50%)", touchAction: "none" }}
                    >
                      <span className="block h-[14px] w-[14px] rounded-full bg-white shadow-[0_1px_3px_rgba(0,0,0,0.35)] ring-2 ring-[#8B3DFF]" />
                    </span>
                  ))
                : null}

              {enquadrando === escolhida.id && escolhida.tipo === "imagem" ? (
                <EnquadreDaFoto
                  foto={escolhida}
                  escala={escala}
                  aoPegar={(e) => {
                    e.stopPropagation();
                    e.preventDefault();
                    const img = palco.current?.querySelector<HTMLImageElement>(`[data-peca="${CSS.escape(escolhida.id)}"] img`);
                    const base = escolhida.enquadre || { zoom: 1, x: 50, y: 50 };
                    let excesso = { x: 0, y: 0 };
                    if (img?.naturalWidth && img.naturalHeight) {
                      const s = Math.max(escolhida.w / img.naturalWidth, escolhida.h / img.naturalHeight) * base.zoom;
                      excesso = { x: img.naturalWidth * s - escolhida.w, y: img.naturalHeight * s - escolhida.h };
                    }
                    aoComecarGesto();
                    gesto.current = { tipo: "enquadre", id: escolhida.id, inicioX: e.clientX, inicioY: e.clientY, base, excesso };
                  }}
                  aoTerminar={() => aoEnquadrar(null)}
                  aoZoom={(zoom) => aoMexer(escolhida.id, { enquadre: { ...(escolhida.enquadre || { zoom: 1, x: 50, y: 50 }), zoom } } as Partial<Peca>)}
                />
              ) : null}

              {degrade && modoAtual && !medida?.startsWith("X ") ? (
                <AlcasDoDegrade
                  peca={escolhida}
                  modo={modoAtual}
                  alcas={degrade}
                  escala={escala}
                  ponta={ponta && ponta.id === escolhida.id && ponta.modo === modoAtual ? ponta.qual : null}
                  aoPegar={(qual, e) => {
                    e.stopPropagation();
                    e.preventDefault();
                    aoComecarGesto();
                    gesto.current = { tipo: "alca", id: escolhida.id, modo: modoAtual, qual, inicioX: e.clientX, inicioY: e.clientY, mexeu: false };
                  }}
                  aoCobrir={(qual, valor) => {
                    const campos = camposDasAlcas(escolhida, modoAtual, degrade.pontos, qual === 1 ? { alfa: valor } : { alfa2: valor });
                    if (campos) aoMexer(escolhida.id, campos);
                  }}
                  aoComecarGesto={aoComecarGesto}
                />
              ) : null}

              {reticulaViva && reticulaViva.id === escolhida.id && escolhida.reticulaViva && !escolhida.bloqueada && !medida?.startsWith("X ") ? (
                <PontosDaReticulaViva
                  peca={escolhida}
                  escala={escala}
                  escolhido={reticulaViva.ponto}
                  aoPegarPonto={(i, e) => {
                    e.stopPropagation();
                    e.preventDefault();
                    aoEscolherPontoVivo?.(i);
                    aoComecarGesto();
                    gesto.current = { tipo: "pontoVivo", id: escolhida.id, indice: i, inicioX: e.clientX, inicioY: e.clientY, mexeu: false };
                  }}
                  aoPegarAlcance={(i, e) => {
                    e.stopPropagation();
                    e.preventDefault();
                    aoComecarGesto();
                    gesto.current = { tipo: "alcanceVivo", id: escolhida.id, indice: i };
                  }}
                />
              ) : null}

              {escolhida.bloqueada || editando === escolhida.id || emPerspectiva ? null : (
                <span
                  onPointerDown={(e) => {
                    e.stopPropagation();
                    e.preventDefault();
                    aoComecarGesto();
                    gesto.current = { tipo: "girar", id: escolhida.id, cx: escolhida.x + escolhida.w / 2, cy: escolhida.y + escolhida.h / 2 };
                  }}
                  className="pointer-events-auto absolute left-1/2 flex h-9 w-9 -translate-x-1/2 cursor-grab items-center justify-center"
                  style={entortada ? { top: limitesDoDesenho.y1 * escolhida.h * escala + 10, left: `${((limitesDoDesenho.x0 + limitesDoDesenho.x1) / 2) * 100}%`, touchAction: "none" } : { bottom: -46, touchAction: "none" }}
                  title="Girar (Shift: de 15 em 15)"
                >
                  <span className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-[#4B4760] shadow-[0_1px_4px_rgba(0,0,0,0.3)] ring-1 ring-black/10">
                    <RotateCw size={13} strokeWidth={2.4} />
                  </span>
                </span>
              )}

              {medida ? (
                <span className="absolute left-1/2 top-[calc(100%+54px)] -translate-x-1/2 whitespace-nowrap rounded-[8px] bg-[#1F1B2E] px-2.5 py-1 text-[11.5px] font-semibold tabular-nums text-white shadow-lg" style={{ transform: `translateX(-50%) rotate(${-escolhida.rot}deg)` }}>
                  {medida}
                </span>
              ) : null}
            </div>
          ) : null}
        </div>
      </div>
      {/* A mãozinha: com o espaço segurado (ou arrastando a vista), uma camada
          por cima de tudo pega o mouse. Sem ela, cada peça trocava o cursor
          para "mover" e acendia o contorno no caminho (piscava). */}
      {espaco || arrastandoVista ? <div data-mao className="absolute inset-0 z-[80]" style={{ cursor: arrastandoVista ? "grabbing" : "grab", touchAction: "none" }} /> : null}
    </div>
  );
}

/** Linha simples (dois pontos, reta): mostra as duas pontas para arrastar, sem a caixa de tamanho. */
function linhaSimples(p: PecaDeTraco) {
  return p.pontos.length === 2 && !p.pontos.some((q) => q.e || q.s);
}

/**
 * A ferramenta Forma no traço escolhido (como a do Corel): os nós (quadradinhos;
 * cheio = escolhido), as alças dos escolhidos e a própria linha. Arrastar nó
 * move (Shift soma à escolha), arrastar alça curva (Alt solta a do outro
 * lado), arrastar a linha entorta o trecho. Dois cliques na linha põem nó;
 * dois cliques no nó tiram.
 */
function EditorDeNos({
  traco,
  escala,
  nos,
  aoNos,
  fracao,
  aoComecar,
  aoTrocar,
}: {
  traco: PecaDeTraco;
  escala: number;
  nos: number[];
  aoNos: (indices: number[]) => void;
  fracao: (clienteX: number, clienteY: number) => { x: number; y: number };
  aoComecar: (g: Gesto) => void;
  aoTrocar: (pontos: PontoDoTraco[], escolher: number[]) => void;
}) {
  const W = traco.w * escala;
  const H = traco.h * escala;
  const pontos = comAlcas(traco.pontos, traco.fechado, traco.suave);
  const naTela = (q: { x: number; y: number }) => ({ x: (traco.espelharX ? 1 - q.x : q.x) * W, y: (traco.espelharY ? 1 - q.y : q.y) * H });
  const caminho = caminhoDasCurvas(
    pontos.map((q) => ({ ...q, ...naTela(q), e: q.e ? naTela(q.e) : q.e, s: q.s ? naTela(q.s) : q.s })),
    traco.fechado,
    false,
    1,
    1,
  );
  const escolhidos = nos.filter((i) => i < pontos.length);

  const pegarLinha = (e: React.PointerEvent) => {
    if (e.button !== 0) return;
    e.stopPropagation();
    e.preventDefault();
    const f = fracao(e.clientX, e.clientY);
    const perto = maisPerto(pontos, traco.fechado, false, traco.w, traco.h, f.x * traco.w, f.y * traco.h);
    if (perto.trecho < 0) return;
    if (!e.shiftKey) aoNos([]);
    aoComecar({ tipo: "entortar", id: traco.id, trecho: perto.trecho, t: perto.t, inicio: f, original: pontos, mexeu: false });
  };
  /* Dois cliques na linha: nó novo ali (o desenho não muda). O evento de ponteiro não conta os cliques; o dblclick conta. */
  const porNo = (e: React.MouseEvent) => {
    e.stopPropagation();
    const f = fracao(e.clientX, e.clientY);
    const perto = maisPerto(pontos, traco.fechado, false, traco.w, traco.h, f.x * traco.w, f.y * traco.h);
    if (perto.trecho < 0) return;
    const dividido = dividirTrecho(pontos, traco.fechado, false, perto.trecho, perto.t);
    aoTrocar(dividido.pontos, [dividido.novo]);
  };

  return (
    <div className="pointer-events-none absolute inset-0" data-editor-de-nos={traco.id}>
      <svg className="absolute left-0 top-0 overflow-visible" width={W} height={H} aria-hidden="true">
        <path d={caminho} fill="none" stroke="#8B3DFF" strokeWidth={1.25} strokeDasharray="4 3" />
        {/* A linha larga e invisível que pega o clique (entortar e pôr nó). */}
        <path d={caminho} fill="none" stroke="transparent" strokeWidth={16} style={{ pointerEvents: "stroke", cursor: "crosshair" }} onPointerDown={pegarLinha} onDoubleClick={porNo} data-linha-do-traco />
        {escolhidos.flatMap((i) => {
          const q = pontos[i];
          const centro = naTela(q);
          return (["e", "s"] as const).map((qual) => {
            const alca = q[qual];
            if (!alca) return null;
            const ponta = naTela(alca);
            return (
              <g key={`${i}${qual}`}>
                <line x1={centro.x} y1={centro.y} x2={ponta.x} y2={ponta.y} stroke="#8B3DFF" strokeWidth={1.25} />
                <circle
                  cx={ponta.x}
                  cy={ponta.y}
                  r={5.5}
                  fill="#FFFFFF"
                  stroke="#8B3DFF"
                  strokeWidth={2}
                  data-alca-do-no={`${i}${qual}`}
                  style={{ pointerEvents: "all", cursor: "move" }}
                  onPointerDown={(e) => {
                    if (e.button !== 0) return;
                    e.stopPropagation();
                    e.preventDefault();
                    aoComecar({ tipo: "alcaDoNo", id: traco.id, indice: i, qual, original: pontos, mexeu: false });
                  }}
                />
              </g>
            );
          });
        })}
        {pontos.map((q, i) => {
          const c = naTela(q);
          const escolhido = escolhidos.includes(i);
          return (
            <rect
              key={i}
              x={c.x - 5.5}
              y={c.y - 5.5}
              width={11}
              height={11}
              rx={q.e || q.s ? 5.5 : 1.5}
              fill={escolhido ? "#8B3DFF" : "#FFFFFF"}
              stroke="#8B3DFF"
              strokeWidth={2}
              data-no-do-traco={i}
              style={{ pointerEvents: "all", cursor: "move" }}
              onDoubleClick={(e) => {
                /* Dois cliques no nó: tira ele (se sobrar o bastante). */
                e.stopPropagation();
                const resto = apagarNos(pontos, [i], traco.fechado);
                if (resto) aoTrocar(resto, []);
              }}
              onPointerDown={(e) => {
                if (e.button !== 0) return;
                e.stopPropagation();
                e.preventDefault();
                const escolha = e.shiftKey ? (escolhido ? escolhidos.filter((j) => j !== i) : [...escolhidos, i]) : escolhido ? escolhidos : [i];
                aoNos(escolha);
                if (escolha.includes(i)) aoComecar({ tipo: "no", id: traco.id, indices: escolha, inicio: fracao(e.clientX, e.clientY), original: pontos, mexeu: false });
              }}
            />
          );
        })}
      </svg>
      <span className="pointer-events-none absolute left-1/2 top-[calc(100%+54px)] whitespace-nowrap rounded-full bg-[#1F1B2E]/85 px-3 py-1.5 text-[11px] font-semibold text-white" style={{ transform: `translateX(-50%) rotate(${-traco.rot}deg)` }}>
        Arraste nós, alças ou a linha · dois cliques põem ou tiram nó · Alt solta a alça
      </span>
    </div>
  );
}

/** Tira pontos que quase não mudam o desenho (Ramer-Douglas-Peucker), para o desenho livre ficar leve e liso. */
function simplificar(pontos: PontoDoTraco[], tolerancia: number): PontoDoTraco[] {
  if (pontos.length < 3) return pontos;
  const [a, b] = [pontos[0], pontos[pontos.length - 1]];
  const comprimento = Math.hypot(b.x - a.x, b.y - a.y) || 1;
  let maior = 0;
  let onde = 0;
  for (let i = 1; i < pontos.length - 1; i += 1) {
    const p = pontos[i];
    const d = Math.abs((b.y - a.y) * p.x - (b.x - a.x) * p.y + b.x * a.y - b.y * a.x) / comprimento;
    if (d > maior) {
      maior = d;
      onde = i;
    }
  }
  if (maior <= tolerancia) return [a, b];
  return [...simplificar(pontos.slice(0, onde + 1), tolerancia).slice(0, -1), ...simplificar(pontos.slice(onde), tolerancia)];
}

/** A camada da caneta e do desenho livre: pega os cliques por cima da arte e mostra o que já foi desenhado. */
function CamadaDeDesenho({
  ferramenta,
  escala,
  largura,
  altura,
  rascunho,
  cursor,
  aoPonto,
  aoMover,
  aoTerminar,
}: {
  ferramenta: "caneta" | "lapis";
  escala: number;
  largura: number;
  altura: number;
  rascunho: PontoDoTraco[];
  cursor: PontoDoTraco | null;
  aoPonto: (e: React.PointerEvent) => void;
  aoMover: (e: React.PointerEvent) => void;
  aoTerminar: () => void;
}) {
  const pts = rascunho.map((q) => `${q.x * escala},${q.y * escala}`).join(" ");
  const ultimo = rascunho.at(-1);
  const curva = ferramenta === "caneta" && rascunho.length >= 2 ? caminhoDasCurvas(rascunho, false, false, escala, escala) : "";
  return (
    <div
      data-desenho={ferramenta}
      className="absolute left-0 top-0 z-[35]"
      style={{ width: largura * escala, height: altura * escala, cursor: "crosshair", touchAction: "none" }}
      onPointerDown={(e) => {
        if (e.button !== 0) return;
        e.stopPropagation();
        e.preventDefault();
        aoPonto(e);
      }}
      onPointerMove={aoMover}
      onDoubleClick={(e) => {
        e.stopPropagation();
        if (ferramenta === "caneta") aoTerminar();
      }}
    >
      <svg className="pointer-events-none absolute left-0 top-0 overflow-visible" width={largura * escala} height={altura * escala} aria-hidden="true">
        {ferramenta === "lapis" && rascunho.length ? <polyline points={pts} fill="none" stroke="#8B3DFF" strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" /> : null}
        {curva ? <path d={curva} fill="none" stroke="#8B3DFF" strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" /> : null}
        {/* O próximo trecho até o mouse (curva se o último nó tem alça). */}
        {ferramenta === "caneta" && ultimo && cursor ? (
          <path
            d={ultimo.s ? `M${ultimo.x * escala} ${ultimo.y * escala} C${ultimo.s.x * escala} ${ultimo.s.y * escala} ${cursor.x * escala} ${cursor.y * escala} ${cursor.x * escala} ${cursor.y * escala}` : `M${ultimo.x * escala} ${ultimo.y * escala} L${cursor.x * escala} ${cursor.y * escala}`}
            fill="none"
            stroke="#8B3DFF"
            strokeWidth={1.5}
            strokeDasharray="5 4"
          />
        ) : null}
        {ferramenta === "caneta" && ultimo
          ? (["e", "s"] as const).map((qual) => {
              const alca = ultimo[qual];
              return alca ? (
                <g key={qual}>
                  <line x1={ultimo.x * escala} y1={ultimo.y * escala} x2={alca.x * escala} y2={alca.y * escala} stroke="#8B3DFF" strokeWidth={1.25} />
                  <circle cx={alca.x * escala} cy={alca.y * escala} r={4.5} fill="#8B3DFF" />
                </g>
              ) : null;
            })
          : null}
        {ferramenta === "caneta"
          ? rascunho.map((q, i) => <circle key={i} cx={q.x * escala} cy={q.y * escala} r={i === 0 && rascunho.length >= 3 ? 7 : 4.5} fill="#FFFFFF" stroke="#8B3DFF" strokeWidth={2} />)
          : null}
      </svg>
      <span className="pointer-events-none absolute left-1/2 top-3 -translate-x-1/2 whitespace-nowrap rounded-full bg-[#1F1B2E]/85 px-3 py-1.5 text-[11.5px] font-semibold text-white">
        {ferramenta === "caneta" ? (rascunho.length >= 3 ? "Clique no primeiro nó para fechar · dois cliques ou Enter terminam · Esc desiste" : "Clique: nó reto · clique e arraste: curva (Alt: canto) · Shift trava a direção") : "Arraste para desenhar · Esc sai do desenho livre"}
      </span>
    </div>
  );
}

/** O enquadre da foto: a área que arrasta a foto dentro da moldura, com o zoom e o "Pronto" em cima. */
function EnquadreDaFoto({ foto, escala, aoPegar, aoTerminar, aoZoom }: { foto: PecaDeImagem; escala: number; aoPegar: (e: React.PointerEvent) => void; aoTerminar: () => void; aoZoom: (zoom: number) => void }) {
  const zoom = foto.enquadre?.zoom || 1;
  return (
    <>
      <div className="pointer-events-auto absolute inset-0 cursor-grab active:cursor-grabbing" style={{ outline: "2px dashed #FFFFFF", outlineOffset: -2, boxShadow: "0 0 0 9999px rgba(20,11,43,0.35)", touchAction: "none" }} onPointerDown={aoPegar} title="Arraste para mover a foto dentro da moldura" />
      <div
        className="pointer-events-auto absolute left-1/2 flex items-center gap-2 whitespace-nowrap rounded-[12px] bg-white px-3 py-2 shadow-[0_8px_30px_rgba(20,11,43,0.22)] ring-1 ring-black/5"
        style={{ top: -58, transform: `translateX(-50%) rotate(${-foto.rot}deg)` }}
        onPointerDown={(e) => e.stopPropagation()}
      >
        <span className="text-[11.5px] font-semibold text-[#4B4760]">Aproximar</span>
        <input type="range" min={100} max={500} value={Math.round(zoom * 100)} onChange={(e) => aoZoom(Number(e.target.value) / 100)} className="w-28 accent-[#7C3AED]" aria-label="Aproximar a foto" />
        <button type="button" onClick={aoTerminar} className="rounded-[8px] bg-[#7C3AED] px-3 py-1 text-[12px] font-bold text-white hover:bg-[#6D28D9]">
          Pronto
        </button>
      </div>
      <span className="pointer-events-none absolute -bottom-9 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full bg-[#1F1B2E]/85 px-3 py-1 text-[11px] font-semibold text-white" style={{ fontSize: Math.max(10, Math.min(11.5, 11.5 * escala * 3)) }}>
        Arraste a foto · a roda do mouse aproxima
      </span>
    </>
  );
}

/**
 * A seta do degradê em cima da peça: linha do começo ao fim, uma alça em cada
 * ponta (pintada com a cor e o quanto cobre, sobre o xadrez de transparente)
 * e, na ponta clicada, o controle de quanto ela cobre.
 */
function AlcasDoDegrade({
  peca,
  modo,
  alcas,
  escala,
  ponta,
  aoPegar,
  aoCobrir,
  aoComecarGesto,
}: {
  peca: Peca;
  modo: ModoDasAlcas;
  alcas: NonNullable<ReturnType<typeof alcasDa>>;
  escala: number;
  ponta: 1 | 2 | null;
  aoPegar: (qual: 1 | 2 | 3, e: React.PointerEvent) => void;
  aoCobrir: (qual: 1 | 2, valor: number) => void;
  aoComecarGesto: () => void;
}) {
  const w = peca.w * escala;
  const h = peca.h * escala;
  const { pontos } = alcas;
  const [x1, y1, x2, y2] = [pontos.x1 * w, pontos.y1 * h, pontos.x2 * w, pontos.y2 * h];
  const angulo = Math.atan2(y2 - y1, x2 - x1);
  /* A ponta da seta fica um pouco antes da alça do fim. */
  const seta = (dist: number, lado: number) => `${x2 - Math.cos(angulo) * dist + Math.cos(angulo + Math.PI / 2) * lado},${y2 - Math.sin(angulo) * dist + Math.sin(angulo + Math.PI / 2) * lado}`;
  const xadrez = "repeating-conic-gradient(#D9D6E3 0% 25%, #FFFFFF 0% 50%) 0 0/8px 8px";
  const cobre = (qual: 1 | 2) => (qual === 1 ? alcas.alfa : alcas.alfa2);
  const cor = (qual: 1 | 2) => (qual === 1 ? alcas.cor : alcas.cor2);
  const rgba = (hex: string, a: number) => {
    const v = hex.replace("#", "");
    return `rgba(${parseInt(v.slice(0, 2), 16)},${parseInt(v.slice(2, 4), 16)},${parseInt(v.slice(4, 6), 16)},${a})`;
  };
  return (
    <>
      <svg className="pointer-events-none absolute left-0 top-0 overflow-visible" width={w} height={h} aria-hidden="true">
        {/* Radial: o círculo até a borda (o holofote), além da linha do centro à borda. */}
        {alcas.radial ? (
          <>
            <circle cx={x1} cy={y1} r={Math.hypot(x2 - x1, y2 - y1)} fill="none" stroke="#1F1B2E" strokeOpacity={0.35} strokeWidth={4} />
            <circle cx={x1} cy={y1} r={Math.hypot(x2 - x1, y2 - y1)} fill="none" stroke="#FFFFFF" strokeWidth={2} strokeDasharray="6 4" data-circulo-do-radial />
          </>
        ) : null}
        <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#1F1B2E" strokeOpacity={0.35} strokeWidth={4} strokeLinecap="round" />
        <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#FFFFFF" strokeWidth={2} strokeDasharray="6 4" strokeLinecap="round" />
        <polygon points={`${seta(16, 0)} ${seta(28, 6)} ${seta(28, -6)}`} fill="#FFFFFF" stroke="#1F1B2E" strokeOpacity={0.35} strokeWidth={1} />
      </svg>
      {/* O tracinho do meio: corre pela seta e muda onde fica o meio a meio. */}
      <span
        title="Meio: arraste pela seta para mudar onde as duas pontas ficam meio a meio"
        onPointerDown={(e) => aoPegar(3, e)}
        className="pointer-events-auto absolute flex h-7 w-7 cursor-grab items-center justify-center active:cursor-grabbing"
        style={{ left: x1 + (x2 - x1) * alcas.meio, top: y1 + (y2 - y1) * alcas.meio, transform: `translate(-50%,-50%) rotate(${angulo}rad)`, touchAction: "none" }}
      >
        <span className="block h-[18px] w-[6px] rounded-full bg-white shadow-[0_1px_4px_rgba(0,0,0,0.4)] ring-1 ring-black/20" />
      </span>
      {([1, 2] as const).map((qual) => {
        const [x, y] = qual === 1 ? [x1, y1] : [x2, y2];
        return (
          <span
            key={qual}
            title={`${qual === 1 ? "Começo" : "Fim"}: arraste para mudar a direção · clique para ajustar quanto cobre`}
            onPointerDown={(e) => aoPegar(qual, e)}
            className="pointer-events-auto absolute flex h-8 w-8 cursor-grab items-center justify-center active:cursor-grabbing"
            style={{ left: x, top: y, transform: "translate(-50%,-50%)", touchAction: "none" }}
          >
            <span className={`block h-[22px] w-[22px] overflow-hidden rounded-full bg-white p-[3px] shadow-[0_1px_4px_rgba(0,0,0,0.35)] ${ponta === qual ? "ring-2 ring-[#8B3DFF]" : "ring-1 ring-black/15"}`}>
              <span className="block h-full w-full rounded-full" style={{ background: `linear-gradient(${rgba(cor(qual), cobre(qual))}, ${rgba(cor(qual), cobre(qual))}), ${xadrez}` }} />
            </span>
          </span>
        );
      })}
      {ponta ? (
        <div
          className="pointer-events-auto absolute z-40 w-[196px] rounded-[12px] bg-white p-3 shadow-[0_8px_30px_rgba(20,11,43,0.22)] ring-1 ring-black/5"
          style={{ left: (ponta === 1 ? x1 : x2) + 22, top: (ponta === 1 ? y1 : y2) - 30, transform: `rotate(${-peca.rot}deg)`, transformOrigin: "0 30px" }}
          onPointerDown={(e) => e.stopPropagation()}
        >
          <div className="mb-1.5 flex items-center justify-between text-[11.5px] font-semibold text-[#2B2640]">
            {modo === "transparencia" ? (ponta === 1 ? "Aparece no começo" : "Aparece no fim") : ponta === 1 ? "Cor do começo cobre" : "Cor do fim cobre"}
            <span className="tabular-nums text-[#8A849E]">{Math.round(cobre(ponta) * 100)}%</span>
          </div>
          <input
            type="range"
            min={0}
            max={100}
            value={Math.round(cobre(ponta) * 100)}
            onPointerDown={aoComecarGesto}
            onChange={(e) => aoCobrir(ponta, Number(e.target.value) / 100)}
            className="w-full accent-[#7C3AED]"
            aria-label="Quanto cobre"
          />
        </div>
      ) : null}
    </>
  );
}

/** O texto (com quebras) de um campo editável: <br> e blocos viram "\n"; o text-transform não entra (o texto guardado é o digitado). */
function textoDoCampo(el: HTMLElement) {
  let texto = "";
  const andar = (no: Node, primeiro: boolean) => {
    if (no.nodeType === Node.TEXT_NODE) {
      texto += no.textContent || "";
      return;
    }
    if (!(no instanceof HTMLElement)) return;
    if (no.tagName === "BR") {
      texto += "\n";
      return;
    }
    const bloco = no.tagName === "DIV" || no.tagName === "P";
    if (bloco && !primeiro && !texto.endsWith("\n")) texto += "\n";
    no.childNodes.forEach((filho, i) => andar(filho, i === 0 && primeiro));
  };
  el.childNodes.forEach((filho, i) => andar(filho, i === 0));
  return texto.replace(/\n$/, "");
}

/**
 * O texto em escrita: a peça desenhada do jeito de sempre, com a letra
 * (data-texto) virando campo editável. O HTML só é refeito quando muda o
 * estilo da peça, nunca a cada letra nem a cada trecho formatado (senão o
 * cursor pularia para o começo).
 */
function TextoEmEscrita({ peca, aoEscrever, aoCrescer, aoSair }: { peca: PecaDeTexto; aoEscrever: (texto: string, html: string | null) => void; aoCrescer: (h: number) => void; aoSair: () => void }) {
  const caixa = useRef<HTMLDivElement | null>(null);
  const ultimo = useRef(peca);
  /* As funções de quem chama mudam a cada desenho; o campo usa sempre as de agora. */
  const retornos = useRef({ aoEscrever, aoCrescer, aoSair });
  useEffect(() => {
    ultimo.current = peca;
    retornos.current = { aoEscrever, aoCrescer, aoSair };
  });
  const { texto: _texto, html: _html, h: _h, x: _x, y: _y, w: _w, rot: _rot, ...estilo } = peca;
  void _texto;
  void _html;
  void _h;
  void _x;
  void _y;
  void _w;
  void _rot;
  const chaveDoEstilo = JSON.stringify(estilo);

  useLayoutEffect(() => {
    const el = caixa.current;
    if (!el) return;
    /* Escrevendo, o texto fica reto e sem marca-texto (a curva e a faixa voltam ao sair). */
    el.innerHTML = conteudoDaPeca({ ...ultimo.current, curva: 0, destaque: null });
    const campo = el.querySelector<HTMLElement>("[data-texto]");
    if (!campo) return;
    campo.contentEditable = "true";
    campo.spellcheck = false;
    campo.style.outline = "none";
    campo.style.cursor = "text";
    campo.style.minWidth = "0.5em";
    campo.focus();
    const selecao = window.getSelection();
    const faixa = document.createRange();
    faixa.selectNodeContents(campo);
    selecao?.removeAllRanges();
    selecao?.addRange(faixa);
    registrarCampo(campo);

    const crescer = () => {
      const recheio = campo.parentElement;
      if (!recheio) return;
      const falta = recheio.scrollHeight - recheio.clientHeight;
      if (falta > 1) retornos.current.aoCrescer(ultimo.current.h + falta);
    };
    const aoDigitar = () => {
      const html = htmlDoTextoLimpo(campo.innerHTML);
      retornos.current.aoEscrever(textoDoCampo(campo), temTrechos(html) ? html : null);
      window.requestAnimationFrame(crescer);
    };
    const aoTeclar = (e: KeyboardEvent) => {
      e.stopPropagation();
      if (e.key === "Escape") {
        e.preventDefault();
        retornos.current.aoSair();
        return;
      }
      /* Ctrl+B / Ctrl+I / Ctrl+U: o navegador já aplica no trecho; só avisa que mudou. */
      if ((e.ctrlKey || e.metaKey) && ["b", "i", "u"].includes(e.key.toLowerCase())) window.setTimeout(aoDigitar, 0);
    };
    const aoColar = (e: ClipboardEvent) => {
      e.preventDefault();
      const puro = e.clipboardData?.getData("text/plain") || "";
      document.execCommand("insertText", false, puro);
    };
    campo.addEventListener("input", aoDigitar);
    campo.addEventListener("keydown", aoTeclar);
    campo.addEventListener("paste", aoColar);
    return () => {
      campo.removeEventListener("input", aoDigitar);
      campo.removeEventListener("keydown", aoTeclar);
      campo.removeEventListener("paste", aoColar);
      registrarCampo(null);
    };
    /* Refaz só quando muda o estilo (fonte, cor, fundo...), não o texto. */
  }, [chaveDoEstilo]);

  return (
    <div
      ref={caixa}
      data-peca={peca.id}
      onPointerDown={(e) => e.stopPropagation()}
      style={{ position: "absolute", ...caixaDaPeca(peca), cursor: "text", outline: `2px solid ${ROXO}`, outlineOffset: 0 }}
    />
  );
}
