Hooking dropzones

This commit is contained in:
James Barnsley
2022-01-08 22:46:05 +13:00
parent 69c2b9df1e
commit 83c15278e9
17 changed files with 617 additions and 4452 deletions

View File

@ -33,6 +33,7 @@ class Dragger extends React.Component {
handleMouseMove(e) {
const { dragger, uiActions: { dragActive } } = this.props;
const { target } = e;
console.debug('mouseMove', e)
if (!dragger) return null;
const threshold = 10;
@ -48,11 +49,11 @@ class Dragger extends React.Component {
const dropzones = document.getElementsByClassName('dropzone');
for (let i = 0; i < dropzones.length; i++) {
dropzones[i].classList.remove('hover');
dropzones[i].classList.remove('drag-over');
}
if (target.classList && target.classList.contains('dropzone') && !target.classList.contains('hover')) {
target.className += ' hover';
if (target.classList && target.classList.contains('dropzone') && !target.classList.contains('drag-over')) {
target.className += ' drag-over';
}
// if not already, activate
@ -74,7 +75,7 @@ class Dragger extends React.Component {
return (
<div
className="dragger"
className="dragger"
style={{
left: position_x,
top: position_y,

View File

@ -1,81 +1,75 @@
import React from 'react';
import Sortable from 'react-sortablejs';
import React, { useState, useEffect } from 'react';
import { ReactSortable } from 'react-sortablejs';
import Icon from '../Icon';
import Link from '../Link';
import { sortItems } from '../../util/arrays';
import { sortItems, indexToArray } from '../../util/arrays';
export default class Commands extends React.Component {
constructor(props) {
super(props);
}
const Commands = ({
commands,
runCommand,
onChange,
}) => {
const [list, setList] = useState([]);
onChange(order) {
const commands = {};
for (let i = 0; i <= order.length; i++) {
const command = this.props.commands[order[i]];
if (command) {
commands[command.id] = { ...command, ...{ sort_order: i } };
useEffect(() => {
setList(sortItems(indexToArray(commands), 'sort_order'));
}, [commands]);
useEffect(() => {
setList(sortItems(indexToArray(commands), 'sort_order'));
}, []);
const onSort = () => {
const nextCommands = {};
if (!list || !list.length) return;
list.forEach((item, index) => {
nextCommands[item.id] = {
...item,
sort_order: index,
};
});
onChange(nextCommands);
};
return (
<ReactSortable
options={{
handle: '.commands-setup__item__drag-handle',
animation: 150,
}}
className="list commands-setup"
list={list}
setList={setList}
onSort={onSort}
>
{
list.map((command) => (
<div className="list__item commands-setup__item list__item--no-interaction" key={command.id} data-id={command.id}>
<div className="commands-setup__item__details">
<Icon className="commands-setup__item__drag-handle" name="drag_indicator" />
<div className="commands-setup__item__command-item commands__item commands__item--small">
<Icon className="commands__item__icon" name={command.icon} />
<span className={`${command.colour}-background commands__item__background`} />
</div>
<div className="commands-setup__item__url commands__item__url">
{command.name ? command.name : <span className="grey-text">{command.url}</span>}
</div>
</div>
<div className="commands-setup__item__actions">
<a className="commands-setup__item__run-button action" onClick={() => runCommand(command.id, true)}>
<Icon name="play_arrow" />
</a>
<Link className="commands-setup__item__edit-button action" to={`/modal/edit-command/${command.id}`}>
<Icon name="edit" />
</Link>
</div>
</div>
))
}
}
this.props.onChange(commands);
}
commands() {
let commands = [];
if (this.props.commands) {
for (const key of Object.keys(this.props.commands)) {
commands.push({ ...this.props.commands[key] });
}
}
commands = sortItems(commands, 'sort_order');
return commands;
}
render() {
const commands = this.commands();
if (!commands) {
return null;
}
return (
<Sortable
options={{
handle: '.commands-setup__item__drag-handle',
animation: 150,
}}
className="list commands-setup"
onChange={(order, sortable, e) => { this.onChange(order); }}
>
{
commands.map((command) => (
<div className="list__item commands-setup__item list__item--no-interaction" key={command.id} data-id={command.id}>
<div className="commands-setup__item__details">
<Icon className="commands-setup__item__drag-handle" name="drag_indicator" />
<div className="commands-setup__item__command-item commands__item commands__item--small">
<Icon className="commands__item__icon" name={command.icon} />
<span className={`${command.colour}-background commands__item__background`} />
</div>
<div className="commands-setup__item__url commands__item__url">
{command.name ? command.name : <span className="grey-text">{command.url}</span>}
</div>
</div>
<div className="commands-setup__item__actions">
<a className="commands-setup__item__run-button action" onClick={(e) => this.props.runCommand(command.id, true)}>
<Icon name="play_arrow" />
</a>
<Link className="commands-setup__item__edit-button action" to={`/modal/edit-command/${command.id}`}>
<Icon name="edit" />
</Link>
</div>
</div>
))
}
</Sortable>
);
}
</ReactSortable>
);
}
export default Commands;

View File

@ -1,53 +0,0 @@
import React from 'react';
import Icon from '../Icon';
export default class Dropzone extends React.Component {
constructor(props) {
super(props);
this.state = {
hover: false,
};
this.handleMouseOver = this.handleMouseOver.bind(this);
this.handleMouseOut = this.handleMouseOut.bind(this);
}
componentDidMount() {
window.addEventListener('mouseover', this.handleMouseOver, false);
window.addEventListener('mouseout', this.handleMouseOut, false);
}
componentWillUnmount() {
window.removeEventListener('mouseover', this.handleMouseOver, false);
window.removeEventListener('mouseout', this.handleMouseOut, false);
}
handleMouseOver = () => {
this.setState({ hover: true });
}
handleMouseOut = () => {
this.setState({ hover: false });
}
render = () => {
const {
data,
handleMouseUp,
} = this.props;
const { hover } = this.state;
if (!data) return null;
return (
<div
className={hover ? 'dropzone hover' : 'dropzone'}
onMouseUp={() => handleMouseUp(data)}
>
<Icon name={data.icon} />
<span className="title">{ data.title }</span>
</div>
);
}
}

View File

@ -1,51 +1,65 @@
import React from 'react';
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import Dropzone from './Dropzone';
import * as uiActions from '../../services/ui/actions';
import * as mopidyActions from '../../services/mopidy/actions';
import Icon from '../Icon';
import { arrayOf } from '../../util/arrays';
import { i18n } from '../../locale';
import { encodeUri } from '../../util/format';
const zones = [
{
action: 'enqueue',
title: i18n('actions.add_to_queue'),
icon: 'play_arrow',
},
{
action: 'enqueue_next',
title: i18n('actions.play_next'),
icon: 'play_arrow',
},
{
action: 'add_to_playlist',
title: i18n('actions.add_to_playlist'),
icon: 'playlist_add',
accepts: ['tltrack', 'track', 'album', 'playlist', 'artist'],
},
{
action: 'create_playlist_and_add',
title: i18n('modal.edit_playlist.create_playlist'),
icon: 'playlist_add',
accepts: ['tltrack', 'track', 'album', 'playlist', 'artist'],
},
];
const Dropzones = () => {
const dispatch = useDispatch();
const history = useHistory();
const [dropTarget, setDropTarget] = useState();
const {
victims,
from_uri,
active,
} = useSelector((state) => state.ui.dragger || {});
const zones = [
{
title: i18n('actions.add_to_queue'),
icon: 'play_arrow',
action: 'enqueue',
},
{
title: i18n('actions.play_next'),
icon: 'play_arrow',
action: 'enqueue_next',
},
{
title: i18n('actions.add_to_playlist'),
icon: 'playlist_add',
action: 'add_to_playlist',
accepts: ['tltrack', 'track', 'album', 'playlist', 'artist'],
},
{
title: i18n('modal.edit_playlist.create_playlist'),
icon: 'playlist_add',
action: 'create_playlist_and_add',
accepts: ['tltrack', 'track', 'album', 'playlist', 'artist'],
},
];
if (!active) return null;
const handleMouseUp = (zone) => {
const onDragEnter = (e, action) => {
e.preventDefault();
e.stopPropagation();
setDropTarget(action);
}
const onDragOver = (e, action) => {
e.preventDefault();
e.stopPropagation();
setDropTarget(action);
}
const onDrop = (e, action) => {
e.preventDefault();
e.stopPropagation();
const uris = arrayOf('uri', victims);
switch (zone.action) {
switch (action) {
case 'enqueue':
dispatch(mopidyActions.enqueueURIs(uris, from_uri));
break;
@ -65,15 +79,18 @@ const Dropzones = () => {
return (
<div className="dropzones">
{
zones.map((zone) => (
<Dropzone
key={zone.action}
data={zone}
handleMouseUp={handleMouseUp}
/>
))
}
{zones.map(({ title, icon, action }) => (
<div
key={action}
className={`dropzones__item ${dropTarget === action ? ' hover' : ''}`}
onDragEnter={(e) => onDragEnter(e, action)}
onDragOver={(e) => onDragOver(e, action)}
onDrop={(e) => onDrop(e, action)}
>
<Icon name={icon} />
<span className="title">{title}</span>
</div>
))}
</div>
);
};

View File

@ -1,55 +1,67 @@
import React from 'react';
import Sortable from 'react-sortablejs';
import React, { useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { ReactSortable } from 'react-sortablejs';
import { set as uiSet } from '../../services/ui/actions';
import Icon from '../Icon';
import { titleCase } from '../../util/helpers';
export default class SourcesPriority extends React.Component {
handleSort(order) {
this.props.uiActions.set({ uri_schemes_priority: order });
}
const SourcesPriority = ({
uri_schemes,
uri_schemes_priority,
}) => {
const [list, setList] = useState([]);
const dispatch = useDispatch();
render() {
const className = 'sources-priority-field';
const ordered_schemes = [];
const unordered_schemes = [];
useEffect(() => {
processList();
}, []);
for (var i = 0; i < this.props.uri_schemes.length; i++) {
const index = this.props.uri_schemes_priority.indexOf(this.props.uri_schemes[i]);
useEffect(() => {
processList();
}, [uri_schemes]);
const processList = () => {
let seen = [];
let unseen = [];
uri_schemes.forEach((uri) => {
const index = uri_schemes_priority.indexOf(uri);
if (index > -1) {
ordered_schemes[index] = this.props.uri_schemes[i];
seen[index] = { uri };
} else {
unordered_schemes.push(this.props.uri_schemes[i]);
unseen.push({ uri });
}
}
for (var i = 0; i < unordered_schemes.length; i++) {
ordered_schemes.push(unordered_schemes[i]);
}
return (
<Sortable
options={{
animation: 150,
}}
className={className}
onChange={(order, sortable, e) => {
this.handleSort(order);
}}
>
{
ordered_schemes.map((scheme) => {
const name = titleCase(scheme.replace(':', '').replace('+', ' '));
return (
<span className="source flag flag--grey" key={scheme} data-id={scheme}>
<Icon name="drag_indicator" />
{name}
</span>
);
})
}
</Sortable>
);
});
setList([ ...seen, ...unseen ]);
}
const onSort = () => {
dispatch(uiSet({ uri_schemes_priority: list.map(({ uri }) => uri) }));
}
return (
<ReactSortable
options={{
animation: 150,
}}
className="sources-priority-field"
list={list}
setList={setList}
onSort={onSort}
>
{
list.map(({ uri }) => {
const name = titleCase(uri.replace(':', '').replace('+', ' '));
return (
<span className="source flag flag--grey" key={`uri_scheme_${uri}`}>
<Icon name="drag_indicator" />
{name}
</span>
);
})
}
</ReactSortable>
);
}
export default SourcesPriority;

View File

@ -58,7 +58,7 @@ const Track = ({
stream_title,
play_state,
is_selected,
is_dropping,
// is_dropping,
can_sort,
show_source_icon,
getItemIndex,
@ -113,7 +113,7 @@ const Track = ({
const track_middle_column = <MiddleColumn context={context} item={item} />;
if (is_selected(index)) className += ' list__item--selected';
if (is_dropping(index)) className += ' list__item--dropping';
// if (is_dropping(index)) className += ' list__item--dropping';
if (can_sort) className += ' list__item--can-sort';
if (item.type !== undefined) className += ` list__item--${item.type}`;
if (item.playing) className += ' list__item--playing';
@ -126,21 +126,23 @@ const Track = ({
const onDoubleClick = (e) => events.onDoubleClick(item, index, e);
const onContextMenu = (e) => events.onContextMenu(item, index, e);
const onDragStart = (e) => events.onDragStart(item, index, e);
const onDragEnd = (e) => events.onDragEnd(item, index, e);
const onDragOver = (e) => events.onDragOver(item, index, e);
const onDragEnter = (e) => events.onDragEnter(item, index, e);
const onDragLeave = (e) => events.onDragLeave(item, index, e);
const onDrop = (e) => events.onDrop(item, index, e);
return (
<ErrorBoundary>
<div
className={className}
className={`${className} dropzone`}
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={() => console.debug("DROP")}
onDrop={onDrop}
draggable="true"
>
<div className="list__item__column list__item__column--name">

View File

@ -29,6 +29,7 @@ const TrackList = ({
createNotification,
showContextMenu,
dragStart,
dragEnd,
},
mopidyActions: {
playURIs,
@ -39,10 +40,10 @@ const TrackList = ({
useEffect(() => {
window.addEventListener('keydown', onKeyDown, false);
window.addEventListener('dragend', events.onDragEnd, false);
window.addEventListener('dragend', onDragEnd, false);
return () => {
window.removeEventListener('keydown', onKeyDown, false);
window.removeEventListener('dragend', events.onDragEnd, false);
window.removeEventListener('dragend', onDragEnd, false);
};
}, []);
@ -94,25 +95,24 @@ const TrackList = ({
const events = {
onDragStart: (item, index, e) => {
const items = getOrUpdateSelected(item, index, e).map(({ item: selectedItem }) => selectedItem);
console.debug('DRAGGING', items.length)
dragStart(e, context, context, items, selected);
},
onDrop: (item, index, e) => {
console.debug('DROP')
reorderTracks(arrayOf('index', selected), index);
setSelected([]);
},
onDragEnd: (item, index, e) => {
dragEnd();
},
onDragEnter: (item, index, e) => {
e.stopPropagation();
e.preventDefault();
console.debug('onDragEnter', index);
setDropTarget(index);
// e.preventDefault();
},
onDragLeave: (item, index, e) => {
console.debug('onDragLeave', index);
// e.preventDefault();
},
onDragEnd: (e) => {
console.debug('onDragEnd');
setDropTarget(null);
onDragOver: (item, index, e) => {
e.stopPropagation();
e.preventDefault();
},
onClick: (item, index, e) => {
// e.preventDefault();
@ -142,6 +142,37 @@ const TrackList = ({
},
};
/**
* NEXT STEPS
* Collate these events into a coherant package, possibly using hooks?
* Create redux handlers to push dragging items into state. This allows a nice drag shadow and
* activation of dropzones in sidebar.
* Figure out where to leave touch events. Perhaps dragging one-by-one with a simple handle is
* sufficient.
* Apply drag-ability to other assets, like album tiles, etc.
*
* Also, fix shuffle play; first track goes first, but all subsequent ones go to end of tracklist
*/
const onDragLeave = (e) => {
e.persist();
const { target, relatedTarget } = e;
e.stopPropagation();
// The element that we just left is the element with the listener
// Unfortunately this event triggers for EVERY element that is 'left' during dragging, not just
// the one bound to the event. This check allows us to only act when we leave the bound element.
// if (relatedTarget.id === 'tracklist_container') {
console.debug('onDragLeave', e)
if (target.id === 'tracklist-container') {
console.debug('OUTSIDE, WHEE!');
setDropTarget(null);
}
};
const onDragEnd = (e) => {
console.debug('onDragEnd');
setDropTarget(null);
};
const onRemoveTracks = () => {
if (!removeTracks) {
createNotification({
@ -218,7 +249,7 @@ const TrackList = ({
selected_tracks,
can_sort: context?.can_edit,
is_selected,
is_dropping,
// is_dropping,
mini_zones: slim_mode || isTouchDevice(),
events,
}}

View File

@ -80,7 +80,7 @@ export default function reducer(ui = {}, action) {
...ui,
dragger: {
dragging: true,
active: false,
active: true,
context: action.context,
from_uri: action.from_uri,
victims: action.victims,

View File

@ -34,7 +34,7 @@
background: colour('faint_grey');
}
.dropzone {
&__item {
@include animate();
@include gradient_overlay(3px,0);
margin: 15px;
@ -61,7 +61,7 @@
width: 32px;
}
&.hover {
&.drag-over {
border-color: colour('blue');
&:before {

View File

@ -288,7 +288,7 @@
}
}
&--dropping {
&.drag-over {
&:before {
opacity: 1;
}