Notepad Neo

Mudando o tamanho da fonte em contenteditable sem HierarchyRequestError

A forma óbvia de aplicar um tamanho de fonte a uma seleção é envolvê-la em um span. A forma óbvia de envolver uma seleção em um span lança exceção na maioria das seleções reais. O que a substituiu é mais feio, obsoleto e correto.

DH

— cria e mantém o Notepad Neo

· atualizado em · 11 min de leitura

Você tem uma seleção em um contenteditable e um número vindo de um seletor. Você quer que o texto selecionado fique com aquele tamanho. O DOM tem uma API que parece feita sob medida:

const span = document.createElement('span');
span.style.fontSize = '24px';
range.surroundContents(span);   // throws, most of the time

Ela lança exceção por causa de uma frase da especificação do DOM: o range só pode conter nós de texto e nós completamente selecionados. Selecione parcialmente qualquer elemento e a chamada é recusada.

Em uma textarea simples essa restrição quase nunca incomodaria. Em um editor de texto formatado ela é o caso normal. Arraste sobre uma palavra que por acaso está em negrito. Arraste do meio de um parágrafo para o seguinte. Arraste sobre um link. Em todos esses casos os pontos de início e fim do range ficam em elementos diferentes, e a chamada lança exceção.

Por que o surroundContents recusa uma seleção que cruza a fronteira de um elemento Uma seleção que começa dentro de um elemento em negrito e termina no texto simples depois dele. O ponto de início fica dentro do elemento b e o ponto de fim fica no elemento pai, então envolver o range em um novo elemento exigiria partir o negrito ao meio. O surroundContents recusa em vez de escolher onde dividir. THE SELECTION <b>he llo</b> wor ld start: inside <b> end: in the parent WHAT WRAPPING WOULD REQUIRE <b>he</b> ✕ split here <span><b>llo</b> wor</span> There is no single correct way to wrap this range in one element. The bold has to be broken in half, and the spec does not say where. So surroundContents declines to guess, and throws instead. That is a principled refusal — and completely unhelpful.
A recusa é o comportamento correto. Ela apenas deixa a divisão por sua conta, que é justamente a parte em que todo framework de editor gasta seu orçamento de complexidade.

Fazer isso direito significa percorrer o range, dividir nós de texto nas duas fronteiras, clonar a cadeia de ancestrais de cada elemento parcialmente coberto, remontar o resultado e depois normalizar o que você produziu para que a operação seguinte não agrave a bagunça. Isso não é projeto de fim de semana; é o núcleo de um modelo de documento.

Pegando emprestado o divisor do próprio navegador

Existe um atalho, e ele é uma API obsoleta.

O document.execCommand('fontSize') faz exatamente esse trabalho de divisão de nós dentro de todo motor de navegador há duas décadas. A MDN o lista como obsoleto e desaconselha o uso, o que é justo — mas a lógica de divisão por baixo dele é o mesmo código que o navegador roda quando você aperta Ctrl+B, e ela é testada em batalha de um jeito que nada escrito em uma tarde vai ser.

O problema é o que ele emite. O execCommand('fontSize') fala o vocabulário <font size> do HTML 3.2 — sete faixas fixas, de 1 a 7, mapeadas para tamanhos definidos pelo navegador. Ele não consegue expressar "24px" e nunca vai conseguir.

Então ele é usado puramente como divisor. Peça o tamanho 7, o valor com menos chance de já existir no documento, depois encontre cada <font size="7"> que ele acabou de criar e troque cada um por um span com o tamanho que você realmente queria.

Usando o execCommand como divisor de nós e substituindo o que ele emite Quatro estágios. Uma seleção que cruza a fronteira de um negrito é passada ao execCommand com fontSize 7. O navegador divide os nós corretamente e os envolve em elementos font com size 7. Esses elementos são consultados por seletor e cada um é substituído por um span com o tamanho real em pixels e um line-height. Por fim, declarações conflitantes de font-size e line-height são removidas dos descendentes do novo span. 1 · SELECTION <b>he[llo</b> wor]ld crosses a boundary 2 · BROWSER SPLITS execCommand( 'fontSize', false, '7') deprecated, and correct 3 · SENTINEL MARKUP <b>he<font size="7">llo</font></b> <font size="7"> wor</font>ld bold survived the split 4 · REPLACE EACH SENTINEL querySelectorAll('font[size="7"]') → <span style="font-size:24px; line-height:1.4"> Size 7 is a sentinel, not a size. Nothing else in the document uses it, so it is safe to query back and overwrite wholesale.
O navegador faz a parte difícil — dividir nós de texto, clonar ancestrais, preservar negrito, itálico e estrutura de link através da fronteira. Nós ganhamos um marcador que dá para encontrar por seletor.
document.execCommand('fontSize', false, '7');

editorEl.querySelectorAll('font[size="7"]').forEach(el => {
  const span = document.createElement('span');
  span.style.fontSize = size;
  span.style.lineHeight = '1.4';
  while (el.firstChild) span.appendChild(el.firstChild);
  el.parentNode?.replaceChild(span, el);
  // …strip conflicting sizes from descendants (see below)
});

Um detalhe aí é fácil de escrever errado. Os filhos são movidos, não clonados — while (el.firstChild) span.appendChild(el.firstChild) realoca os nós vivos. Clonar produziria uma marcação de aparência idêntica, mas a seleção da pessoa está ancorada nos nós de texto originais, e clonar deixa essas âncoras órfãs. A seleção colapsa, e o cursor vai parar em um lugar onde ninguém o colocou.

Isso funciona. E também cria cinco problemas.

Problema 1 — as linhas se sobrepõem se você não definir também o line-height

O papel do editor tem font-size: 14px e line-height: 1.6. Como esse line-height é sem unidade, ele resolve para 22.4px no papel, e todo descendente herda esse valor em pixels já calculado — não a razão.

Então um span com font-size: 32px e sem line-height próprio recebe glifos de 32 pixels espremidos em uma caixa de linha de 22,4 pixels. Ascendentes e descendentes colidem com as linhas de cima e de baixo.

Texto grande dentro de um line-height em pixels herdado À esquerda, texto de 32 pixels dentro de uma caixa de linha de 22,4 pixels herdada da base de 14 pixels do papel vezes 1,6. Os glifos transbordam sua caixa e colidem com as linhas acima e abaixo. À direita, o span carrega line-height 1,4, que é recalculado contra seu próprio tamanho de fonte de 32 pixels e resulta em uma caixa de 44,8 pixels, e as linhas deixam de se tocar. NO LINE-HEIGHT ON THE SPAN line-height: 1.4 ordinary body text at 14px Heading 22.4px box the line below it ✕ glyphs cross the box edges in both directions — the collision is real, not a rounding artefact ordinary body text at 14px Heading 44.8px box the line below it ✓ the box grows with the text 14px × 1.6 = 22.4px, inherited as a length. 32px × 1.4 = 44.8px, recomputed per element.
Um line-height sem unidade no pai é o que torna possível a sobrescrita no filho. Se a regra base fosse line-height: 22.4px, o span teria herdado o comprimento e não haveria nada contra o que recalcular.

Todo span de tamanho recebe isso, sem exceção:

span.style.fontSize = size;
span.style.lineHeight = '1.4';   // never omit — inherited px line-height clips large text

Problema 2 — reaplicar um tamanho não faz nada, em silêncio

Esse foi o que mais demorou a caracterizar, porque a reprodução tem três passos e cada passo parece correto isoladamente.

  1. Selecione uma frase. Aplique 24px. Ela fica com 24px.
  2. Selecione a mesma frase de novo. Aplique 12px.
  3. Nada acontece. O texto continua em 24px.

A causa é aninhamento. Depois do passo 1 o DOM tem um span de 24px. No passo 2, o execCommand envolve esse span existente em um novo <font size="7">, que é substituído por um span de 12px. As duas declarações são estilos inline, então a especificidade é idêntica e a mais interna vence. O span de 12px é real, corretamente aplicado e completamente invisível.

Por que aplicar um segundo tamanho parece não fazer nada Aplicar 24 pixels e depois 12 pixels produz um span de 12 pixels envolvendo um span de 24 pixels. Os dois são estilos inline com especificidade igual, então a declaração interna vence e o texto continua renderizando em 24 pixels. Remover font-size e line-height dos descendentes do novo span elimina a declaração interna e deixa a externa valer. AFTER APPLY 24, THEN APPLY 12 <span style="font-size: 12px"> <span style="font-size: 24px"> text </span> Equal specificity — both are inline styles. The innermost declaration wins, so the text still renders at 24px. AFTER STRIPPING DESCENDANTS <span style="font-size: 12px"> text </span> Only font-size and line-height are removed. Colour, background and font-family on the same descendants survive untouched.
O atributo style vazio também é descartado, que é o que impede o documento de acumular entulho de <span style=""> toda vez que alguém muda de ideia sobre um tamanho.
span.querySelectorAll<HTMLElement>('[style]').forEach(child => {
  child.style.removeProperty('font-size');
  child.style.removeProperty('line-height');
  if (!child.style.cssText.trim()) child.removeAttribute('style');
});

Problema 3 — uma seleção colapsada não tem o que envolver

Se a pessoa escolhe um tamanho sem nenhum texto selecionado, ela quer dizer "digite neste tamanho a partir daqui". Não há conteúdo de range para o execCommand dividir, então ele não faz absolutamente nada — sem erro, sem marcação, sem retorno.

Esse ramo é tratado à parte inserindo um span vazio contendo um espaço de largura zero e deixando o cursor logo depois dele, para que a próxima tecla caia dentro de um span com o tamanho correto:

const span = document.createElement('span');
span.style.fontSize = size;
span.style.lineHeight = '1.4';
span.innerHTML = '&#8203;';        // U+200B — keeps the span alive
range.insertNode(span);
range.setStartAfter(span);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);

O espaço de largura zero é estrutural. Um elemento inline vazio não gera caixa de layout, então o cursor não pode ser colocado nele de forma significativa e o navegador ou descarta o elemento ou passa direto por ele. Um caractere que não ocupa largura visual mas que existe mantém o span no documento tempo suficiente para receber a digitação.

Um caractere que você precisa limpar em todo lugar depois

O U+200B vaza. Ele é invisível no editor e invisível no HTML salvo, e aí aparece em todo consumidor daquele HTML. Os dois exportadores o removem explicitamente — o gerador de DOCX porque o caractere iria parar em word/document.xml, e o de PDF porque ele não é representável em WinAnsiEncoding e faria a verificação prévia de "esta nota precisa da impressão" disparar em praticamente toda nota já escrita.

Se você introduzir um caractere sentinela em um modelo de documento, reserve orçamento para encontrar cada lugar que lê o documento.

Problema 4 — títulos reagem, nos dois sentidos

Títulos recebem seu tamanho de uma regra de folha de estilo no próprio elemento — h1 { font-size: 2em } — enquanto os nossos tamanhos chegam em um span dentro dele. Os dois não interagem do jeito que se imagina.

Reduza o texto dentro de um <h1> para 12px e o span obedientemente renderiza em 12px, mas o h1 ainda estabelece uma caixa de linha dimensionada para texto de 28px. O texto pequeno flutua em uma faixa alta, com espaçamento estranho. As métricas do próprio bloco definem o piso da caixa de linha — um "strut" — e nenhum elemento inline dentro dele consegue encolher isso.

Então, depois de envolver, o código sobe até o título mais próximo e atualiza o próprio título:

let n: Node | null = freshSel.getRangeAt(0).startContainer;
while (n && n !== editorEl) {
  if (n instanceof HTMLElement && /^H[1-6]$/.test(n.tagName)) {
    n.style.fontSize = size;
    n.style.lineHeight = '1.4';
    break;
  }
  n = n.parentNode;
}

A imagem espelhada desse bug vive no seletor de tipo de bloco. Converter um parágrafo em título precisa limpar qualquer font-size e line-height inline que tenham sobrado no bloco, senão o estilo inline remanescente vence a regra de título da folha de estilo e o novo <h1> renderiza no tamanho do corpo. Essa limpeza roda em um setTimeout(…, 0), porque o execCommand('formatBlock') ainda não terminou de substituir o elemento quando a chamada retorna.

Duas correções puxando em sentidos opostos, pelo mesmo motivo de fundo: estilos inline e regras no nível do elemento não competem em condições iguais, e quem vence depende de onde a declaração caiu, não do que a pessoa quis dizer.

Problema 5 — o seletor lê o valor errado

O seletor da barra de ferramentas deveria acompanhar o cursor: clique dentro de um texto de 18px e ele deveria dizer 18. Existe uma API que parece responder a isso, e não responde.

Duas formas de ler o tamanho da fonte no cursor O queryCommandValue com fontSize informa na escala legada de um a sete e não consegue expressar um tamanho em pixels. Ler getComputedStyle no elemento sob o cursor devolve um valor resolvido em pixels, que é arredondado e comparado com as opções do seletor. DO NOT USE document.queryCommandValue('fontSize') → "1" … "7" The legacy scale execCommand consumes. And since we overwrite every font element with a USE THIS getComputedStyle(el).fontSize → "17.6px" Inheritance, stylesheet rules and inline styles are already resolved. Round, then match. styled span, it usually has nothing to report. Rounding matters: browser zoom and fractional em values routinely produce 17.6px, not 18.
O arredondamento não é zelo defensivo. Comparado estritamente, 17.6 não corresponde a nenhuma opção da lista e o seletor silenciosamente para de acompanhar o cursor.
const node = sel.getRangeAt(0).startContainer;
const el = (node.nodeType === Node.TEXT_NODE ? node.parentElement : node) as HTMLElement | null;
if (el) {
  const pxSize = parseFloat(getComputedStyle(el).fontSize);
  if (!isNaN(pxSize)) {
    const rounded = Math.round(pxSize);
    const opts = Array.from(this.fontSizeEl.options);
    const match = opts.findIndex(o => Number(o.value) === rounded);
    if (match >= 0) this.fontSizeEl.selectedIndex = match;
  }
}

A guarda if (match >= 0) também faz trabalho de verdade. O seletor oferece dezesseis tamanhos discretos, e um valor calculado que não é um deles — 28px dentro de um h1 que pegou seu tamanho de 2em, por exemplo — deixa o seletor mostrando o valor anterior em vez de voltar a um padrão. Mostrar um número desatualizado é ruim; mostrar um número errado que a pessoa pode então aplicar é pior.

Aplicar um formato e lê-lo de volta são dois problemas diferentes

Tudo acima é sobre o caminho de escrita. O caminho de leitura — manter a barra de ferramentas mostrando o que de fato é verdade no cursor — acabou dando mais ou menos o mesmo trabalho, por uma razão estrutural e não acidental.

Em um editor com modelo de documento, a barra de ferramentas é uma função do modelo. Aqui não há modelo, então a barra é uma função do que quer que o DOM esteja dizendo, re-derivada a cada selectionchange. Isso significa que todo formato tem duas implementações que precisam concordar: uma que o escreve e uma que o reconhece.

A ida e volta entre a barra de ferramentas e o documento Uma mudança no seletor dispara um evento DOM personalizado carregando o tamanho pedido. O controlador altera o documento. O navegador dispara selectionchange, e o controlador lê o estado de volta do DOM para atualizar a barra. O caminho de escrita e o de leitura usam APIs completamente diferentes. Toolbar UI size dropdown event ToolbarController applyFontSize() The DOM the document selectionchange → updateFromSelection() nn:font-cmd Write path: execCommand + span surgery. Read path: getComputedStyle + queryCommandState. No shared code.
A barra de ferramentas e o controlador nunca guardam referências um do outro. Eles se comunicam por eventos DOM personalizados, porque vivem em componentes Astro separados e um import de módulo compartilhado não sobreviveria à fronteira entre componentes.

O caminho de leitura coleta tudo em uma passagem. Negrito, itálico, sublinhado, tachado e os quatro alinhamentos vêm do queryCommandState, que é confiável para eles porque são booleanos e o navegador é dono da resposta. A direção vem de getComputedStyle(el).direction. O tamanho vem do estilo calculado, como acima. E a família da fonte vem de queryCommandValue('fontName') — que devolve algo como "Arial, sans-serif", uma pilha CSS inteira de fontes, não um nome.

Então o seletor de família também não pode comparar por igualdade. Ele converte para minúsculas e faz correspondência por substring contra cada opção, o que é exatamente tão frágil quanto parece e é a resposta pragmática a uma API que devolve um formato de string diferente conforme o jeito como a fonte foi aplicada.

Sobrou um pedaço de peso morto honesto de tudo isso. O getSelectionState() ainda preenche um campo fontSize a partir de queryCommandValue('fontSize'), e a barra de ferramentas o ignora por completo em favor do estilo calculado. Ele sobrevive porque todos os outros campos daquele objeto são usados, e remover um membro de uma struct de estado é o tipo de arrumação fácil de errar depois. Vale saber que ele está ali para que ninguém o reconecte.

Um framework teria sido melhor?

Honestamente, para este recurso específico — provavelmente.

ProseMirror, Lexical e TipTap mantêm um modelo de documento separado do DOM e, em um modelo assim, "aplicar uma marca a um intervalo" é uma operação bem definida, sem nenhum dos modos de falha acima. Todo problema deste artigo é consequência de tratar o próprio DOM como fonte da verdade: o bug de aninhamento, a colisão do strut, o espaço de largura zero, a sincronização do seletor. Nenhum deles existe se o documento é uma árvore que você controla e o DOM é só uma renderização dela.

A troca é tamanho de bundle e controle. O Notepad Neo não embarca nenhum framework de runtime — os módulos centrais do editor somam por volta de 2.700 linhas de TypeScript puro, com mais umas 2.100 para os geradores de DOCX e PDF. Os dois geradores leem a formatação direto do DOM vivo via getComputedStyle, o que só é possível porque o DOM é o documento. Adotar um framework de editor para resolver o tamanho de fonte teria significado reconstruir os dois exportadores, o tratamento de direção e a persistência de abas em torno do modelo de outra pessoa.

O que não recomendaríamos a ninguém é o caminho do meio: escrever à mão a lógica de divisão de range que o execCommand já contém. Ou você se apoia na API obsoleta que tem o divisor, ou você migra para um modelo de documento de verdade. Escrever o seu próprio surroundContents que lide com seleções parciais é um projeto muito maior do que parece, e é a parte do editor em que os bugs são mais difíceis de ver e mais fáceis de publicar.

Sobre depender de uma API obsoleta

O execCommand está obsoleto, não removido, e não há substituto para as partes dele que importam aqui — divisão de nós que respeita a estrutura inline existente, e mutações que participam da pilha nativa de desfazer do navegador. Nada no pipeline de padrões oferece hoje nenhuma das duas. Remover isso quebraria de uma vez uma fração enorme dos editores de texto formatado da web, e é por isso que todo motor ainda o embarca.

Isso não é argumento de que ele é seguro para sempre. É argumento de que o custo da migração já está precificado: no dia em que ele sumir, você vai migrar para um modelo de documento de qualquer forma, e isso sempre seria uma reescrita, não um remendo.

Resumo

Relacionado: por que dir="auto" quebra um editor multilíngue cobre o outro caso em que um único atributo parece resolver um problema e o resolve na granularidade errada.

← Todos os artigos de engenharia Experimentar o editor