Discourse Lightbox - Migrando de Magnific popup

O Discourse permite que os usuários façam upload de imagens em posts (e em outros lugares). Às vezes, essas imagens são grandes demais para serem exibidas diretamente, então o Discourse cria uma versão dimensionada da imagem e a adiciona ao post. Quando você clica nessa imagem dimensionada, aparece um overlay elegante contendo a imagem em tamanho original — isso é comumente chamado de “lightboxing”.

Atualmente, o Discourse utiliza uma biblioteca chamada Magnific Popup para gerenciar o comportamento de lightbox. Este tópico trata de uma reformulação dos lightboxes no Discourse, migrando do Magnific Popup para uma solução mais alinhada com as expectativas atuais de usuários e desenvolvedores.

O porquê

O Magnific é uma biblioteca excelente, e o número de estrelas na página do projeto indica significativamente quantos problemas ela resolveu para usuários e desenvolvedores ao longo dos anos.

Ela também estava à frente de seu tempo em termos de facilidade de uso e recursos. Isso fica evidente ao considerar que quase toda a funcionalidade que você vê hoje já estava presente na versão 1.0, lançada em 2014.

Então, onde isso nos deixa? Vou ser breve. O Magnific Popup foi criado para um mundo completamente diferente, onde a compatibilidade entre navegadores estava muito atrás do que é hoje. Isso significa quatro coisas:

  1. Ele depende do jQuery.
  2. Contém código destinado a navegadores antigos.
  3. O dispositivo médio usado para acessar a web mudou muito desde então.
  4. Foi projetado com algo diferente de aplicações de página única (como o Discourse) como prioridade — especificamente, páginas estáticas. Isso gera preocupações de desempenho específicas para aplicações de página única, que abordaremos mais adiante.

Dado o estado atual dos padrões da web e como é possível fazer todo tipo de mágica com JavaScript puro (vanilla), é compreensível que grandes projetos como o Discourse estejam se afastando da dependência do jQuery. Note que discussões sobre o jQuery estão fora do escopo aqui; isso serve apenas como contexto.

Então, esse é o porquê… menos jQuery, menos código para navegadores antigos, melhor desempenho geral e melhor suporte para o dispositivo médio — agora muito diferente.

O como

Não há escassez de soluções disponíveis, e há uma discussão a ser feita sobre não reinventar a roda, mas… vamos evitar esse caminho e manter as coisas breves.

Talvez a maneira mais curta de abordar esse ponto difícil seja dizer… não importa o quão bem algo pronto se encaixe… nada se encaixará tão bem quanto algo feito sob medida. Vamos ficar por aqui.

Há várias coisas a considerar aqui, seja excesso de funcionalidade em bibliotecas que cobrem numerosos casos de uso, como imagens, iframes, vídeos e afins, ou falta de clareza em torno das licenças.

Conhecer os casos de uso específicos que o Discourse precisa suportar torna possível evitar código desnecessário, permitindo que recursos adicionais e direcionados sejam adicionados sem gerar muita sobrecarga.

Portanto, agora temos uma boa base. Os requisitos são:

  1. Sem jQuery.
  2. Focar apenas em imagens por enquanto.
  3. Suporte a melhorias de qualidade de vida, como deslizar (swipe) em dispositivos móveis.
  4. Integração com o Discourse para permitir futuras personalizações e melhorias usando os sistemas atuais de temas e plugins.

O quê

O Discourse é um projeto baseado em Ember, então o novo lightbox precisaria ser um componente Ember para lidar com o markup e os dados. Além disso, como há a expectativa de que seja possível configurar lightboxes em qualquer lugar do aplicativo, ter um serviço de lightbox faz muito sentido. Isso permitirá que os desenvolvedores:

  1. Injetem o serviço em qualquer componente e possam vincular os métodos de configuração/limpeza do serviço ao ciclo de vida do componente.
  2. Procurem o serviço em qualquer lugar do aplicativo e possam chamar seus métodos de configuração/limpeza.

Além disso, como uma melhoria de qualidade de vida, podemos adicionar um pouco de abstração e criar algumas funções utilitárias que podem ser usadas em temas. Mais sobre isso depois.

Como um dos objetivos é permitir que temas e plugins estendam a funcionalidade, o serviço de lightbox comunicará mudanças de estado por meio de appEvents. Ele emitirá os seguintes eventos:

  • lightbox:opened
  • lightbox:item-will-change
  • lightbox:item-did-change
  • lightbox:closed

Cada evento será acionado com o tipo de dados que os desenvolvedores normalmente esperariam. Por exemplo, o evento lightbox:opened conterá uma lista de todos os itens e o item atual em que o lightbox foi aberto. Mais sobre isso depois.

Com isso resolvido, vamos continuar.

Nas últimas semanas, tenho trabalhado em um PR de introdução do Discourse Lightbox.

À primeira vista, pode parecer um pouco grande, então vamos decompor.

Testar algo que depende de muita interação do usuário é complicado, por isso o conjunto de testes do lightbox inclui 63 testes com 291 asserções.

Com o tamanho dos testes fora do caminho, vamos fazer uma rápida comparação de tamanho com o Magnific Popup (ambos não minificados):

Discourse Lightbox LOC Magnific Popup LOC Diferença
JavaScript 1197 1860 (35%)
CSS 813 351 131%
Templates 401 0 -
Total 2411 2211 9.1%

Portanto, o Discourse Lightbox tem cerca de 9% a mais de código que o Magnific. Claro, isso é apenas parte da história por dois motivos:

  1. Não consideramos o jQuery, do qual o Magnific depende. O jQuery 3.6 tem aproximadamente 11k LOC.
  2. O Discourse Lightbox adiciona mais recursos em comparação ao Magnific.

Vamos entrar nesses recursos.

Qualquer travamento que você veja nos vídeos abaixo está relacionado à gravação, não à experiência que os usuários terão. Todas as animações/transições serão executadas a 60 FPS sólidos.

Então, sem mais delongas,

Layout básico

Para comparação, aqui está o mesmo na implementação atual do Magnific:

Alguns pontos sobre os vídeos acima:

  1. O novo lightbox usará a imagem como fundo (backdrop) em vez do fundo escuro semitransparente genérico. A imagem, por definição, se complementará. Note que isso não adiciona nenhuma sobrecarga de rede. A imagem usada no fundo é a do corpo do post, o que significa que ela já estará em cache quando os usuários abrirem o lightbox.

  2. O Discourse Lightbox opta por uma interface fixa. Em vez de ter o botão de fechar, o título e os metadados da imagem anexados à imagem, eles têm seus próprios espaços reservados. Isso ajudará a reduzir oscilações ao navegar entre imagens de dimensões diferentes.

  3. Navegar entre imagens pode ser feito com as setas no lightbox ou por meio de atalhos de teclado. ou para o próximo e ou para o anterior. Em locais com direção da direita para a esquerda (RTL), os atalhos são invertidos conforme necessário.

O layout em dispositivos móveis é muito semelhante, exceto que as setas não são exibidas, pois há gestos de deslizar (swipe) para navegação.

Deslize para a esquerda no dispositivo móvel para o próximo e para a direita para o anterior. Em locais RTL, os gestos de deslizar são invertidos.

Zoom

O novo lightbox possui um botão de zoom dedicado, visível quando a imagem que você está visualizando é maior que as dimensões da viewport. Clicar no botão de zoom aumentará a imagem, e clicar novamente diminuirá. Além disso, agora há um atalho de teclado para zoom in/out: Z. Por fim, se a imagem for ampliável, clicar nela terá o mesmo efeito.

Aqui está um vídeo demonstrando os três métodos:

Note que, embora não seja demonstrado no vídeo acima, o cursor, ao passar o mouse sobre uma imagem que pode ser ampliada, mudará para refletir isso. Ele também mudará para um ícone de zoom-out ao passar o mouse sobre uma imagem que já está ampliada.

O zoom funciona de maneira diferente no novo lightbox. No desktop, a seção ampliada da imagem seguirá o cursor.

Não há hover em dispositivos móveis; usa o toque normal (touch-scroll) para se mover dentro de uma imagem ampliada.

Rotação

O novo lightbox adiciona um botão de rotação dedicado para girar a imagem em incrementos de 90 graus. A rotação também possui um atalho de teclado: a tecla R. Veja como isso fica:

Rotação e zoom podem ser combinados.

Modo de tela cheia

O novo lightbox adiciona um botão de tela cheia. O botão fará com que a janela do navegador entre no modo de tela cheia. O atalho de teclado é M.

Ele manterá o rastreamento de seu estado e retornará ao modo normal quando a tela cheia for desativada ou quando o lightbox for fechado.

Download

O lightbox atual adiciona um link de “download” abaixo da imagem. O novo lightbox faz o mesmo, mas altera para um ícone e o adiciona ao rodapé do lightbox. Ele ainda respeita as mesmas permissões. Se…

prevent_anons_from_downloading_images

estiver ativado e o usuário não estiver logado, o ícone de download não será exibido.

Nova Aba

O lightbox atual adiciona um link de “original” abaixo da imagem que a abre em uma nova aba. O novo lightbox faz o mesmo, mas adiciona como um ícone no cabeçalho do lightbox. Ele também respeitará a mesma permissão definida para o ícone de download.

Título da Imagem

O novo lightbox foca principalmente na imagem. Os títulos das imagens são truncados para uma linha por padrão, mas suportam expansão. Aqui está um exemplo de como isso fica:

Há um atalho de teclado para expandir/recolher o título: T, e o título não será exibido quando a imagem estiver ampliada ou rotacionada.

Carrossel

Um recurso recém-disponível é mostrar todas as imagens da galeria em um carrossel. O layout dependerá da tela do dispositivo; pode ser horizontal ou vertical, e aqui está como fica:

Há um atalho de teclado para alternar o carrossel: A.

E aqui está como fica em dispositivos móveis:

Deslizar para baixo em dispositivos móveis alternará o carrossel ligado/desligado.

Fechamento

O Esc ainda funciona como antes no desktop para fechar o lightbox. Agora há um gesto de deslizar adicional em dispositivos móveis: você pode deslizar para cima para fechar o lightbox.

Acessibilidade

Além do básico, como rótulos de botões, o novo lightbox adiciona um elemento anunciador para leitores de tela fora da tela. Ao navegar para uma imagem dentro do lightbox, ele lerá seu índice e título com base no seguinte formato:

imagem %{current} de %{total}: %{title}

Aqui está um breve exemplo disso:

O novo lightbox também remove todos os botões desnecessários que não servem a nenhum propósito para leitores de tela, usando aria-hidden.

Lembre-se de que a acessibilidade é uma missão contínua e isso está longe de estar completo. Sempre há algo a melhorar, mas parei aqui para manter as coisas simples para a versão 1.

Isso cobre todos os recursos do novo lightbox.

Vamos passar para um pouco de linguagem de desenvolvedor.

Listeners de Eventos

O lightbox atual adiciona listeners de evento de clique a cada imagem individual de lightbox em posts processados. Isso significa que um post com 20 imagens terá 20 listeners de evento de clique para lightboxes.

O novo lightbox aproveita a delegação de eventos e adiciona apenas um listener de evento ao próprio post.

Aqui está a contagem de listeners de evento do lightbox atual em uma janela anônima/incógnita em um post com 20 imagens após forçar a coleta de lixo:

E aqui está o mesmo post com o novo lightbox:

Além disso, navegar dentro do lightbox atualmente adiciona listeners de evento, que acabam ficando órfãos e, portanto, interferem na coleta de lixo. Aqui está um gráfico de:

  1. Carregar uma página de tópico com um post contendo 20 imagens.
  2. Abrir o lightbox e navegar por todas as 20 imagens três vezes consecutivas.
  3. Fechar o lightbox.
  4. Forçar a coleta de lixo do navegador.

Lightbox atual:

Novo lightbox:

Quanto aos listeners de evento nos próprios lightboxes, eles parecem não ser limpos atualmente no Magnific (lembre-se, ele não foi construído para aplicações de página única).

Uma nota rápida sobre os testes acima: eles são muito rudimentares e os números não devem ser considerados “científicos”; o objetivo aqui é descobrir a direção, não os números exatos.

Notas para Desenvolvedores

Vamos falar sobre configurar e limpar lightboxes com o novo Discourse Lightbox.

Configurando e limpando lightboxes

Desenvolvedores têm duas opções.

  1. Injetar o serviço de lightbox em um componente via:

    import { inject as service } from "@ember/service";
    
    //...
    
    @service lightbox
    

    Em seguida, você pode chamar:

    this.lightbox.setupLightboxes({
      container: yourContainer // Nó DOM
      selector: ".css-selector" // Seletor de string para os elementos que você deseja lightbox
    })
    

    Sempre que quiser limpar, basta chamar:

    this.lightbox.cleanupLightboxes()

    É isso.

  2. Se você não quiser injetar o serviço de lightbox, pode importar setupLightboxes e cleanupLightboxes assim:

    import {
      cleanupLightboxes,
      setupLightboxes,
    } from "discourse/lib/lightbox";
    

    O resto é o mesmo que injetar o serviço. Essas duas funções procurarão o serviço por você. Então:

    setupLightboxes({
     container: yourContainer // Nó DOM
     selector: ".css-selector" // Seletor de string para os elementos que você deseja lightbox
    })
    
    //....
    
    cleanupLightboxes()
    

Note que tanto chamar diretamente do serviço quanto via funções utilitárias também aceitarão um nodeList para compatibilidade retroativa, mas não é recomendado.

Uma última nota sobre isso: você também pode fazer com que um elemento não imagem atue como um gatilho para abrir um lightbox que você configurou. Por exemplo:

<div class="my-container">
  <img class="my-selector" src="foo">
  <img class="my-selector" src="bar">
  ....
  <button>Abrir Lightbox</button>
</div>

Eu faria algo assim para configurar lightboxes básicos no div acima:

import {
  cleanupLightboxes,
  setupLightboxes,
} from "discourse/lib/lightbox";

//...

setupLightboxes({
   container: document.querySelector(".my-container"),
   selector: ".my-selector"
})

Para fazer o botão abrir o lightbox, tudo o que você precisa fazer é adicionar data-lightbox-trigger assim:

<button data-lightbox-trigger>Abrir Lightbox</button>

O resto é tratado automaticamente.

Finalmente, sempre que quiser limpar, chame:

cleanupLightboxes()

A limpeza não é realmente crítica, pois o serviço de lightbox limpará automaticamente sempre que o evento dom:clean for disparado no aplicativo (em transições de rota).

Ouvindo eventos do lightbox

O novo lightbox disparará eventos, como discutimos anteriormente. Esses eventos são:

  • lightbox:opened
  • lightbox:item-will-change
  • lightbox:item-did-change
  • lightbox:closed

lightbox:opened

Este evento será disparado quando o lightbox for aberto e conterá dois objetos.

  1. items: este é um array de todas as imagens no lightbox atual. Cada uma delas será um objeto.
  2. currentItem: este é o objeto do item atual em que o lightbox foi aberto.

Um objeto de item se parece com isso:

{
  "fullsizeURL": "https://global.discourse-cdn.com/meta/original/4X/2/3/c/23c5746c48803deac5c105081dba20555187c3ff.jpeg",
  "smallURL": "https://global.discourse-cdn.com/meta/optimized/4X/2/3/c/23c5746c48803deac5c105081dba20555187c3ff_2_600x750.jpeg",
  "downloadURL": "/uploads/short-url/56rKTvkvmL6c2C8OFQtMo3D8sIn.jpeg?dl=1",
  "title": "Close-up Photo of a Black Microphone on Stand",
  "fileDetails": "1200×1500 88.9 KB",
  "dominantColor": "793C6D",
  "aspectRatio": "400 / 500",
  "index": 0,
  "cssVars": "--dominant-color: #793C6D;--aspect-ratio: 400 / 500;--small-url: url(https://global.discourse-cdn.com/meta/optimized/4X/2/3/c/23c5746c48803deac5c105081dba20555187c3ff_2_600x750.jpeg);",
  "isLoaded": true,
  "hasLoadingError": false,
  "width": 1200,
  "height": 1500,
  "canZoom": true
}

lightbox:item-will-change

Este evento é disparado logo antes do item atual no lightbox mudar. Ele terá o currentItem (o que está prestes a mudar).

lightbox:item-did-change

Este evento é disparado logo após o item no lightbox mudar e terminar de carregar, e terá currentItem como argumento.

lightbox:closed

Este evento é disparado logo após o lightbox ser fechado e não tem argumentos.

Com os eventos acima, um componente de tema teórico pode facilmente adicionar análises ao lightbox assim:

api.onAppEvent('lightbox:opened', ({items, currentItem}) => {
  console.log({items});
  console.log({currentItem});

  // seu código de análise aqui
});

ou outras ideias semelhantes.

O CSS

O novo lightbox usa a convenção de nomenclatura BEM para classes HTML. Aqui está uma lista completa dos seletores que você pode usar:

html.has-lightbox {
  // css para o elemento HTML quando os lightboxes estão abertos
}

.d-lightbox {
  &--is-visible {
    // Elemento principal do lightbox
  }

  &__content {
    // Wrapper do conteúdo interno do lightbox
  }
}

.d-lightbox {
  &__content__header {
    // Cabeçalho do lightbox
  }
}

.d-lightbox {
  &__content__body {
    // Corpo do lightbox (contém a imagem principal)

    &__backdrop {
      // Fundo do lightbox
    }

    &__main-image {
      // Imagem principal do lightbox
    }

    &__error-message {
      // Mensagem de erro do lightbox
    }

    &__previous-button,
    &__next-button {
      // Botões anterior/próximo principais do lightbox
    }
  }
}

.d-lightbox {
  &__content__footer {
    // Rodapé do lightbox

    &__main-title {
      // Título da imagem do lightbox
     
      &__item-file-details {
        // Detalhes do arquivo do lightbox, ex: "1000x582 183KB"
      }
    }
  }
}

.d-lightbox {
  &__content__carousel {
    // Container do carrossel do lightbox

    &__previous-button,
    &__next-button {
      // Botões anterior/próximo do carrossel do lightbox
    }
  }
}

.d-lightbox {
  &__content__carousel {
    &__carousel-items {
      // Container dos itens do carrossel do lightbox

      &__item,
      &__item--is-current {
        // Item do carrossel do lightbox
      }

      &__item--is-current {
        // Item atual do carrossel do lightbox
      }
    }
  }
}

.d-lightbox {
  &--is-vertical &__content__carousel {
    // Estilos verticais do carrossel do lightbox
  }
}

.d-lightbox {
  &--is-horizontal &__content__carousel {
    // Estilos horizontais do carrossel do lightbox
  }
}

.d-lightbox {
  .btn-flat {
    // Estilos para todos os botões do lightbox
  }
}

.d-lightbox {
  &__content {
    &__focus-trap,
    &__screen-reader-announcer {
      // Armadilha de foco e anunciador para leitores de tela do lightbox. Estes estão fora da tela
    }
  }
}

/* Estilos de estado */

// Carrossel
.d-lightbox {
  &--has-carousel {
    // Estilos do lightbox quando o carrossel está aberto
  }
}

// Título expandido
.d-lightbox {
  &--has-expanded-title {
    // Estilos do lightbox quando o título está expandido
  }
}

// Zoom
.d-lightbox {
  &--can-zoom {
    // Estilos do lightbox quando a imagem pode ser ampliada
  }

  &--is-zoomed {
    // Estilos do lightbox quando a imagem está ampliada
  }
}

// Rotação
.d-lightbox {
  &--is-rotated {
    // Estilos do lightbox quando a imagem está rotacionada
  }
}

// Tela cheia
.d-lightbox {
  &--is-fullscreen {
    // Estilos do lightbox quando a imagem está em tela cheia
  }
}

Agora que cobrimos o quê, finalmente podemos passar para…

O quando

No momento, o PR está pronto para revisão, o que precisa acontecer primeiro. Após passar pela revisão, estará disponível para sites que atualizarem. O PR adiciona uma nova configuração de site temporária enquanto fazemos a transição do Magnific Popup para o Discourse Lightbox. O nome da configuração é:

enable_experimental_lightbox

Se a configuração estiver desativada, o PR não terá efeito e tudo continuará funcionando como antes com o Magnific Popup.

O Discourse Lightbox substituirá o Magnific Popup em posts processados, mensagens de chat e componentes de uploader de imagem quando a configuração for ativada.

Roadmap

  1. Revisão do PR
  2. Mesclagem do PR
  3. Janela de feedback geral (1-2 semanas)
  4. PR para remover o Magnific Popup do núcleo e remover a configuração experimental do site.
  5. A definir: metas estendidas, como um componente de tema que explora diferentes layouts (deve ser direto, já que o novo lightbox usa CSS Grid para o layout).

Agradecimentos

  • Este trabalho foi generosamente patrocinado pela CDCK :pray:
  • Muito amor :heart: vai para Dmytro Semenov, o criador do Magnific Popup, por criar algo que estava muito à frente de seu tempo.
  • As imagens usadas nas demonstrações acima são cortesia de Irina Iriser @pexels
38 curtidas

Estou acostumado, do YouTube e de outros aplicativos de desktop, que F entra em tela cheia.
Existe algum motivo para não usar a memória muscular para o atalho?

4 curtidas

Este está particularmente bonito. :raised_hands:

Uma coisa que eu teria gostado é poder ir para as imagens da próxima postagem enquanto uso as setas. Dessa forma, se eu estiver em um tópico como “Gatos fofos”, posso simplesmente usar as setas para navegar pelas imagens da próxima postagem sem problemas. Haveria várias coisas a serem consideradas, mas isso seria muito bom. Atualmente, sou forçado a fechar a caixa de luz, ir para a próxima postagem, clicar na primeira imagem, …

16 curtidas

O bootstrap 3 também requer jqwery. O Discourse usa bootstrap 3. Portanto, o jqwery será instalado como uma dependência em nosso fórum. Isso é verdade?

2 curtidas

O EmberJS também costumava exigir o jQuery e o Discourse é um aplicativo EmberJS. Temos trabalhado, há alguns anos, na remoção do uso do jQuery do próprio Discourse, plugins oficiais e componentes.

É muito trabalho, mas estamos chegando mais perto. Este tópico é um dos muitos esforços para isso.

11 curtidas

Isso foi implementado. :partying_face:

Coletaremos feedback neste tópico por um curto período, então dê uma olhada e nos diga o que você pensa. :slight_smile: :+1:

6 curtidas