Skip to content

Commit

Permalink
GH-56 - Drag and drop (#125)
Browse files Browse the repository at this point in the history
* GH-56 - Drag and drop

* Support uploading depths of up to 1000 folders deep
* Filters out unsupported file types and extensions
* Supports all major browsers: Chrome, Safari, Firefox, iOS Safari
* Supports singular uploads

Resolves: GH-56

* Minor fixes
  • Loading branch information
auniverseaway committed May 6, 2024
1 parent cbfa1cd commit 78aab3a
Show file tree
Hide file tree
Showing 8 changed files with 328 additions and 22 deletions.
38 changes: 35 additions & 3 deletions blocks/browse/da-browse/da-browse.css
Expand Up @@ -265,7 +265,6 @@ input[type="checkbox"] {
.da-breadcrumb-list-item {
position: relative;
display: block;
text-transform: uppercase;
font-size: 16px;
font-weight: 700;
cursor: pointer;
Expand Down Expand Up @@ -508,15 +507,48 @@ li.da-actions-menu-item button:hover {

/* Empty list */
.empty-list {
border: 1px solid rgb(234, 234, 234);
background-color: rgb(248, 248, 248);
border: 1px solid rgb(234 234 234);
background-color: rgb(248 248 248);
border-radius: 6px;
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}

/* Drag & Drop */
.da-browse-panel {
position: relative;
}

.da-browse-panel.is-dragged-over > * {
position: relative;
opacity: 0.1;
}

.da-drop-area {
display: none;
justify-content: center;
align-items: center;
border-radius: 6px;
background-color: rgb(180 255 175 / 23%);
border: 2px dotted rgb(0 194 68);
z-index: 1;
}

.da-drop-area::after {
font-size: 24px;
font-weight: 700;
content: attr(data-message);
}

.da-browse-panel.is-dragged-over > .da-drop-area {
display: flex;
opacity: 1;
position: absolute;
inset: 0;
}

@media (min-width: 900px) {
.da-breadcrumb {
margin-bottom: 12px;
Expand Down
75 changes: 71 additions & 4 deletions blocks/browse/da-browse/da-browse.js
Expand Up @@ -27,6 +27,8 @@ export default class DaBrowse extends LitElement {
_createName: { state: true },
_createFile: { state: true },
_fileLabel: { state: true },
_dropFiles: { state: true },
_dropMessage: { state: true },
_canPaste: {},
};

Expand All @@ -38,6 +40,8 @@ export default class DaBrowse extends LitElement {
this._createName = '';
this._createFile = '';
this._fileLabel = 'Select file';
this._dropFiles = [];
this._dropMessage = 'Drop content here';
this._tabItems = [
{ id: 'browse', label: 'Browse', selected: true },
{ id: 'search', label: 'Search', selected: false },
Expand Down Expand Up @@ -104,9 +108,12 @@ export default class DaBrowse extends LitElement {
window.location = editPath;
} else {
await saveToDa({ path });
const item = { name: this._createName, path };
if (ext) item.ext = ext;
this._listItems.unshift(item);
const hasName = this._listItems.some((item) => item.name === this._createName);
if (!hasName) {
const item = { name: this._createName, path };
if (ext) item.ext = ext;
this._listItems.unshift(item);
}
}
this.resetCreate();
this.requestUpdate();
Expand Down Expand Up @@ -273,6 +280,62 @@ export default class DaBrowse extends LitElement {
this.searchItems = e.detail.items;
}

dragenter(e) {
e.stopPropagation();
e.target.closest('.da-browse-panel').classList.add('is-dragged-over');
e.preventDefault();
}

dragleave(e) {
if (!e.target.classList.contains('da-drop-area')) return;
e.target.closest('.da-browse-panel').classList.remove('is-dragged-over');
e.preventDefault();
}

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

setDropMessage() {
const { length } = this._dropFiles.filter((file) => !file.imported);
if (length === 0) {
this._dropMessage = 'Drop content here';
return;
}
const prefix = `Importing - ${length} `;
const suffix = length === 1 ? 'item' : 'items';
this._dropMessage = `${prefix} ${suffix}`;
}

async drop(e) {
e.preventDefault();
const { fullpath } = this.details;
const items = e.dataTransfer?.items;
if (!items) return;

const entries = [...items].map((item) => item.webkitGetAsEntry());
const makeBatches = (await import(`${getNx()}/utils/batch.js`)).default;
const { getFullEntryList, handleUpload } = await import('./helpers/drag-n-drop.js');
this._dropFiles = await getFullEntryList(entries);

this.setDropMessage();

const batches = makeBatches(this._dropFiles);
for (const batch of batches) {
await Promise.all(batch.map(async (file) => {
const item = await handleUpload(this._listItems, fullpath, file);
this.setDropMessage();
if (item) {
this._listItems.unshift(item);
this.requestUpdate();
}
}));
}
this._dropFiles = [];
this.setDropMessage();
e.target.shadowRoot.querySelector('.da-browse-panel').classList.remove('is-dragged-over');
}

renderConfig(length, crumb, idx) {
if (this.details.depth <= 2 && idx + 1 === length) {
return html`
Expand Down Expand Up @@ -423,7 +486,11 @@ export default class DaBrowse extends LitElement {
}

renderBrowse() {
return html`${this._listItems?.length > 0 ? this.listView(this._listItems, true) : this.emptyView()}`;
return html`
<div class="da-browse-panel" @dragenter=${this.dragenter} @dragleave=${this.dragleave}>
${this._listItems?.length > 0 ? this.listView(this._listItems, true) : this.emptyView()}
<div class="da-drop-area" data-message=${this._dropMessage} @dragover=${this.dragover} @drop=${this.drop}></div>
</div>`;
}

render() {
Expand Down
112 changes: 112 additions & 0 deletions blocks/browse/da-browse/helpers/drag-n-drop.js
@@ -0,0 +1,112 @@
import { SUPPORTED_FILES, DA_ORIGIN } from '../../../shared/constants.js';
import { daFetch } from '../../../shared/utils.js';

const MAX_DEPTH = 1000;

function traverseFolder(entry) {
const reader = entry.createReader();
// Resolved when the entire directory is traversed
return new Promise((resolveDirectory) => {
const iterationAttempts = [];
const errorHandler = () => {};
function readEntries() {
// According to the FileSystem API spec, readEntries() must be called until
// it calls the callback with an empty array.
reader.readEntries((batchEntries) => {
if (!batchEntries.length) {
// Done iterating this folder
resolveDirectory(Promise.all(iterationAttempts));
} else {
// Add a list of promises for each directory entry. If the entry is itself
// a directory, then that promise won't resolve until it is fully traversed.
iterationAttempts.push(Promise.all(batchEntries.map((batchEntry) => {
if (batchEntry.isDirectory) {
return traverseFolder(batchEntry);
}
return Promise.resolve(batchEntry);
})));
// Try calling readEntries() again for the same dir, according to spec
readEntries();
}
}, errorHandler);
}
// Initial call to recursive entry reader function
readEntries();
});
}

function packageFile(file, entry) {
const { name } = file;
let { type } = file;

// No content type fallback
const ext = (file.name || '').split('.').pop();
if (!type) type = SUPPORTED_FILES[ext];

// Check if supported type
const isSupported = Object.keys(SUPPORTED_FILES)
.some((key) => type === SUPPORTED_FILES[key]);
if (!isSupported) return null;

// Sanitize path
const path = entry.fullPath.replaceAll(' ', '-').toLowerCase();
return { data: file, name, type, ext, path };
}

function getFile(entry) {
return new Promise((resolve) => {
const callback = (file) => { resolve(packageFile(file, entry)); };
entry.file(callback);
});
}

export async function getFullEntryList(entries) {
const folderEntries = [];
const fileEntries = [];

for (const entry of entries) {
if (entry.isDirectory) {
folderEntries.push(entry);
} else {
fileEntries.push(entry);
}
}

for (const entry of folderEntries) {
const traversed = await traverseFolder(entry);
fileEntries.push(...traversed.flat(MAX_DEPTH));
}

const files = await Promise.all(fileEntries.map((entry) => getFile(entry)));
return files.filter((file) => file);
}

export async function handleUpload(list, fullpath, file) {
const { data, path } = file;
const formData = new FormData();
formData.append('data', data);
const opts = { method: 'POST', body: formData };
const postpath = `${fullpath}${path}`;

try {
await daFetch(`${DA_ORIGIN}/source${postpath}`, opts);
file.imported = true;

const [displayName] = path.split('/').slice(1);
const [filename, ...rest] = displayName.split('.');
const ext = rest.pop();
const rejoined = [filename, ...rest].join('.');

const listHasName = list.some((item) => item.name === rejoined);

if (listHasName) return null;

const item = { name: rejoined, path: `${fullpath}/${displayName}` };
if (ext) item.ext = ext;

return item;
} catch (e) {
console.log(e);

Check warning on line 109 in blocks/browse/da-browse/helpers/drag-n-drop.js

View workflow job for this annotation

GitHub Actions / Running tests (20.x)

Unexpected console statement
}
return null;
}
12 changes: 12 additions & 0 deletions blocks/shared/constants.js
@@ -1,6 +1,18 @@
export const CON_ORIGIN = 'https://content.da.live';
export const AEM_ORIGIN = 'https://admin.hlx.page';

export const SUPPORTED_FILES = {
html: 'text/html',
jpeg: 'image/jpeg',
json: 'application/json',
jpg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
mp4: 'video/mp4',
pdf: 'application/pdf',
svg: 'image/svg+xml',
};

const DA_ADMIN_ENVS = {
local: 'http://localhost:8787',
stage: 'https://stage-admin.da.live',
Expand Down
8 changes: 7 additions & 1 deletion blocks/shared/utils.js
Expand Up @@ -20,13 +20,19 @@ export async function initIms() {

export const daFetch = async (url, opts = {}) => {
opts.headers = opts.headers || {};
let accessToken;
if (localStorage.getItem('nx-ims')) {
const { accessToken } = await initIms();
({ accessToken } = await initIms());
const canToken = ALLOWED_TOKEN.some((origin) => url.startsWith(origin));
if (accessToken && canToken) opts.headers.Authorization = `Bearer ${accessToken.token}`;
}
const resp = await fetch(url, opts);
if (resp.status === 401) {
if (accessToken) {
window.location = `${window.location.origin}/not-found`;
return { ok: false };
}

const { loadIms, handleSignIn } = await import(`${getNx()}/utils/ims.js`);
await loadIms();
handleSignIn();
Expand Down
2 changes: 1 addition & 1 deletion blocks/sheet/index.js
Expand Up @@ -8,7 +8,7 @@ const loadScript = (await import(`${getNx()}/utils/script.js`)).default;
const SHEET_TEMPLATE = { minDimensions: [20, 20], sheetName: 'data' };

function resetSheets(el) {
el.querySelector('da-sheet-tabs')?.remove();
document.querySelector('da-sheet-tabs')?.remove();
if (!el.jexcel) return;
delete el.jexcel;
el.innerHTML = '';
Expand Down
19 changes: 6 additions & 13 deletions scripts/scripts.js
Expand Up @@ -46,22 +46,15 @@ function loadStyles() {
}

export default async function loadPage() {
// TODO: AEM markup doesn't do colspan in blocks correctly
const divs = document.querySelectorAll('div[class] div');
divs.forEach((div) => { if (div.innerHTML.trim() === '') div.remove(); });

await loadArea();
}

// Side-effects
(async function daPreview() {
const { searchParams } = new URL(window.location.href);
if (searchParams.get('dapreview') === 'on') {
const { default: livePreview } = await import('./dapreview.js');
livePreview(loadPage);
}
}());

loadStyles();
decorateArea();
loadPage();

// Side-effects
(async function loadDa() {
if (!new URL(window.location.href).searchParams.get('dapreview')) return;
import('https://da.live/scripts/dapreview.js').then(({ default: daPreview }) => daPreview(loadPage));
}());

0 comments on commit 78aab3a

Please sign in to comment.