Budujemy rozszerzenie do Chrome - #3 (Strona frontowa)

Wpis jest trzecim z cyklu “Budujemy rozszerzenie do Chrome”. W tej części skupimy się na wyświetleniu na ekranie strony startowej widgetów, które zdefiniowaliśmy w poprzedniej części, czyli na plikach newtab.html oraz newtab.js.

  1. Budujemy rozszerzenie do Chrome - #1 (Plik manifestu)
  2. Budujemy rozszerzenie do Chrome - #2 (Panel ustawień)
  3. Budujemy rozszerzenie do Chrome - #3 (Strona frontowa)
  4. Budujemy rozszerzenie do Chrome - #4 (Wielojęzyczność i publikacja w Chrome Store)

Umożliwimy wyświetlenie zdefiniowanego w opcjach tła dla strony startowej, wyświetlimy widgety oraz pozwolimy na przenoszenie ich myszką za pomocą metody “przeciągnij i upuść”.

Do dzieła!

Kod HTML

Kod HTML podobnie jak w części administracyjnej, będzie dość prosty:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<html>
<head>
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/newtab.css">
</head>
<body id="wrapper">
<div class="container">
    <div class="row"></div>
</div>
<div id="messages"></div>
<script src="js/common.js"></script>
<script src="js/newtab.js"></script>
</body>
</html>

Oprócz wcześniej wymienionych plików, dołączone są jeszcze dwa: common.js oraz common.css. Są to po prostu dodatkowe narzędzia oraz reużywalne style css, z których korzystamy w ramach całego rozszerzenia.

Kod JavaScript

Starałem się, aby kod JS części administracyjnej i frontowej był w pewien sposób ze sobą spójny, więc jeśli pamiętasz najważniejsze elementy poprzedniej części cyklu, poniższy kod będzie już na pierwszy rzut oka wyglądał znajomo.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
let storage = {};
let widgets = {};
let constants = {
  backgroundDir: 'images/background/',
};

class Helpers {
  static ucfirst(string) {
    return string.charAt(0).toUpperCase() + string.slice(1);
  }
}

/**
 * Bookmarks Widget
 * @type {widgetBookmarks}
 */
widgets.bookmarks = class widgetBookmarks {
  static draw(widget) {
    chrome.bookmarks.getChildren(widget.group, function(bookmarkTreeNodes) {
      let widgetElement = document.createElement('div');
      widgetElement.setAttribute('class', 'card');
      widgetElement.setAttribute('data-widget-id', widget.id);
      widgetElement.setAttribute('draggable', true);

      let widgetHtml = `
        <div class="header"><h2>${widget.title}</h2><div class="toolbar">
            <i class="fa fa-ellipsis-v dnd"></i>
        </div></div>
        <div class="body"></div>
      `;

      widgetElement.innerHTML = widgetHtml;
      let bookmarks = widgetBookmarks.drawList(bookmarkTreeNodes);

      widgetElement.querySelector('.body').appendChild(bookmarks);
      document.querySelector('[data-column-id="' + widget.column + '"]').appendChild(widgetElement);

      widgetElement.querySelector('.dnd').addEventListener('mousedown', DragAndDrop.dragstart);
      widgetElement.addEventListener('mouseup', DragAndDrop.dragend);
    });
  }

  static drawList(bookmarkNodes) {
    let ul = document.createElement('ul');
    for (let i = 0; i < bookmarkNodes.length; i++) {
      if (bookmarkNodes[i].title && bookmarkNodes[i].parentId !== '0') {
        let li = document.createElement('li');
        let anchor = document.createElement('a');
        anchor.setAttribute('href', bookmarkNodes[i].url);
        anchor.innerText = bookmarkNodes[i].title;
        li.appendChild(anchor);
        ul.appendChild(li);
      } else if (bookmarkNodes[i].children && bookmarkNodes[i].children.length > 0) {
        ul.appendChild(widgetBookmarks.drawList(bookmarkNodes[i].children));
      }
    }

    return ul;
  }
};

class App {
  static init() {
    for (let i =0; i < storage.settings.columns;i++) {
      let col = document.createElement('div');
      col.setAttribute('class', 'col');
      col.setAttribute('data-column-id', i);
      document.querySelector('.row').appendChild(col);
    }

    App.backgroundDraw();
    App.widgetsDraw();
  }

  static backgroundDraw() {
    if (localStorage.getItem('backgroundBase64')) {
      document.getElementById('wrapper').setAttribute('style', 'background-image: url(' + localStorage.getItem('backgroundBase64') + ');');
    } else {
      document.getElementById('wrapper').setAttribute('style', 'background-image: url("' + chrome.runtime.getURL(constants.backgroundDir + storage.settings.background) + '");');
    }
  }

  static widgetsDraw() {
    storage.widgets
      .sort(function (a, b) {
        return a.order > b.order;
      })
      .forEach(function (widget) {
        widgets[widget.type].draw(widget);
      });
  }

  static widgetsSave(node) {
    let columnId = parseInt(node.getAttribute('data-column-id'));
    let widgetsOrder = [];
    node.childNodes.forEach(function (node) {
      widgetsOrder.push(parseInt(node.getAttribute('data-widget-id')));
    });

    storage.widgets.forEach(widget => {
      if (widgetsOrder.indexOf(widget.id) !== -1) {
        widget.order = widgetsOrder.indexOf(widget.id);
        widget.column = columnId;
      }
    });

    chrome.storage.sync.set(storage, function () {
      Message.success(chrome.i18n.getMessage('widget_saved'));
    });
  }
}

class DragAndDrop {
  static init() {
    const containers = document.getElementsByClassName('col');
    for(const container of containers) {
      container.addEventListener("dragover", DragAndDrop.dragover);
      container.addEventListener("dragenter", DragAndDrop.dragenter);
      container.addEventListener("dragleave", DragAndDrop.dragleave);
      container.addEventListener("drop", DragAndDrop.drop);
    }
  }

  static dragstart(event) {
    DragAndDrop.box = event.target.parentElement.parentElement.parentElement;
    this.className += " held";
  }

  static dragend(event) {
    this.className = "card";
  }

  static dragover(e) {
    e.preventDefault();
  }

  static dragenter(e) {
    e.preventDefault();
    this.className += " hovered";
  }

  static dragleave() {
    this.className = "col";
  }

  static drop(event) {
    if (event.target.childNodes.length > 0) {
      event.target.childNodes.forEach(function (node) {
        if (node.offsetTop > event.offsetY) {
          node.parentNode.insertBefore(DragAndDrop.box, node);
        } else {
          node.parentNode.append(DragAndDrop.box);
        }
      });
    } else {
      this.append(DragAndDrop.box);
    }

    App.widgetsSave(event.target);

    this.className = "col";
  }
}

document.body.onload = function() {
  chrome.storage.sync.get(function(result) {
    if (!chrome.runtime.error) {
      storage = result;
      App.init();
      DragAndDrop.init();
    }
  });
};

Kod możemy podzielić na trzy kategorie:

  • generowanie tła i kolumn
  • wyświetlenie widgetów
  • obsługa grag’n’drop

Deklaracje zawarte w liniach 1-5 oraz klasę Helpers pominę. Deklaracje są analogiczne do kodu zakładki ustawienia, natomiast klasa Helpers zawiera tylko jedną funkcję wspomagającą.

Generowanie tła i kolumn

Znajduje się w liniach 62-111.

63-73 - metoda init iteruje ustaloną w opcjach ilość kolumn i wstrzykuje je do DOM strony startowej. Następnie uruchamia dwie metody: backgroundDraw oraz widgetsDraw.

83-91 i metoda widgetsDraw zajmuje się posortowaniem widgetów zapisanych w storage według atrybutu order, a następnie dla każdego widgetu uruchamia metodę draw w klasie która konkretny typ widgetu obsługuje.

93-110 - metoda widgetsSave wykorzystywana jest przez klasę DragAndDrop i wyzwalana w momencie wystąpienia eventu drop. Zapisuje ona w storage kolumnę i kolejność widgetu, jaką zajął po przeciągnięciu. Przy opisywaniu klasy DragAndDrop opiszę to zagadnienie bardziej szczegółowo.

Wyświetlanie widgetów

Znajduje się w liniach 17-60.

18-41 - metoda draw jak jej nazwa wskazuje, zajmuje się wyrysowaniem widgetu. W pierwszej kolejności, uruchamia się callback pobierający listę zakładek dla id pobranego z atrybutu group. Po uzyskaniu listy, renderujemy HTML.

Zwróć uwagę na linię 27. W niej dodajemy ikonę zawierającą klasę css dnd. Na tej klasie będą nasłuchiwały metody obsługujące przeciąganie widgetów.

43-59 - metoda drawList jest funkcją wywoływaną rekurencyjnie i ma ona za zadanie zwrócić w formie drzewa, listę linków zawartych w grupie zakładek.

Obsługa drag’n’drop

Znajduje się w liniach 113-163 oraz 38-39 podczas generowania każdego z widgetów, a także w liniach 93-110 podczas zapisu zmian w storage.

Do obsługi tego zagadnienia, użyłem zdarzeń wbudowanych w HTML i opisanych w Mozilla Developer Network.

Zależało mi na jak najprostszej obsłudze tematu “przeciągnij i upuść”, bez stosowania jakiegokolwiek gotowego rozwiązania do tego celu, choć pokusa do skorzystania z takich bibliotek jak draggable była duża, głównie ze względu na ciekawe funkcjonalności dodatkowe, np. animacje upiększające całą zabawę.

Jeśli zatem Ty nie narzucasz sobie takiego reżimu jak ja w tym projekcie, skorzystanie z istniejącej już biblioteki, może zaoszczędzić Ci sporo czasu i znacznie upiększy przenoszenie elementów.

38-39 - linie definiują dwie metody klasy DragAndDrop obsługujące moment rozpoczęcia i zakończenia przeciągania elementu. Metoda dragstart zawartość całego widgetu dodaje do atrybutu this.box. Możemy tam też dodać klasę css, która zmodyfikuje wygląd widgetu w trakcie przenoszenia, np. Nada mu efekt przeźroczystości. Metoda dragend uruchamia się w sytuacji zakończenia przenoszenia. Możemy w niej np. przywrócić standardowe style widgetu.

114-122 - metoda init iteruje po wszystkich kolumnach z widgetami i uruchamia na nich zdarzenia dragenter, dragover, dragleave, drop, które odnoszą się do elementów, do których przenosimy elementy metodą drand’n’drop. Same metody podpięte pod te zdarzenia nie robią nic spektakularnego poza nadaniem styli kolumnom.

Wyjątkiem jest zdarzenie drop podpięte pod metodę o tej samej nazwie. Metoda przyjmuje przenoszony element jako argument i iteruje po wszystkich widgetach w kolumnie, aby ocenić gdzie osadzić przenoszony element. Jeśli położenie iterowanego widgetu jest w osi y niżej niż przeniesiony widget, używamy metody insertBefore, aby umieścić nowy widget ponad już istniejącym. W innym przypadku używamy metody append.

93-110 - tutaj dzieje się zapis zmian w storage po przeniesieniu widgetu w inne miejsce.

W pierwszej kolejności pobieramy id kolumny, której dotyczy zapis. Następnie budujemy nową kolekcję elementów kolumny i zapisujemy do tablicy. W kolejnym kroku ze storage wyciągamy wszystkie widgety i na podstawie wcześniej utworzonej tablicy i id kolumny, ustalamy dla przeniesionego widgetu wartość atrybutu order oraz column.

Na koniec zapisujemy wszystko w storage przeglądarki.

Podsumowanie

Rozszerzenie jest już gotowe. Gdy odpalimy kartę nowej strony, powinniśmy ujrzeć mniej więcej coś takiego:

Podgląd

Jedynym co nam już postało, aby cieszyć się sławą, jest publikacja w Chrome Store i tym zajmiemy się w kolejnej, ostatniej już części cyklu.

Całość kodu rozszerzenia dostępna jest na GitHub.