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:
@ -152,7 +152,7 @@ class DocumentPreview extends React.Component<Props> {
|
|||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<Heading>
|
<Heading>
|
||||||
<Title text={document.title} highlight={highlight} />
|
<Title text={document.title || 'Untitled'} highlight={highlight} />
|
||||||
{!document.isDraft &&
|
{!document.isDraft &&
|
||||||
!document.isArchived && (
|
!document.isArchived && (
|
||||||
<Actions>
|
<Actions>
|
||||||
|
@ -113,7 +113,7 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p:nth-child(2):last-child {
|
p:nth-child(1):last-child {
|
||||||
${Placeholder} {
|
${Placeholder} {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
@ -131,6 +131,15 @@ const StyledEditor = styled(RichMarkdownEditor)`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1:first-child,
|
||||||
|
h2:first-child,
|
||||||
|
h3:first-child,
|
||||||
|
h4:first-child,
|
||||||
|
h5:first-child,
|
||||||
|
h6:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { inject } from 'mobx-react';
|
import { inject, observer } from 'mobx-react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import Time from 'shared/components/Time';
|
import Time from 'shared/components/Time';
|
||||||
import Breadcrumb from 'shared/components/Breadcrumb';
|
import Breadcrumb from 'shared/components/Breadcrumb';
|
||||||
import CollectionsStore from 'stores/CollectionsStore';
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
|
import AuthStore from 'stores/AuthStore';
|
||||||
|
|
||||||
const Container = styled(Flex)`
|
const Container = styled(Flex)`
|
||||||
color: ${props => props.theme.textTertiary};
|
color: ${props => props.theme.textTertiary};
|
||||||
@ -23,29 +24,33 @@ const Modified = styled.span`
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
collections: CollectionsStore,
|
collections: CollectionsStore,
|
||||||
|
auth: AuthStore,
|
||||||
showCollection?: boolean,
|
showCollection?: boolean,
|
||||||
showPublished?: boolean,
|
showPublished?: boolean,
|
||||||
document: Document,
|
document: Document,
|
||||||
views?: number,
|
children: React.Node,
|
||||||
};
|
};
|
||||||
|
|
||||||
function PublishingInfo({
|
function PublishingInfo({
|
||||||
|
auth,
|
||||||
collections,
|
collections,
|
||||||
showPublished,
|
showPublished,
|
||||||
showCollection,
|
showCollection,
|
||||||
document,
|
document,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
modifiedSinceViewed,
|
modifiedSinceViewed,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
updatedBy,
|
updatedBy,
|
||||||
|
createdAt,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
archivedAt,
|
archivedAt,
|
||||||
deletedAt,
|
deletedAt,
|
||||||
isDraft,
|
isDraft,
|
||||||
} = document;
|
} = document;
|
||||||
|
|
||||||
const neverUpdated = publishedAt === updatedAt;
|
|
||||||
let content;
|
let content;
|
||||||
|
|
||||||
if (deletedAt) {
|
if (deletedAt) {
|
||||||
@ -60,7 +65,13 @@ function PublishingInfo({
|
|||||||
archived <Time dateTime={archivedAt} /> ago
|
archived <Time dateTime={archivedAt} /> ago
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else if (publishedAt && (neverUpdated || showPublished)) {
|
} else if (createdAt === updatedAt) {
|
||||||
|
content = (
|
||||||
|
<span>
|
||||||
|
created <Time dateTime={updatedAt} /> ago
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
|
||||||
content = (
|
content = (
|
||||||
<span>
|
<span>
|
||||||
published <Time dateTime={publishedAt} /> ago
|
published <Time dateTime={publishedAt} /> ago
|
||||||
@ -81,10 +92,11 @@ function PublishingInfo({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const collection = collections.get(document.collectionId);
|
const collection = collections.get(document.collectionId);
|
||||||
|
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container align="center">
|
<Container align="center" {...rest}>
|
||||||
{updatedBy.name}
|
{updatedByMe ? 'You' : updatedBy.name}
|
||||||
{content}
|
{content}
|
||||||
{showCollection &&
|
{showCollection &&
|
||||||
collection && (
|
collection && (
|
||||||
@ -95,8 +107,9 @@ function PublishingInfo({
|
|||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default inject('collections')(PublishingInfo);
|
export default inject('collections', 'auth')(observer(PublishingInfo));
|
||||||
|
@ -40,18 +40,9 @@ export default class Document extends BaseModel {
|
|||||||
shareUrl: ?string;
|
shareUrl: ?string;
|
||||||
revision: number;
|
revision: number;
|
||||||
|
|
||||||
constructor(data?: Object = {}, store: DocumentsStore) {
|
get emoji() {
|
||||||
super(data, store);
|
const { emoji } = parseTitle(this.title);
|
||||||
this.updateTitle();
|
return emoji;
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
updateTitle() {
|
|
||||||
const { title, emoji } = parseTitle(this.text);
|
|
||||||
|
|
||||||
if (title) {
|
|
||||||
set(this, { title, emoji });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
@ -61,15 +52,7 @@ export default class Document extends BaseModel {
|
|||||||
|
|
||||||
@computed
|
@computed
|
||||||
get isOnlyTitle(): boolean {
|
get isOnlyTitle(): boolean {
|
||||||
const { title } = parseTitle(this.text);
|
return !this.text.trim();
|
||||||
|
|
||||||
// find and extract title
|
|
||||||
const trimmedBody = this.text
|
|
||||||
.trim()
|
|
||||||
.replace(/^#/, '')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
return unescape(trimmedBody) === title;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
@ -117,7 +100,6 @@ export default class Document extends BaseModel {
|
|||||||
@action
|
@action
|
||||||
updateFromJson = data => {
|
updateFromJson = data => {
|
||||||
set(this, data);
|
set(this, data);
|
||||||
this.updateTitle();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
archive = () => {
|
archive = () => {
|
||||||
@ -182,7 +164,6 @@ export default class Document extends BaseModel {
|
|||||||
|
|
||||||
const isCreating = !this.id;
|
const isCreating = !this.id;
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
this.updateTitle();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
@ -221,14 +202,17 @@ export default class Document extends BaseModel {
|
|||||||
// Ensure the document is upto date with latest server contents
|
// Ensure the document is upto date with latest server contents
|
||||||
await this.fetch();
|
await this.fetch();
|
||||||
|
|
||||||
const blob = new Blob([unescape(this.text)], { type: 'text/markdown' });
|
const body = unescape(this.text);
|
||||||
|
const blob = new Blob([`# ${this.title}\n\n${body}`], {
|
||||||
|
type: 'text/markdown',
|
||||||
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
|
||||||
// Firefox support requires the anchor tag be in the DOM to trigger the dl
|
// Firefox support requires the anchor tag be in the DOM to trigger the dl
|
||||||
if (document.body) document.body.appendChild(a);
|
if (document.body) document.body.appendChild(a);
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${this.title}.md`;
|
a.download = `${this.title || 'Untitled'}.md`;
|
||||||
a.click();
|
a.click();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
import stores from '../stores';
|
|
||||||
|
|
||||||
describe('Document model', () => {
|
|
||||||
test('should initialize with data', () => {
|
|
||||||
const document = stores.documents.add({
|
|
||||||
id: 123,
|
|
||||||
text: '# Onboarding\nSome body text',
|
|
||||||
});
|
|
||||||
expect(document.title).toBe('Onboarding');
|
|
||||||
});
|
|
||||||
});
|
|
@ -43,6 +43,15 @@ export default function Contents({ document }: Props) {
|
|||||||
[position]
|
[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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
@ -52,7 +61,7 @@ export default function Contents({ document }: Props) {
|
|||||||
{headings.map(heading => (
|
{headings.map(heading => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={heading.slug}
|
key={heading.slug}
|
||||||
level={heading.level}
|
level={heading.level - headingAdjustment}
|
||||||
active={activeSlug === heading.slug}
|
active={activeSlug === heading.slug}
|
||||||
>
|
>
|
||||||
<Link href={`#${heading.slug}`}>{heading.title}</Link>
|
<Link href={`#${heading.slug}`}>{heading.title}</Link>
|
||||||
|
@ -5,6 +5,7 @@ import styled from 'styled-components';
|
|||||||
import breakpoint from 'styled-components-breakpoint';
|
import breakpoint from 'styled-components-breakpoint';
|
||||||
import { observable } from 'mobx';
|
import { observable } from 'mobx';
|
||||||
import { observer, inject } from 'mobx-react';
|
import { observer, inject } from 'mobx-react';
|
||||||
|
import { schema } from 'rich-markdown-editor';
|
||||||
import { Prompt, Route, withRouter } from 'react-router-dom';
|
import { Prompt, Route, withRouter } from 'react-router-dom';
|
||||||
import type { Location, RouterHistory } from 'react-router-dom';
|
import type { Location, RouterHistory } from 'react-router-dom';
|
||||||
import keydown from 'react-keydown';
|
import keydown from 'react-keydown';
|
||||||
@ -37,8 +38,6 @@ import AuthStore from 'stores/AuthStore';
|
|||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import Revision from 'models/Revision';
|
import Revision from 'models/Revision';
|
||||||
|
|
||||||
import schema from '../schema';
|
|
||||||
|
|
||||||
let EditorImport;
|
let EditorImport;
|
||||||
const AUTOSAVE_DELAY = 3000;
|
const AUTOSAVE_DELAY = 3000;
|
||||||
const IS_DIRTY_DELAY = 500;
|
const IS_DIRTY_DELAY = 500;
|
||||||
@ -75,9 +74,11 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
@observable isDirty: boolean = false;
|
@observable isDirty: boolean = false;
|
||||||
@observable isEmpty: boolean = true;
|
@observable isEmpty: boolean = true;
|
||||||
@observable moveModalOpen: boolean = false;
|
@observable moveModalOpen: boolean = false;
|
||||||
|
@observable title: string;
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super();
|
super();
|
||||||
|
this.title = props.document.title;
|
||||||
this.loadEditor();
|
this.loadEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,13 +169,20 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
|
|
||||||
// get the latest version of the editor text value
|
// get the latest version of the editor text value
|
||||||
const text = this.getEditorText ? this.getEditorText() : document.text;
|
const text = this.getEditorText ? this.getEditorText() : document.text;
|
||||||
|
const title = this.title;
|
||||||
|
|
||||||
// prevent save before anything has been written (single hash is empty doc)
|
// 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
|
// 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;
|
document.text = text;
|
||||||
|
|
||||||
let isNew = !document.id;
|
let isNew = !document.id;
|
||||||
@ -201,10 +209,12 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
updateIsDirty = () => {
|
updateIsDirty = () => {
|
||||||
const { document } = this.props;
|
const { document } = this.props;
|
||||||
const editorText = this.getEditorText().trim();
|
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
|
// a single hash is a doc with just an empty title
|
||||||
this.isEmpty = !editorText || editorText === '#';
|
this.isEmpty = (!editorText || editorText === '#') && !this.title;
|
||||||
this.isDirty = !!document && editorText !== document.text.trim();
|
this.isDirty = bodyChanged || titleChanged;
|
||||||
};
|
};
|
||||||
|
|
||||||
updateIsDirtyDebounced = debounce(this.updateIsDirty, IS_DIRTY_DELAY);
|
updateIsDirtyDebounced = debounce(this.updateIsDirty, IS_DIRTY_DELAY);
|
||||||
@ -223,6 +233,12 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
this.autosave();
|
this.autosave();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onChangeTitle = event => {
|
||||||
|
this.title = event.target.value;
|
||||||
|
this.updateIsDirtyDebounced();
|
||||||
|
this.autosave();
|
||||||
|
};
|
||||||
|
|
||||||
goBack = () => {
|
goBack = () => {
|
||||||
let url;
|
let url;
|
||||||
if (this.props.document.url) {
|
if (this.props.document.url) {
|
||||||
@ -245,7 +261,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
} = this.props;
|
} = this.props;
|
||||||
const team = auth.team;
|
const team = auth.team;
|
||||||
const Editor = this.editorComponent;
|
const Editor = this.editorComponent;
|
||||||
const isShare = match.params.shareId;
|
const isShare = !!match.params.shareId;
|
||||||
|
|
||||||
if (!Editor) {
|
if (!Editor) {
|
||||||
return <Loading location={location} />;
|
return <Loading location={location} />;
|
||||||
@ -334,13 +350,16 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
readOnly && <Contents document={revision || document} />}
|
readOnly && <Contents document={revision || document} />}
|
||||||
<Editor
|
<Editor
|
||||||
id={document.id}
|
id={document.id}
|
||||||
|
isDraft={document.isDraft}
|
||||||
key={disableEmbeds ? 'embeds-disabled' : 'embeds-enabled'}
|
key={disableEmbeds ? 'embeds-disabled' : 'embeds-enabled'}
|
||||||
|
title={revision ? revision.title : this.title}
|
||||||
|
document={document}
|
||||||
defaultValue={revision ? revision.text : document.text}
|
defaultValue={revision ? revision.text : document.text}
|
||||||
pretitle={document.emoji}
|
|
||||||
disableEmbeds={disableEmbeds}
|
disableEmbeds={disableEmbeds}
|
||||||
onImageUploadStart={this.onImageUploadStart}
|
onImageUploadStart={this.onImageUploadStart}
|
||||||
onImageUploadStop={this.onImageUploadStop}
|
onImageUploadStop={this.onImageUploadStop}
|
||||||
onSearchLink={this.props.onSearchLink}
|
onSearchLink={this.props.onSearchLink}
|
||||||
|
onChangeTitle={this.onChangeTitle}
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
onSave={this.onSave}
|
onSave={this.onSave}
|
||||||
onPublish={this.onPublish}
|
onPublish={this.onPublish}
|
||||||
@ -350,7 +369,6 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
schema={schema}
|
schema={schema}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{readOnly &&
|
{readOnly &&
|
||||||
!isShare &&
|
!isShare &&
|
||||||
!revision && (
|
!revision && (
|
||||||
@ -389,7 +407,6 @@ const MaxWidth = styled(Flex)`
|
|||||||
|
|
||||||
${breakpoint('desktopLarge')`
|
${breakpoint('desktopLarge')`
|
||||||
max-width: calc(48px + 46em);
|
max-width: calc(48px + 46em);
|
||||||
box-sizing:
|
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -1,19 +1,32 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { inject, observer } from 'mobx-react';
|
||||||
import Editor from 'components/Editor';
|
import Editor from 'components/Editor';
|
||||||
|
import PublishingInfo from 'components/PublishingInfo';
|
||||||
import ClickablePadding from 'components/ClickablePadding';
|
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';
|
import plugins from './plugins';
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
defaultValue?: string,
|
onChangeTitle: (event: SyntheticInputEvent<>) => void,
|
||||||
|
title: string,
|
||||||
|
defaultValue: string,
|
||||||
|
document: Document,
|
||||||
|
views: ViewsStore,
|
||||||
|
isDraft: boolean,
|
||||||
readOnly?: boolean,
|
readOnly?: boolean,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
|
@observer
|
||||||
class DocumentEditor extends React.Component<Props> {
|
class DocumentEditor extends React.Component<Props> {
|
||||||
editor: ?Editor;
|
editor: ?Editor;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (!this.props.defaultValue) {
|
if (this.props.title) {
|
||||||
setImmediate(this.focusAtStart);
|
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() {
|
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 (
|
return (
|
||||||
|
<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>
|
<React.Fragment>
|
||||||
|
· Viewed{' '}
|
||||||
|
{totalViews === 1 ? 'once' : `${totalViews} times`}
|
||||||
|
</React.Fragment>
|
||||||
|
) : null}
|
||||||
|
</Meta>
|
||||||
<Editor
|
<Editor
|
||||||
ref={ref => (this.editor = ref)}
|
ref={ref => (this.editor = ref)}
|
||||||
autoFocus={!this.props.defaultValue}
|
autoFocus={title && !this.props.defaultValue}
|
||||||
|
placeholder="…the rest is up to you"
|
||||||
plugins={plugins}
|
plugins={plugins}
|
||||||
grow
|
grow
|
||||||
{...this.props}
|
{...this.props}
|
||||||
/>
|
/>
|
||||||
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
|
{!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);
|
||||||
|
@ -180,6 +180,7 @@ class Header extends React.Component<Props> {
|
|||||||
<Status>Saving…</Status>
|
<Status>Saving…</Status>
|
||||||
</Action>
|
</Action>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Collaborators
|
<Collaborators
|
||||||
document={document}
|
document={document}
|
||||||
currentUserId={auth.user ? auth.user.id : undefined}
|
currentUserId={auth.user ? auth.user.id : undefined}
|
||||||
@ -280,7 +281,6 @@ class Header extends React.Component<Props> {
|
|||||||
/>
|
/>
|
||||||
</Action>
|
</Action>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
@ -1,31 +1,8 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { Node, Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import Placeholder from 'rich-markdown-editor/lib/plugins/Placeholder';
|
|
||||||
import isModKey from 'rich-markdown-editor/lib/lib/isModKey';
|
import isModKey from 'rich-markdown-editor/lib/lib/isModKey';
|
||||||
|
|
||||||
export default [
|
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) {
|
onKeyDown(ev: SyntheticKeyboardEvent<>, editor: Editor, next: Function) {
|
||||||
if (ev.key === 'p' && ev.shiftKey && isModKey(ev)) {
|
if (ev.key === 'p' && ev.shiftKey && isModKey(ev)) {
|
||||||
|
@ -1,42 +0,0 @@
|
|||||||
// @flow
|
|
||||||
import { cloneDeep } from 'lodash';
|
|
||||||
import { Block, SlateError, Editor } from 'slate';
|
|
||||||
import { schema as originalSchema } from 'rich-markdown-editor';
|
|
||||||
|
|
||||||
const schema = cloneDeep(originalSchema);
|
|
||||||
|
|
||||||
// add rules to the schema to ensure the first node is a heading
|
|
||||||
schema.document.nodes.unshift({ match: { type: 'heading1' }, min: 1, max: 1 });
|
|
||||||
schema.document.normalize = (editor: Editor, error: SlateError) => {
|
|
||||||
switch (error.code) {
|
|
||||||
case 'child_max_invalid': {
|
|
||||||
return editor.setNodeByKey(
|
|
||||||
error.child.key,
|
|
||||||
error.index === 0 ? 'heading1' : 'paragraph'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'child_min_invalid': {
|
|
||||||
const missingTitle = error.index === 0;
|
|
||||||
const firstNode = editor.value.document.nodes.get(0);
|
|
||||||
if (!firstNode) {
|
|
||||||
editor.insertNodeByKey(error.node.key, 0, Block.create('heading1'));
|
|
||||||
} else {
|
|
||||||
editor.setNodeByKey(firstNode.key, { type: 'heading1' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const secondNode = editor.value.document.nodes.get(1);
|
|
||||||
if (!secondNode) {
|
|
||||||
editor.insertNodeByKey(error.node.key, 1, Block.create('paragraph'));
|
|
||||||
} else {
|
|
||||||
editor.setNodeByKey(secondNode.key, { type: 'paragraph' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingTitle) setImmediate(() => editor.moveFocusToStartOfDocument());
|
|
||||||
|
|
||||||
return editor;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default schema;
|
|
@ -1,5 +1,5 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { filter, find, orderBy } from 'lodash';
|
import { reduce, filter, find, orderBy } from 'lodash';
|
||||||
import BaseStore from './BaseStore';
|
import BaseStore from './BaseStore';
|
||||||
import RootStore from './RootStore';
|
import RootStore from './RootStore';
|
||||||
import View from 'models/View';
|
import View from 'models/View';
|
||||||
@ -19,6 +19,11 @@ export default class ViewsStore extends BaseStore<View> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
countForDocument(documentId: string): number {
|
||||||
|
const views = this.inDocument(documentId);
|
||||||
|
return reduce(views, (memo, view) => memo + view.count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
touch(documentId: string, userId: string) {
|
touch(documentId: string, userId: string) {
|
||||||
const view = find(
|
const view = find(
|
||||||
this.orderedData,
|
this.orderedData,
|
||||||
|
@ -425,7 +425,7 @@ router.post('documents.revision', auth(), async ctx => {
|
|||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
pagination: ctx.state.pagination,
|
pagination: ctx.state.pagination,
|
||||||
data: presentRevision(revision),
|
data: await presentRevision(revision),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -445,9 +445,13 @@ router.post('documents.revisions', auth(), pagination(), async ctx => {
|
|||||||
limit: ctx.state.pagination.limit,
|
limit: ctx.state.pagination.limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const data = await Promise.all(
|
||||||
|
revisions.map(revision => presentRevision(revision))
|
||||||
|
);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
pagination: ctx.state.pagination,
|
pagination: ctx.state.pagination,
|
||||||
data: revisions.map(presentRevision),
|
data,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
19
server/migrations/20200330053639-document-version.js
Normal file
19
server/migrations/20200330053639-document-version.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn('documents', 'version', {
|
||||||
|
type: Sequelize.SMALLINT,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
await queryInterface.addColumn('revisions', 'version', {
|
||||||
|
type: Sequelize.SMALLINT,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn('documents', 'version');
|
||||||
|
await queryInterface.removeColumn('revisions', 'version');
|
||||||
|
}
|
||||||
|
};
|
@ -17,7 +17,8 @@ import Revision from './Revision';
|
|||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
const Markdown = new MarkdownSerializer();
|
const Markdown = new MarkdownSerializer();
|
||||||
const URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/;
|
const URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/;
|
||||||
const DEFAULT_TITLE = 'Untitled';
|
|
||||||
|
export const DOCUMENT_VERSION = 1;
|
||||||
|
|
||||||
slug.defaults.mode = 'rfc3986';
|
slug.defaults.mode = 'rfc3986';
|
||||||
const slugify = text =>
|
const slugify = text =>
|
||||||
@ -38,6 +39,7 @@ const createRevision = (doc, options = {}) => {
|
|||||||
text: doc.text,
|
text: doc.text,
|
||||||
userId: doc.lastModifiedById,
|
userId: doc.lastModifiedById,
|
||||||
editorVersion: doc.editorVersion,
|
editorVersion: doc.editorVersion,
|
||||||
|
version: doc.version,
|
||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -50,16 +52,19 @@ const createUrlId = doc => {
|
|||||||
return (doc.urlId = doc.urlId || randomstring.generate(10));
|
return (doc.urlId = doc.urlId || randomstring.generate(10));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const beforeCreate = async doc => {
|
||||||
|
doc.version = DOCUMENT_VERSION;
|
||||||
|
return beforeSave(doc);
|
||||||
|
};
|
||||||
|
|
||||||
const beforeSave = async doc => {
|
const beforeSave = async doc => {
|
||||||
const { emoji, title } = parseTitle(doc.text);
|
const { emoji } = parseTitle(doc.text);
|
||||||
|
|
||||||
// emoji in the title is split out for easier display
|
// emoji in the title is split out for easier display
|
||||||
doc.emoji = emoji;
|
doc.emoji = emoji;
|
||||||
|
|
||||||
// ensure documents have a title
|
// ensure documents have a title
|
||||||
if (!title) {
|
doc.title = doc.title || '';
|
||||||
doc.title = DEFAULT_TITLE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// add the current user as a collaborator on this doc
|
// add the current user as a collaborator on this doc
|
||||||
if (!doc.collaboratorIds) doc.collaboratorIds = [];
|
if (!doc.collaboratorIds) doc.collaboratorIds = [];
|
||||||
@ -92,6 +97,7 @@ const Document = sequelize.define(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
version: DataTypes.SMALLINT,
|
||||||
editorVersion: DataTypes.STRING,
|
editorVersion: DataTypes.STRING,
|
||||||
text: DataTypes.TEXT,
|
text: DataTypes.TEXT,
|
||||||
isWelcome: { type: DataTypes.BOOLEAN, defaultValue: false },
|
isWelcome: { type: DataTypes.BOOLEAN, defaultValue: false },
|
||||||
@ -105,7 +111,7 @@ const Document = sequelize.define(
|
|||||||
paranoid: true,
|
paranoid: true,
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeValidate: createUrlId,
|
beforeValidate: createUrlId,
|
||||||
beforeCreate: beforeSave,
|
beforeCreate: beforeCreate,
|
||||||
beforeUpdate: beforeSave,
|
beforeUpdate: beforeSave,
|
||||||
afterCreate: createRevision,
|
afterCreate: createRevision,
|
||||||
afterUpdate: createRevision,
|
afterUpdate: createRevision,
|
||||||
@ -427,6 +433,26 @@ Document.addHook('afterCreate', async model => {
|
|||||||
|
|
||||||
// Instance methods
|
// Instance methods
|
||||||
|
|
||||||
|
Document.prototype.toMarkdown = function() {
|
||||||
|
const text = unescape(this.text);
|
||||||
|
|
||||||
|
if (this.version) {
|
||||||
|
return `# ${this.title}\n\n${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
Document.prototype.migrateVersion = function() {
|
||||||
|
// migrate from document version 0 -> 1 means removing the title from the
|
||||||
|
// document text attribute.
|
||||||
|
if (!this.version) {
|
||||||
|
this.text = this.text.replace(/^#\s(.*)\n/, '');
|
||||||
|
this.version = 1;
|
||||||
|
return this.save({ silent: true, hooks: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Note: This method marks the document and it's children as deleted
|
// Note: This method marks the document and it's children as deleted
|
||||||
// in the database, it does not permanantly delete them OR remove
|
// in the database, it does not permanantly delete them OR remove
|
||||||
// from the collection structure.
|
// from the collection structure.
|
||||||
|
@ -7,6 +7,7 @@ const Revision = sequelize.define('revision', {
|
|||||||
defaultValue: DataTypes.UUIDV4,
|
defaultValue: DataTypes.UUIDV4,
|
||||||
primaryKey: true,
|
primaryKey: true,
|
||||||
},
|
},
|
||||||
|
version: DataTypes.SMALLINT,
|
||||||
editorVersion: DataTypes.STRING,
|
editorVersion: DataTypes.STRING,
|
||||||
title: DataTypes.STRING,
|
title: DataTypes.STRING,
|
||||||
text: DataTypes.TEXT,
|
text: DataTypes.TEXT,
|
||||||
@ -31,4 +32,14 @@ Revision.associate = models => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Revision.prototype.migrateVersion = function() {
|
||||||
|
// migrate from revision version 0 -> 1 means removing the title from the
|
||||||
|
// revision text attribute.
|
||||||
|
if (!this.version) {
|
||||||
|
this.text = this.text.replace(/^#\s(.*)\n/, '');
|
||||||
|
this.version = 1;
|
||||||
|
return this.save({ silent: true, hooks: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default Revision;
|
export default Revision;
|
||||||
|
@ -31,7 +31,9 @@ export default async function present(document: Document, options: ?Options) {
|
|||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
const text = options.isPublic
|
await document.migrateVersion();
|
||||||
|
|
||||||
|
let text = options.isPublic
|
||||||
? await replaceImageAttachments(document.text)
|
? await replaceImageAttachments(document.text)
|
||||||
: document.text;
|
: document.text;
|
||||||
|
|
||||||
|
@ -2,7 +2,9 @@
|
|||||||
import { Revision } from '../models';
|
import { Revision } from '../models';
|
||||||
import presentUser from './user';
|
import presentUser from './user';
|
||||||
|
|
||||||
export default function present(revision: Revision) {
|
export default async function present(revision: Revision) {
|
||||||
|
await revision.migrateVersion();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: revision.id,
|
id: revision.id,
|
||||||
documentId: revision.documentId,
|
documentId: revision.documentId,
|
||||||
|
@ -3,14 +3,13 @@ import fs from 'fs';
|
|||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import tmp from 'tmp';
|
import tmp from 'tmp';
|
||||||
import * as Sentry from '@sentry/node';
|
import * as Sentry from '@sentry/node';
|
||||||
import unescape from '../../shared/utils/unescape';
|
|
||||||
import { Attachment, Collection, Document } from '../models';
|
import { Attachment, Collection, Document } from '../models';
|
||||||
import { getImageByKey } from './s3';
|
import { getImageByKey } from './s3';
|
||||||
|
|
||||||
async function addToArchive(zip, documents) {
|
async function addToArchive(zip, documents) {
|
||||||
for (const doc of documents) {
|
for (const doc of documents) {
|
||||||
const document = await Document.findByPk(doc.id);
|
const document = await Document.findByPk(doc.id);
|
||||||
let text = unescape(document.text);
|
let text = document.toMarkdown();
|
||||||
|
|
||||||
const attachments = await Attachment.findAll({
|
const attachments = await Attachment.findAll({
|
||||||
where: { documentId: document.id },
|
where: { documentId: document.id },
|
||||||
@ -21,7 +20,7 @@ async function addToArchive(zip, documents) {
|
|||||||
text = text.replace(attachment.redirectUrl, encodeURI(attachment.key));
|
text = text.replace(attachment.redirectUrl, encodeURI(attachment.key));
|
||||||
}
|
}
|
||||||
|
|
||||||
zip.file(`${document.title}.md`, text);
|
zip.file(`${document.title || 'Untitled'}.md`, text);
|
||||||
|
|
||||||
if (doc.children && doc.children.length) {
|
if (doc.children && doc.children.length) {
|
||||||
const folder = zip.folder(document.title);
|
const folder = zip.folder(document.title);
|
||||||
|
Reference in New Issue
Block a user