Criando extensões para o Google Chrome com React

Criando extensões para navegadores com React

Criando extensões para o Google Chrome com React

Talvez não seja algo tão conhecido, mas extensões para o Google chrome são desenvolvidos com tecnologias web (Javascript, Html e Css).
Dessa forma, é totalmente possível utilizarmos o React (assim como outra lib/framework frontend) para criar extensões.
Nesse post, vou mostrar como criar uma extensão bem legal usando o React!
Sem mais delongas, vamos pôr a mão na massa.

O projeto 📝

Vamos fazer uma extensão simples, um app de TODO.
Vamos poder adicionar tarefas, listar as tarefas e marca-las como concluída.

Previa do app

Configurando o projeto ⚙

Vamos começar iniciando um projeto React, vamos utilizar o CRA para iniciar nosso projeto, então vamos começar com comando:

npx create-react-app todo-chrome

Com a estrutura básica criada pelo CRA, vamos instalar algumas dependências, vou utilizar o Material UI para esse projeto, então vamos adiciona-lo como dependência do nosso projeto junto com os ícones:

yarn add @material-ui/core @material-ui/icons
#ou
npm install @material-ui/core @material-ui/icons

Agora vamos adicionar a fonte, podemos adiciona-la utilizando CDN ou como dependência do projeto, nesse caso, vou utilizar CDN, então, no index.html dentro da pasta public vamos adicionar essa tag dentro do <header>:

<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />

Iniciando com React ⚛️

Com tudo instalado e configurado, vamos a estrutura do projeto. Aqui não é algo que influencia diretamente no "produto final", portanto, se você estiver acostumado com outra estrutura, pode segui-la, a minha ficou da seguinte forma:

Estrutura de pastas do projeto

Vamos iniciar o desenvolvimento real do nosso app, vamos criar e editar um arquivo index.jsx dentro da pasta src/pages/home e vamos começar a estrutura visual do nosso app.
Antes não esqueça de importar o componente e chama-lo no App.js para poder renderiza-lo na tela.
Todo o desenvolvimento vai ser de um app react "normal", então vamos utilizar o hot reload para auxiliar durante o desenvolvimento.
Uma pequena observação é sobre o tamanho da aplicação (altura X largura), tendo em vista que é uma extensão, elas não costumam tomar muito espaço, então criei um arquivo chamado global.css e nele coloquei uma altura e largura fixa, além de algumas outras estilizações e importei no  o arquivo no index.js:

/* global.css */

body {
  width: 350px;
  height: 500px;
  background-color: #ccc;
}

#root {
  height: 100%;
}

.App {
  font-family: "Roboto";
  height: 100%;
  background-color: #ccc;
  overflow-y: scroll;
}

.container {
  position: relative;
  min-height: 100%;
}

.finished {
  text-decoration: line-through;
  opacity: 0.6;
}

.no-tasks {
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  width: 100%;
  height: 450px;
}

* {
  scrollbar-width: thin;
  scrollbar-color: rgba(0, 0, 0, 0.2) rgba(0, 0, 0, 0.2);
}

*::-webkit-scrollbar {
  width: 4px;
}

*::-webkit-scrollbar-track {
  background: rgba(0, 0, 0, 0.2);
}

*::-webkit-scrollbar-thumb {
  background-color: rgba(0, 0, 0, 0.2);
  border-radius: 20px;
  border: 20px solid rgba(0, 0, 0, 0.2);
}

No index.js ficou assim:

// index.js
import React from "react";
import ReactDOM from "react-dom";
import "./assets/global.css";
import App from "./App";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

Criando componente de item 🧱

Agora, podemos voltar a atenção para a pagina home.
Vou começar criando meu componente de item, que será a tarefa que vamos mostrar na lista, para isso, vou criar uma pasta chamada components dentro da pasta home, e vou criar um arquivo chamado Item.List.jsx. Nele vou importar alguns componentes do Material UI e teremos o seguinte resultado:

Componente de item da lista

Nosso arquivo ficará assim:

import React from "react";
import { Box, Paper, Grid } from "@material-ui/core";
import { DateRange, AccessTime } from "@material-ui/icons";

function randomColor() {
  const colors = ["#eb4034", "#B8FF33", "#33FFA8", "#7B5EBF"];

  return colors[Math.floor(Math.random() * (colors.length - 1)) + 1];
}

function ItemList() {
  return (
    <Box
      paddingLeft={0.5}
      borderRadius={4}
      style={{ backgroundColor: randomColor() }}
    >
      <Paper style={{ minHeight: 60, padding: 10, paddingTop: 1 }}>
        <h3>Teste de todo para design</h3>
        <Grid container justify="space-between">
          <Grid>
            <small>
              <Grid container justify="center" alignItems="center">
                <DateRange fontSize="small" /> 01/01/2020
              </Grid>
            </small>
          </Grid>
          <Grid>
            <small>
              <Grid container justify="center" alignItems="center">
                <AccessTime fontSize="small" />
                10 minutos atrás
              </Grid>
            </small>
          </Grid>
        </Grid>
      </Paper>
    </Box>
  );
}

export default ItemList;

Criando componente de lista 📄

Agora vamos criar o nosso componente de lista, que será responsável por receber os itens a serem renderizados e chamar um ItemList pra cada um deles, crie um componente ainda na pasta components dentro de src/pages/home e chame-o de List.jsx, seu conteúdo inicialmente ficará da seguinte forma:

import React from "react";
import { Box } from "@material-ui/core";

import ItemList from "./Item.List";

function List() {
  return (
    <Box padding={1}>
      <ItemList />
    </Box>
  );
}

export default List;

Basicamente importamos nosso item e exibimos ele dentro do componente de lista.
O que precisamos fazer agora é receber via props as nossas tarefas, percorre-las e passar as informações para nosso item, e vamos fazer isso assim:

...
function List({ tasks }) {
  return (
    <Box padding={1}>
      {tasks && tasks.map((task) => <ItemList task={task} />)}
    </Box>
  );
}
...

Agora vamos modificar o nosso Item.List.jsx para receber a prop task e criar um callback para quando ele for clicado. Ficando dessa forma:

...
function ItemList({ task, onClick }) {
  return (
    <Box
      paddingLeft={0.5}
      borderRadius={4}
      style={{ backgroundColor: randomColor() }}
      onClick={() => onClick(task.id)}
    >
      <Paper style={{ minHeight: 60, padding: 10, paddingTop: 1 }}>
        <h3>{task.title}</h3>
        <Grid container justify="space-between">
          <Grid>
            <small>
              <Grid container justify="center" alignItems="center">
                <DateRange fontSize="small" /> {task.date}
              </Grid>
            </small>
          </Grid>
          <Grid>
            <small>
              <Grid container justify="center" alignItems="center">
                <AccessTime fontSize="small" />
                {task.time}
              </Grid>
            </small>
          </Grid>
        </Grid>
      </Paper>
    </Box>
  );
}
...

Agora no nosso List.jsx, vamos receber esse callback e por enquanto, vamos apenas disparar um alert.
Outra modificação para fazermos nesse componente é adicionar uma mensagem de "Sem tarefas" quando o array estiver vazio, ficando assim:

...
function List({ tasks }) {
  function handleItemClick(id) {
    alert(`Clicou no item ${id}`);
  }

  return (
    <Box padding={1}>
      {tasks &&
        tasks.map((task) => <ItemList task={task} onClick={handleItemClick} />)}

     {tasks.length === 0 && (
        <div className="no-tasks">
          <span>Sem tarefas, crie uma agora mesmo!</span>
        </div>
      )}
    </Box>
  );
}
...

Gerenciando estado das tasks 🔮

Voltando ao nosso componente da Home page, vamos fazer nosso gerenciamento de tasks utilizando o hook de estado do React, o que vai deixar tudo super simples e eficiente, vamos começar declarando um novo estado para as nossas tarefas e inicializa-las como um array vazio, em seguida vamos passar esse estado na prop da nossa lista:

// src/pages/home/index.jsx
import React, { useState } from "react";

import List from "./components/List";

function HomePage() {
  const [tasks, setTasks] = useState([]);

  return <List tasks={tasks} />;
}

export default HomePage;

Criando botão de ação (FAB) 🎬

Agora vamos criar um float action button para adicionar novas tarefas quando clicarmos nele e em seguida vamos criar um modal com um campo de texto para o usuário digitar o título da tarefa.
Vamos importar o FAB e um ícone do Material UI e colocar junto com a nossa lista no componente da tela inicial, ambos envolvidos por uma div com uma class:

import React, { useState } from "react";
import { Fab } from "@material-ui/core";
import { Add } from "@material-ui/icons";
import List from "./components/List";

function HomePage() {
  const [tasks, setTasks] = useState([]);

  return (
    <div class="container">
      <List tasks={tasks} />
      <Fab
        color="primary"
        style={{ position: "absolute", bottom: "30px", right: "10px" }}
      >
        <Add />
      </Fab>
    <div/>
  );
}

export default HomePage;

O resultado até agora é esse:

Previa do app

Criando Modal para adicionar tasks ❎

Vamos criar uma modal com dois botões e um input para o usuário pode digitar o título da tarefa, porém, não vamos usar o componente Modal do Material UI e sim o componente Dialog, então vou criar um novo componente chamado Modal.NewTask.jsx dentro da pasta src/pages/home/components e vamos pôr o seguinte conteúdo:

import React, { useState } from "react";
import {
  Dialog,
  DialogActions,
  DialogContent,
  DialogContentText,
  DialogTitle,
  TextField,
  Button,
} from "@material-ui/core";

function NewTaskModal({ show, onClose, onAdd }) {
  const [taskName, setTaskName] = useState("");

  function addNewTask() {
    onAdd(taskName);
    setTaskName("");
    onClose();
  }

  return (
    <Dialog open={show} onClose={onClose} aria-labelledby="form-dialog-title">
      <DialogTitle id="form-dialog-title">
        Adicionar uma nova tarefa
      </DialogTitle>
      <DialogContent>
        <DialogContentText>
          Insira o nome da tarefa que deseja inserir
        </DialogContentText>
        <TextField
          id="task_name"
          autoFocus
          margin="dense"
          label="Titulo da tarefa"
          type="text"
          fullWidth
          value={taskName}
          onChange={(e) => setTaskName(e.target.value)}
        />
      </DialogContent>
      <DialogActions>
        <Button onClick={onClose} color="secondary">
          Cancelar
        </Button>
        <Button onClick={addNewTask} color="primary">
          Adicionar
        </Button>
      </DialogActions>
    </Dialog>
  );
}

export default NewTaskModal;

Visualmente nosso modal ficou assim:

Previa modal de adicionar tarefa


Ficou um componente bem grande, principalmente por causa da estrutura do Dialog do Material UI, mas aqui tem algumas coisas acontecendo que vou explicar. A primeira coisa a se observar são as três props que recebemos: show, onClose e onAdd, elas são respectivamente: uma variável que controla se exibe ou não a modal, uma função para fechar a modal(clicando fora ou cancelando) e uma função para adicionar a nova tarefa quando clicar no botão Adicionar.
Ainda no Modal, temos um hook de estado para gerenciar o que está sendo digitado no campo de texto e temos uma função addNewTask, que: chama uma função de callback, limpa o texto do campo e fecha a modal.
Agora, vamos importar esse componente também na nossa tela inicial, ficando assim:

...
import NewTaskModal from "./components/Modal.NewTask";
...
function HomePage() {
...
const [showNewTaskModal, setNewTaskModal] = useState(false);

  function handleNewTask(taskName) {
    alert(`Nova tarefa: ${taskName}`);
  }

return (
...
  <NewTaskModal
        show={showNewTaskModal}
        onClose={() => setNewTaskModal(false)}
        onAdd={handleNewTask}
      />
)
...

Importamos nossa modal, usamos um hook para gerenciar o estado dela e declaramos uma função que por enquanto, apenas exibe um alert com o que o usuário digitou. Agora vamos exibir nossa Modal quando o usuário clicar no nosso FAB:

// src/pages/home/index.jsx

...
<Fab
        color="primary"
        style={{ position: "absolute", bottom: "30px", right: "30px" }}
        onClick={() => setShowModal(true)}
      >
...

Adicionando tasks ao estado 🔩

Vamos deixar tudo mais interessante agora que vamos adicionar a nova task a nosso estado para que ela seja renderizada na nossa lista, para isso, vamos precisar pegar além do titulo da tarefa, uma data, hora e gerar um id.
Vou criar uma função para gerar IDs em um arquivo na pasta utils e chama-lo de IdGenerator.js, seu conteúdo ficará o seguinte:

function generateId() {
  return "_" + Math.random().toString(36).substr(2, 9);
}

export default generateId;

Vou importar minha função de gerar id na minha pagina inicial e vou implementar minha criação de tarefas, ficando assim a minha função handleNewTask():

...
  function handleNewTask(taskName) {
    const newTask = {
      id: generateId(),
      title: taskName,
      date: new Date().toDateString(),
      time: new Date().toDateString(),
    };

    setTasks([...tasks, newTask]);
  }
...

Com essa função implementada, já é possível criar uma nova tarefa:

App com tarefa criada


E se clicarmos em um item:

Mensagem após clicar em uma tarefa

Melhorias 🔝

Bem legal o funcionamento até aqui, uma melhoria é na data e no horário, estamos exibindo o objeto Date direto, sem formatação, e podemos melhorar isso, para formatar vamos usar o date-fns, uma biblioteca javascript que manipula datas e horários de forma super simples e vai nos ajudar principalmente para exibir o horário no formato que queremos (X minutos/horas atrás). Então, instale o date-fans:

yarn add date-fns
#ou
npm install date-fns

E dentro do nosso componente de Item vamos modificar onde exibimos a data e hora, vamos importar as funções format() e formatDistance() e o objeto de idioma em português ptBR, então, vamos atualizar nosso Item.List.jsx:

import React from "react";
import { Box, Paper, Grid } from "@material-ui/core";
import { DateRange, AccessTime } from "@material-ui/icons";
import { format, formatDistance } from "date-fns";
import { ptBR } from "date-fns/locale";

function randomColor() {
  const colors = ["#eb4034", "#B8FF33", "#33FFA8", "#7B5EBF"];

  return colors[Math.floor(Math.random() * (colors.length - 1)) + 1];
}

function ItemList({ task, onClick }) {
  return (
    <Box
      paddingLeft={0.5}
      marginTop={1}
      borderRadius={4}
      style={{ backgroundColor: randomColor() }}
      onClick={() => onClick(task.id)}
    >
      <Paper style={{ minHeight: 60, padding: 10, paddingTop: 1 }}>
        <h3>{task.title}</h3>
        <Grid container justify="space-between">
          <Grid>
            <small>
              <Grid container justify="center" alignItems="center">
                <DateRange fontSize="small" /> {format(task.date, "dd/MM/yyyy")}
              </Grid>
            </small>
          </Grid>
          <Grid>
            <small>
              <Grid container justify="center" alignItems="center">
                <AccessTime fontSize="small" />
                {formatDistance(task.date, new Date(), {
                  locale: ptBR,
                })}
              </Grid>
            </small>
          </Grid>
        </Grid>
      </Paper>
    </Box>
  );
}

export default ItemList;

Perceba que estamos usando o task.date tanto para a data quanto para a hora, então na nossa home podemos remover a propriedade time e vamos remover o toDateString() que temos no date também. Ficando dessa forma:

 function handleNewTask(taskName) {
    const newTask = {
      id: generateId(),
      title: taskName,
      date: new Date(),
    };

    setTasks([...tasks, newTask]);
  }

E aqui está nosso resultado até aqui:

Item da lista com data e hora correta

Criando Modal de concluir task ✅

Agora vamos fazer criar uma modal para quando o usuário clicar para marcar a task como finalizada.
Para isso vamos começar criando um novo componente chamado Modal.ChangeTaskStatus.jsx e como conteúdo vamos ter:

import React from "react";
import {
  Dialog,
  DialogActions,
  DialogContent,
  DialogContentText,
  DialogTitle,
  Button,
} from "@material-ui/core";

function ModalChangeStatus({ show, onClose, onSave, taskToEdit }) {
  function finishTask() {
    onSave(taskToEdit.id);
    onClose();
  }

  return (
    <Dialog open={show} onClose={onClose} aria-labelledby="form-dialog-title">
      <DialogTitle id="form-dialog-title">Concluir tarefa</DialogTitle>
      <DialogContent>
        <DialogContentText>
          Deseja marcar a tarefa "{taskToEdit.title}" como concluida ?
        </DialogContentText>
      </DialogContent>
      <DialogActions>
        <Button onClick={onClose} color="secondary">
          Cancelar
        </Button>
        <Button onClick={finishTask} color="primary">
          Concluir
        </Button>
      </DialogActions>
    </Dialog>
  );
}

export default ModalChangeStatus;

Agora vamos importar isso na nossa tela inicial, criar dois novos hooks de estado, um para gerenciar a visibilidade da modal e outro para "segurar" o item que será editado.
As modificações são:

...
import EditTaskModal from "./components/Modal.ChangeTaskStatus";
...
const [taskToEdit, setTaskToEdit] = useState("");
const [showEditModal, setShowEditModal] = useState(true);

...
     <EditTaskModal
        show={showEditModal}
        onClose={() => setShowEditModal(false)}
        onSave={handleUpdateTask}
        taskToEdit={taskToEdit}
      />
...

Agora podemos chamar nossa modal toda vez que clicarmos em um item da lista, porém, vamos precisar refatorar uma pequena parte no nosso componente de lista, vamos remover nossa função handleItemClick() e vamos receber ela via props:

import React from "react";
import { Box } from "@material-ui/core";

import ItemList from "./Item.List";

function List({ tasks, handleItemClick }) {
  return (
    <Box padding={1}>
      {tasks &&
        tasks.map((task) => <ItemList task={task} onClick={handleItemClick} />)}

      {tasks.length === 0 && (
        <div className="no-tasks">
          <span>Sem tarefas, crie uma agora mesmo!</span>
        </div>
      )}
    </Box>
  );
}

export default List;

E na nossa tela inicial, podemos passar uma função que vai receber o id clicado, vamos buscar esse id no nosso estado e em seguida chamar a função para mudar o status passando o item completo, vamos implementar também a função que atualiza no estado o status do item, o que significa que também vamos refatorar a função de criação da tarefa, adicionando a propriedade status a ela, o que vai deixar nossa pagina inicial assim:

import React, { useState } from "react";
import { Fab } from "@material-ui/core";
import { Add } from "@material-ui/icons";
import List from "./components/List";
import generateId from "../../utils/IdGenerator";
import NewTaskModal from "./components/Modal.NewTask";
import EditTaskModal from "./components/Modal.ChangeTaskStatus";

function HomePage() {
  const [tasks, setTasks] = useState([]);
  const [taskToEdit, setTaskToEdit] = useState();
  const [showNewTaskModal, setNewTaskModal] = useState(false);
  const [showEditModal, setShowEditModal] = useState(false);

  function handleNewTask(taskName) {
    const newTask = {
      id: generateId(),
      title: taskName,
      date: new Date(),
      status: "enabled",
    };

    setTasks([...tasks, newTask]);
  }

  function handleUpdateTask(id) {
    const taskIndex = tasks.findIndex((task) => task.id === id);

    if (taskIndex === -1) {
      return;
    }

    const tasksCopy = [...tasks];
    const taskUpdate = { ...tasks[taskIndex] };
    
    taskUpdate.status = "disabled";
    tasksCopy[taskIndex] = taskUpdate;

    setTasks(tasksCopy);
  }

  function handleItemClick(id) {
    const itemToEdit = tasks.find((task) => task.id === id);

    if (!itemToEdit) {
      return;
    }

    setTaskToEdit(itemToEdit);
    setShowEditModal(true);
  }

  return (
    <div class="container">
      <List tasks={tasks} handleItemClick={handleItemClick} />
      <Fab
        color="primary"
        style={{ position: "absolute", bottom: "30px", right: "30px" }}
        onClick={() => setNewTaskModal(true)}
      >
        <Add />
      </Fab>

      <NewTaskModal
        show={showNewTaskModal}
        onClose={() => setNewTaskModal(false)}
        onAdd={handleNewTask}
      />

      <EditTaskModal
        show={showEditModal}
        onClose={() => setShowEditModal(false)}
        onSave={handleUpdateTask}
        taskToEdit={taskToEdit}
      />
    </div>
  );
}

export default HomePage;

Finalizando App React 🙅‍♂️

E vamos precisar adicionar um pequeno detalhe no nosso Item.List.jsx, vamos adicionar uma classe quando o status do item for igual a disabled, dessa forma, podemos aplicar uma estilização para distinguir um item concluído de outro, então no componente Box vamos apenas adicionar uma linha:

...
<Box
...
className={task.status === "disabled" ? "finished" : ""}
/>
...

Ficando assim:

Lista com itens finalizados

Transformando o app em extensão 🌠

E finalizamos a nossa aplicação, agora vamos a parte que realmente importa, a parte da integração com o Google Chrome.
Segundo a documentação, precisamos preencher o manifesto de acordo com algumas diretrizes do Google, então vamos editar nosso arquivo manifest.json que fica na pasta public e adicionar algumas entradas novas que são necessárias para que o Google Chrome reconheça nosso projeto como uma extensão ficando assim:

{
  "name": "TODO - Tulio Calil",
  "description": "Aplicação de TODO com React",
  "version": "1.0",
  "manifest_version": 2,
  "browser_action": {
    "default_popup": "index.html",
    "default_title": "TODO App"
  }
}

As tags name, description e version são autoexplicativas, então vamos para manifest_version, que como o nome diz é a versão do manifesto, ela é necessária para que o Chrome entenda qual versão estamos requerindo para o nosso projeto, informamos a versão 2 por que a versão 1 é para Google Chrome anterior a versão 18. Leia mais aqui sobre essa tag.
Em browser_action temos duas opções, uma é a default_popup que informa qual arquivo padrão a ser carregado pela extensão e a outra default_title que é o titulo padrão.
Com tudo isso configurado, só nos resta mais um pequeno passo, que é desativar a opção de scripts em linha (inline scripts) quando gerarmos nossa build, pois por padrão o React gera builds dessa forma. Então vamos criar um arquivo na raiz do nosso projeto chamado: .env.production e nele vamos colocar o seguinte conteúdo:

INLINE_RUNTIME_CHUNK=false

Agora podemos finalmente gerar uma build do nosso projeto e importa-lo como uma extensão, então no terminal vamos rodar:

yarn build
#ou
npm run build

E após concluir o processo de build, vamos no Google chrome: clique no ícone de três pontos ... > Mais ferramentas > Extensões, iremos direto para a tela de extensões do Chrome. Vamos habilitar a opção de desenvolvedor e vamos clicar em "Carregar se compactação":

Tela de extensões do Chrome


Agora, navegue até a pasta do projeto e abra a pasta build e selecione-a:

Explorer selecionando pasta do build


Você verá que sua extensão foi adicionada com sucesso as extensões do Chrome:

Extensão adicionada ao Chrome


Para abri-la basta clicar no botão de extensões (perto dos três pontos) e clicar (ou fixar) que ela irá abrir:

Extensão rodando com tarefas adicionadas

Conclusão 🎯

Como podem ver, não temos muito segredo quando vamos criar uma extensão para o Google Chrome utilizando React, obviamente temos diversas apis do chrome para acessar como: Histórico, armazenamento, controle de abas e janelas e várias outras coisas. Abordei um exemplo mais simples para que esse tutorial sirva como uma introdução.
Espero que tenham gostado, até a próxima!

Aqui tem o projeto no Github caso queira usar para consultar ou mesmo fazer o clone e rodar:
{% github tuliocll/todo-google-chrome %}

Aqui tem a doc das extensões com todos os recursos que você pode ter acesso.