mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
Add control to collapse pinned docs (#13233)
* Add control to collapse pinned docs * feedback
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import type Document from "~/models/Document";
|
||||
|
||||
type Props = {
|
||||
/** The document to display */
|
||||
document: Document;
|
||||
};
|
||||
|
||||
/**
|
||||
* A compact, single line representation of a document displaying its icon and
|
||||
* title, linking to the document.
|
||||
*/
|
||||
export const DocumentChip = observer(function DocumentChip_({
|
||||
document,
|
||||
}: Props) {
|
||||
const { icon, color } = document;
|
||||
const title = document.titleWithDefault;
|
||||
const isEmoji = determineIconType(icon) === IconType.Emoji;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
dir={document.dir}
|
||||
to={{
|
||||
pathname: document.path,
|
||||
state: {
|
||||
title,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{icon ? (
|
||||
<Icon
|
||||
value={icon}
|
||||
color={color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
)}
|
||||
<Title>{isEmoji ? title.replace(icon!, "") : title}</Title>
|
||||
</Chip>
|
||||
);
|
||||
});
|
||||
|
||||
const Title = styled.span`
|
||||
${ellipsis()}
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const Chip = styled(Link)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
height: 24px;
|
||||
padding: 0px 4px 0px 2px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
color: ${s("textSecondary")};
|
||||
cursor: var(--pointer);
|
||||
user-select: none;
|
||||
|
||||
&:${hover},
|
||||
&:active,
|
||||
&:focus-visible {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
}
|
||||
`;
|
||||
@@ -17,11 +17,20 @@ import {
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapseIcon } from "outline-icons";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s, hover, hideScrollbars } from "@shared/styles";
|
||||
import type Pin from "~/models/Pin";
|
||||
import DocumentCard from "~/components/DocumentCard";
|
||||
import { DocumentChip } from "~/components/DocumentChip";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ResizingHeightContainer } from "./ResizingHeightContainer";
|
||||
|
||||
@@ -34,6 +43,11 @@ type Props = {
|
||||
placeholderCount?: number;
|
||||
/** Whether the user has permission to update pins */
|
||||
canUpdate?: boolean;
|
||||
/**
|
||||
* When provided the section can be collapsed by the user, the preference is
|
||||
* persisted locally under this key.
|
||||
*/
|
||||
collapseKey?: string;
|
||||
};
|
||||
|
||||
function PinnedDocuments({
|
||||
@@ -41,14 +55,29 @@ function PinnedDocuments({
|
||||
pins,
|
||||
placeholderCount,
|
||||
canUpdate,
|
||||
collapseKey,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const isMobile = useMobile();
|
||||
const [items, setItems] = useState(pins.map((pin) => pin.documentId));
|
||||
const showPlaceholderRef = useRef(true);
|
||||
// Only ever read or written when the section is collapsible
|
||||
const [collapsed, setCollapsed] = usePersistedState<boolean>(
|
||||
collapseKey ? `pins-collapsed-${collapseKey}` : "pins-collapsed",
|
||||
false
|
||||
);
|
||||
const isCollapsible =
|
||||
!isMobile && !!collapseKey && (!!items.length || !!placeholderCount);
|
||||
const isCollapsed = isCollapsible && collapsed;
|
||||
const showPlaceholder =
|
||||
placeholderCount && !items.length && showPlaceholderRef.current;
|
||||
|
||||
const handleToggleCollapsed = useCallback(() => {
|
||||
setCollapsed((state) => !state);
|
||||
}, [setCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
setItems(pins.map((pin) => pin.documentId));
|
||||
}, [pins]);
|
||||
@@ -106,59 +135,131 @@ function PinnedDocuments({
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<ResizingHeightContainer
|
||||
config={{
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SortableContext items={items} strategy={rectSortingStrategy}>
|
||||
<List>
|
||||
{showPlaceholder ? (
|
||||
Array(placeholderCount)
|
||||
.fill(undefined)
|
||||
.map((_, index) => (
|
||||
<div key={index} style={{ width: 170, height: 180 }} />
|
||||
))
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((documentId) => {
|
||||
const document = documents.get(documentId);
|
||||
const pin = pins.find((p) => p.documentId === documentId);
|
||||
<Wrapper $collapsible={isCollapsible} $collapsed={isCollapsed}>
|
||||
{isCollapsible && (
|
||||
<Tooltip content={isCollapsed ? t("Expand") : t("Collapse")}>
|
||||
<ToggleButton
|
||||
$visible={isCollapsed}
|
||||
onClick={handleToggleCollapsed}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={isCollapsed ? t("Expand") : t("Collapse")}
|
||||
>
|
||||
<ToggleIcon />
|
||||
</ToggleButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isCollapsed && (
|
||||
<Chips>
|
||||
{items.map((documentId) => {
|
||||
const document = documents.get(documentId);
|
||||
return document ? (
|
||||
<DocumentChip key={documentId} document={document} />
|
||||
) : null;
|
||||
})}
|
||||
</Chips>
|
||||
)}
|
||||
<ResizingHeightContainer
|
||||
config={{
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? null : (
|
||||
<SortableContext items={items} strategy={rectSortingStrategy}>
|
||||
<List $flush={isCollapsible}>
|
||||
{showPlaceholder ? (
|
||||
Array(placeholderCount)
|
||||
.fill(undefined)
|
||||
.map((_, index) => (
|
||||
<div key={index} style={{ width: 170, height: 180 }} />
|
||||
))
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((documentId) => {
|
||||
const document = documents.get(documentId);
|
||||
const pin = pins.find((p) => p.documentId === documentId);
|
||||
|
||||
// Once any document is loaded, never render the placeholder again
|
||||
showPlaceholderRef.current = false;
|
||||
// Once any document is loaded, never render the placeholder again
|
||||
showPlaceholderRef.current = false;
|
||||
|
||||
return document ? (
|
||||
<DocumentCard
|
||||
key={documentId}
|
||||
document={document}
|
||||
canUpdatePin={canUpdate}
|
||||
isDraggable={items.length > 1}
|
||||
pin={pin}
|
||||
{...rest}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</List>
|
||||
</SortableContext>
|
||||
</ResizingHeightContainer>
|
||||
return document ? (
|
||||
<DocumentCard
|
||||
key={documentId}
|
||||
document={document}
|
||||
canUpdatePin={canUpdate}
|
||||
isDraggable={items.length > 1}
|
||||
pin={pin}
|
||||
{...rest}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</List>
|
||||
</SortableContext>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
</Wrapper>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
const List = styled.div`
|
||||
const ToggleIcon = styled(CollapseIcon)`
|
||||
background: ${s("background")};
|
||||
`;
|
||||
|
||||
const ToggleButton = styled(NudeButton)<{ $visible: boolean }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
color: ${s("textTertiary")};
|
||||
opacity: ${(props) => (props.$visible ? 1 : 0)};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:${hover},
|
||||
&:active,
|
||||
&:focus-visible {
|
||||
color: ${s("text")};
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div<{ $collapsible: boolean; $collapsed: boolean }>`
|
||||
position: relative;
|
||||
|
||||
// replaces the top margin of the list so the toggle takes no additional space
|
||||
padding-top: ${(props) =>
|
||||
props.$collapsible && !props.$collapsed ? 16 : 0}px;
|
||||
|
||||
${`&:${hover}`} ${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Chips = styled(Fade)`
|
||||
${hideScrollbars()}
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
height: 24px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 16px;
|
||||
|
||||
// leave room for the collapse toggle, chips scrolling beneath it fade out
|
||||
padding-right: 28px;
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
`;
|
||||
|
||||
const List = styled.div<{ $flush?: boolean }>`
|
||||
display: grid;
|
||||
column-gap: 8px;
|
||||
row-gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
margin: 16px 0 32px;
|
||||
margin: ${(props) => (props.$flush ? "0" : "16px")} 0 32px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
|
||||
@@ -51,6 +51,7 @@ function Home() {
|
||||
pins={pins}
|
||||
canUpdate={can.update}
|
||||
placeholderCount={count}
|
||||
collapseKey="home"
|
||||
/>
|
||||
<Documents>
|
||||
<Tabs>
|
||||
|
||||
@@ -455,6 +455,8 @@
|
||||
"Callback URLs": "Callback URLs",
|
||||
"Published": "Published",
|
||||
"Allow this app to be installed by other workspaces": "Allow this app to be installed by other workspaces",
|
||||
"Expand": "Expand",
|
||||
"Collapse": "Collapse",
|
||||
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
|
||||
@@ -545,8 +547,6 @@
|
||||
"New doc": "New doc",
|
||||
"New nested document": "New nested document",
|
||||
"No collections": "No collections",
|
||||
"Collapse": "Collapse",
|
||||
"Expand": "Expand",
|
||||
"Import files": "Import files",
|
||||
"Recent": "Recent",
|
||||
"Go back": "Go back",
|
||||
|
||||
Reference in New Issue
Block a user