frontend > app

This commit is contained in:
Tom Moor
2017-10-25 22:49:04 -07:00
parent aa34db8318
commit 4863680d86
239 changed files with 11 additions and 11 deletions

View File

@@ -0,0 +1,196 @@
// @flow
import React, { Component } from 'react';
import EditList from '../plugins/EditList';
import getDataTransferFiles from 'utils/getDataTransferFiles';
import Portal from 'react-portal';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import styled from 'styled-components';
import { color } from 'styles/constants';
import PlusIcon from 'components/Icon/PlusIcon';
import BlockMenu from 'menus/BlockMenu';
import type { State } from '../types';
const { transforms } = EditList;
type Props = {
state: State,
onChange: Function,
onInsertImage: File => Promise<*>,
};
@observer
export default class BlockInsert extends Component {
props: Props;
mouseMoveTimeout: number;
file: HTMLInputElement;
@observable active: boolean = false;
@observable menuOpen: boolean = false;
@observable top: number;
@observable left: number;
@observable mouseX: number;
componentDidMount = () => {
this.update();
window.addEventListener('mousemove', this.handleMouseMove);
};
componentWillUpdate = (nextProps: Props) => {
this.update(nextProps);
};
componentWillUnmount = () => {
window.removeEventListener('mousemove', this.handleMouseMove);
};
setInactive = () => {
if (this.menuOpen) return;
this.active = false;
};
handleMouseMove = (ev: SyntheticMouseEvent) => {
const windowWidth = window.innerWidth / 3;
let active = ev.clientX < windowWidth;
if (active !== this.active) {
this.active = active || this.menuOpen;
}
if (active) {
clearTimeout(this.mouseMoveTimeout);
this.mouseMoveTimeout = setTimeout(this.setInactive, 2000);
}
};
handleMenuOpen = () => {
this.menuOpen = true;
};
handleMenuClose = () => {
this.menuOpen = false;
};
update = (props?: Props) => {
if (!document.activeElement) return;
const { state } = props || this.props;
const boxRect = document.activeElement.getBoundingClientRect();
const selection = window.getSelection();
if (!selection.focusNode) return;
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.top <= 0 || boxRect.left <= 0) return;
if (state.startBlock.type === 'heading1') {
this.active = false;
}
this.top = Math.round(rect.top + window.scrollY);
this.left = Math.round(boxRect.left + window.scrollX - 20);
};
insertBlock = (
ev: SyntheticEvent,
options: {
type: string | Object,
wrapper?: string | Object,
append?: string | Object,
}
) => {
ev.preventDefault();
const { type, wrapper, append } = options;
let { state } = this.props;
let transform = state.transform();
const { document } = state;
const parent = document.getParent(state.startBlock.key);
// lists get some special treatment
if (parent && parent.type === 'list-item') {
transform = transforms.unwrapList(
transforms
.splitListItem(transform.collapseToStart())
.collapseToEndOfPreviousBlock()
);
}
transform = transform.insertBlock(type);
if (wrapper) transform = transform.wrapBlock(wrapper);
if (append) transform = transform.insertBlock(append);
state = transform.focus().apply();
this.props.onChange(state);
this.active = false;
};
onPickImage = (ev: SyntheticEvent) => {
// simulate a click on the file upload input element
this.file.click();
};
onChooseImage = async (ev: SyntheticEvent) => {
const files = getDataTransferFiles(ev);
for (const file of files) {
await this.props.onInsertImage(file);
}
};
render() {
const style = { top: `${this.top}px`, left: `${this.left}px` };
const todo = { type: 'list-item', data: { checked: false } };
const rule = { type: 'horizontal-rule', isVoid: true };
return (
<Portal isOpened>
<Trigger active={this.active} style={style}>
<HiddenInput
type="file"
innerRef={ref => (this.file = ref)}
onChange={this.onChooseImage}
accept="image/*"
/>
<BlockMenu
label={<PlusIcon />}
onPickImage={this.onPickImage}
onInsertList={ev =>
this.insertBlock(ev, {
type: 'list-item',
wrapper: 'bulleted-list',
})}
onInsertTodoList={ev =>
this.insertBlock(ev, { type: todo, wrapper: 'todo-list' })}
onInsertBreak={ev =>
this.insertBlock(ev, { type: rule, append: 'paragraph' })}
onOpen={this.handleMenuOpen}
onClose={this.handleMenuClose}
/>
</Trigger>
</Portal>
);
}
}
const HiddenInput = styled.input`
position: absolute;
top: -100px;
left: -100px;
visibility: hidden;
`;
const Trigger = styled.div`
position: absolute;
z-index: 1;
opacity: 0;
background-color: ${color.white};
transition: opacity 250ms ease-in-out, transform 250ms ease-in-out;
line-height: 0;
margin-top: -2px;
margin-left: -4px;
transform: scale(.9);
${({ active }) => active && `
transform: scale(1);
opacity: .9;
`}
`;

View File

@@ -0,0 +1,22 @@
// @flow
import React from 'react';
import styled from 'styled-components';
type Props = {
onClick?: ?Function,
grow?: boolean,
};
const ClickablePadding = (props: Props) => {
return <Container grow={props.grow} onClick={props.onClick} />;
};
const Container = styled.div`
min-height: 150px;
padding-top: 50px;
cursor: ${({ onClick }) => (onClick ? 'text' : 'default')};
${({ grow }) => grow && `flex-grow: 1;`}
`;
export default ClickablePadding;

View File

@@ -0,0 +1,3 @@
// @flow
import ClickablePadding from './ClickablePadding';
export default ClickablePadding;

View File

@@ -0,0 +1,42 @@
// @flow
import React from 'react';
import styled from 'styled-components';
import CopyButton from './CopyButton';
import { color } from 'styles/constants';
import type { Props } from '../types';
export default function Code({ children, node, readOnly, attributes }: Props) {
const language = node.data.get('language') || 'javascript';
return (
<Container>
{readOnly && <CopyButton text={node.text} />}
<Pre className={`language-${language}`}>
<code {...attributes} className={`language-${language}`}>
{children}
</code>
</Pre>
</Container>
);
}
const Pre = styled.pre`
padding: .5em 1em;
background: ${color.smokeLight};
border-radius: 4px;
border: 1px solid ${color.smokeDark};
code {
padding: 0;
}
`;
const Container = styled.div`
position: relative;
&:hover {
> span {
opacity: 1;
}
}
`;

View File

@@ -0,0 +1,149 @@
// @flow
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { List } from 'immutable';
import { color } from 'styles/constants';
import headingToSlug from '../headingToSlug';
import type { State, Block } from '../types';
import styled from 'styled-components';
type Props = {
state: State,
};
@observer class Contents extends Component {
props: Props;
@observable activeHeading: ?string;
componentDidMount() {
window.addEventListener('scroll', this.updateActiveHeading);
this.updateActiveHeading();
}
componentWillUnmount() {
window.removeEventListener('scroll', this.updateActiveHeading);
}
updateActiveHeading = () => {
const elements = this.headingElements;
if (!elements.length) return;
let activeHeading = elements[0].id;
for (const element of elements) {
const bounds = element.getBoundingClientRect();
if (bounds.top <= 0) activeHeading = element.id;
}
this.activeHeading = activeHeading;
};
get headingElements(): HTMLElement[] {
const elements = [];
const tagNames = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
for (const tagName of tagNames) {
for (const ele of document.getElementsByTagName(tagName)) {
elements.push(ele);
}
}
return elements;
}
get headings(): List<Block> {
const { state } = this.props;
return state.document.nodes.filter((node: Block) => {
if (!node.text) return false;
return node.type.match(/^heading/);
});
}
render() {
// If there are one or less headings in the document no need for a minimap
if (this.headings.size <= 1) return null;
return (
<Wrapper>
<Sections>
{this.headings.map(heading => {
const slug = headingToSlug(heading);
const active = this.activeHeading === slug;
return (
<ListItem type={heading.type} active={active}>
<Anchor href={`#${slug}`} active={active}>
{heading.text}
</Anchor>
</ListItem>
);
})}
</Sections>
</Wrapper>
);
}
}
const Wrapper = styled.div`
position: fixed;
right: 0;
top: 150px;
z-index: 100;
`;
const Anchor = styled.a`
color: ${props => (props.active ? color.slateDark : color.slate)};
font-weight: ${props => (props.active ? 500 : 400)};
opacity: 0;
transition: all 100ms ease-in-out;
margin-right: -5px;
padding: 2px 0;
pointer-events: none;
text-overflow: ellipsis;
&:hover {
color: ${color.primary};
}
`;
const ListItem = styled.li`
position: relative;
margin-left: ${props => (props.type.match(/heading[12]/) ? '8px' : '16px')};
text-align: right;
color: ${color.slate};
padding-right: 16px;
white-space: nowrap;
&:after {
color: ${props => (props.active ? color.slateDark : color.slate)};
content: "${props => (props.type.match(/heading[12]/) ? '—' : '')}";
position: absolute;
right: 0;
}
`;
const Sections = styled.ol`
margin: 0 0 0 -8px;
padding: 0;
list-style: none;
font-size: 13px;
width: 100px;
transition-delay: 1s;
transition: width 100ms ease-in-out;
&:hover {
width: 300px;
transition-delay: 0s;
${Anchor} {
opacity: 1;
margin-right: 0;
background: ${color.white};
pointer-events: all;
}
}
`;
export default Contents;

View File

@@ -0,0 +1,49 @@
// @flow
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { color } from 'styles/constants';
import styled from 'styled-components';
import CopyToClipboard from 'components/CopyToClipboard';
@observer class CopyButton extends Component {
@observable copied: boolean = false;
copiedTimeout: ?number;
componentWillUnmount() {
clearTimeout(this.copiedTimeout);
}
handleCopy = () => {
this.copied = true;
this.copiedTimeout = setTimeout(() => (this.copied = false), 3000);
};
render() {
return (
<StyledCopyToClipboard onCopy={this.handleCopy} {...this.props}>
<span>{this.copied ? 'Copied!' : 'Copy to clipboard'}</span>
</StyledCopyToClipboard>
);
}
}
const StyledCopyToClipboard = styled(CopyToClipboard)`
position: absolute;
top: 0;
right: 0;
opacity: 0;
transition: opacity 50ms ease-in-out;
z-index: 1;
font-size: 12px;
background: ${color.slateLight};
border-radius: 2px;
padding: 1px 6px;
&:hover {
background: ${color.slate};
}
`;
export default CopyButton;

View File

@@ -0,0 +1,97 @@
// @flow
import React from 'react';
import { Document } from 'slate';
import styled from 'styled-components';
import headingToSlug from '../headingToSlug';
import type { Node, Editor } from '../types';
import Placeholder from './Placeholder';
type Props = {
children: React$Element<*>,
placeholder?: boolean,
parent: Node,
node: Node,
editor: Editor,
readOnly: boolean,
component?: string,
};
function Heading(props: Props) {
const {
parent,
placeholder,
node,
editor,
readOnly,
children,
component = 'h1',
...rest
} = props;
const parentIsDocument = parent instanceof Document;
const firstHeading = parentIsDocument && parent.nodes.first() === node;
const showPlaceholder = placeholder && firstHeading && !node.text;
const slugish = headingToSlug(node);
const showHash = readOnly && !!slugish;
const Component = component;
const emoji = editor.props.emoji || '';
const title = node.text.trim();
const startsWithEmojiAndSpace =
emoji && title.match(new RegExp(`^${emoji}\\s`));
return (
<Component {...rest} id={slugish}>
<Wrapper hasEmoji={startsWithEmojiAndSpace}>
{children}
</Wrapper>
{showPlaceholder &&
<Placeholder contentEditable={false}>
{editor.props.placeholder}
</Placeholder>}
{showHash && <Anchor name={slugish} href={`#${slugish}`}>#</Anchor>}
</Component>
);
}
const Wrapper = styled.div`
display: inline;
margin-left: ${(props: Props) => (props.hasEmoji ? '-1.2em' : 0)}
`;
const Anchor = styled.a`
visibility: hidden;
padding-left: .25em;
color: #dedede;
&:hover {
color: #cdcdcd;
}
`;
export const StyledHeading = styled(Heading)`
position: relative;
&:hover {
${Anchor} {
visibility: visible;
text-decoration: none;
}
}
`;
export const Heading1 = (props: Props) => (
<StyledHeading component="h1" {...props} />
);
export const Heading2 = (props: Props) => (
<StyledHeading component="h2" {...props} />
);
export const Heading3 = (props: Props) => (
<StyledHeading component="h3" {...props} />
);
export const Heading4 = (props: Props) => (
<StyledHeading component="h4" {...props} />
);
export const Heading5 = (props: Props) => (
<StyledHeading component="h5" {...props} />
);
export const Heading6 = (props: Props) => (
<StyledHeading component="h6" {...props} />
);

View File

@@ -0,0 +1,17 @@
// @flow
import React from 'react';
import styled from 'styled-components';
import type { Props } from '../types';
import { color } from 'styles/constants';
function HorizontalRule(props: Props) {
const { state, node } = props;
const active = state.isFocused && state.selection.hasEdgeIn(node);
return <StyledHr active={active} />;
}
const StyledHr = styled.hr`
border-bottom: 1px solid ${props => (props.active ? color.slate : color.slateLight)};
`;
export default HorizontalRule;

View File

@@ -0,0 +1,87 @@
// @flow
import React, { Component } from 'react';
import styled from 'styled-components';
import type { Props } from '../types';
import { color } from 'styles/constants';
class Image extends Component {
props: Props;
handleChange = (ev: SyntheticInputEvent) => {
const alt = ev.target.value;
const { editor, node } = this.props;
const data = node.data.toObject();
const state = editor
.getState()
.transform()
.setNodeByKey(node.key, { data: { ...data, alt } })
.apply();
editor.onChange(state);
};
handleClick = (ev: SyntheticInputEvent) => {
ev.stopPropagation();
};
render() {
const { attributes, state, node, readOnly } = this.props;
const loading = node.data.get('loading');
const caption = node.data.get('alt');
const src = node.data.get('src');
const active = state.isFocused && state.selection.hasEdgeIn(node);
const showCaption = !readOnly || caption;
return (
<CenteredImage>
<StyledImg
{...attributes}
src={src}
alt={caption}
active={active}
loading={loading}
/>
{showCaption &&
<Caption
type="text"
placeholder="Write a caption"
onChange={this.handleChange}
onClick={this.handleClick}
defaultValue={caption}
contentEditable={false}
disabled={readOnly}
tabIndex={-1}
/>}
</CenteredImage>
);
}
}
const StyledImg = styled.img`
box-shadow: ${props => (props.active ? `0 0 0 2px ${color.slate}` : '0')};
border-radius: ${props => (props.active ? `2px` : '0')};
opacity: ${props => (props.loading ? 0.5 : 1)};
`;
const CenteredImage = styled.div`
text-align: center;
`;
const Caption = styled.input`
border: 0;
display: block;
font-size: 13px;
font-style: italic;
color: ${color.slate};
padding: 2px 0;
line-height: 16px;
text-align: center;
width: 100%;
outline: none;
&::placeholder {
color: ${color.slate};
}
`;
export default Image;

View File

@@ -0,0 +1,12 @@
// @flow
import styled from 'styled-components';
import { color } from 'styles/constants';
const InlineCode = styled.code`
padding: .25em;
background: ${color.smoke};
border-radius: 4px;
border: 1px solid ${color.smokeDark};
`;
export default InlineCode;

View File

@@ -0,0 +1,38 @@
// @flow
import React from 'react';
import { Link as InternalLink } from 'react-router-dom';
import type { Props } from '../types';
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,16 @@
// @flow
import React from 'react';
import type { Props } from '../types';
import TodoItem from './TodoItem';
export default function ListItem({ children, node, ...props }: Props) {
const checked = node.data.get('checked');
if (checked !== undefined) {
return (
<TodoItem checked={checked} node={node} {...props}>
{children}
</TodoItem>
);
}
return <li>{children}</li>;
}

View File

@@ -0,0 +1,34 @@
// @flow
import React from 'react';
import { Document } from 'slate';
import type { Props } from '../types';
import Placeholder from './Placeholder';
export default function Link({
attributes,
editor,
node,
parent,
children,
readOnly,
}: Props) {
const parentIsDocument = parent instanceof Document;
const firstParagraph = parent && parent.nodes.get(1) === node;
const lastParagraph = parent && parent.nodes.last() === node;
const showPlaceholder =
!readOnly &&
parentIsDocument &&
firstParagraph &&
lastParagraph &&
!node.text;
return (
<p>
{children}
{showPlaceholder &&
<Placeholder contentEditable={false}>
{editor.props.bodyPlaceholder}
</Placeholder>}
</p>
);
}

View File

@@ -0,0 +1,11 @@
// @flow
import styled from 'styled-components';
export default styled.span`
position: absolute;
top: 0;
visibility: hidden;
pointer-events: none;
user-select: none;
color: #B1BECC;
`;

View File

@@ -0,0 +1,51 @@
// @flow
import React, { Component } from 'react';
import styled from 'styled-components';
import { color } from 'styles/constants';
import type { Props } from '../types';
export default class TodoItem extends Component {
props: Props & { checked: boolean };
handleChange = (ev: SyntheticInputEvent) => {
const checked = ev.target.checked;
const { editor, node } = this.props;
const state = editor
.getState()
.transform()
.setNodeByKey(node.key, { data: { checked } })
.apply();
editor.onChange(state);
};
render() {
const { children, checked, readOnly } = this.props;
return (
<ListItem checked={checked}>
<Input
type="checkbox"
checked={checked}
onChange={this.handleChange}
disabled={readOnly}
contentEditable={false}
/>
{children}
</ListItem>
);
}
}
const ListItem = styled.li`
padding-left: 1.4em;
position: relative;
text-decoration: ${props => (props.checked ? 'line-through' : 'none')};
color: ${props => (props.checked ? color.slateDark : 'inherit')};
`;
const Input = styled.input`
position: absolute;
left: 0;
top: 0.4em;
`;

View File

@@ -0,0 +1,13 @@
// @flow
import styled from 'styled-components';
const TodoList = styled.ul`
list-style: none;
padding: 0 !important;
ul {
padding-left: 1em;
}
`;
export default TodoList;

View File

@@ -0,0 +1,159 @@
// @flow
import React, { Component } from 'react';
import Portal from 'react-portal';
import styled from 'styled-components';
import _ from 'lodash';
import type { State } from '../../types';
import FormattingToolbar from './components/FormattingToolbar';
import LinkToolbar from './components/LinkToolbar';
export default class Toolbar extends Component {
props: {
state: State,
onChange: Function,
};
menu: HTMLElement;
state: {
active: boolean,
focused: boolean,
link: React$Element<any>,
top: string,
left: string,
};
state = {
active: false,
focused: false,
link: null,
top: '',
left: '',
};
componentDidMount = () => {
this.update();
};
componentDidUpdate = () => {
this.update();
};
handleFocus = () => {
this.setState({ focused: true });
};
handleBlur = () => {
this.setState({ focused: false });
};
get linkInSelection(): any {
const { state } = this.props;
try {
const selectedLinks = state.startBlock
.getInlinesAtRange(state.selection)
.filter(node => node.type === 'link');
if (selectedLinks.size) {
return selectedLinks.first();
}
} catch (err) {
//
}
}
update = () => {
const { state } = this.props;
const link = this.linkInSelection;
if (state.isBlurred || (state.isCollapsed && !link)) {
if (this.state.active && !this.state.focused) {
this.setState({ active: false, link: null, top: '', left: '' });
}
return;
}
// don't display toolbar for document title
const firstNode = state.document.nodes.first();
if (firstNode === state.startBlock) return;
// don't display toolbar for code blocks
if (state.startBlock.type === 'code') return;
const data = {
...this.state,
active: true,
link,
focused: !!link,
};
if (!_.isEqual(data, this.state)) {
const padding = 16;
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.top === 0 && rect.left === 0) {
this.setState(data);
return;
}
const left =
rect.left + window.scrollX - this.menu.offsetWidth / 2 + rect.width / 2;
data.top = `${Math.round(rect.top + window.scrollY - this.menu.offsetHeight)}px`;
data.left = `${Math.round(Math.max(padding, left))}px`;
this.setState(data);
}
};
setRef = (ref: HTMLElement) => {
this.menu = ref;
};
render() {
const link = this.state.link;
const style = {
top: this.state.top,
left: this.state.left,
};
return (
<Portal isOpened>
<Menu active={this.state.active} innerRef={this.setRef} style={style}>
{link &&
<LinkToolbar
{...this.props}
link={link}
onBlur={this.handleBlur}
/>}
{!link &&
<FormattingToolbar
onCreateLink={this.handleFocus}
{...this.props}
/>}
</Menu>
</Portal>
);
}
}
const Menu = styled.div`
padding: 8px 16px;
position: absolute;
z-index: 1;
top: -10000px;
left: -10000px;
opacity: 0;
background-color: #2F3336;
border-radius: 4px;
transform: scale(.95);
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275), transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
line-height: 0;
height: 40px;
min-width: 260px;
${({ active }) => active && `
transform: translateY(-6px) scale(1);
opacity: 1;
`}
`;

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 NextIcon from 'components/Icon/NextIcon';
type Props = {
innerRef?: Function,
onClick: SyntheticEvent => void,
document: Document,
};
function DocumentResult({ document, ...rest }: Props) {
return (
<ListItem {...rest} href="">
<i><NextIcon 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

@@ -0,0 +1,118 @@
// @flow
import React, { Component } from 'react';
import styled from 'styled-components';
import type { State } from '../../../types';
import ToolbarButton from './ToolbarButton';
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';
class FormattingToolbar extends Component {
props: {
state: State,
onChange: Function,
onCreateLink: Function,
};
/**
* Check if the current selection has a mark with `type` in it.
*
* @param {String} type
* @return {Boolean}
*/
hasMark = (type: string) => {
return this.props.state.marks.some(mark => mark.type === type);
};
isBlock = (type: string) => {
return this.props.state.startBlock.type === type;
};
/**
* When a mark button is clicked, toggle the current mark.
*
* @param {Event} ev
* @param {String} type
*/
onClickMark = (ev: SyntheticEvent, type: string) => {
ev.preventDefault();
let { state } = this.props;
state = state.transform().toggleMark(type).apply();
this.props.onChange(state);
};
onClickBlock = (ev: SyntheticEvent, type: string) => {
ev.preventDefault();
let { state } = this.props;
state = state.transform().setBlock(type).apply();
this.props.onChange(state);
};
onCreateLink = (ev: SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
let { state } = this.props;
const data = { href: '' };
state = state.transform().wrapInline({ type: 'link', data }).apply();
this.props.onChange(state);
this.props.onCreateLink();
};
renderMarkButton = (type: string, IconClass: Function) => {
const isActive = this.hasMark(type);
const onMouseDown = ev => this.onClickMark(ev, type);
return (
<ToolbarButton onMouseDown={onMouseDown} active={isActive}>
<IconClass light />
</ToolbarButton>
);
};
renderBlockButton = (type: string, IconClass: Function) => {
const isActive = this.isBlock(type);
const onMouseDown = ev =>
this.onClickBlock(ev, isActive ? 'paragraph' : type);
return (
<ToolbarButton onMouseDown={onMouseDown} active={isActive}>
<IconClass light />
</ToolbarButton>
);
};
render() {
return (
<span>
{this.renderMarkButton('bold', BoldIcon)}
{this.renderMarkButton('italic', ItalicIcon)}
{this.renderMarkButton('deleted', StrikethroughIcon)}
{this.renderMarkButton('code', CodeIcon)}
<Separator />
{this.renderBlockButton('heading1', Heading1Icon)}
{this.renderBlockButton('heading2', Heading2Icon)}
<Separator />
<ToolbarButton onMouseDown={this.onCreateLink}>
<LinkIcon light />
</ToolbarButton>
</span>
);
}
}
const Separator = styled.div`
height: 100%;
width: 1px;
background: #FFF;
opacity: .2;
display: inline-block;
margin-left: 10px;
`;
export default FormattingToolbar;

View File

@@ -0,0 +1,212 @@
// @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 CloseIcon from 'components/Icon/CloseIcon';
import OpenIcon from 'components/Icon/OpenIcon';
import TrashIcon from 'components/Icon/TrashIcon';
import Flex from 'components/Flex';
@keydown
@observer
class LinkToolbar extends Component {
input: HTMLElement;
firstDocument: HTMLElement;
props: {
state: State,
link: Object,
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) => {
switch (ev.keyCode) {
case 13: // enter
ev.preventDefault();
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 { state } = this.props;
const transform = state.transform();
if (href) {
transform.setInline({ type: 'link', data: { href } });
} else {
transform.unwrapInline('link');
}
this.props.onChange(transform.apply());
this.props.onBlur();
};
setFirstDocumentRef = ref => {
this.firstDocument = ref;
};
render() {
const href = this.props.link.data.get('href');
const hasResults = this.resultIds.length > 0;
return (
<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}>
<OpenIcon light />
</ToolbarButton>}
<ToolbarButton onMouseDown={this.removeLink}>
{this.isEditing ? <TrashIcon light /> : <CloseIcon 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: #2F3336;
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: 4px 8px;
border: 0;
margin: 0;
outline: none;
color: #fff;
flex-grow: 1;
`;
export default withRouter(inject('documents')(LinkToolbar));

View File

@@ -0,0 +1,26 @@
// @flow
import styled from 'styled-components';
export default styled.button`
display: inline-block;
flex: 0;
width: 24px;
height: 24px;
cursor: pointer;
margin-left: 10px;
border: none;
background: none;
transition: opacity 100ms ease-in-out;
padding: 0;
opacity: .7;
&:first-child {
margin-left: 0;
}
&:hover {
opacity: 1;
}
${({ active }) => active && 'opacity: 1;'}
`;

View File

@@ -0,0 +1,3 @@
// @flow
import Toolbar from './Toolbar';
export default Toolbar;