feat: Search shared documents (#3126)
* provide a type-ahead search input on shared document pages that allow search of child document tree * improve keyboard navigation handling of all search views * improve coloring on dark mode list selection states * refactor PaginatedList component to eliminate edge cases
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import debounce from "lodash/debounce";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { transparentize } from "polished";
|
import { transparentize } from "polished";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -9,7 +10,6 @@ import ButtonLink from "~/components/ButtonLink";
|
|||||||
import Editor from "~/components/Editor";
|
import Editor from "~/components/Editor";
|
||||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import useDebouncedCallback from "~/hooks/useDebouncedCallback";
|
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import useToasts from "~/hooks/useToasts";
|
import useToasts from "~/hooks/useToasts";
|
||||||
@@ -49,21 +49,25 @@ function CollectionDescription({ collection }: Props) {
|
|||||||
[isExpanded]
|
[isExpanded]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSave = useDebouncedCallback(async (getValue) => {
|
const handleSave = React.useMemo(
|
||||||
try {
|
() =>
|
||||||
await collection.save({
|
debounce(async (getValue) => {
|
||||||
description: getValue(),
|
try {
|
||||||
});
|
await collection.save({
|
||||||
setDirty(false);
|
description: getValue(),
|
||||||
} catch (err) {
|
});
|
||||||
showToast(
|
setDirty(false);
|
||||||
t("Sorry, an error occurred saving the collection", {
|
} catch (err) {
|
||||||
type: "error",
|
showToast(
|
||||||
})
|
t("Sorry, an error occurred saving the collection", {
|
||||||
);
|
type: "error",
|
||||||
throw err;
|
})
|
||||||
}
|
);
|
||||||
}, 1000);
|
throw err;
|
||||||
|
}
|
||||||
|
}, 1000),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleChange = React.useCallback(
|
const handleChange = React.useCallback(
|
||||||
(getValue) => {
|
(getValue) => {
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ function Highlight({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Mark = styled.mark`
|
export const Mark = styled.mark`
|
||||||
background: ${(props) => props.theme.searchHighlight};
|
background: ${(props) => props.theme.searchHighlight};
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
padding: 0 4px;
|
padding: 0 2px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default Highlight;
|
export default Highlight;
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export type Props = React.HTMLAttributes<HTMLInputElement> & {
|
|||||||
onChange?: (
|
onChange?: (
|
||||||
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||||
) => unknown;
|
) => unknown;
|
||||||
|
innerRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
|
||||||
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||||
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
||||||
@@ -126,7 +127,7 @@ export type Props = React.HTMLAttributes<HTMLInputElement> & {
|
|||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Input extends React.Component<Props> {
|
class Input extends React.Component<Props> {
|
||||||
input = React.createRef<HTMLInputElement | HTMLTextAreaElement>();
|
input = this.props.innerRef;
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
focused = false;
|
focused = false;
|
||||||
@@ -147,10 +148,6 @@ class Input extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
focus() {
|
|
||||||
this.input.current?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
type = "text",
|
type = "text",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { SearchIcon } from "outline-icons";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "styled-components";
|
import { useTheme } from "styled-components";
|
||||||
import Input, { Props as InputProps } from "./Input";
|
import Input, { Props as InputProps } from "~/components/Input";
|
||||||
|
|
||||||
type Props = InputProps & {
|
type Props = InputProps & {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -11,7 +11,10 @@ type Props = InputProps & {
|
|||||||
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function InputSearch(props: Props) {
|
function InputSearch(
|
||||||
|
props: Props,
|
||||||
|
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
|
||||||
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [isFocused, setIsFocused] = React.useState(false);
|
const [isFocused, setIsFocused] = React.useState(false);
|
||||||
@@ -39,7 +42,10 @@ export default function InputSearch(props: Props) {
|
|||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
margin={0}
|
margin={0}
|
||||||
labelHidden
|
labelHidden
|
||||||
|
innerRef={ref}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.forwardRef(InputSearch);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import useBoolean from "~/hooks/useBoolean";
|
|||||||
import useKeyDown from "~/hooks/useKeyDown";
|
import useKeyDown from "~/hooks/useKeyDown";
|
||||||
import { isModKey } from "~/utils/keyboard";
|
import { isModKey } from "~/utils/keyboard";
|
||||||
import { searchPath } from "~/utils/routeHelpers";
|
import { searchPath } from "~/utils/routeHelpers";
|
||||||
import Input from "./Input";
|
import Input, { Outline } from "./Input";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
source: string;
|
source: string;
|
||||||
@@ -30,7 +30,7 @@ function InputSearchPage({
|
|||||||
collectionId,
|
collectionId,
|
||||||
source,
|
source,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const inputRef = React.useRef<Input>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -67,7 +67,7 @@ function InputSearchPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InputMaxWidth
|
<InputMaxWidth
|
||||||
ref={inputRef}
|
innerRef={inputRef}
|
||||||
type="search"
|
type="search"
|
||||||
placeholder={placeholder || `${t("Search")}…`}
|
placeholder={placeholder || `${t("Search")}…`}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -89,6 +89,10 @@ function InputSearchPage({
|
|||||||
|
|
||||||
const InputMaxWidth = styled(Input)`
|
const InputMaxWidth = styled(Input)`
|
||||||
max-width: 30vw;
|
max-width: 30vw;
|
||||||
|
|
||||||
|
${Outline} {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default observer(InputSearchPage);
|
export default observer(InputSearchPage);
|
||||||
|
|||||||
@@ -3,19 +3,24 @@ import * as React from "react";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Fade from "~/components/Fade";
|
import Fade from "~/components/Fade";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import PlaceholderText from "~/components/PlaceholderText";
|
import PlaceholderText, {
|
||||||
|
Props as PlaceholderTextProps,
|
||||||
|
} from "~/components/PlaceholderText";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
count?: number;
|
count?: number;
|
||||||
|
className?: string;
|
||||||
|
header?: PlaceholderTextProps;
|
||||||
|
body?: PlaceholderTextProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ListPlaceHolder = ({ count }: Props) => {
|
const ListPlaceHolder = ({ count, className, header, body }: Props) => {
|
||||||
return (
|
return (
|
||||||
<Fade>
|
<Fade>
|
||||||
{times(count || 2, (index) => (
|
{times(count || 2, (index) => (
|
||||||
<Item key={index} column auto>
|
<Item key={index} className={className} column auto>
|
||||||
<PlaceholderText header delay={0.2 * index} />
|
<PlaceholderText {...header} header delay={0.2 * index} />
|
||||||
<PlaceholderText delay={0.2 * index} />
|
<PlaceholderText {...body} delay={0.2 * index} />
|
||||||
</Item>
|
</Item>
|
||||||
))}
|
))}
|
||||||
</Fade>
|
</Fade>
|
||||||
|
|||||||
@@ -15,32 +15,33 @@ import { dateToHeading } from "~/utils/dates";
|
|||||||
|
|
||||||
type Props = WithTranslation &
|
type Props = WithTranslation &
|
||||||
RootStore & {
|
RootStore & {
|
||||||
fetch?: (options: Record<string, any> | null | undefined) => Promise<any>;
|
fetch?: (
|
||||||
|
options: Record<string, any> | null | undefined
|
||||||
|
) => Promise<any> | undefined;
|
||||||
options?: Record<string, any>;
|
options?: Record<string, any>;
|
||||||
heading?: React.ReactNode;
|
heading?: React.ReactNode;
|
||||||
empty?: React.ReactNode;
|
empty?: React.ReactNode;
|
||||||
items: any[];
|
loading?: React.ReactElement;
|
||||||
|
items?: any[];
|
||||||
renderItem: (
|
renderItem: (
|
||||||
item: any,
|
item: any,
|
||||||
index: number,
|
index: number,
|
||||||
composite: CompositeStateReturn
|
compositeProps: CompositeStateReturn
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||||
|
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class PaginatedList extends React.Component<Props> {
|
class PaginatedList extends React.Component<Props> {
|
||||||
isInitiallyLoaded = this.props.items.length > 0;
|
|
||||||
|
|
||||||
@observable
|
|
||||||
isLoaded = false;
|
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
isFetchingMore = false;
|
isFetchingMore = false;
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
isFetching = false;
|
isFetching = false;
|
||||||
|
|
||||||
|
fetchCounter = 0;
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
renderCount: number = DEFAULT_PAGINATION_LIMIT;
|
renderCount: number = DEFAULT_PAGINATION_LIMIT;
|
||||||
|
|
||||||
@@ -70,7 +71,6 @@ class PaginatedList extends React.Component<Props> {
|
|||||||
this.renderCount = DEFAULT_PAGINATION_LIMIT;
|
this.renderCount = DEFAULT_PAGINATION_LIMIT;
|
||||||
this.isFetching = false;
|
this.isFetching = false;
|
||||||
this.isFetchingMore = false;
|
this.isFetchingMore = false;
|
||||||
this.isLoaded = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchResults = async () => {
|
fetchResults = async () => {
|
||||||
@@ -78,7 +78,9 @@ class PaginatedList extends React.Component<Props> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.isFetching = true;
|
this.isFetching = true;
|
||||||
|
const counter = ++this.fetchCounter;
|
||||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||||
|
|
||||||
const results = await this.props.fetch({
|
const results = await this.props.fetch({
|
||||||
limit,
|
limit,
|
||||||
offset: this.offset,
|
offset: this.offset,
|
||||||
@@ -92,9 +94,12 @@ class PaginatedList extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.renderCount += limit;
|
this.renderCount += limit;
|
||||||
this.isLoaded = true;
|
|
||||||
this.isFetching = false;
|
// only the most recent fetch should end the loading state
|
||||||
this.isFetchingMore = false;
|
if (counter >= this.fetchCounter) {
|
||||||
|
this.isFetching = false;
|
||||||
|
this.isFetchingMore = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -105,7 +110,7 @@ class PaginatedList extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
// If there are already cached results that we haven't yet rendered because
|
// If there are already cached results that we haven't yet rendered because
|
||||||
// of lazy rendering then show another page.
|
// of lazy rendering then show another page.
|
||||||
const leftToRender = this.props.items.length - this.renderCount;
|
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
|
||||||
|
|
||||||
if (leftToRender > 1) {
|
if (leftToRender > 1) {
|
||||||
this.renderCount += DEFAULT_PAGINATION_LIMIT;
|
this.renderCount += DEFAULT_PAGINATION_LIMIT;
|
||||||
@@ -120,20 +125,24 @@ class PaginatedList extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { items, heading, auth, empty, renderHeading } = this.props;
|
const { items, heading, auth, empty, renderHeading, onEscape } = this.props;
|
||||||
let previousHeading = "";
|
let previousHeading = "";
|
||||||
|
|
||||||
|
const showList = !!items?.length;
|
||||||
|
const showEmpty = items?.length === 0;
|
||||||
const showLoading =
|
const showLoading =
|
||||||
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
|
this.isFetching && !this.isFetchingMore && !showList && !showEmpty;
|
||||||
const showEmpty = !items.length && !showLoading;
|
|
||||||
const showList =
|
|
||||||
(this.isLoaded || this.isInitiallyLoaded) && !showLoading && !showEmpty;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showEmpty && empty}
|
{showEmpty && empty}
|
||||||
{showList && (
|
{showList && (
|
||||||
<>
|
<>
|
||||||
{heading}
|
{heading}
|
||||||
<ArrowKeyNavigation aria-label={this.props["aria-label"]}>
|
<ArrowKeyNavigation
|
||||||
|
aria-label={this.props["aria-label"]}
|
||||||
|
onEscape={onEscape}
|
||||||
|
>
|
||||||
{(composite: CompositeStateReturn) =>
|
{(composite: CompositeStateReturn) =>
|
||||||
items.slice(0, this.renderCount).map((item, index) => {
|
items.slice(0, this.renderCount).map((item, index) => {
|
||||||
const children = this.props.renderItem(
|
const children = this.props.renderItem(
|
||||||
@@ -180,11 +189,12 @@ class PaginatedList extends React.Component<Props> {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showLoading && (
|
{showLoading &&
|
||||||
<DelayedMount>
|
(this.props.loading || (
|
||||||
<PlaceholderList count={5} />
|
<DelayedMount>
|
||||||
</DelayedMount>
|
<PlaceholderList count={5} />
|
||||||
)}
|
</DelayedMount>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { randomInteger } from "@shared/random";
|
|||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import { pulsate } from "~/styles/animations";
|
import { pulsate } from "~/styles/animations";
|
||||||
|
|
||||||
type Props = {
|
export type Props = {
|
||||||
header?: boolean;
|
header?: boolean;
|
||||||
height?: number;
|
height?: number;
|
||||||
minWidth?: number;
|
minWidth?: number;
|
||||||
|
|||||||
@@ -1,41 +1,50 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Dialog } from "reakit/Dialog";
|
import { Dialog } from "reakit/Dialog";
|
||||||
import { Popover as ReakitPopover } from "reakit/Popover";
|
import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import { depths } from "@shared/styles";
|
import { depths } from "@shared/styles";
|
||||||
import useMobile from "~/hooks/useMobile";
|
import useMobile from "~/hooks/useMobile";
|
||||||
import { fadeAndScaleIn } from "~/styles/animations";
|
import { fadeAndScaleIn } from "~/styles/animations";
|
||||||
|
|
||||||
type Props = {
|
type Props = PopoverProps & {
|
||||||
tabIndex?: number;
|
children: React.ReactNode;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
shrink?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Popover: React.FC<Props> = ({ children, width = 380, ...rest }) => {
|
const Popover: React.FC<Props> = ({
|
||||||
|
children,
|
||||||
|
shrink,
|
||||||
|
width = 380,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<Dialog {...rest} modal>
|
<Dialog {...rest} modal>
|
||||||
<Contents>{children}</Contents>
|
<Contents $shrink={shrink}>{children}</Contents>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReakitPopover {...rest}>
|
<ReakitPopover {...rest}>
|
||||||
<Contents $width={width}>{children}</Contents>
|
<Contents $shrink={shrink} $width={width}>
|
||||||
|
{children}
|
||||||
|
</Contents>
|
||||||
</ReakitPopover>
|
</ReakitPopover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Contents = styled.div<{ $width?: number }>`
|
const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
|
||||||
animation: ${fadeAndScaleIn} 200ms ease;
|
animation: ${fadeAndScaleIn} 200ms ease;
|
||||||
transform-origin: 75% 0;
|
transform-origin: 75% 0;
|
||||||
background: ${(props) => props.theme.menuBackground};
|
background: ${(props) => props.theme.menuBackground};
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px 24px;
|
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
box-shadow: ${(props) => props.theme.menuShadow};
|
box-shadow: ${(props) => props.theme.menuShadow};
|
||||||
|
|||||||
148
app/components/SearchListItem.tsx
Normal file
148
app/components/SearchListItem.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { CompositeItem } from "reakit/Composite";
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
import breakpoint from "styled-components-breakpoint";
|
||||||
|
import Document from "~/models/Document";
|
||||||
|
import Highlight, { Mark } from "~/components/Highlight";
|
||||||
|
import { hover } from "~/styles";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
document: Document;
|
||||||
|
highlight: string;
|
||||||
|
context: string | undefined;
|
||||||
|
showParentDocuments?: boolean;
|
||||||
|
showCollection?: boolean;
|
||||||
|
showPublished?: boolean;
|
||||||
|
shareId?: string;
|
||||||
|
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||||
|
};
|
||||||
|
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/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\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentListItem(
|
||||||
|
props: Props,
|
||||||
|
ref: React.RefObject<HTMLAnchorElement>
|
||||||
|
) {
|
||||||
|
const { document, highlight, context, shareId, ...rest } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CompositeItem
|
||||||
|
as={DocumentLink}
|
||||||
|
ref={ref}
|
||||||
|
dir={document.dir}
|
||||||
|
to={{
|
||||||
|
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
|
||||||
|
state: {
|
||||||
|
title: document.titleWithDefault,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<Content>
|
||||||
|
<Heading dir={document.dir}>
|
||||||
|
<Title
|
||||||
|
text={document.titleWithDefault}
|
||||||
|
highlight={highlight}
|
||||||
|
dir={document.dir}
|
||||||
|
/>
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{
|
||||||
|
<ResultContext
|
||||||
|
text={context}
|
||||||
|
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||||
|
processResult={replaceResultMarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</Content>
|
||||||
|
</CompositeItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Content = styled.div`
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DocumentLink = styled(Link)<{
|
||||||
|
$isStarred?: boolean;
|
||||||
|
$menuOpen?: boolean;
|
||||||
|
}>`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
max-height: 50vh;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
${breakpoint("tablet")`
|
||||||
|
width: auto;
|
||||||
|
`};
|
||||||
|
|
||||||
|
&:${hover},
|
||||||
|
&:active,
|
||||||
|
&:focus,
|
||||||
|
&:focus-within {
|
||||||
|
background: ${(props) => props.theme.listItemHoverBackground};
|
||||||
|
}
|
||||||
|
|
||||||
|
${(props) =>
|
||||||
|
props.$menuOpen &&
|
||||||
|
css`
|
||||||
|
background: ${(props) => props.theme.listItemHoverBackground};
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Heading = styled.h4<{ rtl?: boolean }>`
|
||||||
|
display: flex;
|
||||||
|
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||||
|
align-items: center;
|
||||||
|
height: 18px;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: ${(props) => props.theme.text};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled(Highlight)`
|
||||||
|
max-width: 90%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
${Mark} {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ResultContext = styled(Highlight)`
|
||||||
|
display: block;
|
||||||
|
color: ${(props) => props.theme.textTertiary};
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: -0.25em;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
${Mark} {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default observer(React.forwardRef(DocumentListItem));
|
||||||
197
app/components/SearchPopover.tsx
Normal file
197
app/components/SearchPopover.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { debounce } from "lodash";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { depths } from "@shared/styles";
|
||||||
|
import Empty from "~/components/Empty";
|
||||||
|
import { Outline } from "~/components/Input";
|
||||||
|
import InputSearch from "~/components/InputSearch";
|
||||||
|
import Placeholder from "~/components/List/Placeholder";
|
||||||
|
import PaginatedList from "~/components/PaginatedList";
|
||||||
|
import Popover from "~/components/Popover";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import SearchListItem from "./SearchListItem";
|
||||||
|
|
||||||
|
type Props = { shareId: string };
|
||||||
|
|
||||||
|
function SearchPopover({ shareId }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { documents } = useStores();
|
||||||
|
|
||||||
|
const popover = usePopoverState({
|
||||||
|
placement: "bottom-start",
|
||||||
|
unstable_offset: [-24, 0],
|
||||||
|
modal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [query, setQuery] = React.useState("");
|
||||||
|
const searchResults = documents.searchResults(query);
|
||||||
|
|
||||||
|
const [cachedQuery, setCachedQuery] = React.useState(query);
|
||||||
|
const [cachedSearchResults, setCachedSearchResults] = React.useState<
|
||||||
|
Record<string, any>[] | undefined
|
||||||
|
>(searchResults);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (searchResults) {
|
||||||
|
setCachedQuery(query);
|
||||||
|
setCachedSearchResults(searchResults);
|
||||||
|
popover.show();
|
||||||
|
}
|
||||||
|
}, [searchResults, query, popover.show]);
|
||||||
|
|
||||||
|
const performSearch = React.useCallback(
|
||||||
|
async ({ query, ...options }: Record<string, any>) => {
|
||||||
|
if (query?.length > 0) {
|
||||||
|
return await documents.search(query, { shareId, ...options });
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[documents, shareId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = React.useMemo(
|
||||||
|
() =>
|
||||||
|
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { value } = event.target;
|
||||||
|
setQuery(value.trim());
|
||||||
|
|
||||||
|
// covers edge case: user manually dismisses popover then
|
||||||
|
// quickly edits input resulting in no change in query
|
||||||
|
// the useEffect that normally shows the popover will miss it
|
||||||
|
if (value === cachedQuery) {
|
||||||
|
popover.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.length) {
|
||||||
|
popover.hide();
|
||||||
|
}
|
||||||
|
}, 300),
|
||||||
|
[popover, cachedQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchInputRef = popover.unstable_referenceRef;
|
||||||
|
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||||
|
|
||||||
|
const handleEscapeList = React.useCallback(
|
||||||
|
() => searchInputRef?.current?.focus(),
|
||||||
|
[searchInputRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
if (searchResults) {
|
||||||
|
popover.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === "ArrowDown" && !ev.shiftKey) {
|
||||||
|
if (ev.currentTarget.value.length) {
|
||||||
|
if (
|
||||||
|
ev.currentTarget.value.length === ev.currentTarget.selectionStart
|
||||||
|
) {
|
||||||
|
popover.show();
|
||||||
|
}
|
||||||
|
firstSearchItem.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === "ArrowUp") {
|
||||||
|
if (popover.visible) {
|
||||||
|
popover.hide();
|
||||||
|
if (!ev.shiftKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.currentTarget.value) {
|
||||||
|
if (ev.currentTarget.selectionEnd === 0) {
|
||||||
|
ev.currentTarget.selectionStart = 0;
|
||||||
|
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === "Escape") {
|
||||||
|
if (popover.visible) {
|
||||||
|
popover.hide();
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[popover, searchResults]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PopoverDisclosure {...popover}>
|
||||||
|
{(props) => {
|
||||||
|
// props assumes the disclosure is a button, but we want a type-ahead
|
||||||
|
// so we take the aria props, and ref and ignore the event handlers
|
||||||
|
return (
|
||||||
|
<StyledInputSearch
|
||||||
|
aria-controls={props["aria-controls"]}
|
||||||
|
aria-expanded={props["aria-expanded"]}
|
||||||
|
aria-haspopup={props["aria-haspopup"]}
|
||||||
|
ref={props.ref}
|
||||||
|
onChange={handleSearch}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</PopoverDisclosure>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
{...popover}
|
||||||
|
aria-label={t("Results")}
|
||||||
|
unstable_autoFocusOnShow={false}
|
||||||
|
style={{ zIndex: depths.sidebar + 1 }}
|
||||||
|
shrink
|
||||||
|
>
|
||||||
|
<PaginatedList
|
||||||
|
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||||
|
items={cachedSearchResults}
|
||||||
|
fetch={performSearch}
|
||||||
|
onEscape={handleEscapeList}
|
||||||
|
empty={
|
||||||
|
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
|
||||||
|
}
|
||||||
|
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
|
||||||
|
renderItem={(item, index, compositeProps) => (
|
||||||
|
<SearchListItem
|
||||||
|
key={item.document.id}
|
||||||
|
shareId={shareId}
|
||||||
|
ref={index === 0 ? firstSearchItem : undefined}
|
||||||
|
document={item.document}
|
||||||
|
context={item.context}
|
||||||
|
highlight={cachedQuery}
|
||||||
|
onClick={popover.hide}
|
||||||
|
{...compositeProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NoResults = styled(Empty)`
|
||||||
|
padding: 0 12px;
|
||||||
|
margin: 6px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlaceholderList = styled(Placeholder)`
|
||||||
|
padding: 6px 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledInputSearch = styled(InputSearch)`
|
||||||
|
${Outline} {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default observer(SearchPopover);
|
||||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Scrollable from "~/components/Scrollable";
|
import Scrollable from "~/components/Scrollable";
|
||||||
|
import SearchPopover from "~/components/SearchPopover";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { NavigationNode } from "~/types";
|
import { NavigationNode } from "~/types";
|
||||||
import Sidebar from "./Sidebar";
|
import Sidebar from "./Sidebar";
|
||||||
@@ -19,6 +20,9 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<ScrollContainer flex>
|
<ScrollContainer flex>
|
||||||
|
<TopSection>
|
||||||
|
<SearchPopover shareId={shareId} />
|
||||||
|
</TopSection>
|
||||||
<Section>
|
<Section>
|
||||||
<DocumentLink
|
<DocumentLink
|
||||||
index={0}
|
index={0}
|
||||||
@@ -38,4 +42,12 @@ const ScrollContainer = styled(Scrollable)`
|
|||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const TopSection = styled(Section)`
|
||||||
|
// this weird looking && increases the specificity of the style rule
|
||||||
|
&& {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export default observer(SharedSidebar);
|
export default observer(SharedSidebar);
|
||||||
|
|||||||
@@ -100,9 +100,31 @@ class Search extends React.Component<Props> {
|
|||||||
return this.goBack();
|
return this.goBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ev.key === "ArrowDown") {
|
if (ev.key === "ArrowUp") {
|
||||||
|
if (ev.currentTarget.value) {
|
||||||
|
const length = ev.currentTarget.value.length;
|
||||||
|
const selectionEnd = ev.currentTarget.selectionEnd || 0;
|
||||||
|
if (selectionEnd === 0) {
|
||||||
|
ev.currentTarget.selectionStart = 0;
|
||||||
|
ev.currentTarget.selectionEnd = length;
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === "ArrowDown" && !ev.shiftKey) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (ev.currentTarget.value) {
|
||||||
|
const length = ev.currentTarget.value.length;
|
||||||
|
const selectionStart = ev.currentTarget.selectionStart || 0;
|
||||||
|
if (selectionStart < length) {
|
||||||
|
ev.currentTarget.selectionStart = length;
|
||||||
|
ev.currentTarget.selectionEnd = length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.compositeRef) {
|
if (this.compositeRef) {
|
||||||
const linkItems = this.compositeRef.querySelectorAll(
|
const linkItems = this.compositeRef.querySelectorAll(
|
||||||
"[href]"
|
"[href]"
|
||||||
@@ -269,7 +291,7 @@ class Search extends React.Component<Props> {
|
|||||||
render() {
|
render() {
|
||||||
const { documents, notFound, t } = this.props;
|
const { documents, notFound, t } = this.props;
|
||||||
const results = documents.searchResults(this.query);
|
const results = documents.searchResults(this.query);
|
||||||
const showEmpty = !this.isLoading && this.query && results.length === 0;
|
const showEmpty = !this.isLoading && this.query && results?.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Scene textTitle={this.title}>
|
<Scene textTitle={this.title}>
|
||||||
@@ -345,7 +367,7 @@ class Search extends React.Component<Props> {
|
|||||||
aria-label={t("Search Results")}
|
aria-label={t("Search Results")}
|
||||||
>
|
>
|
||||||
{(compositeProps) =>
|
{(compositeProps) =>
|
||||||
results.map((result) => {
|
results?.map((result) => {
|
||||||
const document = documents.data.get(result.document.id);
|
const document = documents.data.get(result.document.id);
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type SearchParams = {
|
|||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
collectionId?: string;
|
collectionId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
shareId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ImportOptions = {
|
type ImportOptions = {
|
||||||
@@ -41,7 +42,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
sharedTreeCache: Map<string, NavigationNode | undefined> = new Map();
|
sharedTreeCache: Map<string, NavigationNode | undefined> = new Map();
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
searchCache: Map<string, SearchResult[]> = new Map();
|
searchCache: Map<string, SearchResult[] | undefined> = new Map();
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
backlinks: Map<string, string[]> = new Map();
|
backlinks: Map<string, string[]> = new Map();
|
||||||
@@ -170,8 +171,8 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
return naturalSort(this.inCollection(collectionId), "title");
|
return naturalSort(this.inCollection(collectionId), "title");
|
||||||
}
|
}
|
||||||
|
|
||||||
searchResults(query: string): SearchResult[] {
|
searchResults(query: string): SearchResult[] | undefined {
|
||||||
return this.searchCache.get(query) || [];
|
return this.searchCache.get(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn("search_queries", "shareId", {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: null,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: "shares",
|
||||||
|
key: "id"
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
down: async (queryInterface) => {
|
||||||
|
await queryInterface.removeColumn("search_queries", "shareId");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildCollection,
|
buildCollection,
|
||||||
buildTeam,
|
buildTeam,
|
||||||
buildUser,
|
buildUser,
|
||||||
|
buildShare,
|
||||||
} from "@server/test/factories";
|
} from "@server/test/factories";
|
||||||
import { flushdb, seed } from "@server/test/support";
|
import { flushdb, seed } from "@server/test/support";
|
||||||
import slugify from "@server/utils/slugify";
|
import slugify from "@server/utils/slugify";
|
||||||
@@ -26,7 +27,7 @@ paragraph 2`,
|
|||||||
const document = await buildDocument({
|
const document = await buildDocument({
|
||||||
version: 0,
|
version: 0,
|
||||||
text: `# Heading
|
text: `# Heading
|
||||||
|
|
||||||
*paragraph*`,
|
*paragraph*`,
|
||||||
});
|
});
|
||||||
expect(document.getSummary()).toBe("paragraph");
|
expect(document.getSummary()).toBe("paragraph");
|
||||||
@@ -174,7 +175,7 @@ describe("#searchForTeam", () => {
|
|||||||
expect(results[0].document?.id).toBe(document.id);
|
expect(results[0].document?.id).toBe(document.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not return search results from private collections", async () => {
|
test("should not return results from private collections without providing collectionId", async () => {
|
||||||
const team = await buildTeam();
|
const team = await buildTeam();
|
||||||
const collection = await buildCollection({
|
const collection = await buildCollection({
|
||||||
permission: null,
|
permission: null,
|
||||||
@@ -189,6 +190,52 @@ describe("#searchForTeam", () => {
|
|||||||
expect(results.length).toBe(0);
|
expect(results.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should return results from private collections when collectionId is provided", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const collection = await buildCollection({
|
||||||
|
permission: null,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
await buildDocument({
|
||||||
|
teamId: team.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
title: "test",
|
||||||
|
});
|
||||||
|
const { results } = await Document.searchForTeam(team, "test", {
|
||||||
|
collectionId: collection.id,
|
||||||
|
});
|
||||||
|
expect(results.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return results from document tree of shared document", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const collection = await buildCollection({
|
||||||
|
permission: null,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
const document = await buildDocument({
|
||||||
|
teamId: team.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
title: "test 1",
|
||||||
|
});
|
||||||
|
await buildDocument({
|
||||||
|
teamId: team.id,
|
||||||
|
collectionId: collection.id,
|
||||||
|
title: "test 2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const share = await buildShare({
|
||||||
|
documentId: document.id,
|
||||||
|
includeChildDocuments: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { results } = await Document.searchForTeam(team, "test", {
|
||||||
|
collectionId: collection.id,
|
||||||
|
share,
|
||||||
|
});
|
||||||
|
expect(results.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test("should handle no collections", async () => {
|
test("should handle no collections", async () => {
|
||||||
const team = await buildTeam();
|
const team = await buildTeam();
|
||||||
const { results } = await Document.searchForTeam(team, "test");
|
const { results } = await Document.searchForTeam(team, "test");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import removeMarkdown from "@tommoor/remove-markdown";
|
import removeMarkdown from "@tommoor/remove-markdown";
|
||||||
|
import invariant from "invariant";
|
||||||
import { compact, find, map, uniq } from "lodash";
|
import { compact, find, map, uniq } from "lodash";
|
||||||
import randomstring from "randomstring";
|
import randomstring from "randomstring";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +39,7 @@ import slugify from "@server/utils/slugify";
|
|||||||
import Backlink from "./Backlink";
|
import Backlink from "./Backlink";
|
||||||
import Collection from "./Collection";
|
import Collection from "./Collection";
|
||||||
import Revision from "./Revision";
|
import Revision from "./Revision";
|
||||||
|
import Share from "./Share";
|
||||||
import Star from "./Star";
|
import Star from "./Star";
|
||||||
import Team from "./Team";
|
import Team from "./Team";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
@@ -45,7 +47,7 @@ import View from "./View";
|
|||||||
import ParanoidModel from "./base/ParanoidModel";
|
import ParanoidModel from "./base/ParanoidModel";
|
||||||
import Fix from "./decorators/Fix";
|
import Fix from "./decorators/Fix";
|
||||||
|
|
||||||
type SearchResponse = {
|
export type SearchResponse = {
|
||||||
results: {
|
results: {
|
||||||
ranking: number;
|
ranking: number;
|
||||||
context: string;
|
context: string;
|
||||||
@@ -58,10 +60,13 @@ type SearchOptions = {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
collectionId?: string;
|
collectionId?: string;
|
||||||
|
share?: Share;
|
||||||
dateFilter?: DateFilter;
|
dateFilter?: DateFilter;
|
||||||
collaboratorIds?: string[];
|
collaboratorIds?: string[];
|
||||||
includeArchived?: boolean;
|
includeArchived?: boolean;
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
|
snippetMinWords?: number;
|
||||||
|
snippetMaxWords?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const serializer = new MarkdownSerializer();
|
const serializer = new MarkdownSerializer();
|
||||||
@@ -436,12 +441,24 @@ class Document extends ParanoidModel {
|
|||||||
query: string,
|
query: string,
|
||||||
options: SearchOptions = {}
|
options: SearchOptions = {}
|
||||||
): Promise<SearchResponse> {
|
): Promise<SearchResponse> {
|
||||||
const limit = options.limit || 15;
|
|
||||||
const offset = options.offset || 0;
|
|
||||||
const wildcardQuery = `${escape(query)}:*`;
|
const wildcardQuery = `${escape(query)}:*`;
|
||||||
const collectionIds = await team.collectionIds();
|
const {
|
||||||
|
snippetMinWords = 20,
|
||||||
|
snippetMaxWords = 30,
|
||||||
|
limit = 15,
|
||||||
|
offset = 0,
|
||||||
|
} = options;
|
||||||
|
|
||||||
// If the team has access no public collections then shortcircuit the rest of this
|
// restrict to specific collection if provided
|
||||||
|
// enables search in private collections if specified
|
||||||
|
let collectionIds;
|
||||||
|
if (options.collectionId) {
|
||||||
|
collectionIds = [options.collectionId];
|
||||||
|
} else {
|
||||||
|
collectionIds = await team.collectionIds();
|
||||||
|
}
|
||||||
|
|
||||||
|
// short circuit if no relevant collections
|
||||||
if (!collectionIds.length) {
|
if (!collectionIds.length) {
|
||||||
return {
|
return {
|
||||||
results: [],
|
results: [],
|
||||||
@@ -449,11 +466,25 @@ class Document extends ParanoidModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the SQL query to get documentIds, ranking, and search term context
|
// restrict to documents in the tree of a shared document when one is provided
|
||||||
|
let documentIds;
|
||||||
|
|
||||||
|
if (options.share?.includeChildDocuments) {
|
||||||
|
const sharedDocument = await options.share.$get("document");
|
||||||
|
invariant(sharedDocument, "Cannot find document for share");
|
||||||
|
|
||||||
|
const childDocumentIds = await sharedDocument.getChildDocumentIds();
|
||||||
|
documentIds = [sharedDocument.id, ...childDocumentIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentClause = documentIds ? `"id" IN(:documentIds) AND` : "";
|
||||||
|
|
||||||
|
// Build the SQL query to get result documentIds, ranking, and search term context
|
||||||
const whereClause = `
|
const whereClause = `
|
||||||
"searchVector" @@ to_tsquery('english', :query) AND
|
"searchVector" @@ to_tsquery('english', :query) AND
|
||||||
"teamId" = :teamId AND
|
"teamId" = :teamId AND
|
||||||
"collectionId" IN(:collectionIds) AND
|
"collectionId" IN(:collectionIds) AND
|
||||||
|
${documentClause}
|
||||||
"deletedAt" IS NULL AND
|
"deletedAt" IS NULL AND
|
||||||
"publishedAt" IS NOT NULL
|
"publishedAt" IS NOT NULL
|
||||||
`;
|
`;
|
||||||
@@ -461,7 +492,7 @@ class Document extends ParanoidModel {
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
|
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
|
||||||
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext"
|
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=:snippetMinWords, MaxWords=:snippetMaxWords') as "searchContext"
|
||||||
FROM documents
|
FROM documents
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@@ -479,6 +510,9 @@ class Document extends ParanoidModel {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
query: wildcardQuery,
|
query: wildcardQuery,
|
||||||
collectionIds,
|
collectionIds,
|
||||||
|
documentIds,
|
||||||
|
snippetMinWords,
|
||||||
|
snippetMaxWords,
|
||||||
};
|
};
|
||||||
const resultsQuery = this.sequelize!.query(selectSql, {
|
const resultsQuery = this.sequelize!.query(selectSql, {
|
||||||
type: QueryTypes.SELECT,
|
type: QueryTypes.SELECT,
|
||||||
@@ -526,8 +560,12 @@ class Document extends ParanoidModel {
|
|||||||
query: string,
|
query: string,
|
||||||
options: SearchOptions = {}
|
options: SearchOptions = {}
|
||||||
): Promise<SearchResponse> {
|
): Promise<SearchResponse> {
|
||||||
const limit = options.limit || 15;
|
const {
|
||||||
const offset = options.offset || 0;
|
snippetMinWords = 20,
|
||||||
|
snippetMaxWords = 30,
|
||||||
|
limit = 15,
|
||||||
|
offset = 0,
|
||||||
|
} = options;
|
||||||
const wildcardQuery = `${escape(query)}:*`;
|
const wildcardQuery = `${escape(query)}:*`;
|
||||||
|
|
||||||
// Ensure we're filtering by the users accessible collections. If
|
// Ensure we're filtering by the users accessible collections. If
|
||||||
@@ -580,7 +618,7 @@ class Document extends ParanoidModel {
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
|
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
|
||||||
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext"
|
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=:snippetMinWords, MaxWords=:snippetMaxWords') as "searchContext"
|
||||||
FROM documents
|
FROM documents
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@@ -601,6 +639,8 @@ class Document extends ParanoidModel {
|
|||||||
query: wildcardQuery,
|
query: wildcardQuery,
|
||||||
collectionIds,
|
collectionIds,
|
||||||
dateFilter,
|
dateFilter,
|
||||||
|
snippetMinWords,
|
||||||
|
snippetMaxWords,
|
||||||
};
|
};
|
||||||
const resultsQuery = this.sequelize!.query(selectSql, {
|
const resultsQuery = this.sequelize!.query(selectSql, {
|
||||||
type: QueryTypes.SELECT,
|
type: QueryTypes.SELECT,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Object {
|
|||||||
exports[`#documents.search should require authentication 1`] = `
|
exports[`#documents.search should require authentication 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"error": "authentication_required",
|
"error": "authentication_required",
|
||||||
"message": "Authentication required",
|
"message": "Authentication error",
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"status": 401,
|
"status": 401,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ describe("#documents.info", () => {
|
|||||||
id: document.id,
|
id: document.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.status).toEqual(403);
|
expect(res.status).toEqual(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should require authorization with incorrect token", async () => {
|
it("should require authorization with incorrect token", async () => {
|
||||||
@@ -633,7 +633,7 @@ describe("#documents.export", () => {
|
|||||||
id: document.id,
|
id: document.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.status).toEqual(403);
|
expect(res.status).toEqual(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should require authorization with incorrect token", async () => {
|
it("should require authorization with incorrect token", async () => {
|
||||||
@@ -944,6 +944,59 @@ describe("#documents.search", () => {
|
|||||||
expect(body.data[0].document.text).toEqual("# Much test support");
|
expect(body.data[0].document.text).toEqual("# Much test support");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return results using shareId", async () => {
|
||||||
|
const findableDocument = await buildDocument({
|
||||||
|
title: "search term",
|
||||||
|
text: "random text",
|
||||||
|
});
|
||||||
|
|
||||||
|
await buildDocument({
|
||||||
|
title: "search term",
|
||||||
|
text: "should not be found",
|
||||||
|
userId: findableDocument.createdById,
|
||||||
|
teamId: findableDocument.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const share = await buildShare({
|
||||||
|
includeChildDocuments: true,
|
||||||
|
documentId: findableDocument.id,
|
||||||
|
teamId: findableDocument.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post("/api/documents.search", {
|
||||||
|
body: {
|
||||||
|
query: "search term",
|
||||||
|
shareId: share.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.length).toEqual(1);
|
||||||
|
expect(body.data[0].document.id).toEqual(share.documentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not allow search if child documents are not included", async () => {
|
||||||
|
const findableDocument = await buildDocument({
|
||||||
|
title: "search term",
|
||||||
|
text: "random text",
|
||||||
|
});
|
||||||
|
|
||||||
|
const share = await buildShare({
|
||||||
|
includeChildDocuments: false,
|
||||||
|
document: findableDocument,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post("/api/documents.search", {
|
||||||
|
body: {
|
||||||
|
query: "search term",
|
||||||
|
shareId: share.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
it("should return results in ranked order", async () => {
|
it("should return results in ranked order", async () => {
|
||||||
const { user } = await seed();
|
const { user } = await seed();
|
||||||
const firstResult = await buildDocument({
|
const firstResult = await buildDocument({
|
||||||
@@ -1287,7 +1340,11 @@ describe("#documents.search", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should require authentication", async () => {
|
it("should require authentication", async () => {
|
||||||
const res = await server.post("/api/documents.search");
|
const res = await server.post("/api/documents.search", {
|
||||||
|
body: {
|
||||||
|
query: "search term",
|
||||||
|
},
|
||||||
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(res.status).toEqual(401);
|
expect(res.status).toEqual(401);
|
||||||
expect(body).toMatchSnapshot();
|
expect(body).toMatchSnapshot();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
NotFoundError,
|
NotFoundError,
|
||||||
InvalidRequestError,
|
InvalidRequestError,
|
||||||
AuthorizationError,
|
AuthorizationError,
|
||||||
|
AuthenticationError,
|
||||||
} from "@server/errors";
|
} from "@server/errors";
|
||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
import {
|
import {
|
||||||
@@ -386,7 +387,7 @@ async function loadDocument({
|
|||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
shareId?: string;
|
shareId?: string;
|
||||||
user: User;
|
user?: User;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
document: Document;
|
document: Document;
|
||||||
share?: Share;
|
share?: Share;
|
||||||
@@ -396,6 +397,10 @@ async function loadDocument({
|
|||||||
let collection;
|
let collection;
|
||||||
let share;
|
let share;
|
||||||
|
|
||||||
|
if (!shareId && !(id && user)) {
|
||||||
|
throw AuthenticationError(`Authentication or shareId required`);
|
||||||
|
}
|
||||||
|
|
||||||
if (shareId) {
|
if (shareId) {
|
||||||
share = await Share.findOne({
|
share = await Share.findOne({
|
||||||
where: {
|
where: {
|
||||||
@@ -454,7 +459,7 @@ async function loadDocument({
|
|||||||
|
|
||||||
// If the user has access to read the document, we can just update
|
// If the user has access to read the document, we can just update
|
||||||
// the last access date and return the document without additional checks.
|
// the last access date and return the document without additional checks.
|
||||||
const canReadDocument = can(user, "read", document);
|
const canReadDocument = user && can(user, "read", document);
|
||||||
|
|
||||||
if (canReadDocument) {
|
if (canReadDocument) {
|
||||||
await share.update({
|
await share.update({
|
||||||
@@ -519,9 +524,9 @@ async function loadDocument({
|
|||||||
|
|
||||||
if (document.deletedAt) {
|
if (document.deletedAt) {
|
||||||
// don't send data if user cannot restore deleted doc
|
// don't send data if user cannot restore deleted doc
|
||||||
authorize(user, "restore", document);
|
user && authorize(user, "restore", document);
|
||||||
} else {
|
} else {
|
||||||
authorize(user, "read", document);
|
user && authorize(user, "read", document);
|
||||||
}
|
}
|
||||||
|
|
||||||
collection = document.collection;
|
collection = document.collection;
|
||||||
@@ -739,82 +744,133 @@ router.post("documents.search_titles", auth(), pagination(), async (ctx) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("documents.search", auth(), pagination(), async (ctx) => {
|
router.post(
|
||||||
const {
|
"documents.search",
|
||||||
query,
|
auth({
|
||||||
includeArchived,
|
required: false,
|
||||||
includeDrafts,
|
}),
|
||||||
collectionId,
|
pagination(),
|
||||||
userId,
|
async (ctx) => {
|
||||||
dateFilter,
|
const {
|
||||||
} = ctx.body;
|
|
||||||
const { offset, limit } = ctx.state.pagination;
|
|
||||||
const { user } = ctx.state;
|
|
||||||
|
|
||||||
assertNotEmpty(query, "query is required");
|
|
||||||
|
|
||||||
if (collectionId) {
|
|
||||||
assertUuid(collectionId, "collectionId must be a UUID");
|
|
||||||
const collection = await Collection.scope({
|
|
||||||
method: ["withMembership", user.id],
|
|
||||||
}).findByPk(collectionId);
|
|
||||||
authorize(user, "read", collection);
|
|
||||||
}
|
|
||||||
|
|
||||||
let collaboratorIds = undefined;
|
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
assertUuid(userId, "userId must be a UUID");
|
|
||||||
collaboratorIds = [userId];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateFilter) {
|
|
||||||
assertIn(
|
|
||||||
dateFilter,
|
|
||||||
["day", "week", "month", "year"],
|
|
||||||
"dateFilter must be one of day,week,month,year"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { results, totalCount } = await Document.searchForUser(user, query, {
|
|
||||||
includeArchived: includeArchived === "true",
|
|
||||||
includeDrafts: includeDrafts === "true",
|
|
||||||
collaboratorIds,
|
|
||||||
collectionId,
|
|
||||||
dateFilter,
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documents = results.map((result) => result.document);
|
|
||||||
|
|
||||||
const data = await Promise.all(
|
|
||||||
results.map(async (result) => {
|
|
||||||
const document = await presentDocument(result.document);
|
|
||||||
return { ...result, document };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// When requesting subsequent pages of search results we don't want to record
|
|
||||||
// duplicate search query records
|
|
||||||
if (offset === 0) {
|
|
||||||
SearchQuery.create({
|
|
||||||
userId: user.id,
|
|
||||||
teamId: user.teamId,
|
|
||||||
source: ctx.state.authType,
|
|
||||||
query,
|
query,
|
||||||
results: totalCount,
|
includeArchived,
|
||||||
});
|
includeDrafts,
|
||||||
|
collectionId,
|
||||||
|
userId,
|
||||||
|
dateFilter,
|
||||||
|
shareId,
|
||||||
|
} = ctx.body;
|
||||||
|
assertNotEmpty(query, "query is required");
|
||||||
|
|
||||||
|
const { offset, limit } = ctx.state.pagination;
|
||||||
|
const snippetMinWords = parseInt(ctx.body.snippetMinWords || 20, 10);
|
||||||
|
const snippetMaxWords = parseInt(ctx.body.snippetMaxWords || 30, 10);
|
||||||
|
|
||||||
|
// this typing is a bit ugly, would be better to use a type like ContextWithState
|
||||||
|
// but that doesn't adequately handle cases when auth is optional
|
||||||
|
const { user }: { user: User | undefined } = ctx.state;
|
||||||
|
|
||||||
|
let teamId;
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (shareId) {
|
||||||
|
const { share, document } = await loadDocument({
|
||||||
|
shareId,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!share?.includeChildDocuments) {
|
||||||
|
throw InvalidRequestError("Child documents cannot be searched");
|
||||||
|
}
|
||||||
|
|
||||||
|
teamId = share.teamId;
|
||||||
|
const team = await Team.findByPk(teamId);
|
||||||
|
invariant(team, "Share must belong to a team");
|
||||||
|
|
||||||
|
response = await Document.searchForTeam(team, query, {
|
||||||
|
includeArchived: includeArchived === "true",
|
||||||
|
includeDrafts: includeDrafts === "true",
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
share,
|
||||||
|
dateFilter,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
snippetMinWords,
|
||||||
|
snippetMaxWords,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!user) {
|
||||||
|
throw AuthenticationError("Authentication error");
|
||||||
|
}
|
||||||
|
|
||||||
|
teamId = user.teamId;
|
||||||
|
|
||||||
|
if (collectionId) {
|
||||||
|
assertUuid(collectionId, "collectionId must be a UUID");
|
||||||
|
const collection = await Collection.scope({
|
||||||
|
method: ["withMembership", user.id],
|
||||||
|
}).findByPk(collectionId);
|
||||||
|
authorize(user, "read", collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
let collaboratorIds = undefined;
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
assertUuid(userId, "userId must be a UUID");
|
||||||
|
collaboratorIds = [userId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFilter) {
|
||||||
|
assertIn(
|
||||||
|
dateFilter,
|
||||||
|
["day", "week", "month", "year"],
|
||||||
|
"dateFilter must be one of day,week,month,year"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await Document.searchForUser(user, query, {
|
||||||
|
includeArchived: includeArchived === "true",
|
||||||
|
includeDrafts: includeDrafts === "true",
|
||||||
|
collaboratorIds,
|
||||||
|
collectionId,
|
||||||
|
dateFilter,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
snippetMinWords,
|
||||||
|
snippetMaxWords,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results, totalCount } = response;
|
||||||
|
const documents = results.map((result) => result.document);
|
||||||
|
|
||||||
|
const data = await Promise.all(
|
||||||
|
results.map(async (result) => {
|
||||||
|
const document = await presentDocument(result.document);
|
||||||
|
return { ...result, document };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// When requesting subsequent pages of search results we don't want to record
|
||||||
|
// duplicate search query records
|
||||||
|
if (offset === 0) {
|
||||||
|
SearchQuery.create({
|
||||||
|
userId: user?.id,
|
||||||
|
teamId,
|
||||||
|
shareId,
|
||||||
|
source: ctx.state.authType || "app", // we'll consider anything that isn't "api" to be "app"
|
||||||
|
query,
|
||||||
|
results: totalCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
pagination: ctx.state.pagination,
|
||||||
|
data,
|
||||||
|
policies: user ? presentPolicies(user, documents) : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
);
|
||||||
const policies = presentPolicies(user, documents);
|
|
||||||
|
|
||||||
ctx.body = {
|
|
||||||
pagination: ctx.state.pagination,
|
|
||||||
data,
|
|
||||||
policies,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Deprecated – use stars.create instead
|
// Deprecated – use stars.create instead
|
||||||
router.post("documents.star", auth(), async (ctx) => {
|
router.post("documents.star", auth(), async (ctx) => {
|
||||||
|
|||||||
@@ -136,6 +136,8 @@
|
|||||||
"Dismiss": "Dismiss",
|
"Dismiss": "Dismiss",
|
||||||
"Back": "Back",
|
"Back": "Back",
|
||||||
"Documents": "Documents",
|
"Documents": "Documents",
|
||||||
|
"Results": "Results",
|
||||||
|
"No results for {{query}}": "No results for {{query}}",
|
||||||
"Logo": "Logo",
|
"Logo": "Logo",
|
||||||
"Document archived": "Document archived",
|
"Document archived": "Document archived",
|
||||||
"Move document": "Move document",
|
"Move document": "Move document",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const colors = {
|
|||||||
smokeLight: "#F9FBFC",
|
smokeLight: "#F9FBFC",
|
||||||
smokeDark: "#E8EBED",
|
smokeDark: "#E8EBED",
|
||||||
white: "#FFF",
|
white: "#FFF",
|
||||||
|
white05: "rgba(255, 255, 255, 0.05)",
|
||||||
white10: "rgba(255, 255, 255, 0.1)",
|
white10: "rgba(255, 255, 255, 0.1)",
|
||||||
white50: "rgba(255, 255, 255, 0.5)",
|
white50: "rgba(255, 255, 255, 0.5)",
|
||||||
white75: "rgba(255, 255, 255, 0.75)",
|
white75: "rgba(255, 255, 255, 0.75)",
|
||||||
@@ -169,7 +170,7 @@ export const dark = {
|
|||||||
placeholder: colors.slateDark,
|
placeholder: colors.slateDark,
|
||||||
sidebarBackground: colors.veryDarkBlue,
|
sidebarBackground: colors.veryDarkBlue,
|
||||||
sidebarActiveBackground: lighten(0.02, colors.almostBlack),
|
sidebarActiveBackground: lighten(0.02, colors.almostBlack),
|
||||||
sidebarControlHoverBackground: "rgba(255,255,255,0.1)",
|
sidebarControlHoverBackground: colors.white10,
|
||||||
sidebarDraftBorder: darken("0.35", colors.slate),
|
sidebarDraftBorder: darken("0.35", colors.slate),
|
||||||
sidebarText: colors.slate,
|
sidebarText: colors.slate,
|
||||||
backdrop: "rgba(255, 255, 255, 0.3)",
|
backdrop: "rgba(255, 255, 255, 0.3)",
|
||||||
@@ -188,7 +189,7 @@ export const dark = {
|
|||||||
titleBarDivider: darken(0.4, colors.slate),
|
titleBarDivider: darken(0.4, colors.slate),
|
||||||
inputBorder: colors.slateDark,
|
inputBorder: colors.slateDark,
|
||||||
inputBorderFocused: colors.slate,
|
inputBorderFocused: colors.slate,
|
||||||
listItemHoverBackground: colors.black50,
|
listItemHoverBackground: colors.white10,
|
||||||
toolbarHoverBackground: colors.slate,
|
toolbarHoverBackground: colors.slate,
|
||||||
toolbarBackground: colors.white,
|
toolbarBackground: colors.white,
|
||||||
toolbarInput: colors.black10,
|
toolbarInput: colors.black10,
|
||||||
|
|||||||
Reference in New Issue
Block a user