Link Toolbar Improves (#269)

* WIP

* Internal link handling

* Keyboard navigation of link select

* More constants

* Save relative urls.
Relative urls are atlas urls
This commit is contained in:
Tom Moor
2017-09-27 23:29:48 -04:00
committed by GitHub
parent ca5d8baa4d
commit 606dc69204
7 changed files with 236 additions and 32 deletions

View File

@@ -26,6 +26,7 @@ function Heading(props: Props) {
readOnly,
children,
component = 'h1',
...rest
} = props;
const parentIsDocument = parent instanceof Document;
const firstHeading = parentIsDocument && parent.nodes.first() === node;
@@ -39,7 +40,7 @@ function Heading(props: Props) {
emoji && title.match(new RegExp(`^${emoji}\\s`));
return (
<Component>
<Component {...rest}>
<Wrapper hasEmoji={startsWithEmojiAndSpace}>{children}</Wrapper>
{showPlaceholder &&
<Placeholder contentEditable={false}>

View File

@@ -1,11 +1,38 @@
// @flow
import React from 'react';
import { Link as InternalLink } from 'react-router-dom';
import type { Props } from '../types';
export default function Link({ attributes, node, children }: Props) {
return (
<a {...attributes} href={node.data.get('href')} target="_blank">
{children}
</a>
);
function getPathFromUrl(href: string) {
if (href[0] === '/') return href;
try {
const parsed = new URL(href);
return parsed.pathname;
} catch (err) {
return '';
}
}
function isAtlasUrl(href: string) {
if (href[0] === '/') return true;
try {
const atlas = new URL(BASE_URL);
const parsed = new URL(href);
return parsed.hostname === atlas.hostname;
} catch (err) {
return false;
}
}
export default function Link({ attributes, node, children, readOnly }: Props) {
const href = node.data.get('href');
const path = getPathFromUrl(href);
if (isAtlasUrl(href) && readOnly) {
return <InternalLink {...attributes} to={path}>{children}</InternalLink>;
} else {
return <a {...attributes} href={href} target="_blank">{children}</a>;
}
}

View File

@@ -0,0 +1,47 @@
// @flow
import React from 'react';
import styled from 'styled-components';
import { fontWeight, color } from 'styles/constants';
import Document from 'models/Document';
import Icon from 'components/Icon';
type Props = {
innerRef?: Function,
onClick: SyntheticEvent => void,
document: Document,
};
function DocumentResult({ document, ...rest }: Props) {
return (
<ListItem {...rest} href="">
<i><Icon type="ChevronRight" light /></i>
{document.title}
</ListItem>
);
}
const ListItem = styled.a`
display: flex;
align-items: center;
height: 24px;
padding: 4px 8px 4px 0;
color: ${color.white};
font-size: 15px;
i {
visibility: hidden;
}
&:hover,
&:focus,
&:active {
font-weight: ${fontWeight.medium};
outline: none;
i {
visibility: visible;
}
}
`;
export default DocumentResult;

View File

@@ -6,6 +6,7 @@ import BoldIcon from 'components/Icon/BoldIcon';
import CodeIcon from 'components/Icon/CodeIcon';
import Heading1Icon from 'components/Icon/Heading1Icon';
import Heading2Icon from 'components/Icon/Heading2Icon';
import ItalicIcon from 'components/Icon/ItalicIcon';
import LinkIcon from 'components/Icon/LinkIcon';
import StrikethroughIcon from 'components/Icon/StrikethroughIcon';
import BulletedListIcon from 'components/Icon/BulletedListIcon';
@@ -90,6 +91,7 @@ export default class FormattingToolbar extends Component {
return (
<span>
{this.renderMarkButton('bold', BoldIcon)}
{this.renderMarkButton('italic', ItalicIcon)}
{this.renderMarkButton('deleted', StrikethroughIcon)}
{this.renderBlockButton('heading1', Heading1Icon)}
{this.renderBlockButton('heading2', Heading2Icon)}

View File

@@ -1,20 +1,61 @@
// @flow
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { observable, action } from 'mobx';
import { observer, inject } from 'mobx-react';
import { withRouter } from 'react-router';
import styled from 'styled-components';
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
import ToolbarButton from './ToolbarButton';
import DocumentResult from './DocumentResult';
import type { State } from '../../../types';
import DocumentsStore from 'stores/DocumentsStore';
import keydown from 'react-keydown';
import Icon from 'components/Icon';
import Flex from 'components/Flex';
@keydown
export default class LinkToolbar extends Component {
@observer
class LinkToolbar extends Component {
input: HTMLElement;
firstDocument: HTMLElement;
props: {
state: State,
link: Object,
onBlur: Function,
onChange: Function,
documents: DocumentsStore,
onBlur: () => void,
onChange: State => void,
};
@observable isEditing: boolean = false;
@observable isFetching: boolean = false;
@observable resultIds: string[] = [];
@observable searchTerm: ?string = null;
componentWillMount() {
this.isEditing = !!this.props.link.data.get('href');
}
@action search = async () => {
this.isFetching = true;
if (this.searchTerm) {
try {
this.resultIds = await this.props.documents.search(this.searchTerm);
} catch (err) {
console.error(err);
}
} else {
this.resultIds = [];
}
this.isFetching = false;
};
selectDocument = (ev, document) => {
ev.preventDefault();
this.save(document.url);
};
onKeyDown = (ev: SyntheticKeyboardEvent & SyntheticInputEvent) => {
@@ -24,14 +65,48 @@ export default class LinkToolbar extends Component {
return this.save(ev.target.value);
case 27: // escape
return this.input.blur();
case 40: // down
ev.preventDefault();
if (this.firstDocument) {
const element = ReactDOM.findDOMNode(this.firstDocument);
if (element instanceof HTMLElement) element.focus();
}
break;
default:
}
};
onChange = (ev: SyntheticKeyboardEvent & SyntheticInputEvent) => {
try {
new URL(ev.target.value);
} catch (err) {
// this is not a valid url, show search suggestions
this.searchTerm = ev.target.value;
this.search();
return;
}
this.resultIds = [];
};
onBlur = () => {
if (!this.resultIds.length) {
if (this.input.value) {
this.props.onBlur();
} else {
this.removeLink();
}
}
};
removeLink = () => {
this.save('');
};
openLink = () => {
const href = this.props.link.data.get('href');
window.open(href, '_blank');
};
save = (href: string) => {
href = href.trim();
const transform = this.props.state.transform();
@@ -44,41 +119,94 @@ export default class LinkToolbar extends Component {
const state = transform.apply();
this.props.onChange(state);
this.input.blur();
this.props.onBlur();
};
setFirstDocumentRef = ref => {
this.firstDocument = ref;
};
render() {
const href = this.props.link.data.get('href');
const hasResults = this.resultIds.length > 0;
return (
<LinkEditor>
<Input
innerRef={ref => (this.input = ref)}
defaultValue={href}
placeholder="http://"
onBlur={this.props.onBlur}
onKeyDown={this.onKeyDown}
autoFocus
/>
<ToolbarButton onMouseDown={this.removeLink}>
<Icon type="X" light />
</ToolbarButton>
</LinkEditor>
<span>
<LinkEditor>
<Input
innerRef={ref => (this.input = ref)}
defaultValue={href}
placeholder="Search or paste a link…"
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
autoFocus
/>
{this.isEditing &&
<ToolbarButton onMouseDown={this.openLink}>
<Icon type="ExternalLink" light />
</ToolbarButton>}
<ToolbarButton onMouseDown={this.removeLink}>
{this.isEditing
? <Icon type="Trash2" light />
: <Icon type="XCircle" light />}
</ToolbarButton>
</LinkEditor>
{hasResults &&
<SearchResults>
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.resultIds.map((id, index) => {
const document = this.props.documents.getById(id);
if (!document) return null;
return (
<DocumentResult
innerRef={ref =>
index === 0 && this.setFirstDocumentRef(ref)}
document={document}
key={document.id}
onClick={ev => this.selectDocument(ev, document)}
/>
);
})}
</ArrowKeyNavigation>
</SearchResults>}
</span>
);
}
}
const SearchResults = styled.div`
background: rgba(34, 34, 34, .95);
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
padding: 8px;
margin-top: -3px;
margin-bottom: 0;
border-radius: 0 0 4px 4px;
`;
const LinkEditor = styled(Flex)`
margin-left: -8px;
margin-right: -8px;
`;
const Input = styled.input`
font-size: 15px;
background: rgba(255,255,255,.1);
border-radius: 2px;
padding: 5px 8px;
padding: 4px 8px;
border: 0;
margin: 0;
outline: none;
color: #fff;
flex-grow: 1;
`;
export default withRouter(inject('documents')(LinkToolbar));

View File

@@ -1,6 +1,7 @@
// @flow
import React from 'react';
import styled from 'styled-components';
import { color } from 'styles/constants';
import * as Icons from 'react-feather';
export type Props = {
@@ -22,7 +23,7 @@ export default function Icon({
if (type) {
children = React.createElement(Icons[type], {
size: '1em',
color: light ? '#FFFFFF' : undefined,
color: light ? color.white : undefined,
...rest,
});
@@ -47,6 +48,6 @@ const FeatherWrapper = styled.span`
const Wrapper = styled.span`
svg {
fill: ${props => (props.light ? '#FFF' : '#000')}
fill: ${props => (props.light ? color.white : color.black)}
}
`;

View File

@@ -44,8 +44,7 @@ type Props = {
ev.preventDefault();
if (this.firstDocument) {
const element = ReactDOM.findDOMNode(this.firstDocument);
// $FlowFixMe
if (element && element.focus) element.focus();
if (element instanceof HTMLElement) element.focus();
}
}
};
@@ -54,9 +53,8 @@ type Props = {
this.props.history.push(this.props.document.url);
};
handleFilter = (e: SyntheticInputEvent) => {
const value = e.target.value;
this.searchTerm = value;
handleFilter = (ev: SyntheticInputEvent) => {
this.searchTerm = ev.target.value;
this.updateSearchResults();
};