From 606dc69204aecbe36312749bc72f86a168a5db72 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 27 Sep 2017 23:29:48 -0400 Subject: [PATCH] Link Toolbar Improves (#269) * WIP * Internal link handling * Keyboard navigation of link select * More constants * Save relative urls. Relative urls are atlas urls --- .../components/Editor/components/Heading.js | 3 +- frontend/components/Editor/components/Link.js | 39 ++++- .../Toolbar/components/DocumentResult.js | 47 +++++ .../Toolbar/components/FormattingToolbar.js | 2 + .../Toolbar/components/LinkToolbar.js | 164 ++++++++++++++++-- frontend/components/Icon/Icon.js | 5 +- .../components/DocumentMove/DocumentMove.js | 8 +- 7 files changed, 236 insertions(+), 32 deletions(-) create mode 100644 frontend/components/Editor/components/Toolbar/components/DocumentResult.js diff --git a/frontend/components/Editor/components/Heading.js b/frontend/components/Editor/components/Heading.js index b2487cc2e..c8ceb4464 100644 --- a/frontend/components/Editor/components/Heading.js +++ b/frontend/components/Editor/components/Heading.js @@ -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 ( - + {children} {showPlaceholder && diff --git a/frontend/components/Editor/components/Link.js b/frontend/components/Editor/components/Link.js index dad765f2a..de8a4e2d0 100644 --- a/frontend/components/Editor/components/Link.js +++ b/frontend/components/Editor/components/Link.js @@ -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 ( - - {children} - - ); +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 {children}; + } else { + return {children}; + } } diff --git a/frontend/components/Editor/components/Toolbar/components/DocumentResult.js b/frontend/components/Editor/components/Toolbar/components/DocumentResult.js new file mode 100644 index 000000000..e7ac8c2e4 --- /dev/null +++ b/frontend/components/Editor/components/Toolbar/components/DocumentResult.js @@ -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 ( + + + {document.title} + + ); +} + +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; diff --git a/frontend/components/Editor/components/Toolbar/components/FormattingToolbar.js b/frontend/components/Editor/components/Toolbar/components/FormattingToolbar.js index cd288c985..2b1938bf6 100644 --- a/frontend/components/Editor/components/Toolbar/components/FormattingToolbar.js +++ b/frontend/components/Editor/components/Toolbar/components/FormattingToolbar.js @@ -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 ( {this.renderMarkButton('bold', BoldIcon)} + {this.renderMarkButton('italic', ItalicIcon)} {this.renderMarkButton('deleted', StrikethroughIcon)} {this.renderBlockButton('heading1', Heading1Icon)} {this.renderBlockButton('heading2', Heading2Icon)} diff --git a/frontend/components/Editor/components/Toolbar/components/LinkToolbar.js b/frontend/components/Editor/components/Toolbar/components/LinkToolbar.js index 27ab30b70..e34d04b4b 100644 --- a/frontend/components/Editor/components/Toolbar/components/LinkToolbar.js +++ b/frontend/components/Editor/components/Toolbar/components/LinkToolbar.js @@ -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 ( - - (this.input = ref)} - defaultValue={href} - placeholder="http://" - onBlur={this.props.onBlur} - onKeyDown={this.onKeyDown} - autoFocus - /> - - - - + + + (this.input = ref)} + defaultValue={href} + placeholder="Search or paste a link…" + onBlur={this.onBlur} + onKeyDown={this.onKeyDown} + onChange={this.onChange} + autoFocus + /> + {this.isEditing && + + + } + + {this.isEditing + ? + : } + + + {hasResults && + + + {this.resultIds.map((id, index) => { + const document = this.props.documents.getById(id); + if (!document) return null; + + return ( + + index === 0 && this.setFirstDocumentRef(ref)} + document={document} + key={document.id} + onClick={ev => this.selectDocument(ev, document)} + /> + ); + })} + + } + ); } } +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)); diff --git a/frontend/components/Icon/Icon.js b/frontend/components/Icon/Icon.js index 44811b1ec..5921db7eb 100644 --- a/frontend/components/Icon/Icon.js +++ b/frontend/components/Icon/Icon.js @@ -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)} } `; diff --git a/frontend/scenes/Document/components/DocumentMove/DocumentMove.js b/frontend/scenes/Document/components/DocumentMove/DocumentMove.js index d6268a039..1f7ebfbf7 100644 --- a/frontend/scenes/Document/components/DocumentMove/DocumentMove.js +++ b/frontend/scenes/Document/components/DocumentMove/DocumentMove.js @@ -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(); };