mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
88de417a21
* Allow dragging documents from list views into the sidebar Previously the react-dnd provider was scoped to the sidebar, so only sidebar rows could be dragged. This lifts the DndProvider up to the authenticated layout so both the main content and the sidebar share a single drag-and-drop context, and makes DocumentListItem a drag source. Now any document in search results or paginated lists (Home, Drafts, Collections, etc.) can be dragged into the sidebar to move it between collections, reparent it under another document, star it, or archive it — reusing the existing sidebar drop targets. * Make the whole Starred section a drop target to star documents Previously the only "create star" drop targets in the Starred section were the thin cursors between items, so dragging a document onto the section header or a starred row showed the drop cursor but did nothing. Wrap the section in a catch-all drop target (mirroring the Archive section) so dropping anywhere in Starred stars the document, while the precise inter-item cursors still control ordering. A didDrop guard on useDropToCreateStar prevents the catch-all from double-starring when a nested cursor already handled the drop, and the hover highlight uses a shallow isOver check so it only lights up when not over a nested target. * Let document list drag ghost follow the cursor The sidebar drag placeholder tethers the ghost near its starting x so it stays aligned with the sidebar during reordering. When a drag starts out in the main content (a document list item), that clamp pinned the ghost to a narrow band, making it look stuck in a small area. Thread a constrainToSidebar flag through the drag item (true for sidebar drags, false for document list drags) and let the placeholder follow the cursor freely when the drag originated outside the sidebar. * Clarify constrainToSidebar JSDoc to match placeholder behavior The placeholder treats an unset flag as tethered (constrainToSidebar !== false), so external drags must set it explicitly to false rather than leaving it unset. Update the comment to reflect that. * css
346 lines
8.9 KiB
TypeScript
346 lines
8.9 KiB
TypeScript
import {
|
|
useFocusEffect,
|
|
useRovingTabIndex,
|
|
} from "@getoutline/react-roving-tabindex";
|
|
import { observer } from "mobx-react";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { mergeRefs } from "react-merge-refs";
|
|
import { Link } from "react-router-dom";
|
|
import { DocumentIcon } from "outline-icons";
|
|
import styled, { css, useTheme } from "styled-components";
|
|
import breakpoint from "styled-components-breakpoint";
|
|
import EventBoundary from "@shared/components/EventBoundary";
|
|
import Icon from "@shared/components/Icon";
|
|
import { s, hover } from "@shared/styles";
|
|
import type Document from "~/models/Document";
|
|
import Badge from "~/components/Badge";
|
|
import DocumentMeta from "~/components/DocumentMeta";
|
|
import Flex from "~/components/Flex";
|
|
import Highlight from "~/components/Highlight";
|
|
import NudeButton from "~/components/NudeButton";
|
|
import StarButton, { AnimatedStar } from "~/components/Star";
|
|
import Tooltip from "~/components/Tooltip";
|
|
import useBoolean from "~/hooks/useBoolean";
|
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
|
import useMobile from "~/hooks/useMobile";
|
|
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
|
import DocumentMenu from "~/menus/DocumentMenu";
|
|
import { documentPath } from "~/utils/routeHelpers";
|
|
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
|
import { useDragDocument } from "./Sidebar/hooks/useDragAndDrop";
|
|
import { ActionContextProvider } from "~/hooks/useActionContext";
|
|
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
|
import { ContextMenu } from "./Menu/ContextMenu";
|
|
import useStores from "~/hooks/useStores";
|
|
|
|
type Props = {
|
|
document: Document;
|
|
highlight?: string | undefined;
|
|
context?: string | undefined;
|
|
showParentDocuments?: boolean;
|
|
showCollection?: boolean;
|
|
showPublished?: boolean;
|
|
showDraft?: boolean;
|
|
};
|
|
|
|
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
|
|
|
function replaceResultMarks(tag: string) {
|
|
// don't use SEARCH_RESULT_REGEX directly here as it causes an infinite loop
|
|
return tag.replace(new RegExp(SEARCH_RESULT_REGEX.source), "$1");
|
|
}
|
|
|
|
function DocumentListItem(
|
|
props: Props,
|
|
ref: React.RefObject<HTMLAnchorElement>
|
|
) {
|
|
const { t } = useTranslation();
|
|
const user = useCurrentUser();
|
|
const theme = useTheme();
|
|
const { userMemberships, groupMemberships } = useStores();
|
|
const locationSidebarContext = useLocationSidebarContext();
|
|
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
|
const isMobile = useMobile();
|
|
|
|
let itemRef: React.Ref<HTMLAnchorElement> =
|
|
React.useRef<HTMLAnchorElement>(null);
|
|
if (ref) {
|
|
itemRef = ref;
|
|
}
|
|
|
|
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
|
|
useFocusEffect(focused, itemRef);
|
|
|
|
const {
|
|
document,
|
|
showParentDocuments,
|
|
showCollection,
|
|
showPublished,
|
|
showDraft = true,
|
|
highlight,
|
|
context,
|
|
...rest
|
|
} = props;
|
|
const queryIsInTitle =
|
|
!!highlight &&
|
|
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
|
const canStar = !document.isArchived;
|
|
|
|
const isShared = !!(
|
|
userMemberships.getByDocumentId(document.id) ||
|
|
groupMemberships.getByDocumentId(document.id)
|
|
);
|
|
|
|
const sidebarContext = determineSidebarContext({
|
|
document,
|
|
user,
|
|
currentContext: locationSidebarContext,
|
|
});
|
|
|
|
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
|
|
|
|
const [{ isDragging }, draggableRef] = useDragDocument(
|
|
document.asNavigationNode,
|
|
0,
|
|
document,
|
|
false,
|
|
false
|
|
);
|
|
|
|
const mergedRef = React.useMemo(
|
|
() =>
|
|
mergeRefs<HTMLAnchorElement>([
|
|
itemRef,
|
|
draggableRef,
|
|
] as React.Ref<HTMLAnchorElement>[]),
|
|
[itemRef, draggableRef]
|
|
);
|
|
|
|
return (
|
|
<ActionContextProvider
|
|
value={{
|
|
activeModels: [
|
|
document,
|
|
...(!isShared && document.collection ? [document.collection] : []),
|
|
],
|
|
}}
|
|
>
|
|
<ContextMenu
|
|
action={contextMenuAction}
|
|
ariaLabel={t("Document options")}
|
|
onOpen={handleMenuOpen}
|
|
onClose={handleMenuClose}
|
|
>
|
|
<DocumentLink
|
|
ref={mergedRef}
|
|
dir={document.dir}
|
|
$isStarred={document.isStarred}
|
|
$isDragging={isDragging}
|
|
$menuOpen={menuOpen}
|
|
to={{
|
|
pathname: documentPath(document),
|
|
search: highlight
|
|
? `?q=${encodeURIComponent(highlight)}`
|
|
: undefined,
|
|
state: {
|
|
title: document.titleWithDefault,
|
|
sidebarContext,
|
|
},
|
|
}}
|
|
{...rest}
|
|
{...rovingTabIndex}
|
|
>
|
|
<Flex gap={4} auto>
|
|
<IconWrapper>
|
|
{document.icon ? (
|
|
<Icon
|
|
value={document.icon}
|
|
color={document.color ?? undefined}
|
|
initial={document.initial}
|
|
/>
|
|
) : (
|
|
<DocumentIcon
|
|
outline={document.isDraft}
|
|
color={theme.textSecondary}
|
|
/>
|
|
)}
|
|
</IconWrapper>
|
|
<Content>
|
|
<Heading dir={document.dir}>
|
|
<Title
|
|
text={document.titleWithDefault}
|
|
highlight={highlight}
|
|
dir={document.dir}
|
|
/>
|
|
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
|
<Badge yellow>{t("New")}</Badge>
|
|
)}
|
|
{document.isDraft && showDraft && (
|
|
<Tooltip content={t("Only visible to you")} placement="top">
|
|
<Badge>{t("Draft")}</Badge>
|
|
</Tooltip>
|
|
)}
|
|
{canStar && !isMobile && <StarButton document={document} />}
|
|
</Heading>
|
|
|
|
{!queryIsInTitle && (
|
|
<ResultContext
|
|
text={context}
|
|
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
|
processResult={replaceResultMarks}
|
|
/>
|
|
)}
|
|
<DocumentMeta
|
|
document={document}
|
|
showCollection={showCollection}
|
|
showPublished={showPublished}
|
|
showParentDocuments={showParentDocuments}
|
|
showLastViewed
|
|
/>
|
|
</Content>
|
|
</Flex>
|
|
<Actions>
|
|
<DocumentMenu
|
|
document={document}
|
|
onOpen={handleMenuOpen}
|
|
onClose={handleMenuClose}
|
|
/>
|
|
</Actions>
|
|
</DocumentLink>
|
|
</ContextMenu>
|
|
</ActionContextProvider>
|
|
);
|
|
}
|
|
|
|
const IconWrapper = styled.div`
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: flex-start;
|
|
width: 24px;
|
|
`;
|
|
|
|
const Content = styled.div`
|
|
flex-grow: 1;
|
|
flex-shrink: 1;
|
|
min-width: 0;
|
|
`;
|
|
|
|
const Actions = styled(EventBoundary)`
|
|
display: none;
|
|
align-items: center;
|
|
margin: 8px;
|
|
flex-shrink: 0;
|
|
flex-grow: 0;
|
|
color: ${s("textSecondary")};
|
|
|
|
${NudeButton}:${hover},
|
|
${NudeButton}[aria-expanded= "true"] {
|
|
background: ${s("sidebarControlHoverBackground")};
|
|
}
|
|
|
|
${breakpoint("tablet")`
|
|
display: flex;
|
|
`};
|
|
`;
|
|
|
|
const DocumentLink = styled(Link)<{
|
|
$isStarred?: boolean;
|
|
$isDragging?: boolean;
|
|
$menuOpen?: boolean;
|
|
}>`
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 10px -8px;
|
|
padding: 6px 8px;
|
|
border-radius: 8px;
|
|
max-height: 50vh;
|
|
width: calc(100vw - 8px);
|
|
cursor: var(--pointer);
|
|
transition: opacity 250ms ease;
|
|
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
|
|
|
|
&:focus-visible {
|
|
outline: none;
|
|
}
|
|
|
|
${breakpoint("tablet")`
|
|
width: auto;
|
|
`};
|
|
|
|
${Actions} {
|
|
opacity: 0;
|
|
}
|
|
|
|
${AnimatedStar} {
|
|
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
|
|
}
|
|
|
|
&:${hover},
|
|
&:active,
|
|
&:focus,
|
|
&:focus-within {
|
|
background: ${s("listItemHoverBackground")};
|
|
|
|
${Actions} {
|
|
opacity: 1;
|
|
}
|
|
|
|
${AnimatedStar} {
|
|
opacity: 0.5;
|
|
|
|
&:${hover} {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
${(props) =>
|
|
props.$menuOpen &&
|
|
css`
|
|
background: ${s("listItemHoverBackground")};
|
|
|
|
${Actions} {
|
|
opacity: 1;
|
|
}
|
|
|
|
${AnimatedStar} {
|
|
opacity: 0.5;
|
|
}
|
|
`}
|
|
`;
|
|
|
|
const Heading = styled.span<{ rtl?: boolean }>`
|
|
display: flex;
|
|
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
|
align-items: center;
|
|
margin-top: 0;
|
|
margin-bottom: 0.1em;
|
|
white-space: nowrap;
|
|
color: ${s("text")};
|
|
font-family: ${s("fontFamily")};
|
|
font-weight: 500;
|
|
font-size: 18px;
|
|
line-height: 1.2;
|
|
gap: 4px;
|
|
`;
|
|
|
|
const Title = styled(Highlight)`
|
|
max-width: 90%;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`;
|
|
|
|
const ResultContext = styled(Highlight)`
|
|
display: block;
|
|
color: ${s("textSecondary")};
|
|
font-size: 15px;
|
|
margin-top: -0.25em;
|
|
margin-bottom: 0.25em;
|
|
max-height: 90px;
|
|
overflow: hidden;
|
|
`;
|
|
|
|
export default observer(React.forwardRef(DocumentListItem));
|