"use client";

import { EfeitoDeAnalise } from "./efeito-de-analise";
import { Borracha } from "./borracha";
import { useEffect, useLayoutEffect, 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, htmlDoFundo } from "./desenho-guardado";
import { cantosDoGrupo, distorcoesDoGrupo, gradeDaPerspectiva, type CaixaReta, type Ponto, type Quadrilatero } from "@/lib/banner-editor/perspectiva-do-grupo";
import { camposDoTexto, faltaNoTexto, naoCabe, textoDeEnfeite } from "./texto-que-cabe";

/*
  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).

  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.

  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 };

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 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;
}

export function Palco({
  doc,
  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,
}: {
  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;
}) {
  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);
  /* 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");
    pintarMiolo();
  };
  /* 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", "");
    }
    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(doc);
  const escalaRef = useRef(escala);
  const selecionadasRef = useRef(selecionadas);
  const ferramentaRef = useRef(ferramenta);
  const enquadrandoRef = useRef(enquadrando);
  useEffect(() => {
    docRef.current = doc;
    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))
          .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;

      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)}`);
        aoMexerVarias(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)}`);
        aoMexerVarias(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)}°`);
        aoMexerVarias(
          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}°`);
        aoMexer(g.id, { 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)}`);
      aoMexer(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;
      /* 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;
      }
      /* 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;
      }
      /* 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;
        }
        if (g.ids.length === 1 && peca?.tipo === "texto" && !peca.bloqueada) 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 = doc.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);
    };
  }, [doc.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;
  const debaixo = sobre && !selecionadas.includes(sobre) ? doc.pecas.find((p) => p.id === sobre && !p.oculta) : null;

  const comecarMover = (e: React.PointerEvent, peca: Peca) => {
    if (e.button !== 0 || espacoRef.current) return;
    e.stopPropagation();
    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 };
  };

  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;
  const degrade = escolhida && modoAtual ? alcasDa(escolhida, modoAtual) : null;

  return (
    <div
      ref={area}
      className="relative h-full w-full overflow-hidden"
      style={compacto ? { touchAction: "none" } : undefined}
      /* 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 (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. */}
      <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)" }} />
          <div ref={palco} data-palco={escala} className="absolute left-0 top-0 overflow-hidden bg-white" 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.map((peca) =>
              peca.oculta ? null : editando === peca.id && peca.tipo === "texto" ? (
                <TextoEmEscrita key={peca.id} peca={peca} aoEscrever={(texto, html) => aoEscrever(peca.id, texto, html)} aoCrescer={(h) => aoMexer(peca.id, { h })} aoSair={() => aoEditarTexto(null)} />
              ) : (
                <div
                  key={peca.id}
                  data-peca={peca.id}
                  onPointerDown={(e) => comecarMover(e, peca)}
                  onPointerEnter={() => setSobre(peca.id)}
                  onPointerLeave={() => setSobre((s) => (s === peca.id ? null : s))}
                  onDoubleClick={(e) => {
                    e.stopPropagation();
                    if (peca.tipo === "texto" && !peca.bloqueada) {
                      aoSelecionar(peca.id, false, true);
                      aoEditarTexto(peca.id);
                    } else if (peca.tipo === "imagem" && peca.ajuste === "cobrir" && !peca.bloqueada) {
                      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);
                  }}
                  onContextMenu={(e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    if (!selecionadasRef.current.includes(peca.id)) aoSelecionar(peca.id);
                    aoMenu(e.clientX, e.clientY);
                  }}
                  className="select-none"
                  style={{ position: "absolute", ...(exibicaoGuardada(peca, doc.pecas, contagem)?.estilo ?? { ...caixaDaPeca(peca), opacity: "0", pointerEvents: selecionadas.includes(peca.id) ? "auto" : "none" }), cursor: pincel ? "copy" : peca.bloqueada ? "default" : peca.tipo === "texto" && escolhida?.id === peca.id ? "text" : "move", touchAction: "none" }}
                  /* A foto que é recheio de texto não aparece sozinha: fica uma caixa invisível no lugar dela (escolhida pelas camadas, dá para arrastar e mudar o recheio). */
                  dangerouslySetInnerHTML={exibicaoGuardada(peca, doc.pecas, contagem)?.html ?? { __html: "" }}
                />
              ),
            )}
            {/* 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>

          {/* 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 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)}>
              <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 e enquanto escreve). */}
              {!medida && editando !== escolhida.id ? (
                <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: -52, 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: `${((hx + 1) / 2) * 100}%`, top: `${((hy + 1) / 2) * 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}

              {escolhida.bloqueada || editando === escolhida.id ? 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={{ 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 }}
    />
  );
}
