Files
outline/app/scenes/DocumentPublish.tsx
Apoorv Mishra 6b286d82b8 Ability to choose publish location for a document (#4582)
* feat: initial base structure

* feat: utils for constructing and flattening collection tree

* feat: basic demo to display tree-like structure with virtualization

* feat: make it searchable

* feat: row component

* fix: handle row selection

* fix: scroll jitter

* fix: popover max-height to eliminate extra scroll

* fix: position scrollbar correctly

* fix: do not sort to maintain correct tree-like view

* feat: footer

* fix: scroll to selected item

* fix: deselect item

* fix: display selected location in footer

* fix: deselect item if any upon search trigger

* fix: create draft without collection

* fix: pass down collectionId to all the nodes

* feat: publish document under selected location

* fix: move the doc post publish in case it is supposed to be a nested doc

* fix: wrap text for selected location

* fix: footer background in dark mode and unused css

* fix: popover height in small devices

* fix: no need to spread

* refactor: remove outline

* refactor: border-radius is common

* refactor: remove active and focus

* fix: do not shrink spacer

* fix: scroll list padding with correctly adjusted scrolling

* refactor: use constants

* fix: use padding in favor of spacer

* refactor: border attrs not needed

* refactor: control title padding and icon size centrally

* fix: rename param

* fix: import path

* fix: refactor styles, avoid magic numbers

* fix: type err

* feat: make rows collapsible

* fix: fully expanded without disclosure upon search

* fix: use modal in place of popover

* fix: collapse descendants

* fix: rename PublishPopover to PublishModal

* fix: adjust collapse icon as part of tree view

* fix: enable keyboard navigation

* not sure why collapse and expand shortcuts are not working

* fix: row expansion and search box focus and blur

* fix: remove css hover, handle it via active prop

* fix: discard tree like view for search results

* fix: minor tweaks

* refactor: no need to pass onPublish

* refactor: remove unnecessary attrs from search component

* fix: publish button text

* fix: reset intial scroll offset to 0 on search

* fix: remove search highlights

* fix: clean up search component

* refactor: search and row collapse

* refactor: PublishLocation

* fix: show emoji or star icon if present

* fix: shift focus only from top item

* fix: leading emoji

* fix: baseline text

* fix: make path tertiary

* fix: do not show path for collections

* fix: path text color upon selection

* fix: deleted collection case

* fix: no results found

* fix: space around slash

* Refinement, some small refactors

* fix: Publish shortcut, use Button action

* Allow new document creation from command menu without active collection

* fix: duplicate

* fix: Unneccessary truncation

* fix: Scroll on expand/collapse
Remove wraparound

* fix: tsc

* fix: Horizontal overflow on PublishLocation
Remove pointless moveTo method

* fix: Missing translation

* Remove method indirection
Show expanded collection icon in tree when expanded

* Shrink font size a point

* Remove feature flag

* fix: Path color contrast in light mode
Remove unused expanded/show attributes

* shrink -> collapse, fix expanded disclosure without items after searching

* Mobile styles

* fix: scroll just into view

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2023-01-23 17:38:24 -08:00

429 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import FuzzySearch from "fuzzy-search";
import { includes, difference, concat, filter } from "lodash";
import { observer } from "mobx-react";
import { StarredIcon, DocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import PublishLocation from "~/components/PublishLocation";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { isModKey } from "~/utils/keyboard";
import { flattenTree, descendants } from "~/utils/tree";
type Props = {
/** Document to publish */
document: Document;
};
function DocumentPublish({ document }: Props) {
const isMobile = useMobile();
const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedLocation, setLocation] = React.useState<any>();
const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>(
0
);
const { collections, documents } = useStores();
const { showToast } = useToasts();
const theme = useTheme();
const [items, setItems] = React.useState<any>(
flattenTree(collections.tree.root).slice(1)
);
const [activeItem, setActiveItem] = React.useState<number>(0);
const [expandedItems, setExpandedItems] = React.useState<string[]>([]);
const inputSearchRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(
null
);
const { t } = useTranslation();
const { dialogs } = useStores();
const listRef = React.useRef<List<HTMLDivElement>>(null);
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const nextItem = () => {
return Math.min(activeItem + 1, items.length - 1);
};
const prevItem = () => {
return Math.max(activeItem - 1, 0);
};
const searchIndex = React.useMemo(() => {
const data = flattenTree(collections.tree.root).slice(1);
return new FuzzySearch(data, ["data.title"], {
caseSensitive: false,
});
}, [collections.tree]);
React.useEffect(() => {
if (searchTerm) {
setLocation(null);
setExpandedItems([]);
}
setActiveItem(0);
}, [searchTerm]);
React.useEffect(() => {
let results = flattenTree(collections.tree.root).slice(1);
if (collections.isLoaded) {
if (searchTerm) {
results = searchIndex.search(searchTerm);
} else {
results = results.filter((r) => r.data.type === "collection");
}
}
setInitialScrollOffset(0);
setItems(results);
}, [document, collections, searchTerm, searchIndex]);
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(ev.target.value);
};
const isExpanded = (index: number) => {
const item = items[index];
return includes(expandedItems, item.data.id);
};
const calculateInitialScrollOffset = (itemCount: number) => {
if (listRef.current) {
const { height, itemSize } = listRef.current.props;
const { scrollOffset } = listRef.current.state as {
scrollOffset: number;
};
const itemsHeight = itemCount * itemSize;
return itemsHeight < height ? 0 : scrollOffset;
}
return 0;
};
const collapse = (item: number) => {
const descendantIds = descendants(items[item]).map((des) => des.data.id);
setExpandedItems(
difference(expandedItems, [...descendantIds, items[item].data.id])
);
// remove children
const newItems = filter(
items,
(item: any) => !includes(descendantIds, item.data.id)
);
const scrollOffset = calculateInitialScrollOffset(newItems.length);
setInitialScrollOffset(scrollOffset);
setItems(newItems);
};
const expand = (item: number) => {
setExpandedItems(concat(expandedItems, items[item].data.id));
// add children
const newItems = items.slice();
newItems.splice(item + 1, 0, ...descendants(items[item], 1));
const scrollOffset = calculateInitialScrollOffset(newItems.length);
setInitialScrollOffset(scrollOffset);
setItems(newItems);
};
const isSelected = (item: number) => {
if (!selectedLocation) {
return false;
}
const selectedItemId = selectedLocation.data.id;
const itemId = items[item].data.id;
return selectedItemId === itemId;
};
const toggleCollapse = (item: number) => {
if (isExpanded(item)) {
collapse(item);
} else {
expand(item);
}
};
const toggleSelect = (item: number) => {
if (isSelected(item)) {
setLocation(null);
} else {
setLocation(items[item]);
}
};
const publish = async () => {
if (!selectedLocation) {
showToast(t("Select a location to publish"), {
type: "info",
});
return;
}
try {
const {
collectionId,
type,
id: parentDocumentId,
} = selectedLocation.data;
// Also move it under if selected path corresponds to another doc
if (type === "document") {
await document.move(collectionId, parentDocumentId);
}
document.collectionId = collectionId;
await document.save({ publish: true });
showToast(t("Document published"), {
type: "success",
});
dialogs.closeAllModals();
} catch (err) {
showToast(t("Couldnt publish the document, try again?"), {
type: "error",
});
}
};
const row = ({
index,
data,
style,
}: {
index: number;
data: any[];
style: React.CSSProperties;
}) => {
const result = data[index];
const isCollection = result.data.type === "collection";
let icon;
if (isCollection) {
const col = collections.get(result.data.collectionId);
icon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
} else {
const doc = documents.get(result.data.id);
const { emoji } = result.data;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
} else {
icon = <DocumentIcon />;
}
}
return (
<PublishLocation
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveItem(index)}
onClick={() => toggleSelect(index)}
onDisclosureClick={(ev) => {
ev.stopPropagation();
toggleCollapse(index);
}}
location={result}
selected={isSelected(index)}
active={activeItem === index}
expanded={isExpanded(index)}
icon={icon}
isSearchResult={!!searchTerm}
/>
);
};
if (!document || !collections.isLoaded) {
return null;
}
const focusSearchInput = () => {
inputSearchRef.current?.focus();
};
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
switch (ev.key) {
case "ArrowDown": {
ev.preventDefault();
setActiveItem(nextItem());
break;
}
case "ArrowUp": {
ev.preventDefault();
if (activeItem === 0) {
focusSearchInput();
} else {
setActiveItem(prevItem());
}
break;
}
case "ArrowLeft": {
if (!searchTerm && isExpanded(activeItem)) {
toggleCollapse(activeItem);
}
break;
}
case "ArrowRight": {
if (!searchTerm) {
toggleCollapse(activeItem);
}
break;
}
case "Enter": {
if (isModKey(ev)) {
publish();
} else {
toggleSelect(activeItem);
}
break;
}
}
};
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ style, ...rest }, ref) => (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
));
return (
<FlexContainer column tabIndex={-1} onKeyDown={handleKeyDown}>
<Search
ref={inputSearchRef}
onChange={handleSearch}
placeholder={`${t("Search collections & documents")}`}
autoFocus
/>
{items.length ? (
<Results>
<AutoSizer>
{({ width, height }: { width: number; height: number }) => (
<Flex role="listbox" column>
<List
ref={listRef}
key={items.length}
width={width}
height={height}
itemData={items}
itemCount={items.length}
itemSize={isMobile ? 48 : 32}
innerElementType={innerElementType}
initialScrollOffset={initialScrollOffset}
itemKey={(index, results: any) => results[index].data.id}
>
{row}
</List>
</Flex>
)}
</AutoSizer>
</Results>
) : (
<NoResults>
<Text type="secondary">{t("No results found")}.</Text>
</NoResults>
)}
<Footer justify="space-between" align="center" gap={8}>
{selectedLocation ? (
<SelectedLocation type="secondary">
<Trans
defaults="Publish in <em>{{ location }}</em>"
values={{
location: selectedLocation.data.title,
}}
components={{
em: <strong />,
}}
/>
</SelectedLocation>
) : (
<SelectedLocation type="tertiary">
{t("Select a location to publish")}
</SelectedLocation>
)}
<Button disabled={!selectedLocation} onClick={publish}>
{t("Publish")}
</Button>
</Footer>
</FlexContainer>
);
}
const NoResults = styled(Flex)`
align-items: center;
justify-content: center;
height: 65vh;
${breakpoint("tablet")`
height: 40vh;
`}
`;
const Search = styled(InputSearch)`
${Outline} {
border-radius: 16px;
}
margin-bottom: 4px;
padding-left: 24px;
padding-right: 24px;
`;
const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
const Results = styled.div`
height: 65vh;
${breakpoint("tablet")`
height: 40vh;
`}
`;
const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
`;
const SelectedLocation = styled(Text)`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0;
`;
export default observer(DocumentPublish);