feat: Collection Icons (#1281)

* wip: Working for creation, and display

* feat: IconPicker

* fix

* feat: Invert collection icon color when dark in dark mode

* Improve readability of dropdown menus in dark mode
Suggest icon based on collection name

* Add additional icons
Tweaks and final polish

* fix: Write default icon as empty icon column

* feat: Improve icon selection logic
add more keywords
Improve icon coloring when selected and in dark mode

* lint

* lint
This commit is contained in:
Tom Moor
2020-06-19 17:18:03 -07:00
committed by GitHub
parent f3ea02fdd0
commit d864e228e7
21 changed files with 417 additions and 190 deletions

View File

@@ -0,0 +1,45 @@
// @flow
import * as React from 'react';
import { inject, observer } from 'mobx-react';
import { getLuminance } from 'polished';
import { PrivateCollectionIcon, CollectionIcon } from 'outline-icons';
import Collection from 'models/Collection';
import { icons } from 'components/IconPicker';
import UiStore from 'stores/UiStore';
type Props = {
collection: Collection,
expanded?: boolean,
size?: number,
ui: UiStore,
};
function ResolvedCollectionIcon({ collection, expanded, size, ui }: Props) {
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const color =
ui.resolvedTheme === 'dark'
? getLuminance(collection.color) > 0.12
? collection.color
: 'currentColor'
: collection.color;
if (collection.icon && collection.icon !== 'collection') {
try {
const Component = icons[collection.icon].component;
return <Component color={color} size={size} />;
} catch (error) {
console.warn('Failed to render custom icon ' + collection.icon);
}
}
if (collection.private) {
return (
<PrivateCollectionIcon color={color} expanded={expanded} size={size} />
);
}
return <CollectionIcon color={color} expanded={expanded} size={size} />;
}
export default inject('ui')(observer(ResolvedCollectionIcon));

View File

@@ -1,106 +0,0 @@
// @flow
import * as React from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { TwitterPicker } from 'react-color';
import styled from 'styled-components';
import Fade from 'components/Fade';
import { LabelText } from 'components/Input';
const colors = [
'#4E5C6E',
'#19B7FF',
'#7F6BFF',
'#FC7419',
'#FC2D2D',
'#FFE100',
'#14CF9F',
'#00D084',
'#EE84F0',
'#2F362F',
];
type Props = {
onChange: (color: string) => void,
value?: string,
};
@observer
class ColorPicker extends React.Component<Props> {
@observable isOpen: boolean = false;
node: ?HTMLElement;
componentDidMount() {
window.addEventListener('click', this.handleClickOutside);
}
componentWillUnmount() {
window.removeEventListener('click', this.handleClickOutside);
}
handleClose = () => {
this.isOpen = false;
};
handleOpen = () => {
this.isOpen = true;
};
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
// $FlowFixMe
if (ev.target && this.node && this.node.contains(ev.target)) {
return;
}
this.handleClose();
};
render() {
return (
<Wrapper ref={ref => (this.node = ref)}>
<label>
<LabelText>Color</LabelText>
</label>
<Swatch
role="button"
onClick={this.isOpen ? this.handleClose : this.handleOpen}
color={this.props.value}
/>
<Floating>
{this.isOpen && (
<Fade>
<TwitterPicker
colors={colors}
color={this.props.value}
onChange={color => this.props.onChange(color.hex)}
triangle="top-right"
/>
</Fade>
)}
</Floating>
</Wrapper>
);
}
}
const Wrapper = styled('div')`
display: inline-block;
position: relative;
`;
const Floating = styled('div')`
position: absolute;
top: 60px;
right: 0;
z-index: 1;
`;
const Swatch = styled('div')`
display: inline-block;
width: 48px;
height: 32px;
border: 1px solid ${({ active, color }) => (active ? 'white' : 'transparent')};
border-radius: 4px;
background: ${({ color }) => color};
`;
export default ColorPicker;

View File

@@ -253,6 +253,10 @@ const Menu = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${props => (props.left !== undefined ? '25%' : '75%')} 0;
background: ${props => props.theme.menuBackground};
${props =>
props.theme.menuBorder
? `border: 1px solid ${props.theme.menuBorder}`
: ''};
border-radius: 2px;
padding: 0.5em 0;
min-width: 180px;
@@ -261,6 +265,10 @@ const Menu = styled.div`
box-shadow: ${props => props.theme.menuShadow};
pointer-events: all;
hr {
margin: 0.5em 12px;
}
@media print {
display: none;
}

View File

@@ -0,0 +1,238 @@
// @flow
import * as React from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { TwitterPicker } from 'react-color';
import {
CollectionIcon,
CoinsIcon,
AcademicCapIcon,
BeakerIcon,
BuildingBlocksIcon,
CloudIcon,
CodeIcon,
EditIcon,
EyeIcon,
LeafIcon,
LightBulbIcon,
MoonIcon,
NotepadIcon,
PadlockIcon,
PaletteIcon,
QuestionMarkIcon,
SunIcon,
VehicleIcon,
} from 'outline-icons';
import styled from 'styled-components';
import { LabelText } from 'components/Input';
import { DropdownMenu } from 'components/DropdownMenu';
import NudeButton from 'components/NudeButton';
import Flex from 'shared/components/Flex';
export const icons = {
collection: {
component: CollectionIcon,
keywords: 'collection',
},
coins: {
component: CoinsIcon,
keywords: 'coins money finance sales income revenue cash',
},
academicCap: {
component: AcademicCapIcon,
keywords: 'learn teach lesson guide tutorial onboarding training',
},
beaker: {
component: BeakerIcon,
keywords: 'lab research experiment test',
},
buildingBlocks: {
component: BuildingBlocksIcon,
keywords: 'app blocks product prototype',
},
cloud: {
component: CloudIcon,
keywords: 'cloud service aws infrastructure',
},
code: {
component: CodeIcon,
keywords: 'developer api code development engineering programming',
},
eye: {
component: EyeIcon,
keywords: 'eye view',
},
leaf: {
component: LeafIcon,
keywords: 'leaf plant outdoors nature ecosystem climate',
},
lightbulb: {
component: LightBulbIcon,
keywords: 'lightbulb idea',
},
moon: {
component: MoonIcon,
keywords: 'night moon dark',
},
notepad: {
component: NotepadIcon,
keywords: 'journal notepad write notes',
},
padlock: {
component: PadlockIcon,
keywords: 'padlock private security authentication authorization auth',
},
palette: {
component: PaletteIcon,
keywords: 'design palette art brand',
},
pencil: {
component: EditIcon,
keywords: 'copy writing post blog',
},
question: {
component: QuestionMarkIcon,
keywords: 'question help support faq',
},
sun: {
component: SunIcon,
keywords: 'day sun weather',
},
vehicle: {
component: VehicleIcon,
keywords: 'truck car travel transport',
},
};
const colors = [
'#4E5C6E',
'#0366d6',
'#7F6BFF',
'#E76F51',
'#FC2D2D',
'#FFBE0B',
'#2A9D8F',
'#00D084',
'#EE84F0',
'#2F362F',
];
type Props = {
onOpen?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
};
function preventEventBubble(event) {
event.stopPropagation();
}
@observer
class IconPicker extends React.Component<Props> {
@observable isOpen: boolean = false;
node: ?HTMLElement;
componentDidMount() {
window.addEventListener('click', this.handleClickOutside);
}
componentWillUnmount() {
window.removeEventListener('click', this.handleClickOutside);
}
handleClose = () => {
this.isOpen = false;
};
handleOpen = () => {
this.isOpen = true;
if (this.props.onOpen) {
this.props.onOpen();
}
};
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
// $FlowFixMe
if (ev.target && this.node && this.node.contains(ev.target)) {
return;
}
this.handleClose();
};
render() {
const Component = icons[this.props.icon || 'collection'].component;
return (
<Wrapper ref={ref => (this.node = ref)}>
<label>
<LabelText>Icon</LabelText>
</label>
<DropdownMenu
label={
<LabelButton>
<Component role="button" color={this.props.color} size={30} />
</LabelButton>
}
>
<Icons onClick={preventEventBubble}>
{Object.keys(icons).map(name => {
const Component = icons[name].component;
return (
<IconButton
key={name}
onClick={() => this.props.onChange(this.props.color, name)}
style={{ width: 30, height: 30 }}
>
<Component color={this.props.color} size={30} />
</IconButton>
);
})}
</Icons>
<Flex onClick={preventEventBubble}>
<ColorPicker
color={this.props.color}
onChange={color =>
this.props.onChange(color.hex, this.props.icon)
}
colors={colors}
triangle="hide"
/>
</Flex>
</DropdownMenu>
</Wrapper>
);
}
}
const Icons = styled.div`
padding: 15px 9px 9px 15px;
width: 276px;
`;
const LabelButton = styled(NudeButton)`
border: 1px solid ${props => props.theme.inputBorder};
width: 32px;
height: 32px;
`;
const IconButton = styled(NudeButton)`
border-radius: 4px;
margin: 0px 6px 6px 0px;
width: 30px;
height: 30px;
`;
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
`;
const Wrapper = styled('div')`
display: inline-block;
position: relative;
`;
export default IconPicker;

View File

@@ -2,12 +2,13 @@
import * as React from 'react';
import { observer } from 'mobx-react';
import styled from 'styled-components';
import { GoToIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
import { GoToIcon } from 'outline-icons';
import Flex from 'shared/components/Flex';
import Document from 'models/Document';
import Collection from 'models/Collection';
import type { DocumentPath } from 'stores/CollectionsStore';
import CollectionIcon from 'components/CollectionIcon';
type Props = {
result: DocumentPath,
@@ -41,12 +42,7 @@ class PathToDocument extends React.Component<Props> {
return (
<Component ref={ref} onClick={this.handleClick} href="" selectable>
{collection &&
(collection.private ? (
<PrivateCollectionIcon color={collection.color} />
) : (
<CollectionIcon color={collection.color} />
))}
{collection && <CollectionIcon collection={collection} />}
{result.path
.map(doc => <Title key={doc.id}>{doc.title}</Title>)
.reduce((prev, curr) => [prev, <StyledGoToIcon />, curr])}

View File

@@ -84,7 +84,7 @@ class MainSidebar extends React.Component<Props> {
<Section>
<SidebarLink
to="/home"
icon={<HomeIcon />}
icon={<HomeIcon color="currentColor" />}
exact={false}
label="Home"
/>
@@ -93,19 +93,19 @@ class MainSidebar extends React.Component<Props> {
pathname: '/search',
state: { fromMenu: true },
}}
icon={<SearchIcon />}
icon={<SearchIcon color="currentColor" />}
label="Search"
exact={false}
/>
<SidebarLink
to="/starred"
icon={<StarredIcon />}
icon={<StarredIcon color="currentColor" />}
exact={false}
label="Starred"
/>
<SidebarLink
to="/drafts"
icon={<EditIcon />}
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
Drafts{draftDocumentsCount > 0 && (
@@ -127,7 +127,7 @@ class MainSidebar extends React.Component<Props> {
<Section>
<SidebarLink
to="/archive"
icon={<ArchiveIcon />}
icon={<ArchiveIcon color="currentColor" />}
exact={false}
label="Archive"
active={
@@ -138,7 +138,7 @@ class MainSidebar extends React.Component<Props> {
/>
<SidebarLink
to="/trash"
icon={<TrashIcon />}
icon={<TrashIcon color="currentColor" />}
exact={false}
label="Trash"
active={
@@ -149,7 +149,7 @@ class MainSidebar extends React.Component<Props> {
<SidebarLink
to="/settings/people"
onClick={this.handleInviteModalOpen}
icon={<PlusIcon />}
icon={<PlusIcon color="currentColor" />}
label="Invite people…"
/>
)}

View File

@@ -2,7 +2,6 @@
import * as React from 'react';
import { observer } from 'mobx-react';
import { observable } from 'mobx';
import { CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
import Collection from 'models/Collection';
import Document from 'models/Document';
import CollectionMenu from 'menus/CollectionMenu';
@@ -10,6 +9,7 @@ import UiStore from 'stores/UiStore';
import DocumentsStore from 'stores/DocumentsStore';
import SidebarLink from './SidebarLink';
import DocumentLink from './DocumentLink';
import CollectionIcon from 'components/CollectionIcon';
import DropToImport from 'components/DropToImport';
import Flex from 'shared/components/Flex';
@@ -44,16 +44,7 @@ class CollectionLink extends React.Component<Props> {
<SidebarLink
key={collection.id}
to={collection.url}
icon={
collection.private ? (
<PrivateCollectionIcon
expanded={expanded}
color={collection.color}
/>
) : (
<CollectionIcon expanded={expanded} color={collection.color} />
)
}
icon={<CollectionIcon collection={collection} expanded={expanded} />}
iconColor={collection.color}
expanded={expanded}
hideDisclosure

View File

@@ -70,7 +70,7 @@ class Collections extends React.Component<Props> {
<SidebarLink
to="/collections"
onClick={this.props.onCreateCollection}
icon={<PlusIcon />}
icon={<PlusIcon color="currentColor" />}
label="New collection…"
exact
/>