* 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>
429 lines
11 KiB
TypeScript
429 lines
11 KiB
TypeScript
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("Couldn’t 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);
|