diff --git a/app/components/Arrow.js b/app/components/Arrow.js
new file mode 100644
index 00000000..2b6ade16
--- /dev/null
+++ b/app/components/Arrow.js
@@ -0,0 +1,23 @@
+// @flow
+import * as React from "react";
+
+export default function Arrow() {
+ return (
+
+ );
+}
diff --git a/app/components/CollectionDescription.js b/app/components/CollectionDescription.js
new file mode 100644
index 00000000..553503df
--- /dev/null
+++ b/app/components/CollectionDescription.js
@@ -0,0 +1,212 @@
+// @flow
+import { observer } from "mobx-react";
+import { transparentize } from "polished";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import styled from "styled-components";
+import Collection from "models/Collection";
+import Arrow from "components/Arrow";
+import ButtonLink from "components/ButtonLink";
+import Editor from "components/Editor";
+import LoadingIndicator from "components/LoadingIndicator";
+import NudeButton from "components/NudeButton";
+import useDebouncedCallback from "hooks/useDebouncedCallback";
+import useStores from "hooks/useStores";
+
+type Props = {|
+ collection: Collection,
+|};
+
+function CollectionDescription({ collection }: Props) {
+ const { collections, ui, policies } = useStores();
+ const { t } = useTranslation();
+ const [isExpanded, setExpanded] = React.useState(false);
+ const [isEditing, setEditing] = React.useState(false);
+ const [isDirty, setDirty] = React.useState(false);
+ const can = policies.abilities(collection.id);
+
+ const handleStartEditing = React.useCallback(() => {
+ setEditing(true);
+ }, []);
+
+ const handleStopEditing = React.useCallback(() => {
+ setEditing(false);
+ }, []);
+
+ const handleClickDisclosure = React.useCallback(
+ (event) => {
+ event.preventDefault();
+
+ if (isExpanded && document.activeElement) {
+ document.activeElement.blur();
+ }
+
+ setExpanded(!isExpanded);
+ },
+ [isExpanded]
+ );
+
+ const handleSave = useDebouncedCallback(async (getValue) => {
+ try {
+ await collection.save({
+ description: getValue(),
+ });
+ setDirty(false);
+ } catch (err) {
+ ui.showToast(
+ t("Sorry, an error occurred saving the collection", {
+ type: "error",
+ })
+ );
+ throw err;
+ }
+ }, 1000);
+
+ const handleChange = React.useCallback(
+ (getValue) => {
+ setDirty(true);
+ handleSave(getValue);
+ },
+ [handleSave]
+ );
+
+ React.useEffect(() => {
+ setEditing(false);
+ }, [collection.id]);
+
+ const placeholder = `${t("Add a description")}…`;
+ const key = isEditing || isDirty ? "draft" : collection.updatedAt;
+
+ return (
+
+
+
+ {collections.isSaving && }
+ {collection.hasDescription || isEditing || isDirty ? (
+ Loading…}>
+
+
+ ) : (
+ can.update && {placeholder}
+ )}
+
+
+ {!isEditing && (
+
+
+
+ )}
+
+ );
+}
+
+const Disclosure = styled(NudeButton)`
+ opacity: 0;
+ color: ${(props) => props.theme.divider};
+ position: absolute;
+ top: calc(25vh - 50px);
+ left: 50%;
+ z-index: 1;
+ transform: rotate(-90deg) translateX(-50%);
+ transition: opacity 100ms ease-in-out;
+
+ &:focus,
+ &:hover {
+ opacity: 1;
+ }
+
+ &:active {
+ color: ${(props) => props.theme.sidebarText};
+ }
+`;
+
+const Placeholder = styled(ButtonLink)`
+ color: ${(props) => props.theme.placeholder};
+ cursor: text;
+ min-height: 27px;
+`;
+
+const MaxHeight = styled.div`
+ position: relative;
+ max-height: 25vh;
+ overflow: hidden;
+ margin: -8px;
+ padding: 8px;
+
+ &[data-editing="true"],
+ &[data-expanded="true"] {
+ max-height: initial;
+ overflow: initial;
+
+ ${Disclosure} {
+ top: initial;
+ bottom: 0;
+ transform: rotate(90deg) translateX(-50%);
+ }
+ }
+
+ &:hover ${Disclosure} {
+ opacity: 1;
+ }
+`;
+
+const Input = styled.div`
+ margin: -8px;
+ padding: 8px;
+ border-radius: 8px;
+ transition: ${(props) => props.theme.backgroundTransition};
+
+ &:after {
+ content: "";
+ position: absolute;
+ top: calc(25vh - 50px);
+ left: 0;
+ right: 0;
+ height: 50px;
+ pointer-events: none;
+ background: linear-gradient(
+ 180deg,
+ ${(props) => transparentize(1, props.theme.background)} 0%,
+ ${(props) => props.theme.background} 100%
+ );
+ }
+
+ &[data-editing="true"],
+ &[data-expanded="true"] {
+ &:after {
+ background: transparent;
+ }
+ }
+
+ &[data-editing="true"] {
+ background: ${(props) => props.theme.secondaryBackground};
+ }
+
+ .block-menu-trigger,
+ .heading-anchor {
+ display: none !important;
+ }
+`;
+
+export default observer(CollectionDescription);
diff --git a/app/components/Editor.js b/app/components/Editor.js
index 103d8777..37d01c5f 100644
--- a/app/components/Editor.js
+++ b/app/components/Editor.js
@@ -27,13 +27,16 @@ export type Props = {|
autoFocus?: boolean,
template?: boolean,
placeholder?: string,
+ maxLength?: number,
scrollTo?: string,
+ handleDOMEvents?: Object,
readOnlyWriteCheckboxes?: boolean,
onBlur?: (event: SyntheticEvent<>) => any,
onFocus?: (event: SyntheticEvent<>) => any,
onPublish?: (event: SyntheticEvent<>) => any,
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
onCancel?: () => any,
+ onDoubleClick?: () => any,
onChange?: (getValue: () => string) => any,
onSearchLink?: (title: string) => any,
onHoverLink?: (event: MouseEvent) => any,
@@ -177,7 +180,7 @@ const StyledEditor = styled(RichMarkdownEditor)`
justify-content: start;
> div {
- transition: ${(props) => props.theme.backgroundTransition};
+ background: transparent;
}
& * {
diff --git a/app/components/Sidebar/components/Toggle.js b/app/components/Sidebar/components/Toggle.js
index 4533c520..01a68791 100644
--- a/app/components/Sidebar/components/Toggle.js
+++ b/app/components/Sidebar/components/Toggle.js
@@ -2,6 +2,7 @@
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
+import Arrow from "components/Arrow";
type Props = {
direction: "left" | "right",
@@ -14,22 +15,7 @@ const Toggle = React.forwardRef(
return (
-
+
);
diff --git a/app/components/Toasts/components/Toast.js b/app/components/Toast.js
similarity index 100%
rename from app/components/Toasts/components/Toast.js
rename to app/components/Toast.js
diff --git a/app/components/Toasts/Toasts.js b/app/components/Toasts.js
similarity index 94%
rename from app/components/Toasts/Toasts.js
rename to app/components/Toasts.js
index c82bedea..df2502fd 100644
--- a/app/components/Toasts/Toasts.js
+++ b/app/components/Toasts.js
@@ -2,7 +2,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
-import Toast from "./components/Toast";
+import Toast from "components/Toast";
import useStores from "hooks/useStores";
function Toasts() {
diff --git a/app/components/Toasts/index.js b/app/components/Toasts/index.js
deleted file mode 100644
index 13373bf8..00000000
--- a/app/components/Toasts/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-// @flow
-import Toasts from "./Toasts";
-export default Toasts;
diff --git a/app/hooks/useDebouncedCallback.js b/app/hooks/useDebouncedCallback.js
new file mode 100644
index 00000000..9ba68d32
--- /dev/null
+++ b/app/hooks/useDebouncedCallback.js
@@ -0,0 +1,31 @@
+// @flow
+import * as React from "react";
+
+export default function useDebouncedCallback(
+ callback: (any) => mixed,
+ wait: number
+) {
+ // track args & timeout handle between calls
+ const argsRef = React.useRef();
+ const timeout = React.useRef();
+
+ function cleanup() {
+ if (timeout.current) {
+ clearTimeout(timeout.current);
+ }
+ }
+
+ // make sure our timeout gets cleared if consuming component gets unmounted
+ React.useEffect(() => cleanup, []);
+
+ return function (...args: any) {
+ argsRef.current = args;
+ cleanup();
+
+ timeout.current = setTimeout(() => {
+ if (argsRef.current) {
+ callback(...argsRef.current);
+ }
+ }, wait);
+ };
+}
diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js
index c638db1f..7dc729a6 100644
--- a/app/scenes/Collection.js
+++ b/app/scenes/Collection.js
@@ -1,12 +1,11 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
-
import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom";
-import styled, { withTheme } from "styled-components";
+import styled from "styled-components";
import CollectionsStore from "stores/CollectionsStore";
import DocumentsStore from "stores/DocumentsStore";
@@ -20,9 +19,9 @@ import Search from "scenes/Search";
import Actions, { Action, Separator } from "components/Actions";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
+import CollectionDescription from "components/CollectionDescription";
import CollectionIcon from "components/CollectionIcon";
import DocumentList from "components/DocumentList";
-import Editor from "components/Editor";
import Flex from "components/Flex";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
@@ -37,7 +36,6 @@ import Tab from "components/Tab";
import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import CollectionMenu from "menus/CollectionMenu";
-import { type Theme } from "types";
import { AuthorizationError } from "utils/errors";
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
@@ -47,7 +45,6 @@ type Props = {
collections: CollectionsStore,
policies: PoliciesStore,
match: Match,
- theme: Theme,
t: TFunction,
};
@@ -57,7 +54,6 @@ class CollectionScene extends React.Component {
@observable isFetching: boolean = true;
@observable permissionsModalOpen: boolean = false;
@observable editModalOpen: boolean = false;
- @observable redirectTo: ?string;
componentDidMount() {
const { id } = this.props.match.params;
@@ -108,14 +104,6 @@ class CollectionScene extends React.Component {
}
};
- onNewDocument = (ev: SyntheticEvent<>) => {
- ev.preventDefault();
-
- if (this.collection) {
- this.redirectTo = newDocumentUrl(this.collection.id);
- }
- };
-
onPermissions = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.permissionsModalOpen = true;
@@ -157,7 +145,12 @@ class CollectionScene extends React.Component {
delay={500}
placement="bottom"
>
- }>
+ }
+ >
{t("New doc")}
@@ -186,9 +179,8 @@ class CollectionScene extends React.Component {
}
render() {
- const { documents, theme, t } = this.props;
+ const { documents, t } = this.props;
- if (this.redirectTo) return ;
if (!this.isFetching && !this.collection) return ;
const pinnedDocuments = this.collection
@@ -197,7 +189,6 @@ class CollectionScene extends React.Component {
const collection = this.collection;
const collectionName = collection ? collection.name : "";
const hasPinnedDocuments = !!pinnedDocuments.length;
- const hasDescription = collection ? collection.hasDescription : false;
return (
@@ -218,7 +209,7 @@ class CollectionScene extends React.Component {
- }>
+ }>
{t("Create a document")}
@@ -257,17 +248,7 @@ class CollectionScene extends React.Component {
{" "}
{collection.name}
-
- {hasDescription && (
- Loading…
}>
-
-
- )}
+
{hasPinnedDocuments && (
<>
@@ -396,10 +377,5 @@ const Wrapper = styled(Flex)`
`;
export default withTranslation()(
- inject(
- "collections",
- "policies",
- "documents",
- "ui"
- )(withTheme(CollectionScene))
+ inject("collections", "policies", "documents", "ui")(CollectionScene)
);
diff --git a/app/scenes/CollectionEdit.js b/app/scenes/CollectionEdit.js
index 84ede833..a759821a 100644
--- a/app/scenes/CollectionEdit.js
+++ b/app/scenes/CollectionEdit.js
@@ -11,7 +11,6 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import IconPicker from "components/IconPicker";
import Input from "components/Input";
-import InputRich from "components/InputRich";
import InputSelect from "components/InputSelect";
import Switch from "components/Switch";
@@ -27,7 +26,6 @@ type Props = {
class CollectionEdit extends React.Component {
@observable name: string = this.props.collection.name;
@observable sharing: boolean = this.props.collection.sharing;
- @observable description: string = this.props.collection.description;
@observable icon: string = this.props.collection.icon;
@observable color: string = this.props.collection.color || "#4E5C6E";
@observable private: boolean = this.props.collection.private;
@@ -43,7 +41,6 @@ class CollectionEdit extends React.Component {
try {
await this.props.collection.save({
name: this.name,
- description: this.description,
icon: this.icon,
color: this.color,
private: this.private,
@@ -69,10 +66,6 @@ class CollectionEdit extends React.Component {
}
};
- handleDescriptionChange = (getValue: () => string) => {
- this.description = getValue();
- };
-
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
@@ -120,15 +113,6 @@ class CollectionEdit extends React.Component {
icon={this.icon}
/>
-
{
@observable name: string = "";
- @observable description: string = "";
@observable icon: string = "";
@observable color: string = "#4E5C6E";
@observable sharing: boolean = true;
@@ -43,7 +41,6 @@ class CollectionNew extends React.Component {
const collection = new Collection(
{
name: this.name,
- description: this.description,
sharing: this.sharing,
icon: this.icon,
color: this.color,
@@ -90,10 +87,6 @@ class CollectionNew extends React.Component {
this.hasOpenedIconPicker = true;
};
- handleDescriptionChange = (getValue: () => string) => {
- this.description = getValue();
- };
-
handlePrivateChange = (ev: SyntheticInputEvent) => {
this.private = ev.target.checked;
};
@@ -115,9 +108,9 @@ class CollectionNew extends React.Component {