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:
@@ -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}>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user