diff --git a/index.scss b/index.scss index eb6e471b..20774216 100644 --- a/index.scss +++ b/index.scss @@ -34,3 +34,4 @@ @import "./src/styles/notifications"; @import "./src/styles/login"; @import "./src/styles/search"; +@import "./src/styles/iconview"; diff --git a/src/adapters/ui/iconview.js b/src/adapters/ui/iconview.js new file mode 100644 index 00000000..02cb31fb --- /dev/null +++ b/src/adapters/ui/iconview.js @@ -0,0 +1,223 @@ +/* + * OS.js - JavaScript Cloud/Web Desktop Platform + * + * Copyright (c) 2011-2019, Anders Evenrud + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * @author Anders Evenrud + * @licence Simplified BSD License + */ +import {EventEmitter} from '@osjs/event-emitter'; +import {h, app} from 'hyperapp'; +import {doubleTap} from '../../utils/input'; + +const tapper = doubleTap(); + +const view = (fileIcon, themeIcon, droppable) => (state, actions) => + h('div', { + class: 'osjs-desktop-iconview__wrapper', + oncontextmenu: ev => actions.openContextMenu({ev}), + oncreate: el => { + droppable(el, { + ondrop: (ev, data, files) => { + if (data && data.path) { + actions.addEntry(data); + } else if (files.length > 0) { + actions.uploadEntries(files); + } + } + }); + } + }, 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' + }, [ + h('div', { + class: 'osjs-desktop-iconview__entry__icon' + }, h('img', { + src: themeIcon(fileIcon(entry).name) + })), + h('div', { + class: 'osjs-desktop-iconview__entry__label' + }, entry.filename) + ]) + ]); + })); + +/** + * Desktop Icon View + */ +export class DesktopIconView extends EventEmitter { + + /** + * @param {Core} core Core reference + */ + constructor(core) { + super('DesktopIconView'); + + this.core = core; + this.$root = null; + this.iconview = null; + this.root = 'home:/.desktop'; + } + + destroy() { + if (this.$root && this.$root.parentNode) { + this.$root.parentNode.removeChild(this.$root); + } + + this.iconview = null; + this.$root = null; + + this.emit('destroy'); + } + + /** + * @param {object} rect Rectangle from desktop + */ + resize(rect) { + if (!this.$root) { + return; + } + + this.$root.style.top = `${rect.top}px`; + this.$root.style.left = `${rect.left}px`; + this.$root.style.bottom = `${rect.bottom}px`; + this.$root.style.right = `${rect.right}px`; + } + + _render(root) { + const oldRoot = this.root; + if (root) { + this.root = root; + } + + if (this.$root) { + if (this.root !== oldRoot) { + this.iconview.reload(); + } + + return false; + } + + return true; + } + + render(root) { + if (!this._render(root)) { + return; + } + + this.$root = document.createElement('div'); + this.$root.className = 'osjs-desktop-iconview'; + this.core.$root.appendChild(this.$root); + + const {droppable} = this.core.make('osjs/dnd'); + const {icon: fileIcon} = this.core.make('osjs/fs'); + const {icon: themeIcon} = this.core.make('osjs/theme'); + const {copy, readdir, unlink} = this.core.make('osjs/vfs'); + const error = err => console.error(err); + + this.iconview = app({ + selected: -1, + entries: [] + }, { + setEntries: entries => ({entries}), + + openContextMenu: ({ev, entry}) => { + if (entry) { + this.createFileContextMenu(ev, entry); + } + }, + + openEntry: ({entry}) => { + if (entry.isDirectory) { + this.core.run('FileManager', { + path: entry + }); + } else { + this.core.open(entry); + } + + return {selected: -1}; + }, + + selectEntry: ({index}) => ({selected: index}), + + uploadEntries: files => { + // TODO + }, + + addEntry: entry => (state, actions) => { + const dest = `${root}/${entry.filename}`; + + copy(entry, dest) + .then(() => actions.reload()) + .catch(error); + + return {selected: -1}; + }, + + removeEntry: entry => (state, actions) => { + unlink(entry) + .then(() => actions.reload()) + .catch(error); + + return {selected: -1}; + }, + + reload: () => (state, actions) => { + readdir(root) + .then(entries => entries.filter(e => e.filename !== '..')) + .then(entries => actions.setEntries(entries)); + } + + }, view(fileIcon, themeIcon, droppable), this.$root); + + this.iconview.reload(); + } + + createFileContextMenu(ev, entry) { + this.core.make('osjs/contextmenu', { + position: ev, + menu: [{ + label: 'Open', + onclick: () => this.iconview.openEntry(({entry})) + }, { + label: 'Remove', + onclick: () => this.iconview.removeEntry(entry) + }] + }); + } +} diff --git a/src/config.js b/src/config.js index d493f786..f970ec12 100644 --- a/src/config.js +++ b/src/config.js @@ -177,7 +177,8 @@ export const defaultConfiguration = { style: 'cover' }, iconview: { - enabled: false + enabled: false, + path: 'home:/.desktop' } } }, diff --git a/src/desktop.js b/src/desktop.js index 752ffb6d..2e2018b2 100644 --- a/src/desktop.js +++ b/src/desktop.js @@ -32,6 +32,7 @@ import {EventEmitter} from '@osjs/event-emitter'; import Application from './application'; import {handleTabOnTextarea} from './utils/dom'; import {matchKeyCombo} from './utils/input'; +import {DesktopIconView} from './adapters/ui/iconview'; import Window from './window'; import Search from './search'; import merge from 'deepmerge'; @@ -121,7 +122,7 @@ export default class Desktop extends EventEmitter { this.core = core; this.options = Object.assign({ - contextmenu: [] + contextmenu: [], }, options); this.$theme = []; this.$icons = []; @@ -129,6 +130,8 @@ export default class Desktop extends EventEmitter { this.$styles.setAttribute('type', 'text/css'); this.contextmenuEntries = []; this.search = core.config('search.enabled') ? new Search(core) : null; + this.iconview = new DesktopIconView(this.core); + this.subtract = { left: 0, top: 0, @@ -145,9 +148,14 @@ export default class Desktop extends EventEmitter { this.search = this.search.destroy(); } + if (this.iconview) { + this.iconview.destroy(); + } + if (this.$styles && this.$styles.parentNode) { this.$styles.remove(); } + this.$styles = null; this._removeIcons(); @@ -212,6 +220,7 @@ export default class Desktop extends EventEmitter { try { this._updateCSS(); Window.getWindows().forEach(w => w.clampToViewport()); + this._updateIconview(); } catch (e) { logger.warn('Panel event error', e); } @@ -394,7 +403,6 @@ export default class Desktop extends EventEmitter { this.core.on('osjs/settings:load', checkRTL); this.core.on('osjs/settings:save', checkRTL); this.core.on('osjs/core:started', checkRTL); - } start() { @@ -403,6 +411,13 @@ export default class Desktop extends EventEmitter { } this._updateCSS(); + this._updateIconview(); + } + + _updateIconview() { + if (this.iconview) { + this.iconview.resize(this.getRect()); + } } /** @@ -466,6 +481,8 @@ export default class Desktop extends EventEmitter { this.applyTheme(newSettings.theme); this.applyIcons(newSettings.icons); + this.applyIconView(newSettings.iconview); + this.core.emit('osjs/desktop:applySettings'); return Object.assign({}, newSettings); @@ -506,6 +523,22 @@ export default class Desktop extends EventEmitter { this.$icons = []; } + /** + * Adds or removes the icon view + */ + applyIconView(settings) { + if (!this.iconview) { + return; + } + + if (settings.enabled) { + this.iconview.render(settings.path); + this.iconview.resize(this.getRect()); + } else { + this.iconview.destroy(); + } + } + /** * Sets the current icon theme from settings */ diff --git a/src/styles/_iconview.scss b/src/styles/_iconview.scss new file mode 100644 index 00000000..599bd535 --- /dev/null +++ b/src/styles/_iconview.scss @@ -0,0 +1,101 @@ +/* + * OS.js - JavaScript Cloud/Web Desktop Platform + * + * Copyright (c) 2011-2019, Anders Evenrud + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * @author Anders Evenrud + * @licence Simplified BSD License + */ + +.osjs-desktop-iconview { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + + &__wrapper { + position: absolute; + top: 1em; + left: 1em; + width: calc(100% - 2em); + height: calc(100% - 2em); + } + + &__entry { + $self: &; + + vertical-align: top; + display: inline-block; + position: relative; + z-index: 0; + text-align: center; + overflow: hidden; + margin: 0.5em; + width: 5em; + height: 6.5em; + + &__inner { + width: 100%; + min-height: 100%; + } + + &__icon { + flex-grow: 1; + height: 4.5em; + width: 100%; + padding: 0.5em; + box-sizing: border-box; + } + + &__label { + height: 1em; + width: 100%; + box-sizing: border-box; + line-height: 1; + overflow: hidden; + text-align: center; + word-break: break-all; + text-overflow: ellipsis; + } + + &--selected { + z-index: 1; + overflow: visible; + + #{ $self }__icon { + background-color: rgba(0, 0, 200, 0.9); + } + + #{ $self }__label { + padding: 0 0.5em 0.5em 0.5em; + background-color: rgba(0, 0, 200, 0.9); + color: #fff; + overflow: visible; + height: auto; + overflow-wrap: break-word; + } + } + } +} diff --git a/src/utils/input.js b/src/utils/input.js index d9aad38d..551d91a8 100644 --- a/src/utils/input.js +++ b/src/utils/input.js @@ -60,3 +60,27 @@ export const getEvent = (ev) => { return {clientX, clientY, touch: touch.length > 0, target}; }; + +/** + * Creates a double-tap event handler + * @param {number} [timeout=250] Timeout + * @return {Function} Handler with => (ev, cb) + */ +export const doubleTap = (timeout = 250) => { + let tapped = false; + let timer; + + return (ev, cb) => { + timer = clearTimeout(timer); + timer = setTimeout(() => (tapped = false), timeout); + + if (tapped) { + ev.preventDefault(); + return cb(ev); + } + + tapped = true; + + return false; + }; +};