blob: 5fee6720220d9f05471c9cf3d60a63f5f95331f3 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, html, css} from 'lit-element';
6
7import {SHARED_STYLES} from 'shared/shared-styles.js';
8
9/**
10 * `<mr-upload>`
11 *
12 * A file uploading widget for use in adding attachments and similar things.
13 *
14 */
15export class MrUpload extends LitElement {
16 /** @override */
17 static get styles() {
18 return [
19 SHARED_STYLES,
20 css`
21 :host {
22 display: block;
23 width: 100%;
24 padding: 0.25em 4px;
25 border: 1px dashed var(--chops-gray-300);
26 box-sizing: border-box;
27 border-radius: 8px;
28 transition: background 0.2s ease-in-out,
29 border-color 0.2s ease-in-out;
30 }
31 :host([hidden]) {
32 display: none;
33 }
34 :host([expanded]) {
35 /* Expand the drag and drop area when a file is being dragged. */
36 min-height: 120px;
37 }
38 :host([highlighted]) {
39 border-color: var(--chops-primary-accent-color);
40 background: var(--chops-active-choice-bg);
41 }
42 input[type="file"] {
43 /* We need the file uploader to be hidden but still accessible. */
44 opacity: 0;
45 width: 0;
46 height: 0;
47 position: absolute;
48 top: -9999;
49 left: -9999;
50 }
51 input[type="file"]:focus + label {
52 /* TODO(zhangtiff): Find a way to either mimic native browser focus
53 * styles or make focus styles more consistent. */
54 box-shadow: 0 0 3px 1px hsl(193, 82%, 63%);
55 }
56 label.button {
57 margin-right: 8px;
58 padding: 0.1em 4px;
59 display: inline-flex;
60 width: auto;
61 cursor: pointer;
62 border: var(--chops-normal-border);
63 margin-left: 0;
64 }
65 label.button i.material-icons {
66 font-size: var(--chops-icon-font-size);
67 }
68 ul {
69 display: flex;
70 align-items: flex-start;
71 justify-content: flex-start;
72 flex-direction: column;
73 }
74 ul[hidden] {
75 display: none;
76 }
77 li {
78 display: inline-flex;
79 align-items: center;
80 }
81 li i.material-icons {
82 font-size: 14px;
83 margin: 0;
84 }
85 /* TODO(zhangtiff): Create a shared Material icon button component. */
86 button {
87 border-radius: 50%;
88 cursor: pointer;
89 background: 0;
90 border: 0;
91 padding: 0.25em;
92 margin-left: 4px;
93 display: inline-flex;
94 align-items: center;
95 justify-content: center;
96 transition: background 0.2s ease-in-out;
97 }
98 button:hover {
99 background: var(--chops-gray-200);
100 }
101 .controls {
102 display: flex;
103 flex-direction: row;
104 align-items: center;
105 justify-content: flex-start;
106 width: 100%;
107 }
108 `,
109 ];
110 }
111
112 /** @override */
113 render() {
114 return html`
115 <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
116 rel="stylesheet">
117 <div class="controls">
118 <input id="file-uploader" type="file" multiple @change=${this._filesChanged}>
119 <label class="button" for="file-uploader">
120 <i class="material-icons" role="presentation">attach_file</i>Add attachments
121 </label>
122 Drop files here to add them (Max: 10.0 MB per comment)
123 </div>
124 <ul ?hidden=${!this.files || !this.files.length}>
125 ${this.files.map((file, i) => html`
126 <li>
127 ${file.name}
128 <button data-index=${i} @click=${this._removeFile}>
129 <i class="material-icons">clear</i>
130 </button>
131 </li>
132 `)}
133 </ul>
134 `;
135 }
136
137 /** @override */
138 static get properties() {
139 return {
140 files: {type: Array},
141 highlighted: {
142 type: Boolean,
143 reflect: true,
144 },
145 expanded: {
146 type: Boolean,
147 reflect: true,
148 },
149 _boundOnDragIntoWindow: {type: Object},
150 _boundOnDragOutOfWindow: {type: Object},
151 _boundOnDragInto: {type: Object},
152 _boundOnDragLeave: {type: Object},
153 _boundOnDrop: {type: Object},
154 };
155 }
156
157 /** @override */
158 constructor() {
159 super();
160
161 this.expanded = false;
162 this.highlighted = false;
163 this.files = [];
164 this._boundOnDragIntoWindow = this._onDragIntoWindow.bind(this);
165 this._boundOnDragOutOfWindow = this._onDragOutOfWindow.bind(this);
166 this._boundOnDragInto = this._onDragInto.bind(this);
167 this._boundOnDragLeave = this._onDragLeave.bind(this);
168 this._boundOnDrop = this._onDrop.bind(this);
169 }
170
171 /** @override */
172 connectedCallback() {
173 super.connectedCallback();
174 this.addEventListener('dragenter', this._boundOnDragInto);
175 this.addEventListener('dragover', this._boundOnDragInto);
176
177 this.addEventListener('dragleave', this._boundOnDragLeave);
178 this.addEventListener('drop', this._boundOnDrop);
179
180 window.addEventListener('dragenter', this._boundOnDragIntoWindow);
181 window.addEventListener('dragover', this._boundOnDragIntoWindow);
182 window.addEventListener('dragleave', this._boundOnDragOutOfWindow);
183 window.addEventListener('drop', this._boundOnDragOutOfWindow);
184 }
185
186 /** @override */
187 disconnectedCallback() {
188 super.disconnectedCallback();
189
190 window.removeEventListener('dragenter', this._boundOnDragIntoWindow);
191 window.removeEventListener('dragover', this._boundOnDragIntoWindow);
192 window.removeEventListener('dragleave', this._boundOnDragOutOfWindow);
193 window.removeEventListener('drop', this._boundOnDragOutOfWindow);
194 }
195
196 reset() {
197 this.files = [];
198 }
199
200 get hasAttachments() {
201 return this.files.length !== 0;
202 }
203
204 async loadFiles() {
205 // TODO(zhangtiff): Add preloading of files on change.
206 if (!this.files || !this.files.length) return [];
207 const loads = this.files.map(this._loadLocalFile);
208 return await Promise.all(loads);
209 }
210
211 _onDragInto(e) {
212 // Combined event handler for dragenter and dragover.
213 if (!this._eventGetFiles(e).length) return;
214 e.preventDefault();
215 this.highlighted = true;
216 }
217
218 _onDragLeave(e) {
219 // Unhighlight the drop area when the user undrops the component.
220 if (!this._eventGetFiles(e).length) return;
221 e.preventDefault();
222 this.highlighted = false;
223 }
224
225 _onDrop(e) {
226 // Add the files the user is dragging when dragging into the component.
227 const files = this._eventGetFiles(e);
228 if (!files.length) return;
229 e.preventDefault();
230 this.highlighted = false;
231 this._addFiles(files);
232 }
233
234 _onDragIntoWindow(e) {
235 // Expand the drop area when any file is being dragged in the window.
236 if (!this._eventGetFiles(e).length) return;
237 e.preventDefault();
238 this.expanded = true;
239 }
240
241 _onDragOutOfWindow(e) {
242 // Unexpand the component when a file is no longer being dragged.
243 if (!this._eventGetFiles(e).length) return;
244 e.preventDefault();
245 this.expanded = false;
246 }
247
248 _eventGetFiles(e) {
249 if (!e || !e.dataTransfer) return [];
250 const dt = e.dataTransfer;
251
252 if (dt.items && dt.items.length) {
253 const filteredItems = [...dt.items].filter(
254 (item) => item.kind === 'file');
255 return filteredItems.map((item) => item.getAsFile());
256 }
257
258 return [...dt.files];
259 }
260
261 _loadLocalFile(f) {
262 // The FileReader API only accepts callbacks for asynchronous handling,
263 // so it's easier to use Promises here. But by wrapping this logic
264 // in a Promise, we can use async/await in outer code.
265 return new Promise((resolve, reject) => {
266 const r = new FileReader();
267 r.onloadend = () => {
268 resolve({filename: f.name, content: btoa(r.result)});
269 };
270 r.onerror = () => {
271 reject(r.error);
272 };
273
274 r.readAsBinaryString(f);
275 });
276 }
277
278 /**
279 * @param {Event} e
280 * @fires CustomEvent#change
281 * @private
282 */
283 _filesChanged(e) {
284 const input = e.currentTarget;
285 if (!input.files) return;
286 this._addFiles(input.files);
287 this.dispatchEvent(new CustomEvent('change'));
288 }
289
290 _addFiles(newFiles) {
291 if (!newFiles) return;
292 // Spread files to convert it from a FileList to an Array.
293 const files = [...newFiles].filter((f1) => {
294 const matchingFile = this.files.some((f2) => this._filesMatch(f1, f2));
295 return !matchingFile;
296 });
297
298 this.files = this.files.concat(files);
299 }
300
301 _filesMatch(a, b) {
302 // NOTE: This function could return a false positive if two files have the
303 // exact same name, lastModified time, size, and type but different
304 // content. This is extremely unlikely, however.
305 return a.name === b.name && a.lastModified === b.lastModified &&
306 a.size === b.size && a.type === b.type;
307 }
308
309 _removeFile(e) {
310 const target = e.currentTarget;
311
312 // This should always be an int.
313 const index = Number.parseInt(target.dataset.index);
314 if (index < 0 || index >= this.files.length) return;
315
316 this.files.splice(index, 1);
317
318 // Trigger an update.
319 this.files = [...this.files];
320 }
321}
322customElements.define('mr-upload', MrUpload);