diff --git a/app/components/DocumentList.js b/app/components/DocumentList.js index 00d009564..d8c8d20ba 100644 --- a/app/components/DocumentList.js +++ b/app/components/DocumentList.js @@ -2,7 +2,7 @@ import ArrowKeyNavigation from "boundless-arrow-key-navigation"; import * as React from "react"; import Document from "models/Document"; -import DocumentPreview from "components/DocumentPreview"; +import DocumentListItem from "components/DocumentListItem"; type Props = { documents: Document[], @@ -18,7 +18,7 @@ export default function DocumentList({ limit, documents, ...rest }: Props) { defaultActiveChildIndex={0} > {items.map((document) => ( - + ))} ); diff --git a/app/components/DocumentListItem.js b/app/components/DocumentListItem.js new file mode 100644 index 000000000..1f88f0aed --- /dev/null +++ b/app/components/DocumentListItem.js @@ -0,0 +1,242 @@ +// @flow +import { observer } from "mobx-react"; +import { StarredIcon, PlusIcon } from "outline-icons"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useHistory } from "react-router-dom"; +import styled, { withTheme } from "styled-components"; +import Document from "models/Document"; +import Badge from "components/Badge"; +import Button from "components/Button"; +import DocumentMeta from "components/DocumentMeta"; +import EventBoundary from "components/EventBoundary"; +import Flex from "components/Flex"; +import Highlight from "components/Highlight"; +import Tooltip from "components/Tooltip"; +import useCurrentUser from "hooks/useCurrentUser"; +import DocumentMenu from "menus/DocumentMenu"; +import { newDocumentUrl } from "utils/routeHelpers"; + +type Props = { + document: Document, + highlight?: ?string, + context?: ?string, + showCollection?: boolean, + showPublished?: boolean, + showPin?: boolean, + showDraft?: boolean, + showTemplate?: boolean, +}; + +const SEARCH_RESULT_REGEX = /]*>(.*?)<\/b>/gi; + +function replaceResultMarks(tag: string) { + // don't use SEARCH_RESULT_REGEX here as it causes + // an infinite loop to trigger a regex inside it's own callback + return tag.replace(/]*>(.*?)<\/b>/gi, "$1"); +} + +function DocumentListItem(props: Props) { + const { t } = useTranslation(); + const currentUser = useCurrentUser(); + const history = useHistory(); + const { + document, + showCollection, + showPublished, + showPin, + showDraft = true, + showTemplate, + highlight, + context, + } = props; + + const handleStar = React.useCallback( + (ev: SyntheticEvent<>) => { + ev.preventDefault(); + ev.stopPropagation(); + document.star(); + }, + [document] + ); + + const handleUnstar = React.useCallback( + (ev: SyntheticEvent<>) => { + ev.preventDefault(); + ev.stopPropagation(); + document.unstar(); + }, + [document] + ); + + const handleNewFromTemplate = React.useCallback( + (ev: SyntheticEvent<>) => { + ev.preventDefault(); + ev.stopPropagation(); + + history.push( + newDocumentUrl(document.collectionId, { + templateId: document.id, + }) + ); + }, + [history, document] + ); + + const queryIsInTitle = + !!highlight && + !!document.title.toLowerCase().includes(highlight.toLowerCase()); + + return ( + + + + {document.isNew && document.createdBy.id !== currentUser.id && ( + <Badge yellow>{t("New")}</Badge> + )} + {!document.isDraft && !document.isArchived && !document.isTemplate && ( + <Actions> + {document.isStarred ? ( + <StyledStar onClick={handleUnstar} solid /> + ) : ( + <StyledStar onClick={handleStar} /> + )} + </Actions> + )} + {document.isDraft && showDraft && ( + <Tooltip + tooltip={t("Only visible to you")} + delay={500} + placement="top" + > + <Badge>{t("Draft")}</Badge> + </Tooltip> + )} + {document.isTemplate && showTemplate && ( + <Badge primary>{t("Template")}</Badge> + )} + <SecondaryActions> + {document.isTemplate && !document.isArchived && !document.isDeleted && ( + <Button onClick={handleNewFromTemplate} icon={<PlusIcon />} neutral> + {t("New doc")} + </Button> + )} +   + <EventBoundary> + <DocumentMenu document={document} showPin={showPin} /> + </EventBoundary> + </SecondaryActions> + </Heading> + + {!queryIsInTitle && ( + <ResultContext + text={context} + highlight={highlight ? SEARCH_RESULT_REGEX : undefined} + processResult={replaceResultMarks} + /> + )} + <DocumentMeta + document={document} + showCollection={showCollection} + showPublished={showPublished} + showLastViewed + /> + </DocumentLink> + ); +} + +const StyledStar = withTheme(styled(({ solid, theme, ...props }) => ( + <StarredIcon color={theme.text} {...props} /> +))` + flex-shrink: 0; + opacity: ${(props) => (props.solid ? "1 !important" : 0)}; + transition: all 100ms ease-in-out; + + &:hover { + transform: scale(1.1); + } + &:active { + transform: scale(0.95); + } +`); + +const SecondaryActions = styled(Flex)` + align-items: center; + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); +`; + +const DocumentLink = styled(Link)` + display: block; + margin: 10px -8px; + padding: 6px 8px; + border-radius: 8px; + max-height: 50vh; + min-width: 100%; + max-width: calc(100vw - 40px); + overflow: hidden; + position: relative; + + ${SecondaryActions} { + opacity: 0; + } + + &:hover, + &:active, + &:focus { + background: ${(props) => props.theme.listItemHoverBackground}; + + ${SecondaryActions} { + opacity: 1; + } + + ${StyledStar} { + opacity: 0.5; + + &:hover { + opacity: 1; + } + } + } +`; + +const Heading = styled.h3` + display: flex; + align-items: center; + height: 24px; + margin-top: 0; + margin-bottom: 0.25em; + overflow: hidden; + white-space: nowrap; + color: ${(props) => props.theme.text}; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; +`; + +const Actions = styled(Flex)` + margin-left: 4px; + align-items: center; +`; + +const Title = styled(Highlight)` + max-width: 90%; + overflow: hidden; + text-overflow: ellipsis; +`; + +const ResultContext = styled(Highlight)` + display: block; + color: ${(props) => props.theme.textTertiary}; + font-size: 14px; + margin-top: -0.25em; + margin-bottom: 0.25em; +`; + +export default observer(DocumentListItem); diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js deleted file mode 100644 index 6ae35efda..000000000 --- a/app/components/DocumentPreview/DocumentPreview.js +++ /dev/null @@ -1,247 +0,0 @@ -// @flow -import { observable } from "mobx"; -import { observer } from "mobx-react"; -import { StarredIcon, PlusIcon } from "outline-icons"; -import * as React from "react"; -import { withTranslation, type TFunction } from "react-i18next"; -import { Link, Redirect } from "react-router-dom"; -import styled, { withTheme } from "styled-components"; -import Document from "models/Document"; -import Badge from "components/Badge"; -import Button from "components/Button"; -import DocumentMeta from "components/DocumentMeta"; -import EventBoundary from "components/EventBoundary"; -import Flex from "components/Flex"; -import Highlight from "components/Highlight"; -import Tooltip from "components/Tooltip"; -import DocumentMenu from "menus/DocumentMenu"; -import { newDocumentUrl } from "utils/routeHelpers"; - -type Props = { - document: Document, - highlight?: ?string, - context?: ?string, - showCollection?: boolean, - showPublished?: boolean, - showPin?: boolean, - showDraft?: boolean, - showTemplate?: boolean, - t: TFunction, -}; - -const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi; - -@observer -class DocumentPreview extends React.Component<Props> { - @observable redirectTo: ?string; - - handleStar = (ev: SyntheticEvent<>) => { - ev.preventDefault(); - ev.stopPropagation(); - this.props.document.star(); - }; - - handleUnstar = (ev: SyntheticEvent<>) => { - ev.preventDefault(); - ev.stopPropagation(); - this.props.document.unstar(); - }; - - replaceResultMarks = (tag: string) => { - // don't use SEARCH_RESULT_REGEX here as it causes - // an infinite loop to trigger a regex inside it's own callback - return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1"); - }; - - handleNewFromTemplate = (event: SyntheticEvent<>) => { - event.preventDefault(); - event.stopPropagation(); - - const { document } = this.props; - - this.redirectTo = newDocumentUrl(document.collectionId, { - templateId: document.id, - }); - }; - - render() { - const { - document, - showCollection, - showPublished, - showPin, - showDraft = true, - showTemplate, - highlight, - context, - t, - } = this.props; - - if (this.redirectTo) { - return <Redirect to={this.redirectTo} push />; - } - - const queryIsInTitle = - !!highlight && - !!document.title.toLowerCase().includes(highlight.toLowerCase()); - - return ( - <DocumentLink - to={{ - pathname: document.url, - state: { title: document.titleWithDefault }, - }} - > - <Heading> - <Title text={document.titleWithDefault} highlight={highlight} /> - {document.isNew && <Badge yellow>{t("New")}</Badge>} - {!document.isDraft && - !document.isArchived && - !document.isTemplate && ( - <Actions> - {document.isStarred ? ( - <StyledStar onClick={this.handleUnstar} solid /> - ) : ( - <StyledStar onClick={this.handleStar} /> - )} - </Actions> - )} - {document.isDraft && showDraft && ( - <Tooltip - tooltip={t("Only visible to you")} - delay={500} - placement="top" - > - <Badge>{t("Draft")}</Badge> - </Tooltip> - )} - {document.isTemplate && showTemplate && ( - <Badge primary>{t("Template")}</Badge> - )} - <SecondaryActions> - {document.isTemplate && - !document.isArchived && - !document.isDeleted && ( - <Button - onClick={this.handleNewFromTemplate} - icon={<PlusIcon />} - neutral - > - {t("New doc")} - </Button> - )} -   - <EventBoundary> - <DocumentMenu document={document} showPin={showPin} /> - </EventBoundary> - </SecondaryActions> - </Heading> - - {!queryIsInTitle && ( - <ResultContext - text={context} - highlight={highlight ? SEARCH_RESULT_REGEX : undefined} - processResult={this.replaceResultMarks} - /> - )} - <DocumentMeta - document={document} - showCollection={showCollection} - showPublished={showPublished} - showLastViewed - /> - </DocumentLink> - ); - } -} - -const StyledStar = withTheme(styled(({ solid, theme, ...props }) => ( - <StarredIcon color={theme.text} {...props} /> -))` - flex-shrink: 0; - opacity: ${(props) => (props.solid ? "1 !important" : 0)}; - transition: all 100ms ease-in-out; - - &:hover { - transform: scale(1.1); - } - &:active { - transform: scale(0.95); - } -`); - -const SecondaryActions = styled(Flex)` - align-items: center; - position: absolute; - right: 16px; - top: 50%; - transform: translateY(-50%); -`; - -const DocumentLink = styled(Link)` - display: block; - margin: 10px -8px; - padding: 6px 8px; - border-radius: 8px; - max-height: 50vh; - min-width: 100%; - max-width: calc(100vw - 40px); - overflow: hidden; - position: relative; - - ${SecondaryActions} { - opacity: 0; - } - - &:hover, - &:active, - &:focus { - background: ${(props) => props.theme.listItemHoverBackground}; - - ${SecondaryActions} { - opacity: 1; - } - - ${StyledStar} { - opacity: 0.5; - - &:hover { - opacity: 1; - } - } - } -`; - -const Heading = styled.h3` - display: flex; - align-items: center; - height: 24px; - margin-top: 0; - margin-bottom: 0.25em; - overflow: hidden; - white-space: nowrap; - color: ${(props) => props.theme.text}; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, - Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; -`; - -const Actions = styled(Flex)` - margin-left: 4px; - align-items: center; -`; - -const Title = styled(Highlight)` - max-width: 90%; - overflow: hidden; - text-overflow: ellipsis; -`; - -const ResultContext = styled(Highlight)` - display: block; - color: ${(props) => props.theme.textTertiary}; - font-size: 14px; - margin-top: -0.25em; - margin-bottom: 0.25em; -`; - -export default withTranslation()<DocumentPreview>(DocumentPreview); diff --git a/app/components/DocumentPreview/index.js b/app/components/DocumentPreview/index.js deleted file mode 100644 index 5b0f0d9da..000000000 --- a/app/components/DocumentPreview/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import DocumentPreview from "./DocumentPreview"; -export default DocumentPreview; diff --git a/app/components/PaginatedDocumentList.js b/app/components/PaginatedDocumentList.js index 8d2188e8c..45884e20b 100644 --- a/app/components/PaginatedDocumentList.js +++ b/app/components/PaginatedDocumentList.js @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import Document from "models/Document"; -import DocumentPreview from "components/DocumentPreview"; +import DocumentListItem from "components/DocumentListItem"; import PaginatedList from "components/PaginatedList"; type Props = { @@ -26,7 +26,7 @@ class PaginatedDocumentList extends React.Component<Props> { fetch={fetch} options={options} renderItem={(item) => ( - <DocumentPreview key={item.id} document={item} {...rest} /> + <DocumentListItem key={item.id} document={item} {...rest} /> )} /> ); diff --git a/app/scenes/Search/Search.js b/app/scenes/Search/Search.js index b87c16417..876b56aa4 100644 --- a/app/scenes/Search/Search.js +++ b/app/scenes/Search/Search.js @@ -21,7 +21,7 @@ import UsersStore from "stores/UsersStore"; import Button from "components/Button"; import CenteredContent from "components/CenteredContent"; -import DocumentPreview from "components/DocumentPreview"; +import DocumentListItem from "components/DocumentListItem"; import Empty from "components/Empty"; import Fade from "components/Fade"; import Flex from "components/Flex"; @@ -350,7 +350,7 @@ class Search extends React.Component<Props> { if (!document) return null; return ( - <DocumentPreview + <DocumentListItem ref={(ref) => index === 0 && this.setFirstDocumentRef(ref)} key={document.id} document={document} diff --git a/app/utils/keyboard.js b/app/utils/keyboard.js index 231e7e7ff..43291f537 100644 --- a/app/utils/keyboard.js +++ b/app/utils/keyboard.js @@ -5,6 +5,6 @@ export const metaDisplay = isMac ? "⌘" : "Ctrl"; export const meta = isMac ? "cmd" : "ctrl"; -export function isMetaKey(event: KeyboardEvent) { +export function isMetaKey(event: KeyboardEvent | MouseEvent) { return isMac ? event.metaKey : event.ctrlKey; }