diff --git a/src/adapters/ui/iconview.js b/src/adapters/ui/iconview.js index 81ed4df9..aeeffb95 100644 --- a/src/adapters/ui/iconview.js +++ b/src/adapters/ui/iconview.js @@ -29,25 +29,69 @@ */ import {EventEmitter} from '@osjs/event-emitter'; import {h, app} from 'hyperapp'; +import {draggable, droppable} from '../../utils/dnd'; +import {emToPx} from '../../utils/dom'; import {doubleTap} from '../../utils/input'; import {pathJoin} from '../../utils/vfs'; const tapper = doubleTap(); const validVfsDrop = data => data && data.path; +const validInternalDrop = data => data && data.internal; +/** + * Drop handler + */ const onDropAction = actions => (ev, data, files, shortcut = true) => { if (validVfsDrop(data)) { - actions.addEntry({entry: data, shortcut}); + actions.addEntry({entry: data, shortcut, ev}); + } else if(validInternalDrop(data)) { + actions.moveEntry({entry: data.internal, ev}); } else if (files.length > 0) { actions.uploadEntries(files); } }; +/** + * Checks event is on a root element + */ const isRootElement = ev => ev.target && ev.target.classList.contains('osjs-desktop-iconview__wrapper'); -const view = (fileIcon, themeIcon, droppable) => (state, actions) => +/** + * Creates UI icon styles + */ +const createIconStyle = (entry, grid, enabled) => { + const [left, top] = grid.getPosition(entry._position || [0, 0]); + + return enabled ? { + position: 'absolute', + top: String(top) + 'px', + left: String(left) + 'px' + } : {}; +}; + +/** + * Creates UI drop ghost + */ +const createGhostStyle = (grid, ghost, enabled) => { + const style = {}; + if (ghost instanceof Event) { + const [col, row] = grid.getEventPosition(ghost); + style.top = row + 'px'; + style.left = col + 'px'; + } + + return Object.assign({ + position: enabled ? 'absolute' : undefined, + display: ghost ? undefined : 'none' + }, style); +}; + +/** + * Creates UI view + */ +const view = (fileIcon, themeIcon, grid) => (state, actions) => h('div', { class: 'osjs-desktop-iconview__wrapper', oncontextmenu: ev => { @@ -68,45 +112,168 @@ const view = (fileIcon, themeIcon, droppable) => (state, actions) => } else { onDropAction(actions)(ev, data, files); } - } + + actions.setGhost(false); + }, + + ondragleave: () => actions.setGhost(false), + ondragenter: () => actions.setGhost(true), + ondragover: ev => actions.setGhost(ev) }); } - }, state.entries.map((entry, index) => { - return h('div', { - class: 'osjs-desktop-iconview__entry' + ( - state.selected === index - ? ' osjs-desktop-iconview__entry--selected' - : '' - ), - oncontextmenu: ev => actions.openContextMenu({ev, entry, index}), - ontouchstart: ev => tapper(ev, () => actions.openEntry({ev, entry, index})), - ondblclick: ev => actions.openEntry({ev, entry, index}), - onclick: ev => actions.selectEntry({ev, entry, index}) - }, [ - h('div', { - class: 'osjs-desktop-iconview__entry__inner' + }, [ + ...state.entries.map((entry, index) => { + return h('div', { + style: createIconStyle(entry, grid, state.grid), + class: 'osjs-desktop-iconview__entry' + ( + state.selected === index + ? ' osjs-desktop-iconview__entry--selected' + : '' + ), + oncontextmenu: ev => actions.openContextMenu({ev, entry, index}), + ontouchstart: ev => tapper(ev, () => actions.openEntry({ev, entry, index})), + ondblclick: ev => actions.openEntry({ev, entry, index}), + onclick: ev => actions.selectEntry({ev, entry, index}), + oncreate: el => { + draggable(el, { + data: {internal: entry} + }); + } }, [ h('div', { - class: 'osjs-desktop-iconview__entry__icon' + class: 'osjs-desktop-iconview__entry__inner' }, [ - h('img', { - src: entry.icon ? entry.icon : themeIcon(fileIcon(entry).name), - class: 'osjs-desktop-iconview__entry__icon__icon' - }), - entry.shortcut !== false - ? h('img', { - src: themeIcon('emblem-symbolic-link'), - class: 'osjs-desktop-iconview__entry__icon__shortcut' - }) - : null - ]), - h('div', { - class: 'osjs-desktop-iconview__entry__label' - }, entry.filename) - ]) - ]); - })); + h('div', { + class: 'osjs-desktop-iconview__entry__icon' + }, [ + h('img', { + src: entry.icon ? entry.icon : themeIcon(fileIcon(entry).name), + class: 'osjs-desktop-iconview__entry__icon__icon' + }), + entry.shortcut !== false + ? h('img', { + src: themeIcon('emblem-symbolic-link'), + class: 'osjs-desktop-iconview__entry__icon__shortcut' + }) + : null + ]), + h('div', { + class: 'osjs-desktop-iconview__entry__label' + }, entry.filename) + ]) + ]); + }), + h('div', { + class: 'osjs-desktop-iconview__entry osjs-desktop-iconview__entry--ghost', + style: createGhostStyle(grid, state.ghost, state.grid) + }) + ]); + +/** + * Handles grid + * FIXME: Excessive render on drop events + */ +const createGrid = (root) => { + + // TODO: Needs real values + const ICON_WIDTH = 5.0; // ems + const ICON_HEIGHT = 6.5; // ems + const ICON_MARGIN = 0.5; // ems + + /* eslint-disable no-unused-vars */ + let rows = 0; + let cols = 0; + let sizeX = 0; + let sizeY = 0; + let positions = []; + + const resize = () => { + const {offsetWidth, offsetHeight} = root; + sizeX = emToPx(ICON_WIDTH) + (emToPx(ICON_MARGIN) * 2); + sizeY = emToPx(ICON_HEIGHT) + (emToPx(ICON_MARGIN) * 2); + cols = Math.floor(offsetWidth / sizeX); + rows = Math.floor(offsetHeight / sizeY); + }; + + const load = () => { + // TODO: Use internal storage + positions = JSON.parse( + localStorage.getItem( + '___osjs_iconview_positions' + ) || '[]' + ); + }; + + const save = () => { + // TODO: Use internal storage + return localStorage.setItem( + '___osjs_iconview_positions', + JSON.stringify(positions || []) + ); + }; + + const getOffset = ev => ([ + Math.floor(ev.clientX / sizeX), + Math.floor(ev.clientY / sizeY) + ]); + + const getPosition = ([left, top]) => ([ + left * sizeX, + top * sizeY + ]); + + const getEventPosition = ev => { + const [col, row] = getOffset(ev); + return [col * sizeX, row * sizeY]; + }; + + const isBusy = (ev, entries) => { + const [col, row] = getOffset(ev); + return entries.findIndex(e => { + return e._position[0] === col && + e._position[1] === row; + }) !== -1; + }; + + const calculate = entries => { + const savedPositions = entries.map(entry => { + const key = entry.shortcut === false ? entry.filename : entry.shortcut; + const found = positions.findIndex(s => s.key === key); + return found === -1 ? undefined : positions[found].position; + }); + + return entries.map((entry, index) => { + const x = index % cols; + const y = Math.floor(index / cols); + const _position = savedPositions[index] || [x, y]; + + return Object.assign(entry, {_position}); + }); + }; + + const move = (ev, key) => { + const [col, row] = getOffset(ev); + const found = positions.findIndex(s => s.key === key); + + const position = [col, row]; + const value = {key, position}; + + if (found !== -1) { + positions[found] = value; + } else { + positions.push(value); + } + + return save(); + }; + + return {resize, load, save, calculate, move, isBusy, getPosition, getEventPosition}; +}; + +/** + * Handles shortcuts + */ const createShortcuts = (root, readfile, writefile) => { const read = () => { const filename = pathJoin(root, '.shortcuts.json'); @@ -121,7 +288,8 @@ const createShortcuts = (root, readfile, writefile) => { const contents = JSON.stringify(shortcuts || []); return writefile(filename, contents) - .catch(() => 0); + .then(() => shortcuts.length - 1) + .catch(() => -1); }; const add = entry => read(root) @@ -138,6 +306,9 @@ const createShortcuts = (root, readfile, writefile) => { return {read, add, remove}; }; +/** + * Wrapper for handling reading the desktop folder + */ const readDesktopFolder = (root, readdir, shortcuts) => { const read = () => readdir(root, { showHiddenFiles: false @@ -193,12 +364,16 @@ export class DesktopIconView extends EventEmitter { this.$root.style.left = `${rect.left}px`; this.$root.style.bottom = `${rect.bottom}px`; this.$root.style.right = `${rect.right}px`; + + if (this.iconview) { + this.iconview.resize(); + } } - _render(root) { + _render(settings) { const oldRoot = this.root; - if (root) { - this.root = root; + if (settings.path) { + this.root = settings.path; } if (this.$root) { @@ -206,14 +381,16 @@ export class DesktopIconView extends EventEmitter { this.iconview.reload(); } + this.iconview.toggleGrid(settings.grid); + return false; } return true; } - render(root) { - if (!this._render(root)) { + render(settings) { + if (!this._render(settings)) { return; } @@ -221,19 +398,26 @@ export class DesktopIconView extends EventEmitter { this.$root.className = 'osjs-desktop-iconview'; this.core.$root.appendChild(this.$root); - const {droppable} = this.core.make('osjs/dnd'); + const root = settings.path; const {icon: fileIcon} = this.core.make('osjs/fs'); const {icon: themeIcon} = this.core.make('osjs/theme'); const {copy, readdir, readfile, writefile, unlink, mkdir} = this.core.make('osjs/vfs'); const error = err => console.error(err); const shortcuts = createShortcuts(root, readfile, writefile); const read = readDesktopFolder(root, readdir, shortcuts); + const grid = createGrid(this.$root); + + grid.load(); this.iconview = app({ selected: -1, - entries: [] + entries: [], + ghost: false, + grid: settings.grid }, { - setEntries: entries => ({entries}), + setEntries: entries => state => ({ + entries: grid.calculate(entries) + }), openDropContextMenu: ({ev, data, files}) => { this.createDropContextMenu(ev, data, files); @@ -274,7 +458,7 @@ export class DesktopIconView extends EventEmitter { // TODO }, - addEntry: ({entry, shortcut}) => (state, actions) => { + addEntry: ({entry, shortcut, ev}) => (state, actions) => { const dest = `${root}/${entry.filename}`; mkdir(root) @@ -285,9 +469,11 @@ export class DesktopIconView extends EventEmitter { } return copy(entry, dest) + .then(() => grid.move(ev, entry.filename)) .then(() => actions.reload()) .catch(error); }) + .then(key => grid.move(ev, key)) .then(() => actions.reload()); return {selected: -1}; @@ -307,13 +493,34 @@ export class DesktopIconView extends EventEmitter { return {selected: -1}; }, + moveEntry: ({entry, ev}) => (state) => { + if (!grid.isBusy(ev, state.entries)) { + const key = entry.shortcut === false ? entry.filename : entry.shortcut; + grid.move(ev, key); + + return {entries: grid.calculate(state.entries)}; + } + return {}; + }, + reload: () => (state, actions) => { read() .then(entries => entries.filter(e => e.filename !== '..')) .then(entries => actions.setEntries(entries)); - } + }, + + resize: () => { + grid.resize(); + }, + + toggleGrid: enabled => ({grid}) => { + return {grid: Object.assign(grid, {enabled})}; + }, - }, view(fileIcon, themeIcon, droppable), this.$root); + setGhost: ev => { + return {ghost: ev}; + } + }, view(fileIcon, themeIcon, grid), this.$root); this.iconview.reload(); } diff --git a/src/config.js b/src/config.js index c5335b13..0613bc91 100644 --- a/src/config.js +++ b/src/config.js @@ -180,7 +180,8 @@ export const defaultConfiguration = { }, iconview: { enabled: false, - path: 'home:/.desktop' + path: 'home:/.desktop', + grid: true } } }, diff --git a/src/desktop.js b/src/desktop.js index f183cb99..2320eca9 100644 --- a/src/desktop.js +++ b/src/desktop.js @@ -532,7 +532,7 @@ export default class Desktop extends EventEmitter { } if (settings.enabled) { - this.iconview.render(settings.path); + this.iconview.render(settings); this.iconview.resize(this.getRect()); } else { this.iconview.destroy(); diff --git a/src/styles/_iconview.scss b/src/styles/_iconview.scss index 4868b493..8a2b8a3d 100644 --- a/src/styles/_iconview.scss +++ b/src/styles/_iconview.scss @@ -88,6 +88,11 @@ text-overflow: ellipsis; } + &--ghost { + pointer-events: none; + background-color: rgba(255, 255, 255, 0.1); + } + &--selected { z-index: 1; overflow: visible; diff --git a/src/utils/dom.js b/src/utils/dom.js index 1650aff1..edcd264f 100644 --- a/src/utils/dom.js +++ b/src/utils/dom.js @@ -253,3 +253,18 @@ export const createNativeNotification = (options, onclick) => { return Promise.reject('Unsupported'); }; + +/** + * Calculates pixel size from em + * @param {number} em Em size + * @param {DOMElement} [root=document.body] Optional root element + * @return {number} Size in pixels + */ +export const emToPx = (em, root = document.body) => { + const px = parseInt( + getComputedStyle(root).fontSize, + 10 + ); + + return Math.round(px * em); +};