feat: Separate title from body (#1216)

* first pass at updating all Time components each second

* fix a couple date variable typos

* use class style state management instead of hooks

* wip: Separate title from body

* address feedback

* test: Remove unused test

* feat: You in publishing info language
fix: Removal of secondary headings

* After much deliberation… a migration is needed for this to be reliable

* fix: Export to work with new title structure

* fix: Untitled

* fix: Consistent spacing of first editor node

* fix: Emoji in title handling

* fix: Time component not updating for new props

* chore: Add createdAt case

* fix: Conflict after merging new TOC

* PR feedback

* lint

* fix: Heading level adjustment

Co-authored-by: Taylor Lapeyre <taylorlapeyre@gmail.com>
This commit is contained in:
Tom Moor
2020-04-05 15:07:34 -07:00
committed by GitHub
parent a0e73bf4c2
commit 9338a54fe0
19 changed files with 241 additions and 145 deletions

View File

@@ -43,6 +43,15 @@ export default function Contents({ document }: Props) {
[position]
);
// calculate the minimum heading level and adjust all the headings to make
// that the top-most. This prevents the contents from being weirdly indented
// if all of the headings in the document are level 3, for example.
const minHeading = headings.reduce(
(memo, heading) => (heading.level < memo ? heading.level : memo),
Infinity
);
const headingAdjustment = minHeading - 1;
return (
<div>
<Wrapper>
@@ -52,7 +61,7 @@ export default function Contents({ document }: Props) {
{headings.map(heading => (
<ListItem
key={heading.slug}
level={heading.level}
level={heading.level - headingAdjustment}
active={activeSlug === heading.slug}
>
<Link href={`#${heading.slug}`}>{heading.title}</Link>

View File

@@ -5,6 +5,7 @@ import styled from 'styled-components';
import breakpoint from 'styled-components-breakpoint';
import { observable } from 'mobx';
import { observer, inject } from 'mobx-react';
import { schema } from 'rich-markdown-editor';
import { Prompt, Route, withRouter } from 'react-router-dom';
import type { Location, RouterHistory } from 'react-router-dom';
import keydown from 'react-keydown';
@@ -37,8 +38,6 @@ import AuthStore from 'stores/AuthStore';
import Document from 'models/Document';
import Revision from 'models/Revision';
import schema from '../schema';
let EditorImport;
const AUTOSAVE_DELAY = 3000;
const IS_DIRTY_DELAY = 500;
@@ -75,9 +74,11 @@ class DocumentScene extends React.Component<Props> {
@observable isDirty: boolean = false;
@observable isEmpty: boolean = true;
@observable moveModalOpen: boolean = false;
@observable title: string;
constructor(props) {
super();
this.title = props.document.title;
this.loadEditor();
}
@@ -168,13 +169,20 @@ class DocumentScene extends React.Component<Props> {
// get the latest version of the editor text value
const text = this.getEditorText ? this.getEditorText() : document.text;
const title = this.title;
// prevent save before anything has been written (single hash is empty doc)
if (text.trim() === '#') return;
if (text.trim() === '' && title.trim === '') return;
// prevent autosave if nothing has changed
if (options.autosave && document.text.trim() === text.trim()) return;
if (
options.autosave &&
document.text.trim() === text.trim() &&
document.title.trim() === title.trim()
)
return;
document.title = title;
document.text = text;
let isNew = !document.id;
@@ -201,10 +209,12 @@ class DocumentScene extends React.Component<Props> {
updateIsDirty = () => {
const { document } = this.props;
const editorText = this.getEditorText().trim();
const titleChanged = this.title !== document.title;
const bodyChanged = editorText !== document.text.trim();
// a single hash is a doc with just an empty title
this.isEmpty = !editorText || editorText === '#';
this.isDirty = !!document && editorText !== document.text.trim();
this.isEmpty = (!editorText || editorText === '#') && !this.title;
this.isDirty = bodyChanged || titleChanged;
};
updateIsDirtyDebounced = debounce(this.updateIsDirty, IS_DIRTY_DELAY);
@@ -223,6 +233,12 @@ class DocumentScene extends React.Component<Props> {
this.autosave();
};
onChangeTitle = event => {
this.title = event.target.value;
this.updateIsDirtyDebounced();
this.autosave();
};
goBack = () => {
let url;
if (this.props.document.url) {
@@ -245,7 +261,7 @@ class DocumentScene extends React.Component<Props> {
} = this.props;
const team = auth.team;
const Editor = this.editorComponent;
const isShare = match.params.shareId;
const isShare = !!match.params.shareId;
if (!Editor) {
return <Loading location={location} />;
@@ -334,13 +350,16 @@ class DocumentScene extends React.Component<Props> {
readOnly && <Contents document={revision || document} />}
<Editor
id={document.id}
isDraft={document.isDraft}
key={disableEmbeds ? 'embeds-disabled' : 'embeds-enabled'}
title={revision ? revision.title : this.title}
document={document}
defaultValue={revision ? revision.text : document.text}
pretitle={document.emoji}
disableEmbeds={disableEmbeds}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
onSearchLink={this.props.onSearchLink}
onChangeTitle={this.onChangeTitle}
onChange={this.onChange}
onSave={this.onSave}
onPublish={this.onPublish}
@@ -350,7 +369,6 @@ class DocumentScene extends React.Component<Props> {
schema={schema}
/>
</Flex>
{readOnly &&
!isShare &&
!revision && (
@@ -389,7 +407,6 @@ const MaxWidth = styled(Flex)`
${breakpoint('desktopLarge')`
max-width: calc(48px + 46em);
box-sizing:
`};
`;

View File

@@ -1,19 +1,32 @@
// @flow
import * as React from 'react';
import styled from 'styled-components';
import { inject, observer } from 'mobx-react';
import Editor from 'components/Editor';
import PublishingInfo from 'components/PublishingInfo';
import ClickablePadding from 'components/ClickablePadding';
import Flex from 'shared/components/Flex';
import parseTitle from 'shared/utils/parseTitle';
import ViewsStore from 'stores/ViewsStore';
import Document from 'models/Document';
import plugins from './plugins';
type Props = {|
defaultValue?: string,
onChangeTitle: (event: SyntheticInputEvent<>) => void,
title: string,
defaultValue: string,
document: Document,
views: ViewsStore,
isDraft: boolean,
readOnly?: boolean,
|};
@observer
class DocumentEditor extends React.Component<Props> {
editor: ?Editor;
componentDidMount() {
if (!this.props.defaultValue) {
if (this.props.title) {
setImmediate(this.focusAtStart);
}
}
@@ -30,22 +43,82 @@ class DocumentEditor extends React.Component<Props> {
}
};
handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => {
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
this.focusAtStart();
}
};
render() {
const { readOnly } = this.props;
const {
views,
document,
title,
onChangeTitle,
isDraft,
readOnly,
} = this.props;
const totalViews = views.countForDocument(document.id);
const { emoji } = parseTitle(title);
const startsWithEmojiAndSpace = !!(
emoji && title.match(new RegExp(`^${emoji}\\s`))
);
return (
<React.Fragment>
<Flex column>
<Title
type="text"
onChange={onChangeTitle}
onKeyDown={this.handleTitleKeyDown}
placeholder="Start with a title…"
value={!title && readOnly ? 'Untitled' : title}
offsetLeft={startsWithEmojiAndSpace}
readOnly={readOnly}
autoFocus={!title}
/>
<Meta document={document}>
{totalViews && !isDraft ? (
<React.Fragment>
&nbsp;&middot; Viewed{' '}
{totalViews === 1 ? 'once' : `${totalViews} times`}
</React.Fragment>
) : null}
</Meta>
<Editor
ref={ref => (this.editor = ref)}
autoFocus={!this.props.defaultValue}
autoFocus={title && !this.props.defaultValue}
placeholder="…the rest is up to you"
plugins={plugins}
grow
{...this.props}
/>
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
</React.Fragment>
</Flex>
);
}
}
export default DocumentEditor;
const Meta = styled(PublishingInfo)`
margin: -12px 0 2em 0;
font-size: 14px;
`;
const Title = styled('input')`
line-height: 1.25;
margin-top: 1em;
margin-bottom: 0.5em;
margin-left: ${props => (props.offsetLeft ? '-1.2em' : 0)};
color: ${props => props.theme.text};
font-size: 2.25em;
font-weight: 500;
outline: none;
border: 0;
padding: 0;
&::placeholder {
color: ${props => props.theme.placeholder};
}
`;
export default inject('views')(DocumentEditor);

View File

@@ -180,6 +180,7 @@ class Header extends React.Component<Props> {
<Status>Saving</Status>
</Action>
)}
&nbsp;
<Collaborators
document={document}
currentUserId={auth.user ? auth.user.id : undefined}
@@ -280,7 +281,6 @@ class Header extends React.Component<Props> {
/>
</Action>
)}
{!isEditing && (
<React.Fragment>
<Separator />

View File

@@ -1,31 +1,8 @@
// @flow
import { Node, Editor } from 'slate';
import Placeholder from 'rich-markdown-editor/lib/plugins/Placeholder';
import { Editor } from 'slate';
import isModKey from 'rich-markdown-editor/lib/lib/isModKey';
export default [
Placeholder({
placeholder: 'Start with a title…',
when: (editor: Editor, node: Node) => {
if (editor.readOnly) return false;
if (node.object !== 'block') return false;
if (node.type !== 'heading1') return false;
if (node.text !== '') return false;
if (editor.value.document.nodes.first() !== node) return false;
return true;
},
}),
Placeholder({
placeholder: '…the rest is your canvas',
when: (editor: Editor, node: Node) => {
if (editor.readOnly) return false;
if (node.object !== 'block') return false;
if (node.type !== 'paragraph') return false;
if (node.text !== '') return false;
if (editor.value.document.getDepth(node.key) !== 1) return false;
return true;
},
}),
{
onKeyDown(ev: SyntheticKeyboardEvent<>, editor: Editor, next: Function) {
if (ev.key === 'p' && ev.shiftKey && isModKey(ev)) {