src/adapters/ui/iconview.js
/*
* OS.js - JavaScript Cloud/Web Desktop Platform
*
* Copyright (c) Anders Evenrud <andersevenrud@gmail.com>
* 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 <andersevenrud@gmail.com>
* @license Simplified BSD License
*/
import {EventEmitter} from '@osjs/event-emitter';
import {h, app} from 'hyperapp';
import {doubleTap} from '../../utils/input';
import {pathJoin} from '../../utils/vfs';
import {invertHex} from '../../utils/colors';
import {isDroppingImage, validVfsDrop} from '../../utils/desktop';
const tapper = doubleTap();
const onDropAction = actions => (ev, data, files, shortcut = true) => {
if (validVfsDrop(data)) {
actions.addEntry({entry: data, shortcut});
} else if (files.length > 0) {
actions.uploadEntries(files);
}
};
const createLabelComputer = (core) => {
const packages = f => core.make('osjs/packages').getPackages(f)[0];
const translate = n => core.make('osjs/locale').translatableFlat(n);
return ({filename, mime, label}) => {
const metadata = (mime === 'osjs/application' ? packages(pkg => (pkg.name === filename)) : null);
return label || (metadata ? translate(metadata.title) : filename);
};
};
const isRootElement = ev =>
ev.target && ev.target.classList.contains('osjs-desktop-iconview__wrapper');
const view = (computeLabel, fileIcon, themeIcon, droppable) => (state, actions) =>
h('div', {
class: 'osjs-desktop-iconview__wrapper',
oncontextmenu: ev => {
if (isRootElement(ev)) {
actions.openContextMenu({ev});
}
},
onclick: ev => {
if (isRootElement(ev)) {
actions.selectEntry({index: -1});
}
},
oncreate: el => {
droppable(el, {
ondrop: (ev, data, files) => {
const droppedImage = isDroppingImage(data);
if (droppedImage || (ev.shiftKey && validVfsDrop(data))) {
actions.openDropContextMenu({ev, data, files});
} else {
onDropAction(actions)(ev, data, 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: 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'
}, computeLabel(entry))
])
]);
}));
const createShortcuts = (root, readfile, writefile) => {
const read = () => {
const filename = pathJoin(root, '.shortcuts.json');
return readfile(filename)
.then(contents => JSON.parse(contents))
.catch(error => ([]));
};
const write = shortcuts => {
const filename = pathJoin(root, '.shortcuts.json');
const contents = JSON.stringify(shortcuts || []);
return writefile(filename, contents)
.catch(() => 0);
};
const add = entry => read(root)
.then(shortcuts => ([...shortcuts, entry]))
.then(write);
const remove = index => read(root)
.then(shortcuts => {
shortcuts.splice(index, 1);
return shortcuts;
})
.then(write);
return {read, add, remove};
};
const readDesktopFolder = (root, readdir, shortcuts) => {
const supressError = () => [];
const read = () => readdir(root, {
showHiddenFiles: false
})
.then(files => files.map(s => ({shortcut: false, ...s})))
.catch(supressError);
const readShortcuts = () => shortcuts.read()
.then(shortcuts => shortcuts.map((s, index) => ({shortcut: index, ...s})))
.catch(supressError);
return () => {
return Promise.all([readShortcuts(), read()])
.then(results => [].concat(...results));
};
};
/**
* 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'; // Default path, changed later
}
destroy() {
if (this.$root && this.$root.parentNode) {
this.$root.remove();
}
this.iconview = null;
this.$root = null;
this.emit('destroy');
super.destroy();
}
_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.$contents.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, 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 computeLabel = createLabelComputer(this.core);
this.iconview = app({
selected: -1,
entries: []
}, {
setEntries: entries => ({entries}),
openDropContextMenu: ({ev, data, files, droppedImage}) => {
this.createDropContextMenu(ev, data, files, droppedImage);
},
openContextMenu: ({ev, entry, index}) => {
if (entry) {
this.createFileContextMenu(ev, entry);
return {selected: index};
} else {
this.createRootContextMenu(ev);
return {selected: -1};
}
},
openEntry: ({entry, forceDialog}) => {
if (entry.isDirectory) {
this.core.run('FileManager', {
path: entry
});
} else if (entry.mime === 'osjs/application') {
this.core.run(entry.filename);
} else {
this.core.open(entry, {
useDefault: true,
forceDialog
});
}
return {selected: -1};
},
selectEntry: ({index}) => ({selected: index}),
uploadEntries: files => {
// TODO
},
addEntry: ({entry, shortcut}) => (state, actions) => {
const dest = `${root}/${entry.filename}`;
mkdir(root)
.catch(() => true)
.then(() => {
if (shortcut || entry.mime === 'osjs/application') {
return shortcuts.add(entry);
}
return copy(entry, dest)
.then(() => actions.reload(true))
.catch(error);
})
.then(() => actions.reload(true));
return {selected: -1};
},
removeEntry: entry => (state, actions) => {
if (entry.shortcut !== false) {
shortcuts.remove(entry.shortcut)
.then(() => actions.reload(true))
.catch(error);
} else {
unlink(entry)
.then(() => actions.reload(true))
.catch(error);
}
return {selected: -1};
},
reload: (fromUI) => (state, actions) => {
if (fromUI && this.core.config('vfs.watch')) {
return;
}
read()
.then(entries => entries.filter(e => e.filename !== '..'))
.then(entries => actions.setEntries(entries));
}
}, view(computeLabel, fileIcon, themeIcon, droppable), this.$root);
this.applySettings();
this.iconview.reload();
this._createWatcher();
this.core.on('osjs/settings:save', () => this.iconview.reload());
}
createFileContextMenu(ev, entry) {
const _ = this.core.make('osjs/locale').translate;
this.core.make('osjs/contextmenu', {
position: ev,
menu: [{
label: _('LBL_OPEN'),
onclick: () => this.iconview.openEntry({entry, forceDialog: false})
}, {
label: _('LBL_OPEN_WITH'),
onclick: () => this.iconview.openEntry({entry, forceDialog: true})
}, {
label: entry.shortcut !== false ? _('LBL_REMOVE_SHORTCUT') : _('LBL_DELETE'),
onclick: () => this.iconview.removeEntry(entry)
}]
});
}
createDropContextMenu(ev, data, files) {
const desktop = this.core.make('osjs/desktop');
const _ = this.core.make('osjs/locale').translate;
const action = shortcut => onDropAction(this.iconview)(ev, data, files, shortcut);
const menu = [{
label: _('LBL_COPY'),
onclick: () => action(false)
}, {
label: _('LBL_CREATE_SHORTCUT'),
onclick: () => action(true)
}, ...desktop.createDropContextMenu(data)];
this.core.make('osjs/contextmenu', {
position: ev,
menu
});
}
createRootContextMenu(ev) {
this.core.make('osjs/desktop')
.openContextMenu(ev);
}
_createWatcher() {
const listener = (args) => {
const currentPath = String(this.root).replace(/\/$/, '');
const watchPath = String(args.path).replace(/\/$/, '');
if (currentPath === watchPath) {
this.iconview.reload();
}
};
this.core.on('osjs/vfs:directoryChanged', listener);
this.on('destroy', () => this.core.off('osjs/vfs:directoryChanged', listener));
}
applySettings() {
if (!this.$root) {
return;
}
const settings = this.core
.make('osjs/settings');
const defaults = this.core
.config('desktop.settings');
const fontColorStyle = settings
.get('osjs/desktop', 'iconview.fontColorStyle', defaults.iconview.fontColorStyle);
const fontColor = settings
.get('osjs/desktop', 'iconview.fontColor', '#ffffff', defaults.iconview.fontColor);
const backgroundColor = settings
.get('osjs/desktop', 'background.color', defaults.background.color);
const styles = {
system: 'inherit',
invert: invertHex(backgroundColor),
custom: fontColor
};
this.$root.style.color = styles[fontColorStyle];
}
}