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.
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.
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.
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.
- Selecione uma frase. Aplique 24px. Ela fica com 24px.
- Selecione a mesma frase de novo. Aplique 12px.
- 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.
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 = '​'; // 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.
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.
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.
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.
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
-
range.surroundContents()lança exceção em qualquer seleção que cruze a fronteira de um elemento. Não construa em cima disso. -
Use
execCommand('fontSize', false, '7')como divisor, e depois substitua cadafont[size="7"]pelo seu próprio span estilizado. Mova os filhos, não os clone. -
Sempre defina
line-heightnesse span, ou um line-height em pixels herdado corta o texto grande. -
Remova
font-sizeeline-heightdos descendentes do novo span, ou reaplicar um tamanho parece não fazer nada. - Trate seleções colapsadas à parte com um span de espaço de largura zero — e remova esse caractere em todo exportador.
- Atualize o tamanho do próprio título quando o cursor estiver dentro de um, e limpe os tamanhos inline ao converter um bloco em título.
-
Sincronize o seletor a partir de
getComputedStyle, arredondado, nunca dequeryCommandValue('fontSize').
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.