Merge pull request #504 from filecoin-project/feat/dragAndSelect

feat: drag and select behavior
This commit is contained in:
CAKE 2021-02-09 17:13:39 -08:00 committed by GitHub
commit 8487c9098f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 1164 additions and 782 deletions

View File

@ -16,6 +16,7 @@ import { CheckBox } from "~/components/system/components/CheckBox";
import { Table } from "~/components/core/Table";
import { FileTypeIcon } from "~/components/core/FileTypeIcon";
import { ButtonPrimary, ButtonWarning } from "~/components/system/components/Buttons";
import { GroupSelectable, Selectable } from "~/components/core/Selectable/";
import SlateMediaObjectPreview from "~/components/core/SlateMediaObjectPreview";
import FilePreviewBubble from "~/components/core/FilePreviewBubble";
@ -283,6 +284,31 @@ export default class DataView extends React.Component {
}
};
_addSelectedItemsOnDrag = (e) => {
let selectedItems = {};
for (const i of e) {
selectedItems[i] = true;
}
this.setState({ checked: { ...this.state.checked, ...selectedItems } });
};
_removeSelectedItemsOnDrag = (e) => {
const selectedItems = { ...this.state.checked };
for (const i in selectedItems) {
selectedItems[i] = selectedItems[i] && !e.includes(+i);
if (!selectedItems[i]) delete selectedItems[i];
}
this.setState({ checked: selectedItems, ...selectedItems });
};
_handleDragAndSelect = (e, { isAltDown }) => {
if (isAltDown) {
this._removeSelectedItemsOnDrag(e);
return;
}
this._addSelectedItemsOnDrag(e);
};
_handleKeyUp = (e) => {
if (e.keyCode === 16 && this.isShiftDown) {
this.isShiftDown = false;
@ -379,6 +405,14 @@ export default class DataView extends React.Component {
});
};
_handleCheckBoxMouseEnter = (i) => {
this.setState({ hover: i });
};
_handleCheckBoxMouseLeave = (i) => {
this.setState({ hover: null });
};
_handleCopy = (e, value) => {
e.stopPropagation();
this._handleHide();
@ -491,135 +525,138 @@ export default class DataView extends React.Component {
if (this.props.view === 0) {
return (
<React.Fragment>
<div css={STYLES_IMAGE_GRID} ref={this.gridWrapperEl}>
{this.props.items.slice(0, this.state.viewLimit).map((each, i) => {
const cid = each.cid;
return (
<div
key={each.id}
css={STYLES_IMAGE_BOX}
style={{
width: this.state.imageSize,
height: this.state.imageSize,
boxShadow: numChecked
? `0px 0px 0px 1px ${Constants.system.lightBorder} inset,
<GroupSelectable onSelection={this._handleDragAndSelect}>
<div css={STYLES_IMAGE_GRID} ref={this.gridWrapperEl}>
{this.props.items.slice(0, this.state.viewLimit).map((each, i) => {
const cid = each.cid;
return (
<Selectable
key={each.id}
selectableKey={i}
css={STYLES_IMAGE_BOX}
style={{
width: this.state.imageSize,
height: this.state.imageSize,
boxShadow: numChecked
? `0px 0px 0px 1px ${Constants.system.lightBorder} inset,
0 0 40px 0 ${Constants.system.shadow}`
: "",
}}
onClick={() => this._handleSelect(i)}
onMouseEnter={() => this.setState({ hover: i })}
onMouseLeave={() => this.setState({ hover: null })}
>
<SlateMediaObjectPreview
blurhash={each.blurhash}
url={Strings.getCIDGatewayURL(each.cid)}
title={each.file || each.name}
type={each.type}
coverImage={each.coverImage}
dataView={true}
/>
<span css={STYLES_MOBILE_HIDDEN}>
{numChecked || this.state.hover === i || this.state.menu === each.id ? (
<React.Fragment>
<div
css={STYLES_ICON_BOX_BACKGROUND}
onClick={(e) => {
e.stopPropagation();
this.setState({
menu: this.state.menu === each.id ? null : each.id,
});
}}
>
<SVG.MoreHorizontal height="24px" />
{this.state.menu === each.id ? (
<Boundary
captureResize={true}
captureScroll={false}
enabled
onOutsideRectEvent={this._handleHide}
>
{this.props.isOwner ? (
<PopoverNavigation
style={{
top: "32px",
right: "0px",
}}
navigation={[
{
text: "Copy CID",
onClick: (e) => this._handleCopy(e, cid),
},
{
text: "Copy link",
onClick: (e) =>
this._handleCopy(e, Strings.getCIDGatewayURL(cid)),
},
{
text: "Delete",
onClick: (e) => {
e.stopPropagation();
this.setState({ menu: null }, () =>
this._handleDelete(cid, each.id)
);
: "",
}}
onClick={() => this._handleSelect(i)}
onMouseEnter={() => this._handleCheckBoxMouseEnter(i)}
onMouseLeave={() => this._handleCheckBoxMouseLeave(i)}
>
<SlateMediaObjectPreview
blurhash={each.blurhash}
url={Strings.getCIDGatewayURL(each.cid)}
title={each.file || each.name}
type={each.type}
coverImage={each.coverImage}
dataView={true}
/>
<span css={STYLES_MOBILE_HIDDEN} style={{ pointerEvents: "auto" }}>
{numChecked || this.state.hover === i || this.state.menu === each.id ? (
<React.Fragment>
<div
css={STYLES_ICON_BOX_BACKGROUND}
onClick={(e) => {
e.stopPropagation();
this.setState({
menu: this.state.menu === each.id ? null : each.id,
});
}}
>
<SVG.MoreHorizontal height="24px" />
{this.state.menu === each.id ? (
<Boundary
captureResize={true}
captureScroll={false}
enabled
onOutsideRectEvent={this._handleHide}
>
{this.props.isOwner ? (
<PopoverNavigation
style={{
top: "32px",
right: "0px",
}}
navigation={[
{
text: "Copy CID",
onClick: (e) => this._handleCopy(e, cid),
},
},
]}
/>
) : (
<PopoverNavigation
style={{
top: "32px",
right: "0px",
}}
navigation={[
{
text: "Copy CID",
onClick: (e) => this._handleCopy(e, cid),
},
{
text: "Copy link",
onClick: (e) =>
this._handleCopy(e, Strings.getCIDGatewayURL(cid)),
},
]}
/>
)}
</Boundary>
) : null}
</div>
{
text: "Copy link",
onClick: (e) =>
this._handleCopy(e, Strings.getCIDGatewayURL(cid)),
},
{
text: "Delete",
onClick: (e) => {
e.stopPropagation();
this.setState({ menu: null }, () =>
this._handleDelete(cid, each.id)
);
},
},
]}
/>
) : (
<PopoverNavigation
style={{
top: "32px",
right: "0px",
}}
navigation={[
{
text: "Copy CID",
onClick: (e) => this._handleCopy(e, cid),
},
{
text: "Copy link",
onClick: (e) =>
this._handleCopy(e, Strings.getCIDGatewayURL(cid)),
},
]}
/>
)}
</Boundary>
) : null}
</div>
<div onClick={(e) => this._handleCheckBox(e, i)}>
<CheckBox
name={i}
value={!!this.state.checked[i]}
boxStyle={{
height: 24,
width: 24,
backgroundColor: this.state.checked[i]
? Constants.system.brand
: "rgba(255, 255, 255, 0.75)",
}}
style={{
position: "absolute",
bottom: 8,
left: 8,
}}
/>
</div>
</React.Fragment>
) : null}
</span>
</div>
);
})}
{[0, 1, 2, 3].map((i) => (
<div
key={i}
css={STYLES_IMAGE_BOX}
style={{ boxShadow: "none", cursor: "default" }}
/>
))}
</div>
<div onClick={(e) => this._handleCheckBox(e, i)}>
<CheckBox
name={i}
value={!!this.state.checked[i]}
boxStyle={{
height: 24,
width: 24,
backgroundColor: this.state.checked[i]
? Constants.system.brand
: "rgba(255, 255, 255, 0.75)",
}}
style={{
position: "absolute",
bottom: 8,
left: 8,
}}
/>
</div>
</React.Fragment>
) : null}
</span>
</Selectable>
);
})}
{[0, 1, 2, 3].map((i) => (
<div
key={i}
css={STYLES_IMAGE_BOX}
style={{ boxShadow: "none", cursor: "default" }}
/>
))}
</div>
</GroupSelectable>
{footer}
<input
ref={(c) => {
@ -688,14 +725,16 @@ export default class DataView extends React.Component {
</div>
),
name: (
<FilePreviewBubble url={cid} type={each.type}>
<div css={STYLES_CONTAINER_HOVER} onClick={() => this._handleSelect(index)}>
<div css={STYLES_ICON_BOX_HOVER} style={{ paddingLeft: 0, paddingRight: 18 }}>
<FileTypeIcon type={each.type} height="24px" />
<Selectable key={each.id} selectableKey={index}>
<FilePreviewBubble url={cid} type={each.type}>
<div css={STYLES_CONTAINER_HOVER} onClick={() => this._handleSelect(index)}>
<div css={STYLES_ICON_BOX_HOVER} style={{ paddingLeft: 0, paddingRight: 18 }}>
<FileTypeIcon type={each.type} height="24px" />
</div>
<div css={STYLES_LINK}>{each.file || each.name}</div>
</div>
<div css={STYLES_LINK}>{each.file || each.name}</div>
</div>
</FilePreviewBubble>
</FilePreviewBubble>
</Selectable>
),
size: <div css={STYLES_VALUE}>{Strings.bytesToSize(each.size)}</div>,
more: (
@ -752,22 +791,32 @@ export default class DataView extends React.Component {
return (
<React.Fragment>
<Table
data={data}
rowStyle={{
padding: "10px 16px",
textAlign: "left",
backgroundColor: Constants.system.white,
}}
topRowStyle={{
padding: "0px 16px",
textAlign: "left",
backgroundColor: Constants.system.white,
}}
onMouseEnter={(i) => this.setState({ hover: i })}
onMouseLeave={() => this.setState({ hover: null })}
isShiftDown={this.isShiftDown}
/>
<GroupSelectable enabled={true} onSelection={this._handleDragAndSelect}>
{({ isSelecting }) => (
<Table
data={data}
rowStyle={{
padding: "10px 16px",
textAlign: "left",
backgroundColor: Constants.system.white,
}}
topRowStyle={{
padding: "0px 16px",
textAlign: "left",
backgroundColor: Constants.system.white,
}}
onMouseEnter={(i) => {
if (isSelecting) return;
this._handleCheckBoxMouseEnter(i);
}}
onMouseLeave={() => {
if (isSelecting) return;
this._handleCheckBoxMouseEnter();
}}
isShiftDown={this.isShiftDown}
/>
)}
</GroupSelectable>
{footer}
<input
ref={(c) => {

View File

@ -0,0 +1,41 @@
const getBoundsForNode = (node) => {
const rect = node.getBoundingClientRect();
return {
top: rect.top + document.body.scrollTop,
left: rect.left + document.body.scrollLeft,
offsetWidth: node.offsetWidth,
offsetHeight: node.offsetHeight,
};
};
const coordsCollide = (aTop, aLeft, bTop, bLeft, aWidth, aHeight, bWidth, bHeight, tolerance) => {
return !(
// 'a' bottom doesn't touch 'b' top
(
aTop + aHeight - tolerance < bTop ||
// 'a' top doesn't touch 'b' bottom
aTop + tolerance > bTop + bHeight ||
// 'a' right doesn't touch 'b' left
aLeft + aWidth - tolerance < bLeft ||
// 'a' left doesn't touch 'b' right
aLeft + tolerance > bLeft + bWidth
)
);
};
export default (a, b, tolerance = 0) => {
const aObj = a instanceof HTMLElement ? getBoundsForNode(a) : a;
const bObj = b instanceof HTMLElement ? getBoundsForNode(b) : b;
return coordsCollide(
aObj.top,
aObj.left,
bObj.top,
bObj.left,
aObj.offsetWidth,
aObj.offsetHeight,
bObj.offsetWidth,
bObj.offsetHeight,
tolerance
);
};

View File

@ -0,0 +1,225 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import { css } from "@emotion/react";
import doObjectsCollide from "./doObjectsCollide";
const Context = React.createContext();
export const useSelectable = () => {
return React.useContext(Context);
};
const GROUP_WRAPPER = css`
position: relative;
overflow: visible;
`;
const SELECTION_BOX_WRAPPER = css`
z-index: 9000;
position: absolute;
cursor: default;
`;
const SELECTION_BOX_INNER = css`
background-color: rgba(255, 255, 255, 0.45);
border: 1px dashed ${Constants.system.border};
width: 100%;
height: 100%;
float: left;
`;
export default function GroupSelectable({
onSelection,
onSelectionStarted,
children,
enabled: enabledProp = true,
...props
}) {
const ref = React.useRef();
const selectBoxRef = React.useRef();
const isShiftDown = useKeyDown(16);
const isAltDown = useKeyDown(18);
const enabled = enabledProp && (isShiftDown || isAltDown);
const { _registerSelectable, _unregisterUnselectable, registery } = useRegistery();
const { isBoxSelecting, boxLeft, boxTop, boxWidth, boxHeight } = useGroupSelectable({
ref,
selectBoxRef,
enabled,
onSelection: (e) => onSelection(e, { isShiftDown, isAltDown }),
onSelectionStarted,
registery,
});
return (
<Context.Provider
value={{
register: _registerSelectable,
unregister: _unregisterUnselectable,
enabled,
}}
>
<div css={GROUP_WRAPPER} ref={ref} {...props}>
{isBoxSelecting ? (
<div
css={SELECTION_BOX_WRAPPER}
style={{ left: boxLeft, top: boxTop, width: boxWidth, height: boxHeight }}
ref={selectBoxRef}
>
<span css={SELECTION_BOX_INNER} />
</div>
) : null}
<div>{typeof children === "function" ? children({ isSelecting: enabled }) : children}</div>
</div>
</Context.Provider>
);
}
const useKeyDown = (id) => {
const [isKeyDown, setKeyDownBool] = React.useState(false);
const _handleKeyDown = (e) => {
if (e.keyCode === id) setKeyDownBool(true);
};
const _handleKeyUp = (e) => {
if (e.keyCode === id) setKeyDownBool(false);
};
React.useEffect(() => {
window.addEventListener("keydown", _handleKeyDown);
window.addEventListener("keyup", _handleKeyUp);
return () => {
window.removeEventListener("keydown", _handleKeyDown);
window.removeEventListener("keyup", _handleKeyUp);
};
}, []);
return isKeyDown;
};
const useRegistery = () => {
const data = React.useRef({ registery: [] });
const _registerSelectable = (key, domNode) => {
data.current.registery.push({ key, domNode });
};
const _unregisterUnselectable = (key) =>
data.current.registery.filter((item) => item.key !== key);
return { _registerSelectable, _unregisterUnselectable, registery: data.current.registery };
};
const _getInitialCoords = (element) => {
const style = window.getComputedStyle(document.body);
const t = style.getPropertyValue("margin-top");
const l = style.getPropertyValue("margin-left");
const mLeft = parseInt(l.slice(0, l.length - 2), 10);
const mTop = parseInt(t.slice(0, t.length - 2), 10);
const bodyRect = document.body.getBoundingClientRect();
const elemRect = element.getBoundingClientRect();
return {
x: Math.round(elemRect.left - bodyRect.left + mLeft),
y: Math.round(elemRect.top - bodyRect.top + mTop),
};
};
const useGroupSelectable = ({
ref,
selectBoxRef,
enabled,
onSelection,
onSelectionStarted,
registery,
}) => {
const [state, setState] = React.useState({
isBoxSelecting: false,
boxHeight: 0,
boxWidth: 0,
});
const data = React.useRef({
mouseDataDown: null,
rect: null,
});
React.useEffect(() => {
if (!ref.current) return;
_applyMouseDown(enabled);
data.current.rect = _getInitialCoords(ref.current);
return () => {
if (!ref.current) return;
_applyMouseDown(false);
};
}, [enabled]);
const _applyMouseDown = (enabled) => {
const fncName = enabled ? "addEventListener" : "removeEventListener";
ref.current[fncName]("mousedown", _mousedown);
};
const _drawBox = (e) => {
const w = Math.abs(data.current.mouseDataDown.initialW - e.pageX + data.current.rect.x);
const h = Math.abs(data.current.mouseDataDown.initialH - e.pageY + data.current.rect.y);
setState({
isBoxSelecting: true,
boxWidth: w,
boxHeight: h,
boxLeft: Math.min(e.pageX - data.current.rect.x, data.current.mouseDataDown.initialW),
boxTop: Math.min(e.pageY - data.current.rect.y, data.current.mouseDataDown.initialH),
});
};
const _selectElements = (e) => {
const currentItems = [];
const _selectbox = selectBoxRef.current;
if (!_selectbox) return;
registery.forEach((item) => {
if (
item.domNode &&
doObjectsCollide(_selectbox, item.domNode) &&
!currentItems.includes(item.key)
) {
currentItems.push(item.key);
}
});
onSelection(currentItems, e);
};
const _mousedown = (e) => {
e.preventDefault();
if (typeof onSelectionStarted === "function") onSelectionStarted(e);
window.addEventListener("mouseup", _mouseUp);
// Right clicks
if (e.which === 3 || e.button === 2) return;
data.current.rect = _getInitialCoords(ref.current);
data.current.mouseDataDown = {
boxLeft: e.pageX - data.current.rect.x,
boxTop: e.pageY - data.current.rect.y,
initialW: e.pageX - data.current.rect.x,
initialH: e.pageY - data.current.rect.y,
};
window.addEventListener("mousemove", _drawBox);
};
const _mouseUp = (e) => {
e.stopPropagation();
window.removeEventListener("mousemove", _drawBox);
window.removeEventListener("mouseup", _mouseUp);
if (!data.current.mouseDataDown) return;
_selectElements(e, true);
data.current.mouseDataDown = null;
setState({
isBoxSelecting: false,
boxWidth: 0,
boxHeight: 0,
});
};
return state;
};

View File

@ -0,0 +1,2 @@
export { default as GroupSelectable } from "./groupSelectable";
export { default as Selectable } from "./selectable";

View File

@ -0,0 +1,26 @@
import * as React from "react";
import { useSelectable } from "./groupSelectable";
export default function Selectable({ children, selectableKey, style, ...props }) {
const ref = React.useRef();
const selectable = useSelectable();
React.useEffect(() => {
if (selectable) {
selectable.register(selectableKey, ref.current);
return () => selectable.unregister(selectableKey);
}
});
return (
<div
ref={ref}
style={{
cursor: selectable?.enabled ? "default" : "pointer",
pointerEvents: selectable?.enabled ? "none" : "auto",
...style,
}}
{...props}
>
{children}
</div>
);
}

File diff suppressed because it is too large Load Diff