@@ -85,21 +86,21 @@ type ContentProps = { const Content = styled(Flex)` margin: 0; transition: ${(props) => - props.$isResizing ? "none" : `margin-left 100ms ease-out`}; + props.$isResizing ? "none" : `margin-inline-start 100ms ease-out`}; @media print { margin: 0 !important; } ${breakpoint("mobile", "tablet")` - margin-left: 0 !important; + margin-inline-start: 0 !important; `} ${breakpoint("tablet")` ${(props: ContentProps) => props.$hasSidebar && props.$sidebarCollapsed && - `margin-left: ${props.theme.sidebarCollapsedWidth}px;`} + `margin-inline-start: ${props.theme.sidebarCollapsedWidth}px;`} `}; `; diff --git a/app/components/LazyLoad.ts b/app/components/LazyLoad.ts index 988188980776..5edf87559ac0 100644 --- a/app/components/LazyLoad.ts +++ b/app/components/LazyLoad.ts @@ -1,6 +1,7 @@ import * as React from "react"; import lazyWithRetry from "~/utils/lazyWithRetry"; +// oxlint-disable no-explicit-any -- ComponentType is the standard React pattern for generic component constraints export interface LazyComponent> { Component: React.LazyExoticComponent; preload: () => Promise<{ default: T }>; diff --git a/app/components/List/Item.tsx b/app/components/List/Item.tsx index 6cb7b93be99b..6319f9452430 100644 --- a/app/components/List/Item.tsx +++ b/app/components/List/Item.tsx @@ -203,7 +203,7 @@ const Wrapper = styled.a<{ `; const Image = styled(Flex)` - padding: 0 8px 0 0; + padding-inline-end: 8px; max-height: 32px; align-items: center; user-select: none; diff --git a/app/components/Menu/ContextMenu.tsx b/app/components/Menu/ContextMenu.tsx index 933f5f2f7e05..a690991689d1 100644 --- a/app/components/Menu/ContextMenu.tsx +++ b/app/components/Menu/ContextMenu.tsx @@ -47,7 +47,7 @@ export const ContextMenu = observer( onClose?.(); } }, - [open, onOpen, onClose] + [onOpen, onClose] ); const enablePointerEvents = React.useCallback(() => { diff --git a/app/components/Menu/transformer.tsx b/app/components/Menu/transformer.tsx index 6439105167f8..0ede28d90049 100644 --- a/app/components/Menu/transformer.tsx +++ b/app/components/Menu/transformer.tsx @@ -42,13 +42,14 @@ export function toMenuItems(items: MenuItem[]) { case "button": return ( ); @@ -56,10 +57,11 @@ export function toMenuItems(items: MenuItem[]) { case "route": return ( ); @@ -67,10 +69,11 @@ export function toMenuItems(items: MenuItem[]) { case "link": return ( + @@ -168,7 +171,7 @@ export function toMobileMenuItems( case "button": return ( { @@ -189,7 +192,7 @@ export function toMobileMenuItems( case "route": return ( { openSubmenu(item.title as string); @@ -253,7 +256,7 @@ export function toMobileMenuItems( } return ( -
+
{item.title} {groupItems}
diff --git a/app/components/Modal.tsx b/app/components/Modal.tsx index b615b9879792..01c2e988728f 100644 --- a/app/components/Modal.tsx +++ b/app/components/Modal.tsx @@ -15,8 +15,8 @@ import usePrevious from "~/hooks/usePrevious"; import { fadeAndScaleIn, fadeIn } from "~/styles/animations"; import Desktop from "~/utils/Desktop"; import ErrorBoundary from "./ErrorBoundary"; -import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; import Tooltip from "./Tooltip"; +import { useDialogContext } from "~/components/DialogContext"; type Props = { children?: React.ReactNode; @@ -31,7 +31,7 @@ type Props = { const Modal: React.FC = ({ children, isOpen, - title = "Untitled", + title, style, width, height, @@ -40,42 +40,43 @@ const Modal: React.FC = ({ const wasOpen = usePrevious(isOpen); const isMobile = useMobile(); const { t } = useTranslation(); + const resolvedTitle = title ?? t("Untitled"); + const dialog = useDialogContext(); + + const onClose = React.useCallback(() => { + dialog.setAnimating(false); // Reset + onRequestClose(); + }, [dialog, onRequestClose]); if (!isOpen && !wasOpen) { return null; } return ( - !open && onRequestClose()} - > + !open && onClose()}> - - {title} - {isMobile ? ( ev.stopPropagation()} column> - {title && ( + - {title} + {resolvedTitle} - )} + {children} - + - + {t("Back")} @@ -89,13 +90,20 @@ const Modal: React.FC = ({ column reverse > - + dialog.setAnimating(false)} + > {children}
- {title && {title}} + + {resolvedTitle} + - + diff --git a/app/components/Notifications/NotificationListItem.tsx b/app/components/Notifications/NotificationListItem.tsx index 2b0b95f58bb9..2fcf91affea3 100644 --- a/app/components/Notifications/NotificationListItem.tsx +++ b/app/components/Notifications/NotificationListItem.tsx @@ -117,8 +117,8 @@ const StyledAvatar = styled(Avatar).attrs({ const Container = styled(Flex)<{ $unread: boolean }>` position: relative; - padding: 8px 12px; - padding-right: 40px; + padding-block: 8px; + padding-inline: 12px 40px; border-radius: 4px; ${StyledLink}[data-state=open] &, diff --git a/app/components/Notifications/Notifications.tsx b/app/components/Notifications/Notifications.tsx index 4ead19727aa2..63a2f4c53763 100644 --- a/app/components/Notifications/Notifications.tsx +++ b/app/components/Notifications/Notifications.tsx @@ -110,8 +110,9 @@ function Notifications( @@ -122,7 +123,7 @@ function Notifications( setFilter(value as NotificationFilter)} diff --git a/app/components/OAuthClient/OAuthClientForm.tsx b/app/components/OAuthClient/OAuthClientForm.tsx index 553a1102f3b0..db90378993fd 100644 --- a/app/components/OAuthClient/OAuthClientForm.tsx +++ b/app/components/OAuthClient/OAuthClientForm.tsx @@ -13,7 +13,7 @@ import Switch from "../Switch"; import EventBoundary from "@shared/components/EventBoundary"; import { InputClientType } from "./InputClientType"; -export interface FormData { +export type FormData = { name: string; developerName: string; developerUrl: string; @@ -22,7 +22,7 @@ export interface FormData { redirectUris: string[]; published: boolean; clientType: "confidential" | "public"; -} +}; export const OAuthClientForm = observer(function OAuthClientForm_({ handleSubmit, diff --git a/app/components/PageTitle.tsx b/app/components/PageTitle.tsx index 6092c1b1f70e..9de1cf5c6525 100644 --- a/app/components/PageTitle.tsx +++ b/app/components/PageTitle.tsx @@ -1,12 +1,11 @@ import { observer } from "mobx-react"; -import * as React from "react"; import { Helmet } from "react-helmet-async"; import env from "~/env"; import useStores from "~/hooks/useStores"; import { useTeamContext } from "./TeamContext"; type Props = { - title: React.ReactNode; + title: string; favicon?: string; }; diff --git a/app/components/PaginatedDocumentList.tsx b/app/components/PaginatedDocumentList.tsx index 3c43d90b09c5..e9f091d1e8ae 100644 --- a/app/components/PaginatedDocumentList.tsx +++ b/app/components/PaginatedDocumentList.tsx @@ -7,7 +7,9 @@ import PaginatedList from "~/components/PaginatedList"; type Props = { documents: Document[]; - fetch: (options: any) => Promise; + // oxlint-disable-next-line no-explicit-any + fetch: (options: Record) => Promise; + // oxlint-disable-next-line no-explicit-any options?: Record; heading?: React.ReactNode; empty?: JSX.Element; diff --git a/app/components/PaginatedList.tsx b/app/components/PaginatedList.tsx index bb9145ac819c..1a1d9df9b1fc 100644 --- a/app/components/PaginatedList.tsx +++ b/app/components/PaginatedList.tsx @@ -35,10 +35,12 @@ interface Props< * @param options Pagination and other query options */ fetch?: ( + // oxlint-disable-next-line no-explicit-any options: Record | undefined ) => Promise | undefined; /** Additional options to pass to the fetch function */ + // oxlint-disable-next-line no-explicit-any options?: Record; /** Optional header content to display above the list */ @@ -78,7 +80,7 @@ interface Props< * Function to render section headings (typically date-based) * @param name The heading text or element to render */ - renderHeading?: (name: React.ReactElement | string) => React.ReactNode; + renderHeading?: (name: React.ReactElement | string) => React.ReactNode; /** * Function to determine if an item is a duplicate of the previous item. diff --git a/app/components/PluginIcon.tsx b/app/components/PluginIcon.tsx index b8027f63e57f..a759b9324c9e 100644 --- a/app/components/PluginIcon.tsx +++ b/app/components/PluginIcon.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react"; import styled from "styled-components"; import Logger from "~/utils/Logger"; -import { Hook, usePluginValue } from "~/utils/PluginManager"; +import { Hook, PluginManager, usePluginValue } from "~/utils/PluginManager"; type Props = { /** The ID of the plugin to render an Icon for. */ @@ -26,7 +26,9 @@ function PluginIcon({ id, color, size = 24 }: Props) { ); } - Logger.warn("No Icon registered for plugin", { id }); + if (PluginManager.isLoaded) { + Logger.warn("No Icon registered for plugin", { id }); + } return null; } diff --git a/app/components/Reactions/Reaction.tsx b/app/components/Reactions/Reaction.tsx index 59829ebf022a..e97fa9f44902 100644 --- a/app/components/Reactions/Reaction.tsx +++ b/app/components/Reactions/Reaction.tsx @@ -49,13 +49,15 @@ const useTooltipContent = ({ ); // If the emoji is a custom emoji ID, we need to get its short name for display - if (isUUID(emoji)) { - emojis.fetch(emoji).then((ce) => { - if (ce) { - setTransformedEmoji(ce.shortName); - } - }); - } + React.useEffect(() => { + if (isUUID(emoji)) { + void emojis.fetch(emoji).then((ce) => { + if (ce) { + setTransformedEmoji(ce.shortName); + } + }); + } + }, [emoji, emojis]); if (!reactedUsers.length) { return; diff --git a/app/components/Scene.tsx b/app/components/Scene.tsx index d01ce177abca..4f1049a5a420 100644 --- a/app/components/Scene.tsx +++ b/app/components/Scene.tsx @@ -34,7 +34,7 @@ const Scene: React.FC = ({ wide, }: Props) => ( - +
& { /** Whether to show shadows at top and bottom when scrolled */ @@ -45,41 +44,37 @@ function Scrollable( const fallbackRef = React.useRef(); const [topShadowVisible, setTopShadow] = React.useState(false); const [bottomShadowVisible, setBottomShadow] = React.useState(false); - const { height } = useWindowSize(); const updateShadows = React.useCallback(() => { const c = (ref || fallbackRef).current; if (!c) { return; } const scrollTop = c.scrollTop; - const tsv = !!((shadow || topShadow || fadeTo) && scrollTop > 0); - - if (tsv !== topShadowVisible) { - setTopShadow(tsv); - } + setTopShadow(!!((shadow || topShadow || fadeTo) && scrollTop > 0)); const wrapperHeight = c.scrollHeight - c.clientHeight; - const bsv = !!( - (shadow || bottomShadow || fadeTo) && - wrapperHeight - scrollTop !== 0 + setBottomShadow( + !!((shadow || bottomShadow || fadeTo) && wrapperHeight - scrollTop > 1) ); + }, [shadow, topShadow, bottomShadow, fadeTo, ref]); - if (bsv !== bottomShadowVisible) { - setBottomShadow(bsv); + React.useEffect(() => { + const c = (ref || fallbackRef).current; + if (!c) { + return; } - }, [ - shadow, - topShadow, - bottomShadow, - fadeTo, - ref, - topShadowVisible, - bottomShadowVisible, - ]); - React.useEffect(() => { updateShadows(); - }, [height, updateShadows]); + + const observer = new ResizeObserver(updateShadows); + observer.observe(c); + + for (const child of Array.from(c.children)) { + observer.observe(child); + } + + return () => observer.disconnect(); + }, [ref, updateShadows]); return ( ; -}; -const SEARCH_RESULT_REGEX = /]*>(.*?)<\/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>/gi, "$1"); -} - -function DocumentListItem( - props: Props, - ref: React.RefObject -) { - const { document, highlight, context, shareId, ...rest } = props; - - let itemRef: React.Ref = - React.useRef(null); - if (ref) { - itemRef = ref; - } - - const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false); - useFocusEffect(focused, itemRef); - - return ( - { - if (rest.onClick) { - rest.onClick(ev); - } - rovingTabIndex.onClick(ev); - }} - > - - - - </Heading> - - { - <ResultContext - text={context} - highlight={highlight ? SEARCH_RESULT_REGEX : undefined} - processResult={replaceResultMarks} - /> - } - </Content> - </DocumentLink> - ); -} - -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; - cursor: var(--pointer); - - &:not(:last-child) { - margin-bottom: 4px; - } - - &:focus-visible { - outline: none; - } - - ${breakpoint("tablet")` - width: auto; - `}; - - &:${hover}, - &:active, - &:focus, - &:focus-within { - background: ${s("listItemHoverBackground")}; - } - - ${(props) => - props.$menuOpen && - css` - background: ${s("listItemHoverBackground")}; - `} -`; - -const Heading = styled.h4<{ rtl?: boolean }>` - display: flex; - justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")}; - align-items: center; - height: 22px; - margin-top: 0; - margin-bottom: 0.25em; - overflow: hidden; - white-space: nowrap; - color: ${s("text")}; -`; - -const Title = styled(Highlight)` - max-width: 90%; - ${ellipsis()} - - ${Mark} { - padding: 0; - } -`; - -const ResultContext = styled(Highlight)` - display: block; - color: ${s("textTertiary")}; - font-size: 14px; - margin-top: -0.25em; - margin-bottom: 0; - ${ellipsis()} - - ${Mark} { - padding: 0; - } -`; - -export default observer(React.forwardRef(DocumentListItem)); diff --git a/app/components/SearchPopover.tsx b/app/components/SearchPopover.tsx deleted file mode 100644 index 592518878c48..000000000000 --- a/app/components/SearchPopover.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import debounce from "lodash/debounce"; -import { observer } from "mobx-react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import styled from "styled-components"; -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, - PopoverAnchor, - PopoverContent, -} from "~/components/primitives/Popover"; -import { id as bodyContentId } from "~/components/SkipNavContent"; -import useKeyDown from "~/hooks/useKeyDown"; -import useStores from "~/hooks/useStores"; -import { preventDefault } from "~/utils/events"; -import type { SearchResult } from "~/types"; -import SearchListItem from "./SearchListItem"; - -interface Props extends React.HTMLAttributes<HTMLInputElement> { - shareId: string; - className?: string; -} - -function SearchPopover({ shareId, className }: Props) { - const { t } = useTranslation(); - const { documents } = useStores(); - const focusRef = React.useRef<HTMLElement | null>(null); - const searchInputRef = React.useRef<HTMLInputElement>(null); - const firstSearchItem = React.useRef<HTMLAnchorElement>(null); - - const [open, setOpen] = React.useState(false); - const [query, setQuery] = React.useState(""); - const [searchResults, setSearchResults] = React.useState< - SearchResult[] | undefined - >(); - - // Cache search results by query string to avoid redundant API calls - const cacheRef = React.useRef(new Map<string, SearchResult[]>()); - const queryRef = React.useRef(query); - queryRef.current = query; - - // When the query changes, restore cached results (including empty) or keep - // previous results visible until new results arrive to avoid layout shift - React.useEffect(() => { - if (!query) { - setSearchResults(undefined); - return; - } - - const cached = cacheRef.current.get(query); - if (cached !== undefined) { - setSearchResults(cached); - if (cached.length) { - setOpen(true); - } - } - }, [query]); - - const performSearch = React.useCallback( - async ({ - query: searchQuery, - offset = 0, - ...options - }: Record<string, any>) => { - if (!searchQuery?.length) { - return undefined; - } - - // Return cached results for first-page lookups - if (offset === 0 && cacheRef.current.has(searchQuery)) { - return cacheRef.current.get(searchQuery)!; - } - - // Force offset to 0 for new queries — PaginatedList's reset() sets - // offset via setState but fetchResults still uses the stale value - // from its closure - if (!cacheRef.current.has(searchQuery)) { - offset = 0; - } - - const response = await documents.search({ - query: searchQuery, - shareId, - offset, - ...options, - }); - - // Build complete result set in cache: replace for new queries, append - // for pagination of an existing query - const existing = cacheRef.current.get(searchQuery); - cacheRef.current.set( - searchQuery, - existing ? [...existing, ...response] : response - ); - - // Only update state if this query is still current to prevent stale - // results from overwriting newer results after a race condition - if (queryRef.current === searchQuery) { - setSearchResults(cacheRef.current.get(searchQuery)!); - setOpen(true); - } - - return response; - }, - [documents, shareId] - ); - - const debouncedSetQuery = React.useMemo( - () => - debounce((value: string) => { - setQuery(value); - setOpen(!!value); - }, 250), - [] - ); - - const handleSearchInputChange = React.useCallback( - (event: React.ChangeEvent<HTMLInputElement>) => { - debouncedSetQuery(event.target.value.trim()); - }, - [debouncedSetQuery] - ); - - React.useEffect(() => () => debouncedSetQuery.cancel(), [debouncedSetQuery]); - - const handleEscapeList = React.useCallback( - () => searchInputRef.current?.focus(), - [] - ); - - const handleSearchInputFocus = React.useCallback(() => { - focusRef.current = searchInputRef.current; - }, []); - - const handleKeyDown = React.useCallback( - (ev: React.KeyboardEvent<HTMLInputElement>) => { - if (ev.nativeEvent.isComposing) { - return; - } - - if (ev.key === "Enter") { - if (searchResults) { - setOpen(true); - } - return; - } - - if (ev.key === "ArrowDown" && !ev.shiftKey) { - if (ev.currentTarget.value.length) { - const atEnd = - ev.currentTarget.value.length === ev.currentTarget.selectionStart; - - if (atEnd) { - setOpen(true); - } - if (open || atEnd) { - ev.preventDefault(); - firstSearchItem.current?.focus(); - } - } - return; - } - - if (ev.key === "ArrowUp") { - if (open) { - setOpen(false); - if (!ev.shiftKey) { - ev.preventDefault(); - } - } - if (ev.currentTarget.value && ev.currentTarget.selectionEnd === 0) { - ev.currentTarget.selectionStart = 0; - ev.currentTarget.selectionEnd = ev.currentTarget.value.length; - ev.preventDefault(); - } - return; - } - - if (ev.key === "Escape" && open) { - setOpen(false); - ev.preventDefault(); - } - }, - [open, searchResults] - ); - - const handleSearchItemClick = React.useCallback(() => { - setOpen(false); - setQuery(""); - if (searchInputRef.current) { - searchInputRef.current.value = ""; - focusRef.current = document.getElementById(bodyContentId); - } - }, []); - - useKeyDown("/", (ev) => { - if ( - searchInputRef.current && - searchInputRef.current !== document.activeElement - ) { - searchInputRef.current.focus(); - ev.preventDefault(); - } - }); - - return ( - <Popover open={open} onOpenChange={setOpen} modal={true}> - <PopoverAnchor> - <StyledInputSearch - role="combobox" - aria-controls="search-results" - aria-expanded={open} - aria-haspopup="listbox" - ref={searchInputRef} - onChange={handleSearchInputChange} - onFocus={handleSearchInputFocus} - onKeyDown={handleKeyDown} - className={className} - label={t("Search")} - labelHidden - /> - </PopoverAnchor> - <PopoverContent - id="search-results" - aria-label={t("Results")} - side="bottom" - align="start" - shrink - onEscapeKeyDown={handleEscapeList} - onOpenAutoFocus={preventDefault} - onInteractOutside={(event) => { - const target = event.target as Element | null; - if (target === searchInputRef.current) { - event.preventDefault(); - } - }} - > - <PaginatedList<SearchResult> - role="listbox" - options={{ - query, - snippetMinWords: 10, - snippetMaxWords: 11, - limit: 10, - }} - items={searchResults} - fetch={performSearch} - onEscape={handleEscapeList} - empty={ - <NoResults>{t("No results for {{query}}", { query })}</NoResults> - } - loading={<PlaceholderList count={3} header={{ height: 20 }} />} - renderItem={(item, index) => ( - <SearchListItem - key={item.document.id} - shareId={shareId} - ref={index === 0 ? firstSearchItem : undefined} - document={item.document} - context={item.context} - highlight={query} - onClick={handleSearchItemClick} - /> - )} - /> - </PopoverContent> - </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); diff --git a/app/components/Sharing/Collection/AccessControlList.tsx b/app/components/Sharing/Collection/AccessControlList.tsx index 814bdbf3e19f..0207d58f23f5 100644 --- a/app/components/Sharing/Collection/AccessControlList.tsx +++ b/app/components/Sharing/Collection/AccessControlList.tsx @@ -16,7 +16,6 @@ import Scrollable from "~/components/Scrollable"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useMaxHeight from "~/hooks/useMaxHeight"; import usePolicy from "~/hooks/usePolicy"; -import useRequest from "~/hooks/useRequest"; import useStores from "~/hooks/useStores"; import type { Permission } from "~/types"; import { EmptySelectValue } from "~/types"; @@ -38,10 +37,12 @@ type Props = { invitedInSession: string[]; /** Whether the popover is visible. */ visible: boolean; + /** Whether the share data is currently loading. */ + loading: boolean; }; export const AccessControlList = observer( - ({ collection, share, invitedInSession, visible }: Props) => { + ({ collection, share, invitedInSession, visible, loading }: Props) => { const { memberships, groupMemberships } = useStores(); const team = useCurrentTeam(); const can = usePolicy(collection); @@ -49,35 +50,13 @@ export const AccessControlList = observer( const theme = useTheme(); const collectionId = collection.id; - const { request: fetchMemberships, loading: membershipLoading } = - useRequest( - React.useCallback( - () => memberships.fetchAll({ id: collectionId }), - [memberships, collectionId] - ) - ); - - const { request: fetchGroupMemberships, loading: groupMembershipLoading } = - useRequest( - React.useCallback( - () => groupMemberships.fetchAll({ collectionId }), - [groupMemberships, collectionId] - ) - ); - const groupMembershipsInCollection = groupMemberships.inCollection(collectionId); const membershipsInCollection = memberships.inCollection(collectionId); const hasMemberships = groupMembershipsInCollection.length > 0 || membershipsInCollection.length > 0; - const showLoading = - !hasMemberships && (membershipLoading || groupMembershipLoading); - - React.useEffect(() => { - void fetchMemberships(); - void fetchGroupMemberships(); - }, [fetchMemberships, fetchGroupMemberships]); + const showLoading = !hasMemberships && loading; const containerRef = React.useRef<HTMLDivElement | null>(null); const publicAccessRef = React.useRef<HTMLDivElement | null>(null); @@ -146,7 +125,7 @@ export const AccessControlList = observer( }} disabled={!can.update} value={collection?.permission} - hideLabel + labelHidden nude shrink /> diff --git a/app/components/Sharing/Collection/PublicAccess.tsx b/app/components/Sharing/Collection/PublicAccess.tsx index 015ed7297142..a489baaa7401 100644 --- a/app/components/Sharing/Collection/PublicAccess.tsx +++ b/app/components/Sharing/Collection/PublicAccess.tsx @@ -1,7 +1,8 @@ +import copy from "copy-to-clipboard"; import debounce from "lodash/debounce"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; -import { CopyIcon, GlobeIcon, QuestionMarkIcon } from "outline-icons"; +import { CopyIcon, GlobeIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -20,7 +21,9 @@ import Text from "~/components/Text"; import Tooltip from "~/components/Tooltip"; import env from "~/env"; import usePolicy from "~/hooks/usePolicy"; +import useStores from "~/hooks/useStores"; import { ListItem } from "../components/ListItem"; +import ShareSettingsPopover from "../components/ShareSettingsPopover"; import { DomainPrefix, ShareLinkInput, StyledInfoIcon } from "../components"; type Props = { @@ -35,68 +38,46 @@ function InnerPublicAccess( ref: React.RefObject<HTMLDivElement> ) { const { t } = useTranslation(); + const { shares } = useStores(); const theme = useTheme(); const [validationError, setValidationError] = React.useState(""); const [urlId, setUrlId] = React.useState(share?.urlId); const inputRef = React.useRef<HTMLInputElement>(null); const can = usePolicy(share); const collectionAbilities = usePolicy(collection); - const canPublish = can.update && collectionAbilities.share; + const canPublish = share ? can.update : collectionAbilities.share; + const [creating, setCreating] = React.useState(false); React.useEffect(() => { setUrlId(share?.urlId); }, [share?.urlId]); - const handleIndexingChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - allowIndexing: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - - const handleShowLastModifiedChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - showLastUpdated: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - - const handleShowTOCChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - showTOC: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - const handlePublishedChange = React.useCallback( async (checked: boolean) => { try { - await share?.save({ - published: checked, - }); + if (checked && !share) { + setCreating(true); + const newShare = await shares.create({ + type: "collection", + collectionId: collection.id, + published: true, + }); + copy(newShare.url); + toast.success(t("Public link copied to clipboard")); + } else if (share) { + await share.save({ published: checked }); + if (checked) { + copy(share.url); + toast.success(t("Public link copied to clipboard")); + } + } } catch (err) { toast.error(err.message); + } finally { + setCreating(false); } }, - [share] + [t, share, shares, collection] ); const handleUrlChange = React.useMemo( @@ -147,7 +128,7 @@ function InnerPublicAccess( return ( <div ref={ref}> <ListItem - title={t("Web")} + title={t("Publish to web")} subtitle={<>{t("Allow anyone with the link to access")}</>} image={ <Squircle color={theme.text} size={AvatarSize.Medium}> @@ -159,7 +140,7 @@ function InnerPublicAccess( aria-label={t("Publish to internet")} checked={share?.published ?? false} onChange={handlePublishedChange} - disabled={!canPublish} + disabled={!canPublish || creating} width={26} height={14} /> @@ -169,96 +150,24 @@ function InnerPublicAccess( <ResizingHeightContainer> {!!share?.published && ( <> - <ListItem - title={ - <Text type="tertiary" as={Flex}> - {t("Search engine indexing")}  - <Tooltip - content={t( - "Disable this setting to discourage search engines from indexing the page" - )} - > - <NudeButton size={18}> - <QuestionMarkIcon size={18} /> - </NudeButton> - </Tooltip> - </Text> - } - actions={ - <Switch - aria-label={t("Search engine indexing")} - checked={share?.allowIndexing ?? false} - onChange={handleIndexingChanged} - width={26} - height={14} - /> - } - /> - <ListItem - title={ - <Text type="tertiary" as={Flex}> - {t("Show last modified")}  - <Tooltip - content={t( - "Display the last modified timestamp on the shared page" - )} - > - <NudeButton size={18}> - <QuestionMarkIcon size={18} /> - </NudeButton> - </Tooltip> - </Text> - } - actions={ - <Switch - aria-label={t("Show last modified")} - checked={share?.showLastUpdated ?? false} - onChange={handleShowLastModifiedChanged} - width={26} - height={14} - /> - } - /> - <ListItem - title={ - <Text type="tertiary" as={Flex}> - {t("Show table of contents")}  - <Tooltip - content={t( - "Display the table of contents on documents by default" - )} - > - <NudeButton size={18}> - <QuestionMarkIcon size={18} /> - </NudeButton> - </Tooltip> - </Text> - } - actions={ - <Switch - aria-label={t("Show table of contents")} - checked={share?.showTOC ?? false} - onChange={handleShowTOCChanged} - width={26} - height={14} - /> - } - /> - <ShareLinkInput - type="text" - ref={inputRef} - placeholder={share?.id} - onChange={handleUrlChange} - error={validationError} - defaultValue={urlId} - prefix={ - <DomainPrefix onClick={() => inputRef.current?.focus()}> - {env.URL.replace(/https?:\/\//, "") + "/s/"} - </DomainPrefix> - } - > - {copyButton} - </ShareLinkInput> + <Flex align="center" gap={2}> + <ShareLinkInput + type="text" + ref={inputRef} + placeholder={share?.id} + onChange={handleUrlChange} + error={validationError} + defaultValue={urlId} + prefix={ + <DomainPrefix onClick={() => inputRef.current?.focus()}> + {env.URL.replace(/https?:\/\//, "") + "/s/"} + </DomainPrefix> + } + > + {copyButton} + </ShareLinkInput> + <ShareSettingsPopover share={share} /> + </Flex> <Flex align="flex-start" gap={4}> <StyledInfoIcon color={theme.textTertiary} /> <Text type="tertiary" size="xsmall"> diff --git a/app/components/Sharing/Collection/SharePopover.tsx b/app/components/Sharing/Collection/SharePopover.tsx index 836bce93be36..f2dd0aab05c1 100644 --- a/app/components/Sharing/Collection/SharePopover.tsx +++ b/app/components/Sharing/Collection/SharePopover.tsx @@ -18,6 +18,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam"; import useKeyDown from "~/hooks/useKeyDown"; import usePolicy from "~/hooks/usePolicy"; import usePrevious from "~/hooks/usePrevious"; +import useShareDataLoader from "~/hooks/useShareDataLoader"; import useStores from "~/hooks/useStores"; import type { Permission } from "~/types"; import { collectionPath, urlify } from "~/utils/routeHelpers"; @@ -35,11 +36,22 @@ type Props = { onRequestClose: () => void; /** Whether the popover is visible. */ visible: boolean; + /** Whether the share data is currently loading, managed externally. */ + loading?: boolean; }; -function SharePopover({ collection, visible, onRequestClose }: Props) { +function SharePopover({ + collection, + visible, + onRequestClose, + loading: externalLoading, +}: Props) { const team = useCurrentTeam(); const { groupMemberships, users, groups, memberships, shares } = useStores(); + const { preload, loading: internalLoading } = useShareDataLoader({ + collection, + }); + const loading = externalLoading ?? internalLoading; const { t } = useTranslation(); const can = usePolicy(collection); const [query, setQuery] = React.useState(""); @@ -54,6 +66,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) { const share = shares.getByCollectionId(collection.id); const prevPendingIds = usePrevious(pendingIds); + const wrapperRef = React.useRef<HTMLDivElement | null>(null); const suggestionsRef = React.useRef<HTMLDivElement | null>(null); const searchInputRef = React.useRef<HTMLInputElement | null>(null); @@ -77,6 +90,15 @@ function SharePopover({ collection, visible, onRequestClose }: Props) { } ); + // Move focus into the popover to account for lazy-loading + React.useLayoutEffect(() => { + if (!hasRendered) { + return; + } + + (searchInputRef.current ?? wrapperRef.current)?.focus(); + }, [hasRendered]); + // Hide the picker when the popover is closed React.useEffect(() => { if (visible) { @@ -94,10 +116,12 @@ function SharePopover({ collection, visible, onRequestClose }: Props) { React.useEffect(() => { if (visible) { - void collection.share(); + if (externalLoading === undefined) { + preload(); + } setHasRendered(true); } - }, [collection, visible]); + }, [visible, externalLoading, preload]); React.useEffect(() => { if (prevPendingIds && pendingIds.length > prevPendingIds.length) { @@ -337,7 +361,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) { ); return ( - <Wrapper> + <Wrapper ref={wrapperRef} tabIndex={-1}> {can.update && ( <SearchInput ref={searchInputRef} @@ -368,6 +392,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) { share={share} invitedInSession={invitedInSession} visible={visible} + loading={loading} /> </div> </Wrapper> diff --git a/app/components/Sharing/Document/AccessControlList.tsx b/app/components/Sharing/Document/AccessControlList.tsx index 22d66641ea09..84bafd41a82d 100644 --- a/app/components/Sharing/Document/AccessControlList.tsx +++ b/app/components/Sharing/Document/AccessControlList.tsx @@ -4,7 +4,6 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import styled, { useTheme } from "styled-components"; import Squircle from "@shared/components/Squircle"; -import { Pagination } from "@shared/constants"; import { s } from "@shared/styles"; import { CollectionPermission, IconType } from "@shared/types"; import { determineIconType } from "@shared/utils/icon"; @@ -43,6 +42,8 @@ type Props = { onRequestClose: () => void; /** Whether the popover is visible. */ visible: boolean; + /** Whether the share data is currently loading. */ + loading: boolean; }; export const AccessControlList = observer( @@ -53,13 +54,14 @@ export const AccessControlList = observer( sharedParent, onRequestClose, visible, + loading, }: Props) => { const { t } = useTranslation(); const theme = useTheme(); const collection = document.collection; const usersInCollection = useUsersInCollection(collection); const user = useCurrentUser(); - const { userMemberships, groupMemberships } = useStores(); + const { groupMemberships } = useStores(); const collectionSharingDisabled = document.collection?.sharing === false; const team = useCurrentTeam(); const can = usePolicy(document); @@ -75,36 +77,10 @@ export const AccessControlList = observer( margin: 24, }); - const { loading: userMembershipLoading, request: fetchUserMemberships } = - useRequest( - React.useCallback( - () => - userMemberships.fetchDocumentMemberships({ - id: documentId, - limit: Pagination.defaultLimit, - }), - [userMemberships, documentId] - ) - ); - - const { loading: groupMembershipLoading, request: fetchGroupMemberships } = - useRequest( - React.useCallback( - () => groupMemberships.fetchAll({ documentId }), - [groupMemberships, documentId] - ) - ); - const hasMemberships = groupMemberships.inDocument(documentId)?.length > 0 || document.members.length > 0; - const showLoading = - !hasMemberships && (groupMembershipLoading || userMembershipLoading); - - React.useEffect(() => { - void fetchUserMemberships(); - void fetchGroupMemberships(); - }, [fetchUserMemberships, fetchGroupMemberships]); + const showLoading = !hasMemberships && loading; React.useEffect(() => { calcMaxHeight(); diff --git a/app/components/Sharing/Document/PublicAccess.tsx b/app/components/Sharing/Document/PublicAccess.tsx index 72a8e5ff525d..5b5812c157fc 100644 --- a/app/components/Sharing/Document/PublicAccess.tsx +++ b/app/components/Sharing/Document/PublicAccess.tsx @@ -1,7 +1,8 @@ +import copy from "copy-to-clipboard"; import debounce from "lodash/debounce"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; -import { CopyIcon, GlobeIcon, QuestionMarkIcon } from "outline-icons"; +import { CopyIcon, GlobeIcon } from "outline-icons"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -14,6 +15,7 @@ import type Share from "~/models/Share"; import Switch from "~/components/Switch"; import env from "~/env"; import usePolicy from "~/hooks/usePolicy"; +import useStores from "~/hooks/useStores"; import { AvatarSize } from "../../Avatar"; import CopyToClipboard from "../../CopyToClipboard"; import NudeButton from "../../NudeButton"; @@ -21,6 +23,7 @@ import { ResizingHeightContainer } from "../../ResizingHeightContainer"; import Text from "../../Text"; import Tooltip from "../../Tooltip"; import { ListItem } from "../components/ListItem"; +import ShareSettingsPopover from "../components/ShareSettingsPopover"; import { DomainPrefix, ShareLinkInput, @@ -45,68 +48,46 @@ function PublicAccess( ref: React.RefObject<HTMLDivElement> ) { const { t } = useTranslation(); + const { shares } = useStores(); const theme = useTheme(); const [validationError, setValidationError] = React.useState(""); const [urlId, setUrlId] = React.useState(share?.urlId); const inputRef = React.useRef<HTMLInputElement>(null); const can = usePolicy(share); const documentAbilities = usePolicy(document); - const canPublish = can.update && documentAbilities.share; + const canPublish = share ? can.update : documentAbilities.share; + const [creating, setCreating] = React.useState(false); React.useEffect(() => { setUrlId(share?.urlId); }, [share?.urlId]); - const handleIndexingChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - allowIndexing: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - - const handleShowLastModifiedChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - showLastUpdated: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - - const handleShowTOCChanged = React.useCallback( - async (checked: boolean) => { - try { - await share?.save({ - showTOC: checked, - }); - } catch (err) { - toast.error(err.message); - } - }, - [share] - ); - const handlePublishedChange = React.useCallback( async (checked: boolean) => { try { - await share?.save({ - published: checked, - }); + if (checked && !share) { + setCreating(true); + const newShare = await shares.create({ + type: "document", + documentId: document.id, + published: true, + }); + copy(newShare.url); + toast.success(t("Public link copied to clipboard")); + } else if (share) { + await share.save({ published: checked }); + if (checked) { + copy(share.url); + toast.success(t("Public link copied to clipboard")); + } + } } catch (err) { toast.error(err.message); + } finally { + setCreating(false); } }, - [share] + [t, share, shares, document] ); const handleUrlChange = React.useMemo( @@ -162,7 +143,7 @@ function PublicAccess( return ( <div ref={ref}> <ListItem - title={t("Web")} + title={t("Publish to web")} subtitle={ <> {sharedParent && !document.isDraft ? ( @@ -202,7 +183,7 @@ function PublicAccess( aria-label={t("Publish to internet")} checked={share?.published ?? false} onChange={handlePublishedChange} - disabled={!canPublish} + disabled={!canPublish || creating} width={26} height={14} /> @@ -211,106 +192,29 @@ function PublicAccess( /> <ResizingHeightContainer> - {share?.published && !sharedParent?.published && ( - <> - <ListItem - title={ - <Text type="tertiary" as={Flex}> - {t("Search engine indexing")}  - <Tooltip - content={t( - "Disable this setting to discourage search engines from indexing the page" - )} - > - <NudeButton size={18}> - <QuestionMarkIcon size={18} /> - </NudeButton> - </Tooltip> - </Text> - } - actions={ - <Switch - aria-label={t("Search engine indexing")} - checked={share?.allowIndexing ?? false} - onChange={handleIndexingChanged} - width={26} - height={14} - /> - } - /> - <ListItem - title={ - <Text type="tertiary" as={Flex}> - {t("Show last modified")}  - <Tooltip - content={t( - "Display the last modified timestamp on the shared page" - )} - > - <NudeButton size={18}> - <QuestionMarkIcon size={18} /> - </NudeButton> - </Tooltip> - </Text> - } - actions={ - <Switch - aria-label={t("Show last modified")} - checked={share?.showLastUpdated ?? false} - onChange={handleShowLastModifiedChanged} - width={26} - height={14} - /> - } - /> - <ListItem - title={ - <Text type="tertiary" as={Flex}> - {t("Show table of contents")}  - <Tooltip - content={t( - "Display the table of contents on documents by default" - )} - > - <NudeButton size={18}> - <QuestionMarkIcon size={18} /> - </NudeButton> - </Tooltip> - </Text> - } - actions={ - <Switch - aria-label={t("Show table of contents")} - checked={share?.showTOC ?? false} - onChange={handleShowTOCChanged} - width={26} - height={14} - /> - } - /> - </> - )} - {sharedParent?.published && !document.isDraft ? ( <ShareLinkInput type="text" disabled defaultValue={shareUrl}> {copyButton} </ShareLinkInput> ) : share?.published ? ( - <ShareLinkInput - type="text" - ref={inputRef} - placeholder={share?.id} - onChange={handleUrlChange} - error={validationError} - defaultValue={urlId} - prefix={ - <DomainPrefix onClick={() => inputRef.current?.focus()}> - {env.URL.replace(/https?:\/\//, "") + "/s/"} - </DomainPrefix> - } - > - {copyButton} - </ShareLinkInput> + <Flex align="center" gap={2}> + <ShareLinkInput + type="text" + ref={inputRef} + placeholder={share?.id} + onChange={handleUrlChange} + error={validationError} + defaultValue={urlId} + prefix={ + <DomainPrefix onClick={() => inputRef.current?.focus()}> + {env.URL.replace(/https?:\/\//, "") + "/s/"} + </DomainPrefix> + } + > + {copyButton} + </ShareLinkInput> + <ShareSettingsPopover share={share} /> + </Flex> ) : null} {share?.published && !share.includeChildDocuments ? ( diff --git a/app/components/Sharing/Document/SharePopover.tsx b/app/components/Sharing/Document/SharePopover.tsx index 19142c27d23f..7bc885ceecfb 100644 --- a/app/components/Sharing/Document/SharePopover.tsx +++ b/app/components/Sharing/Document/SharePopover.tsx @@ -18,6 +18,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam"; import useKeyDown from "~/hooks/useKeyDown"; import usePolicy from "~/hooks/usePolicy"; import usePrevious from "~/hooks/usePrevious"; +import useShareDataLoader from "~/hooks/useShareDataLoader"; import useStores from "~/hooks/useStores"; import type { Permission } from "~/types"; import { documentPath, urlify } from "~/utils/routeHelpers"; @@ -35,9 +36,16 @@ type Props = { onRequestClose: () => void; /** Whether the popover is visible. */ visible: boolean; + /** Whether the share data is currently loading, managed externally. */ + loading?: boolean; }; -function SharePopover({ document, onRequestClose, visible }: Props) { +function SharePopover({ + document, + onRequestClose, + visible, + loading: externalLoading, +}: Props) { const team = useCurrentTeam(); const { t } = useTranslation(); const can = usePolicy(document); @@ -46,6 +54,10 @@ function SharePopover({ document, onRequestClose, visible }: Props) { const sharedParent = shares.getByDocumentParents(document); const [hasRendered, setHasRendered] = React.useState(visible); const { users, userMemberships, groups, groupMemberships } = useStores(); + const { preload, loading: internalLoading } = useShareDataLoader({ + document, + }); + const loading = externalLoading ?? internalLoading; const [query, setQuery] = React.useState(""); const [picker, showPicker, hidePicker] = useBoolean(); const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]); @@ -56,6 +68,7 @@ function SharePopover({ document, onRequestClose, visible }: Props) { const prevPendingIds = usePrevious(pendingIds); + const wrapperRef = React.useRef<HTMLDivElement | null>(null); const suggestionsRef = React.useRef<HTMLDivElement | null>(null); const searchInputRef = React.useRef<HTMLInputElement | null>(null); @@ -79,13 +92,23 @@ function SharePopover({ document, onRequestClose, visible }: Props) { } ); - // Fetch sharefocus the link button when the popover is opened + // Move focus into the popover to account for lazy-loading + React.useLayoutEffect(() => { + if (!hasRendered) { + return; + } + + (searchInputRef.current ?? wrapperRef.current)?.focus(); + }, [hasRendered]); + React.useEffect(() => { if (visible) { - void document.share(); + if (externalLoading === undefined) { + preload(); + } setHasRendered(true); } - }, [document, hidePicker, visible]); + }, [visible, externalLoading, preload]); // Hide the picker when the popover is closed React.useEffect(() => { @@ -345,7 +368,7 @@ function SharePopover({ document, onRequestClose, visible }: Props) { ); return ( - <Wrapper> + <Wrapper ref={wrapperRef} tabIndex={-1}> {can.manageUsers && ( <SearchInput ref={searchInputRef} @@ -377,6 +400,7 @@ function SharePopover({ document, onRequestClose, visible }: Props) { share={share} sharedParent={sharedParent} visible={visible} + loading={loading} onRequestClose={onRequestClose} /> </div> diff --git a/app/components/Sharing/components/Actions.tsx b/app/components/Sharing/components/Actions.tsx index 0e82ae2d6fda..e5e0df51f58d 100644 --- a/app/components/Sharing/components/Actions.tsx +++ b/app/components/Sharing/components/Actions.tsx @@ -1,9 +1,16 @@ import { observer } from "mobx-react"; -import { MoonIcon, SunIcon } from "outline-icons"; +import { MoonIcon, SunIcon, SubscribeIcon } from "outline-icons"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Action } from "~/components/Actions"; import Button from "~/components/Button"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "~/components/primitives/Popover"; import Tooltip from "~/components/Tooltip"; +import { ShareSubscribeForm } from "./ShareSubscribeForm"; import useStores from "~/hooks/useStores"; import { Theme } from "~/stores/UiStore"; @@ -42,3 +49,34 @@ export const AppearanceAction = observer(() => { </Action> ); }); + +export function SubscribeAction({ + shareId, + documentId, +}: { + shareId: string; + documentId?: string; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( + <Action> + <Popover open={open} onOpenChange={setOpen}> + <Tooltip content={t("Subscribe to updates")} placement="bottom"> + <PopoverTrigger> + <Button + icon={<SubscribeIcon />} + aria-label={t("Subscribe to updates")} + neutral + borderOnHover + /> + </PopoverTrigger> + </Tooltip> + <PopoverContent side="bottom" align="end" width={340}> + <ShareSubscribeForm shareId={shareId} documentId={documentId} /> + </PopoverContent> + </Popover> + </Action> + ); +} diff --git a/app/components/Sharing/components/HeaderBranding.tsx b/app/components/Sharing/components/HeaderBranding.tsx new file mode 100644 index 000000000000..958c8fb06e0a --- /dev/null +++ b/app/components/Sharing/components/HeaderBranding.tsx @@ -0,0 +1,52 @@ +import { observer } from "mobx-react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import { ellipsis, s } from "@shared/styles"; +import { AvatarSize } from "~/components/Avatar"; +import Flex from "~/components/Flex"; +import TeamLogo from "~/components/TeamLogo"; +import useShareBranding from "~/hooks/useShareBranding"; +import type Share from "~/models/Share"; + +type Props = { + share: Share; +}; + +/** + * Renders the team or share-customized branding (logo + name) for shared + * documents that do not have a sidebar. + */ +function HeaderBranding({ share }: Props) { + const { t } = useTranslation(); + const { displayName, displayLogoUrl, displayLogoModel, brandingAvailable } = + useShareBranding(share); + + if (!brandingAvailable) { + return null; + } + + return ( + <Wrapper align="center" gap={8}> + <TeamLogo + model={displayLogoModel} + src={displayLogoUrl ?? undefined} + size={AvatarSize.Large} + alt={t("Logo")} + /> + {displayName && <Name>{displayName}</Name>} + </Wrapper> + ); +} + +const Wrapper = styled(Flex)` + min-width: 0; + color: ${s("text")}; +`; + +const Name = styled.span` + ${ellipsis()} + font-size: 15px; + font-weight: 500; +`; + +export default observer(HeaderBranding); diff --git a/app/components/Sharing/components/ShareSettingsPopover.tsx b/app/components/Sharing/components/ShareSettingsPopover.tsx new file mode 100644 index 000000000000..6cf32c8ed58d --- /dev/null +++ b/app/components/Sharing/components/ShareSettingsPopover.tsx @@ -0,0 +1,428 @@ +import debounce from "lodash/debounce"; +import uniqueId from "lodash/uniqueId"; +import { observer } from "mobx-react"; +import { + ImageIcon, + QuestionMarkIcon, + SettingsIcon, + TrashIcon, +} from "outline-icons"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import styled, { useTheme } from "styled-components"; +import { s } from "@shared/styles"; +import { HStack } from "~/components/primitives/HStack"; +import { AttachmentPreset } from "@shared/types"; +import { AttachmentValidation } from "@shared/validations"; +import type Share from "~/models/Share"; +import { createAction } from "~/actions"; +import { ShareSection } from "~/actions/sections"; +import { AvatarSize } from "~/components/Avatar"; +import Input from "~/components/Input"; +import { DropdownMenu } from "~/components/Menu/DropdownMenu"; +import NudeButton from "~/components/NudeButton"; +import Switch from "~/components/Switch"; +import TeamLogo from "~/components/TeamLogo"; +import Text from "~/components/Text"; +import Tooltip from "~/components/Tooltip"; +import env from "~/env"; +import { useMenuAction } from "~/hooks/useMenuAction"; +import useStores from "~/hooks/useStores"; +import { compressImage } from "~/utils/compressImage"; +import { uploadFile } from "~/utils/files"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "~/components/primitives/Popover"; +import { ListItem } from "./ListItem"; + +type Props = { + /** The share model to configure settings for. */ + share: Share; + /** Custom trigger element. If not provided, a default settings icon button is rendered. */ + children?: React.ReactElement; +}; + +/** + * A popover triggered by a settings icon that contains toggle options + * for configuring a published share link (indexing, subscriptions, etc.), + * as well as custom title and logo branding. + */ +function ShareSettingsPopover({ share, children }: Props) { + const { t } = useTranslation(); + const { auth } = useStores(); + const theme = useTheme(); + const fileInputRef = React.useRef<HTMLInputElement>(null); + const hasChangesRef = React.useRef(false); + const [isUploading, setIsUploading] = React.useState(false); + const idPrefix = React.useMemo(() => uniqueId("share-settings-"), []); + const showLastUpdatedId = `${idPrefix}-show-last-updated`; + const showTOCId = `${idPrefix}-show-toc`; + const indexingId = `${idPrefix}-indexing`; + const subscriptionsId = `${idPrefix}-subscriptions`; + + const handleTitleChange = React.useMemo( + () => + debounce(async (ev: React.ChangeEvent<HTMLInputElement>) => { + const val = ev.target.value; + try { + await share.save({ title: val || null }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } + }, 500), + [share] + ); + + const triggerUpload = React.useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleLogoUpload = React.useCallback( + async (ev: React.ChangeEvent<HTMLInputElement>) => { + const file = ev.target.files?.[0]; + if (!file) { + return; + } + + setIsUploading(true); + try { + const compressed = await compressImage(file, { + maxHeight: 512, + maxWidth: 512, + }); + const attachment = await uploadFile(compressed, { + name: file.name, + preset: AttachmentPreset.Avatar, + }); + await share.save({ iconUrl: attachment.url }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } finally { + setIsUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }, + [share] + ); + + const handleLogoRemove = React.useCallback(async () => { + try { + await share.save({ iconUrl: null }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } + }, [share]); + + const handleIndexingChanged = React.useCallback( + async (checked: boolean) => { + try { + await share.save({ allowIndexing: checked }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } + }, + [share] + ); + + const handleSubscriptionsChanged = React.useCallback( + async (checked: boolean) => { + try { + await share.save({ allowSubscriptions: checked }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } + }, + [share] + ); + + const handleShowLastModifiedChanged = React.useCallback( + async (checked: boolean) => { + try { + await share.save({ showLastUpdated: checked }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } + }, + [share] + ); + + const handleShowTOCChanged = React.useCallback( + async (checked: boolean) => { + try { + await share.save({ showTOC: checked }); + hasChangesRef.current = true; + } catch (err) { + toast.error(err.message); + } + }, + [share] + ); + + const flushChangeToast = React.useCallback(() => { + if (hasChangesRef.current) { + toast.success(t("Sharing settings updated")); + hasChangesRef.current = false; + } + }, [t]); + + const handleOpenChange = React.useCallback( + (open: boolean) => { + if (!open) { + flushChangeToast(); + } + }, + [flushChangeToast] + ); + + // Also flush on unmount in case the parent popover closes us before + // onOpenChange fires. + React.useEffect( + () => () => { + flushChangeToast(); + }, + [flushChangeToast] + ); + + const iconActions = React.useMemo( + () => [ + createAction({ + name: ({ t: translate }) => translate("Upload image"), + analyticsName: "Upload share icon", + section: ShareSection, + icon: <ImageIcon />, + perform: triggerUpload, + }), + createAction({ + name: ({ t: translate }) => translate("Remove image"), + analyticsName: "Remove share icon", + section: ShareSection, + icon: <TrashIcon />, + dangerous: true, + perform: handleLogoRemove, + }), + ], + [triggerUpload, handleLogoRemove] + ); + const iconRootAction = useMenuAction(iconActions); + + return ( + <Popover modal onOpenChange={handleOpenChange}> + <Tooltip content={t("Display settings")} placement="top"> + <PopoverTrigger> + {children ?? ( + <SettingsTrigger type="button"> + <SettingsIcon color={theme.placeholder} size={24} /> + </SettingsTrigger> + )} + </PopoverTrigger> + </Tooltip> + <PopoverContent + side="bottom" + align="end" + minWidth={400} + style={{ paddingTop: 20, paddingBottom: 20 }} + > + <Text as="h3" weight="bold"> + {t("Display settings")} + </Text> + <Text as="p" size="small" type="secondary"> + {t("Customize how the published document is displayed")} + </Text> + <input + ref={fileInputRef} + type="file" + accept={AttachmentValidation.avatarContentTypes.join(",")} + onChange={handleLogoUpload} + style={{ display: "none" }} + /> + <HStack spacing={8} style={{ marginBottom: 8 }}> + {share.iconUrl ? ( + <DropdownMenu + action={iconRootAction} + align="start" + ariaLabel={t("Image options")} + > + <LogoButton type="button" disabled={isUploading}> + <TeamLogo + src={share.iconUrl} + size={AvatarSize.Large} + alt={t("Icon")} + /> + </LogoButton> + </DropdownMenu> + ) : ( + <LogoButton + type="button" + onClick={triggerUpload} + disabled={isUploading} + aria-label={t("Upload")} + > + <TeamLogo + model={auth.team ?? undefined} + size={AvatarSize.Large} + alt={t("Icon")} + /> + </LogoButton> + )} + <Input + type="text" + label={t("Site title")} + labelHidden + placeholder={auth.team?.name ?? ""} + defaultValue={share.title ?? ""} + onChange={handleTitleChange} + margin={0} + flex + /> + </HStack> + <ListItem + title={ + <SwitchLabel htmlFor={showLastUpdatedId}> + {t("Show last modified")}  + <Tooltip + content={t( + "Display the last modified timestamp on the shared page" + )} + > + <NudeButton size={18}> + <QuestionMarkIcon size={18} /> + </NudeButton> + </Tooltip> + </SwitchLabel> + } + actions={ + <Switch + id={showLastUpdatedId} + checked={share.showLastUpdated ?? false} + onChange={handleShowLastModifiedChanged} + width={26} + height={14} + /> + } + /> + <ListItem + title={ + <SwitchLabel htmlFor={showTOCId}> + {t("Show table of contents")}  + <Tooltip + content={t( + "Display the table of contents on documents by default" + )} + > + <NudeButton size={18}> + <QuestionMarkIcon size={18} /> + </NudeButton> + </Tooltip> + </SwitchLabel> + } + actions={ + <Switch + id={showTOCId} + checked={share.showTOC ?? false} + onChange={handleShowTOCChanged} + width={26} + height={14} + /> + } + /> + <Text as="h3" weight="bold" style={{ marginTop: 16 }}> + {t("Behavior")} + </Text> + <ListItem + title={ + <SwitchLabel htmlFor={indexingId}> + {t("Search engine indexing")}  + <Tooltip + content={t( + "Disable this setting to discourage search engines from indexing the page" + )} + > + <NudeButton size={18}> + <QuestionMarkIcon size={18} /> + </NudeButton> + </Tooltip> + </SwitchLabel> + } + actions={ + <Switch + id={indexingId} + checked={share.allowIndexing ?? false} + onChange={handleIndexingChanged} + width={26} + height={14} + /> + } + /> + {env.EMAIL_ENABLED && ( + <ListItem + title={ + <SwitchLabel htmlFor={subscriptionsId}> + {t("Email subscriptions")}  + <Tooltip + content={t( + "Allow viewers to subscribe and receive email notifications when documents are updated" + )} + > + <NudeButton size={18}> + <QuestionMarkIcon size={18} /> + </NudeButton> + </Tooltip> + </SwitchLabel> + } + actions={ + <Switch + id={subscriptionsId} + checked={share.allowSubscriptions ?? true} + onChange={handleSubscriptionsChanged} + width={26} + height={14} + /> + } + /> + )} + </PopoverContent> + </Popover> + ); +} + +const SwitchLabel = styled.label` + display: flex; + align-items: center; + color: ${s("textSecondary")}; + cursor: var(--pointer); +`; + +const SettingsTrigger = styled(NudeButton)` + width: 32px; + height: 32px; + flex-shrink: 0; + position: relative; + top: -2px; + right: -4px; +`; + +const LogoButton = styled.button` + background: none; + border: 0; + padding: 0; + cursor: var(--pointer); + flex-shrink: 0; + + &:disabled { + opacity: 0.5; + cursor: default; + } +`; + +export default observer(ShareSettingsPopover); diff --git a/app/components/Sharing/components/ShareSubscribeForm.tsx b/app/components/Sharing/components/ShareSubscribeForm.tsx new file mode 100644 index 000000000000..860d3d134b68 --- /dev/null +++ b/app/components/Sharing/components/ShareSubscribeForm.tsx @@ -0,0 +1,107 @@ +import type { FormEvent, ChangeEvent } from "react"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import { s } from "@shared/styles"; +import Button from "~/components/Button"; +import Flex from "~/components/Flex"; +import Input from "~/components/Input"; +import Text from "~/components/Text"; +import { client } from "~/utils/ApiClient"; + +/** + * Subscribe form content displayed inside the popover. + */ +export function ShareSubscribeForm({ + shareId, + documentId, +}: { + shareId: string; + documentId?: string; +}) { + const { t } = useTranslation(); + const [email, setEmail] = useState(""); + const [status, setStatus] = useState< + "idle" | "loading" | "success" | "error" + >("idle"); + const [errorMessage, setErrorMessage] = useState(""); + + const handleSubmit = useCallback( + async (ev: FormEvent) => { + ev.preventDefault(); + setStatus("loading"); + try { + await client.post("/shares.subscribe", { shareId, documentId, email }); + setStatus("success"); + } catch (err) { + setErrorMessage( + err instanceof Error ? err.message : t("Something went wrong") + ); + setStatus("error"); + } + }, + [shareId, documentId, email, t] + ); + + const handleChange = useCallback( + (ev: ChangeEvent<HTMLInputElement>) => { + setEmail(ev.target.value); + if (status === "error") { + setErrorMessage(""); + setStatus("idle"); + } + }, + [status] + ); + + if (status === "success") { + return ( + <FormContainer> + <Text type="tertiary" size="small"> + {t("Check your email to confirm your subscription")}. + </Text> + </FormContainer> + ); + } + + return ( + <FormContainer> + <StyledForm onSubmit={handleSubmit}> + <Text as="label" type="tertiary" size="small"> + {t("Get notified when this document is updated")} + </Text> + <Flex align="center" gap={8}> + <Input + type="email" + value={email} + onChange={handleChange} + placeholder={t("Email address")} + required + margin={0} + flex + /> + <Button type="submit" disabled={status === "loading"} neutral> + {t("Subscribe")} + </Button> + </Flex> + {status === "error" && <ErrorText>{errorMessage}</ErrorText>} + </StyledForm> + </FormContainer> + ); +} + +const FormContainer = styled.div` + padding: 4px 0; +`; + +const StyledForm = styled.form` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const ErrorText = styled.p` + font-size: 13px; + color: ${s("danger")}; + margin: 0; +`; diff --git a/app/components/Sharing/components/Suggestions.tsx b/app/components/Sharing/components/Suggestions.tsx index 59b04150545a..4f2b9a6b23d2 100644 --- a/app/components/Sharing/components/Suggestions.tsx +++ b/app/components/Sharing/components/Suggestions.tsx @@ -14,6 +14,7 @@ import type User from "~/models/User"; import ArrowKeyNavigation from "~/components/ArrowKeyNavigation"; import type { IAvatar } from "~/components/Avatar"; import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar"; +import ButtonLink from "~/components/ButtonLink"; import Empty from "~/components/Empty"; import Placeholder from "~/components/List/Placeholder"; import Scrollable from "~/components/Scrollable"; @@ -21,6 +22,7 @@ import useCurrentUser from "~/hooks/useCurrentUser"; import useMaxHeight from "~/hooks/useMaxHeight"; import useStores from "~/hooks/useStores"; import useThrottledCallback from "~/hooks/useThrottledCallback"; +import { GroupMembersPopover } from "./GroupMembersPopover"; import { InviteIcon, ListItem } from "./ListItem"; type Suggestion = IAvatar & { @@ -141,16 +143,25 @@ export const Suggestions = observer( ); React.useEffect(() => { - void fetchUsersByQuery(query); + fetchUsersByQuery(query); }, [query, fetchUsersByQuery]); function getListItemProps(suggestion: User | Group) { if (suggestion instanceof Group) { return { title: suggestion.name, - subtitle: t("{{ count }} member", { - count: suggestion.memberCount, - }), + subtitle: ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events + <span onClick={(ev) => ev.stopPropagation()}> + <GroupMembersPopover group={suggestion}> + <StyledButtonLink> + {t("{{ count }} member", { + count: suggestion.memberCount, + })} + </StyledButtonLink> + </GroupMembersPopover> + </span> + ), image: <GroupAvatar group={suggestion} />, }; } @@ -268,6 +279,13 @@ const Separator = styled.div` margin: 12px 0; `; +const StyledButtonLink = styled(ButtonLink)` + color: ${s("textTertiary")}; + &:hover { + text-decoration: underline; + } +`; + const ScrollableContainer = styled(Scrollable)` padding: 12px 24px; margin: -12px -24px; diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx index c107114d1e62..a2c1e139aca1 100644 --- a/app/components/Sidebar/App.tsx +++ b/app/components/Sidebar/App.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useMemo } from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; import styled from "styled-components"; import { metaDisplay } from "@shared/utils/keyboard"; import Scrollable from "~/components/Scrollable"; @@ -30,6 +31,7 @@ import SidebarLink from "./components/SidebarLink"; import Starred from "./components/Starred"; import ToggleButton from "./components/ToggleButton"; import TrashLink from "./components/TrashLink"; +import useMobile from "~/hooks/useMobile"; function AppSidebar() { const { t } = useTranslation(); @@ -37,6 +39,16 @@ function AppSidebar() { const team = useCurrentTeam(); const user = useCurrentUser(); const can = usePolicy(team); + const history = useHistory(); + const isMobile = useMobile(); + + const handleSearchClick = useCallback(() => { + const basePath = searchPath(); + const { pathname, search } = history.location; + if (pathname.startsWith(basePath) && (search || pathname !== basePath)) { + history.push(basePath); + } + }, [history]); useEffect(() => { void collections.fetchAll(); @@ -57,7 +69,6 @@ function AppSidebar() { return ( <Sidebar hidden={!ui.readyToShow} ref={handleSidebarRef}> - <HistoryNavigation /> {dndArea && ( <DndProvider backend={HTML5Backend} options={html5Options}> <DragPlaceholder /> @@ -65,33 +76,29 @@ function AppSidebar() { <TeamMenu> <SidebarButton title={team.name} - image={ - <TeamLogo - model={team} - size={24} - alt={t("Logo")} - style={{ marginLeft: 4 }} - /> - } + image={<TeamLogo model={team} size={24} alt={t("Logo")} />} > - <Tooltip - content={t("Toggle sidebar")} - shortcut={`${metaDisplay}+.`} - > - <ToggleButton - position="bottom" - image={<SidebarIcon />} - aria-label={ - ui.sidebarCollapsed - ? t("Expand sidebar") - : t("Collapse sidebar") - } - onClick={() => { - ui.toggleCollapsedSidebar(); - (document.activeElement as HTMLElement)?.blur(); - }} - /> - </Tooltip> + {isMobile ? null : ( + <Tooltip + content={t("Toggle sidebar")} + shortcut={`${metaDisplay}+.`} + > + <ToggleButton + position="bottom" + image={<SidebarIcon />} + aria-label={ + ui.sidebarCollapsed + ? t("Expand sidebar") + : t("Collapse sidebar") + } + style={{ paddingInline: 4 }} + onClick={() => { + ui.toggleCollapsedSidebar(); + (document.activeElement as HTMLElement)?.blur(); + }} + /> + </Tooltip> + )} </SidebarButton> </TeamMenu> <Overflow> @@ -107,6 +114,7 @@ function AppSidebar() { icon={<SearchIcon />} label={t("Search")} exact={false} + onClick={handleSearchClick} /> {can.createDocument && <DraftsLink />} </Section> @@ -133,6 +141,7 @@ function AppSidebar() { </Scrollable> </DndProvider> )} + <HistoryNavigation /> </Sidebar> ); } diff --git a/app/components/Sidebar/Right.tsx b/app/components/Sidebar/Aside.tsx similarity index 84% rename from app/components/Sidebar/Right.tsx rename to app/components/Sidebar/Aside.tsx index 59df8e18e10f..d3aa0310f6ef 100644 --- a/app/components/Sidebar/Right.tsx +++ b/app/components/Sidebar/Aside.tsx @@ -10,6 +10,7 @@ import ResizeBorder from "~/components/Sidebar/components/ResizeBorder"; import useStores from "~/hooks/useStores"; import useWindowScrollbarWidth from "~/hooks/useWindowScrollbarWidth"; import { sidebarAppearDuration } from "~/styles/animations"; +import { useDirection } from "@radix-ui/react-direction"; interface Props extends React.HTMLAttributes<HTMLDivElement> { children: React.ReactNode; @@ -18,25 +19,25 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> { skipInitialAnimation?: boolean; } -function Right({ children, border, className, skipInitialAnimation }: Props) { +function Aside({ children, border, className, skipInitialAnimation }: Props) { const theme = useTheme(); const { ui } = useStores(); const [isResizing, setResizing] = React.useState(false); const maxWidth = theme.sidebarMaxWidth; const minWidth = theme.sidebarMinWidth + 16; // padding const windowScrollbarWidth = useWindowScrollbarWidth(); + const direction = useDirection(); const handleDrag = React.useCallback( (event: MouseEvent) => { // suppresses text selection event.preventDefault(); - const width = Math.max( - Math.min(window.innerWidth - event.pageX, maxWidth), - minWidth - ); + const distance = + direction === "rtl" ? event.pageX : window.innerWidth - event.pageX; + const width = Math.max(Math.min(distance, maxWidth), minWidth); ui.set({ sidebarRightWidth: width }); }, - [minWidth, maxWidth, ui] + [minWidth, maxWidth, direction, ui] ); const handleReset = React.useCallback(() => { @@ -103,7 +104,13 @@ function Right({ children, border, className, skipInitialAnimation }: Props) { }; return ( - <Sidebar {...animationProps} $border={border} className={className}> + <Sidebar + {...animationProps} + $border={border} + className={className} + role="complementary" + aria-label="Aside" + > <Position style={style} column> <ErrorBoundary>{children}</ErrorBoundary> <ResizeBorder @@ -130,15 +137,15 @@ const Sidebar = styled(m.div)<{ flex-shrink: 0; background: ${s("background")}; max-width: 80%; - border-left: 1px solid ${s("divider")}; - transition: border-left 100ms ease-in-out; + border-inline-start: 1px solid ${s("divider")}; + transition: border-inline-start 100ms ease-in-out; z-index: ${depths.sidebar}; ${breakpoint("mobile", "tablet")` display: flex; position: absolute; top: 0; - right: 0; + inset-inline-end: 0; bottom: 0; z-index: ${depths.mobileSidebar}; `} @@ -148,4 +155,4 @@ const Sidebar = styled(m.div)<{ `} `; -export default observer(Right); +export default observer(Aside); diff --git a/app/components/Sidebar/Settings.tsx b/app/components/Sidebar/Settings.tsx index 129492f3ff88..7c87af556c19 100644 --- a/app/components/Sidebar/Settings.tsx +++ b/app/components/Sidebar/Settings.tsx @@ -21,6 +21,7 @@ import SidebarButton from "./components/SidebarButton"; import SidebarLink from "./components/SidebarLink"; import ToggleButton from "./components/ToggleButton"; import Version from "./components/Version"; +import useMobile from "~/hooks/useMobile"; function SettingsSidebar() { const { ui, integrations } = useStores(); @@ -28,10 +29,11 @@ function SettingsSidebar() { const history = useHistory(); const location = useLocation(); const configs = useSettingsConfig(); + const isMobile = useMobile(); const groupedConfig = groupBy( configs.filter((item) => - item.group === "Integrations" && item.pluginId + item.group === t("Integrations") && item.pluginId ? integrations.findByService(item.pluginId) : true ), @@ -44,25 +46,29 @@ function SettingsSidebar() { return ( <Sidebar> - <HistoryNavigation /> <SidebarButton title={t("Return to App")} image={<StyledBackIcon />} onClick={returnToApp} > - <Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}> - <ToggleButton - aria-label={ - ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar") - } - position="bottom" - image={<SidebarIcon />} - onClick={() => { - ui.toggleCollapsedSidebar(); - (document.activeElement as HTMLElement)?.blur(); - }} - /> - </Tooltip> + {isMobile ? null : ( + <Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}> + <ToggleButton + aria-label={ + ui.sidebarCollapsed + ? t("Expand sidebar") + : t("Collapse sidebar") + } + position="bottom" + image={<SidebarIcon />} + style={{ paddingInline: 4 }} + onClick={() => { + ui.toggleCollapsedSidebar(); + (document.activeElement as HTMLElement)?.blur(); + }} + /> + </Tooltip> + )} </SidebarButton> <Flex auto column> @@ -96,12 +102,17 @@ function SettingsSidebar() { )} </Scrollable> </Flex> + <HistoryNavigation /> </Sidebar> ); } const StyledBackIcon = styled(BackIcon)` - margin-left: 4px; + margin-inline-start: 4px; + + [dir="rtl"] & { + transform: rotate(180deg); + } `; export default observer(SettingsSidebar); diff --git a/app/components/Sidebar/Shared.tsx b/app/components/Sidebar/Shared.tsx index 84e8165ffc56..6fa7f0083ac3 100644 --- a/app/components/Sidebar/Shared.tsx +++ b/app/components/Sidebar/Shared.tsx @@ -1,36 +1,43 @@ +import { useKBar } from "kbar"; import { observer } from "mobx-react"; +import { SearchIcon } from "outline-icons"; +import { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; +import { s } from "@shared/styles"; +import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; +import { metaDisplay, shortcutSeparator } from "@shared/utils/keyboard"; import type Share from "~/models/Share"; import Flex from "~/components/Flex"; import Scrollable from "~/components/Scrollable"; -import SearchPopover from "~/components/SearchPopover"; import useCurrentUser from "~/hooks/useCurrentUser"; +import useShareBranding from "~/hooks/useShareBranding"; import useStores from "~/hooks/useStores"; import history from "~/utils/history"; import { homePath, sharedModelPath } from "~/utils/routeHelpers"; import { AvatarSize } from "../Avatar"; -import { useTeamContext } from "../TeamContext"; import TeamLogo from "../TeamLogo"; import Sidebar from "./Sidebar"; +import SidebarExpansionContext, { + useSidebarExpansionState, +} from "./components/SidebarExpansionContext"; import Section from "./components/Section"; import { SharedCollectionLink } from "./components/SharedCollectionLink"; import { SharedDocumentLink } from "./components/SharedDocumentLink"; import SidebarButton from "./components/SidebarButton"; -import { useEffect } from "react"; -import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; type Props = { share: Share; }; function SharedSidebar({ share }: Props) { - const team = useTeamContext(); const user = useCurrentUser({ rejectOnEmpty: false }); const { ui, documents, collections } = useStores(); const { t } = useTranslation(); + const { query } = useKBar(); - const teamAvailable = !!team?.name; + const { displayName, displayLogoUrl, displayLogoModel, brandingAvailable } = + useShareBranding(share); const rootNode = share.tree; const shareId = share.urlId || share.id; const collection = collections.get(rootNode?.id); @@ -38,6 +45,16 @@ function SharedSidebar({ share }: Props) { ? ProsemirrorHelper.isEmptyData(collection?.data) : false; + const handleOpenSearch = useCallback(() => { + query.toggle(); + }, [query]); + + const rootChildren = useMemo( + () => (rootNode ? [rootNode] : undefined), + [rootNode] + ); + const expansion = useSidebarExpansionState(rootChildren, ui.activeDocumentId); + useEffect(() => { ui.tocVisible = share.showTOC; }, []); @@ -48,11 +65,16 @@ function SharedSidebar({ share }: Props) { return ( <Sidebar canCollapse={false}> - {teamAvailable && ( + {brandingAvailable && ( <SidebarButton - title={team.name} + title={displayName} image={ - <TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} /> + <TeamLogo + model={displayLogoModel} + src={displayLogoUrl ?? undefined} + size={AvatarSize.XLarge} + alt={t("Logo")} + /> } disabled={hideRootNode} onClick={ @@ -64,29 +86,36 @@ function SharedSidebar({ share }: Props) { )} <ScrollContainer topShadow flex> <TopSection> - <SearchWrapper> - <StyledSearchPopover shareId={shareId} /> - </SearchWrapper> + <SearchButton onClick={handleOpenSearch}> + <SearchIcon size={20} /> + <SearchLabel>{t("Search")}</SearchLabel> + <Shortcut> + {metaDisplay} + {shortcutSeparator}K + </Shortcut> + </SearchButton> </TopSection> - <Section> - {share.collectionId ? ( - <SharedCollectionLink - node={rootNode} - shareId={shareId} - hideRootNode={hideRootNode} - /> - ) : ( - <SharedDocumentLink - index={0} - // If the root node has an icon we need some extra space for it - depth={rootNode.icon ? 1 : 0} - shareId={shareId} - node={rootNode} - prefetchDocument={documents.prefetchDocument} - activeDocumentId={ui.activeDocumentId} - activeDocument={documents.active} - /> - )} + <Section as="nav" aria-label={t("Documents")}> + <SidebarExpansionContext.Provider value={expansion}> + {share.collectionId ? ( + <SharedCollectionLink + node={rootNode} + shareId={shareId} + hideRootNode={hideRootNode} + /> + ) : ( + <SharedDocumentLink + index={0} + // If the root node has an icon we need some extra space for it + depth={rootNode.icon ? 1 : 0} + shareId={shareId} + node={rootNode} + prefetchDocument={documents.prefetchDocument} + activeDocumentId={ui.activeDocumentId} + activeDocument={documents.active} + /> + )} + </SidebarExpansionContext.Provider> </Section> </ScrollContainer> </Sidebar> @@ -102,14 +131,34 @@ const TopSection = styled(Flex)` flex-shrink: 0; `; -const SearchWrapper = styled.div` +const SearchButton = styled.button` + display: flex; + align-items: center; + gap: 8px; width: 100%; + padding: 6px 12px; + margin: 8px 0; + border: 1px solid ${s("inputBorder")}; + border-radius: 16px; + background: ${s("background")}; + color: ${s("textTertiary")}; + cursor: var(--pointer); + font-size: 14px; + + &:hover { + border-color: ${s("inputBorderFocused")}; + color: ${s("textSecondary")}; + } `; -const StyledSearchPopover = styled(SearchPopover)` - width: 100%; - transition: width 100ms ease-out; - margin: 8px 0; +const SearchLabel = styled.span` + flex-grow: 1; + text-align: start; +`; + +const Shortcut = styled.span` + flex-shrink: 0; + font-size: 13px; `; export default observer(SharedSidebar); diff --git a/app/components/Sidebar/Sidebar.tsx b/app/components/Sidebar/Sidebar.tsx index c9a942611814..125962bee71d 100644 --- a/app/components/Sidebar/Sidebar.tsx +++ b/app/components/Sidebar/Sidebar.tsx @@ -22,8 +22,7 @@ import ResizeBorder from "./components/ResizeBorder"; import SidebarButton from "./components/SidebarButton"; import ToggleButton from "./components/ToggleButton"; import { useTranslation } from "react-i18next"; -import useKeyDown from "~/hooks/useKeyDown"; -import { isModKey } from "@shared/utils/keyboard"; +import { useDirection } from "@radix-ui/react-direction"; const ANIMATION_MS = 250; @@ -55,6 +54,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_( const maxWidth = theme.sidebarMaxWidth; const minWidth = theme.sidebarMinWidth + 16; // padding const { trigger } = useWebHaptics(); + const direction = useDirection(); const [offset, setOffset] = React.useState(0); const [isHovering, setHovering] = React.useState(false); @@ -64,18 +64,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_( const isSmallerThanMinimum = width < minWidth; const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null); - useKeyDown(".", (event) => { - if (isModKey(event)) { - ui.toggleCollapsedSidebar(); - } - }); - const handleDrag = React.useCallback( (event: MouseEvent) => { // suppresses text selection event.preventDefault(); - // this is simple because the sidebar is always against the left edge - const newWidth = Math.min(event.pageX - offset, maxWidth); + const rawWidth = + direction === "rtl" ? offset - event.pageX : event.pageX - offset; + const newWidth = Math.min(rawWidth, maxWidth); const isSmallerThanCollapsePoint = newWidth < minWidth / 2; if (canCollapse) { @@ -88,7 +83,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_( ui.set({ sidebarWidth: Math.max(newWidth, minWidth) }); } }, - [ui, theme, offset, minWidth, maxWidth] + [ui, theme, offset, minWidth, maxWidth, direction, canCollapse] ); const handleStopDrag = React.useCallback(() => { @@ -112,7 +107,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_( } else { ui.set({ sidebarWidth: width }); } - }, [ui, isSmallerThanMinimum, minWidth, width]); + }, [ui, isSmallerThanMinimum, minWidth, width, canCollapse]); const handleBlur = React.useCallback(() => { setHovering(false); @@ -125,11 +120,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_( return; } - setOffset(event.pageX - width); + setOffset( + direction === "rtl" ? event.pageX + width : event.pageX - width + ); setResizing(true); setAnimating(false); }, - [width] + [width, direction] ); const handlePointerActivity = React.useCallback(() => { @@ -153,16 +150,21 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_( // add a short delay when mouse exits the sidebar before closing hoverTimeoutRef.current = setTimeout(() => { + const withinSidebar = + direction === "rtl" + ? ev.pageX > window.innerWidth - width + : ev.pageX < width; + setHovering( document.hasFocus() && - ev.pageX < width && + withinSidebar && ev.pageY < window.innerHeight && ev.pageY > 0 ); }, 500); } }, - [width, hasPointerMoved] + [width, direction, hasPointerMoved] ); React.useEffect(() => { @@ -227,7 +229,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_( ); const handleCloseSidebar = () => { - trigger("light"); + void trigger("light"); ui.toggleMobileSidebar(); }; @@ -263,7 +265,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_( alt={t("Avatar of {{ name }}", { name: user.name })} model={user} size={24} - style={{ marginLeft: 4 }} /> } > @@ -272,6 +273,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_( position="bottom" image={<NotificationIcon />} aria-label={t("Notifications")} + style={{ paddingInline: 4 }} /> </NotificationsPopover> </SidebarButton> @@ -310,7 +312,7 @@ type ContainerProps = { }; const hoverStyles = (props: ContainerProps) => ` - transform: none; + transform: none !important; box-shadow: ${ props.$collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" @@ -328,22 +330,29 @@ const Container = styled(Flex)<ContainerProps>` position: fixed; top: 0; bottom: 0; + inset-inline-start: 0; width: 100%; background: ${s("sidebarBackground")}; transition: box-shadow 150ms ease-in-out, - transform 150ms ease-out, - ${(props: ContainerProps) => - props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""}; + transform 250ms cubic-bezier(0.34, 1.15, 0.64, 1) + ${(props: ContainerProps) => + props.$isAnimating ? `, width ${ANIMATION_MS}ms ease-out` : ""}; transform: translateX( ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")} ); z-index: ${depths.mobileSidebar}; max-width: 80%; min-width: 280px; - padding-left: var(--sal); + padding-inline-start: var(--sal); ${fadeOnDesktopBackgrounded()} + [dir="rtl"] & { + transform: translateX( + ${(props) => (props.$mobileSidebarVisible ? 0 : "100%")} + ); + } + @media print { display: none; transform: none; @@ -370,11 +379,20 @@ const Container = styled(Flex)<ContainerProps>` z-index: ${depths.sidebar}; margin: 0; min-width: 0; + transition: + box-shadow 150ms ease-in-out, + transform 150ms ease-out${(props: ContainerProps) => + props.$isAnimating ? `, width ${ANIMATION_MS}ms ease-out` : ""}; transform: translateX(${(props: ContainerProps) => props.$collapsed ? `calc(-100% + ${Desktop.hasInsetTitlebar() ? 8 : 16}px)` : 0}); + [dir="rtl"] & { + transform: translateX(${(props: ContainerProps) => + props.$collapsed ? `calc(100% - 8px)` : 0}); + } + ${(props: ContainerProps) => props.$isHovering && css(hoverStyles)} &:hover { diff --git a/app/components/Sidebar/components/CollectionLink.tsx b/app/components/Sidebar/components/CollectionLink.tsx index 97e77b68158c..e96bee59e40e 100644 --- a/app/components/Sidebar/components/CollectionLink.tsx +++ b/app/components/Sidebar/components/CollectionLink.tsx @@ -1,36 +1,22 @@ -import type { Location } from "history"; import { observer } from "mobx-react"; -import { PlusIcon } from "outline-icons"; import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { mergeRefs } from "react-merge-refs"; import { useHistory } from "react-router-dom"; import { UserPreference } from "@shared/types"; import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; -import { CollectionValidation, DocumentValidation } from "@shared/validations"; import type Collection from "~/models/Collection"; import type Document from "~/models/Document"; import type { RefHandle } from "~/components/EditableTitle"; -import EditableTitle from "~/components/EditableTitle"; -import Fade from "~/components/Fade"; -import CollectionIcon from "~/components/Icons/CollectionIcon"; -import NudeButton from "~/components/NudeButton"; -import Tooltip from "~/components/Tooltip"; -import useBoolean from "~/hooks/useBoolean"; import useCurrentUser from "~/hooks/useCurrentUser"; +import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import CollectionMenu from "~/menus/CollectionMenu"; +import useBoolean from "~/hooks/useBoolean"; import { documentEditPath } from "~/utils/routeHelpers"; import { useDropToChangeCollection } from "../hooks/useDragAndDrop"; -import DropToImport from "./DropToImport"; -import Relative from "./Relative"; -import type { SidebarContextType } from "./SidebarContext"; -import { useSidebarContext } from "./SidebarContext"; -import SidebarLink from "./SidebarLink"; -import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction"; -import { ActionContextProvider } from "~/hooks/useActionContext"; import CollectionLinkChildren from "./CollectionLinkChildren"; +import CollectionRow from "./CollectionRow"; +import { useSidebarContext } from "./SidebarContext"; type Props = { collection: Collection; @@ -51,20 +37,16 @@ const CollectionLink: React.FC<Props> = ({ onClick, }: Props) => { const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); - const [isEditing, setIsEditing] = React.useState(false); const { documents } = useStores(); const history = useHistory(); const can = usePolicy(collection); - const { t } = useTranslation(); const sidebarContext = useSidebarContext(); const user = useCurrentUser(); const editableTitleRef = React.useRef<RefHandle>(null); const handleTitleChange = React.useCallback( async (name: string) => { - await collection.save({ - name, - }); + await collection.save({ name }); }, [collection] ); @@ -88,37 +70,26 @@ const CollectionLink: React.FC<Props> = ({ const handleRename = React.useCallback(() => { editableTitleRef.current?.setIsEditing(true); - }, [editableTitleRef]); - - const newChildTitleRef = React.useRef<RefHandle>(null); - const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] = - useBoolean(); + }, []); const handleNewDoc = React.useCallback( - async (input) => { - try { - newChildTitleRef.current?.setIsEditing(false); - const newDocument = await documents.create( - { - collectionId: collection.id, - title: input, - fullWidth: user.getPreference(UserPreference.FullWidthDocuments), - data: ProsemirrorHelper.getEmptyDocument(), - }, - { publish: true } - ); - collection?.addDocument(newDocument); - - closeAddingNewChild(); - history.push({ - pathname: documentEditPath(newDocument), - state: { sidebarContext }, - }); - } catch (_err) { - newChildTitleRef.current?.setIsEditing(true); - } + async (input: string) => { + const newDocument = await documents.create( + { + collectionId: collection.id, + title: input, + fullWidth: user.getPreference(UserPreference.FullWidthDocuments), + data: ProsemirrorHelper.getEmptyDocument(), + }, + { publish: true } + ); + collection?.addDocument(newDocument); + history.push({ + pathname: documentEditPath(newDocument), + state: { sidebarContext }, + }); }, - [user, sidebarContext, closeAddingNewChild, history, collection, documents] + [user, sidebarContext, history, collection, documents] ); const contextMenuAction = useCollectionMenuAction({ @@ -126,98 +97,44 @@ const CollectionLink: React.FC<Props> = ({ onRename: handleRename, }); + const menu = !isDraggingAnyCollection ? ( + <CollectionMenu + collection={collection} + onRename={handleRename} + onOpen={handleMenuOpen} + onClose={handleMenuClose} + /> + ) : undefined; + return ( - <ActionContextProvider value={{ activeModels: [collection] }}> - <Relative ref={mergeRefs([parentRef, dropRef])}> - <DropToImport collectionId={collection.id}> - <SidebarLink - onClick={onClick} - to={{ - pathname: collection.path, - state: { sidebarContext }, - }} - expanded={expanded} - onDisclosureClick={onDisclosureClick} - onClickIntent={handlePrefetch} - contextAction={contextMenuAction} - icon={ - <CollectionIcon collection={collection} expanded={expanded} /> - } - $showActions={menuOpen} - isActiveDrop={isOver && canDrop} - isActive={( - match, - location: Location<{ sidebarContext?: SidebarContextType }> - ) => !!match && location.state?.sidebarContext === sidebarContext} - label={ - <EditableTitle - title={collection.name} - onSubmit={handleTitleChange} - onEditing={setIsEditing} - canUpdate={can.update} - maxLength={CollectionValidation.maxNameLength} - ref={editableTitleRef} - /> - } - ellipsis={!isEditing} - exact={false} - depth={depth ? depth : 0} - menu={ - !isEditing && - !isDraggingAnyCollection && ( - <Fade> - {can.createDocument && ( - <Tooltip content={t("New doc")} delay={500}> - <NudeButton - aria-label={t("New nested document")} - onClick={(ev) => { - ev.preventDefault(); - setIsAddingNewChild(); - handleExpand(); - }} - > - <PlusIcon /> - </NudeButton> - </Tooltip> - )} - <CollectionMenu - collection={collection} - onRename={handleRename} - onOpen={handleMenuOpen} - onClose={handleMenuClose} - /> - </Fade> - ) - } - /> - </DropToImport> - </Relative> + <CollectionRow + collection={collection} + depth={depth} + to={{ pathname: collection.path, state: { sidebarContext } }} + onClick={onClick} + onClickIntent={handlePrefetch} + expanded={expanded} + onDisclosureClick={onDisclosureClick} + onExpand={handleExpand} + canEdit={can.update} + labelText={collection.name} + onTitleChange={handleTitleChange} + editableTitleRef={editableTitleRef} + contextAction={contextMenuAction} + menu={menu} + menuOpen={menuOpen} + canCreateChild={!isDraggingAnyCollection && can.createDocument} + onCreateChild={handleNewDoc} + parentRef={parentRef} + dropRef={dropRef} + isActiveDropTarget={isOver && canDrop} + > <CollectionLinkChildren collection={collection} expanded={!!expanded} prefetchDocument={documents.prefetchDocument} - > - {isAddingNewChild ? ( - <SidebarLink - depth={2} - isActive={() => true} - ellipsis={false} - label={ - <EditableTitle - title="" - canUpdate - isEditing - placeholder={`${t("New doc")}…`} - onCancel={closeAddingNewChild} - onSubmit={handleNewDoc} - maxLength={DocumentValidation.maxTitleLength} - ref={newChildTitleRef} - /> - } - /> - ) : undefined} - </CollectionLinkChildren> - </ActionContextProvider> + /> + </CollectionRow> ); }; diff --git a/app/components/Sidebar/components/CollectionLinkChildren.tsx b/app/components/Sidebar/components/CollectionLinkChildren.tsx index bd4de4698a7a..65cd2a082cca 100644 --- a/app/components/Sidebar/components/CollectionLinkChildren.tsx +++ b/app/components/Sidebar/components/CollectionLinkChildren.tsx @@ -13,10 +13,14 @@ import useStores from "~/hooks/useStores"; import history from "~/utils/history"; import useCollectionDocuments from "../hooks/useCollectionDocuments"; import { useDropToChangeCollection } from "../hooks/useDragAndDrop"; +import SidebarExpansionContext, { + useSidebarExpansionState, +} from "./SidebarExpansionContext"; import DocumentLink from "./DocumentLink"; import DropCursor from "./DropCursor"; import Folder from "./Folder"; import PlaceholderCollections from "./PlaceholderCollections"; +import { useSidebarDisclosure } from "./SidebarDisclosureContext"; import SidebarLink from "./SidebarLink"; // The number of child documents to initially render @@ -42,59 +46,80 @@ function CollectionLinkChildren({ const pageSize = DEFAULT_PAGE_SIZE; const { documents } = useStores(); const { t } = useTranslation(); - const childDocuments = useCollectionDocuments(collection, documents.active); + const activeDocument = documents.active; + const childDocuments = useCollectionDocuments(collection, activeDocument); const [showing, setShowing] = useState(pageSize); useEffect(() => { if (!expanded) { setShowing(pageSize); } - }, [expanded]); + }, [expanded, pageSize]); const showMore = useCallback(() => { if (childDocuments && childDocuments.length > showing) { setShowing((value) => value + pageSize); } - }, [childDocuments, showing]); + }, [childDocuments, showing, pageSize]); + + const expansion = useSidebarExpansionState( + childDocuments, + activeDocument?.id + ); + + // Handle collection-level alt-click cascade from DraggableCollectionLink + const handleCascadeExpand = useCallback(() => { + if (childDocuments) { + expansion.expandAll(childDocuments); + } + }, [expansion, childDocuments]); + + const handleCascadeCollapse = useCallback(() => { + expansion.collapseAll(); + }, [expansion]); + + useSidebarDisclosure(handleCascadeExpand, handleCascadeCollapse); return ( - <Folder expanded={expanded}> - <DynamicDropCursor collection={collection} /> - <DocumentsLoader collection={collection} enabled={expanded}> - {children} - {!childDocuments && ( - <ResizingHeightContainer hideOverflow> - <Loading /> - </ResizingHeightContainer> - )} - {childDocuments?.slice(0, showing).map((node, index) => ( - <DocumentLink - key={node.id} - node={node} - collection={collection} - activeDocument={documents.active} - prefetchDocument={prefetchDocument} - isDraft={node.isDraft} - depth={2} - index={index} - /> - ))} - {childDocuments?.length === 0 && !children && ( - <SidebarLink - label={ - <Text type="tertiary" size="small" italic> - {t("Empty")} - </Text> - } - onClick={() => history.push(collection.url)} - depth={2} - /> - )} - {childDocuments && ( - <Waypoint key={showing} onEnter={showMore} fireOnRapidScroll /> - )} - </DocumentsLoader> - </Folder> + <SidebarExpansionContext.Provider value={expansion}> + <Folder expanded={expanded}> + <DynamicDropCursor collection={collection} /> + <DocumentsLoader collection={collection} enabled={expanded}> + {children} + {!childDocuments && ( + <ResizingHeightContainer hideOverflow> + <Loading /> + </ResizingHeightContainer> + )} + {childDocuments?.slice(0, showing).map((node, index) => ( + <DocumentLink + key={node.id} + node={node} + collection={collection} + activeDocument={activeDocument} + prefetchDocument={prefetchDocument} + isDraft={node.isDraft} + depth={2} + index={index} + /> + ))} + {childDocuments?.length === 0 && !children && ( + <SidebarLink + label={ + <Text type="tertiary" size="small" italic> + {t("Empty")} + </Text> + } + onClick={() => history.push(collection.url)} + depth={2} + /> + )} + {childDocuments && ( + <Waypoint key={showing} onEnter={showMore} fireOnRapidScroll /> + )} + </DocumentsLoader> + </Folder> + </SidebarExpansionContext.Provider> ); } @@ -118,7 +143,7 @@ const DynamicDropCursor = observer( ); const Loading = styled(PlaceholderCollections)` - margin-left: 44px; + margin-inline-start: 44px; min-height: 90px; `; diff --git a/app/components/Sidebar/components/CollectionRow.tsx b/app/components/Sidebar/components/CollectionRow.tsx new file mode 100644 index 000000000000..9518bbd10a11 --- /dev/null +++ b/app/components/Sidebar/components/CollectionRow.tsx @@ -0,0 +1,260 @@ +import type { Location, LocationDescriptor } from "history"; +import { observer } from "mobx-react"; +import { PlusIcon } from "outline-icons"; +import * as React from "react"; +import type { ConnectDropTarget } from "react-dnd"; +import { useTranslation } from "react-i18next"; +import { mergeRefs } from "react-merge-refs"; +import type { match } from "react-router"; +import { CollectionValidation, DocumentValidation } from "@shared/validations"; +import type Collection from "~/models/Collection"; +import EditableTitle, { type RefHandle } from "~/components/EditableTitle"; +import Fade from "~/components/Fade"; +import CollectionIcon from "~/components/Icons/CollectionIcon"; +import NudeButton from "~/components/NudeButton"; +import Tooltip from "~/components/Tooltip"; +import useBoolean from "~/hooks/useBoolean"; +import { ActionContextProvider } from "~/hooks/useActionContext"; +import DropToImport from "./DropToImport"; +import Relative from "./Relative"; +import SidebarLink from "./SidebarLink"; +import type { SidebarContextType } from "./SidebarContext"; +import { useSidebarContext } from "./SidebarContext"; +import type { ActionWithChildren } from "~/types"; + +export type CollectionRowProps = { + /** Collection model for the row. */ + collection: Collection; + /** Indentation depth of the row. */ + depth?: number; + + /** Navigation target for the row. */ + to: LocationDescriptor; + /** Click handler for the row. */ + onClick?: () => void; + /** Called on click intent for prefetching. */ + onClickIntent?: () => void; + /** Optional override for the active-match function. */ + isActiveOverride?: ( + match: match | null, + location: Location<{ sidebarContext?: SidebarContextType }> + ) => boolean; + + /** Icon displayed to the left of the label. Defaults to CollectionIcon. */ + icon?: React.ReactNode; + + /** Whether the row is expanded. Pass undefined to hide the disclosure. */ + expanded?: boolean; + /** Called when the disclosure caret toggles expansion. */ + onDisclosureClick: (ev?: React.MouseEvent<HTMLElement>) => void; + /** Imperative expand, used by the "+" button to auto-expand. */ + onExpand?: () => void; + + /** When true, the name renders as an EditableTitle. */ + canEdit?: boolean; + /** Title displayed and edited when canEdit is true. */ + labelText?: string; + /** Submit handler for the edited title. */ + onTitleChange?: (value: string) => Promise<void>; + /** Forwarded ref to the EditableTitle so the container can trigger rename. */ + editableTitleRef?: React.Ref<RefHandle>; + /** Notifies the container when the inline title's editing state changes. */ + onEditingChange?: (editing: boolean) => void; + + /** Context menu action for the row. */ + contextAction?: ActionWithChildren; + /** Menu content rendered by the container; wrapped in Fade. */ + menu?: React.ReactNode; + /** Whether the menu's action slot is visible (e.g. while the menu is open). */ + menuOpen?: boolean; + + /** When true, the "+" new-child button is rendered in the menu slot. */ + canCreateChild?: boolean; + /** Submit handler for the inline new-child title input. */ + onCreateChild?: (title: string) => Promise<void>; + /** Depth of the inline new-child SidebarLink. Defaults to 2. */ + newChildDepth?: number; + + /** Ref forwarded to the outer Relative; for drag hover timers. */ + parentRef?: React.Ref<HTMLDivElement>; + /** Drop target connector for "change collection" / reorder. */ + dropRef?: ConnectDropTarget; + /** Whether the row is an active drop target (visual highlight). */ + isActiveDropTarget?: boolean; + + /** Content rendered after the row (e.g. CollectionLinkChildren). */ + children?: React.ReactNode; +}; + +function CollectionRow({ + collection, + depth = 0, + to, + onClick, + onClickIntent, + isActiveOverride, + icon, + expanded, + onDisclosureClick, + onExpand, + canEdit, + labelText, + onTitleChange, + editableTitleRef, + onEditingChange, + contextAction, + menu, + menuOpen, + canCreateChild, + onCreateChild, + newChildDepth = 2, + parentRef, + dropRef, + isActiveDropTarget, + children, +}: CollectionRowProps) { + const { t } = useTranslation(); + const sidebarContext = useSidebarContext(); + const [isEditing, setIsEditingState] = React.useState(false); + const setIsEditing = React.useCallback( + (editing: boolean) => { + setIsEditingState(editing); + onEditingChange?.(editing); + }, + [onEditingChange] + ); + const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] = + useBoolean(); + const newChildTitleRef = React.useRef<RefHandle>(null); + + const handleAddChild = React.useCallback( + (ev: React.MouseEvent<HTMLButtonElement>) => { + ev.preventDefault(); + setIsAddingNewChild(); + onExpand?.(); + }, + [setIsAddingNewChild, onExpand] + ); + + const handleNewChildSubmit = React.useCallback( + async (value: string) => { + if (!onCreateChild) { + return; + } + try { + newChildTitleRef.current?.setIsEditing(false); + await onCreateChild(value); + closeAddingNewChild(); + } catch (_err) { + newChildTitleRef.current?.setIsEditing(true); + } + }, + [onCreateChild, closeAddingNewChild] + ); + + const defaultIsActive = React.useCallback( + ( + _m: match | null, + location: Location<{ sidebarContext?: SidebarContextType }> + ) => !!_m && location.state?.sidebarContext === sidebarContext, + [sidebarContext] + ); + + const labelElement = canEdit ? ( + <EditableTitle + title={labelText ?? collection.name} + onSubmit={onTitleChange ?? (async () => undefined)} + isEditing={isEditing} + onEditing={setIsEditing} + canUpdate={canEdit} + maxLength={CollectionValidation.maxNameLength} + ref={editableTitleRef} + /> + ) : ( + collection.name + ); + + const iconElement = icon ?? ( + <CollectionIcon collection={collection} expanded={expanded} /> + ); + + const hasMenuContent = Boolean(menu) || canCreateChild; + const menuVisible = hasMenuContent && !isEditing; + const menuElement = menuVisible ? ( + <Fade> + {canCreateChild && ( + <Tooltip content={t("New doc")} delay={500}> + <NudeButton + aria-label={t("New nested document")} + onClick={handleAddChild} + > + <PlusIcon /> + </NudeButton> + </Tooltip> + )} + {menu} + </Fade> + ) : undefined; + + const mergedRef = React.useMemo( + () => + mergeRefs<HTMLDivElement>( + [parentRef, dropRef].filter(Boolean) as React.Ref<HTMLDivElement>[] + ), + [parentRef, dropRef] + ); + + const sidebarLinkElement = ( + <SidebarLink + // @ts-expect-error react-router type is wrong, string component is fine. + component={isEditing ? "div" : undefined} + depth={depth} + to={to} + onClick={onClick} + onClickIntent={onClickIntent} + contextAction={contextAction} + expanded={expanded} + onDisclosureClick={onDisclosureClick} + icon={iconElement} + isActive={isActiveOverride ?? defaultIsActive} + isActiveDrop={isActiveDropTarget} + label={labelElement} + ellipsis={!isEditing} + exact={false} + $showActions={menuOpen} + menu={menuElement} + /> + ); + + return ( + <ActionContextProvider value={{ activeModels: [collection] }}> + <Relative ref={mergedRef}> + <DropToImport collectionId={collection.id}> + {sidebarLinkElement} + </DropToImport> + </Relative> + {isAddingNewChild && onCreateChild && ( + <SidebarLink + isActive={() => true} + depth={newChildDepth} + ellipsis={false} + label={ + <EditableTitle + title="" + canUpdate + isEditing + placeholder={`${t("New doc")}…`} + onCancel={closeAddingNewChild} + onSubmit={handleNewChildSubmit} + maxLength={DocumentValidation.maxTitleLength} + ref={newChildTitleRef} + /> + } + /> + )} + {children} + </ActionContextProvider> + ); +} + +export default observer(CollectionRow); diff --git a/app/components/Sidebar/components/Disclosure.tsx b/app/components/Sidebar/components/Disclosure.tsx index 48c9c66980c5..0a2dad9004c7 100644 --- a/app/components/Sidebar/components/Disclosure.tsx +++ b/app/components/Sidebar/components/Disclosure.tsx @@ -18,6 +18,7 @@ function Disclosure({ onClick, expanded, ...rest }: Props) { size={20} onClick={onClick} aria-label={expanded ? t("Collapse") : t("Expand")} + aria-expanded={expanded} {...rest} > <StyledCollapsedIcon $expanded={expanded} size={20} /> @@ -27,7 +28,7 @@ function Disclosure({ onClick, expanded, ...rest }: Props) { const Button = styled(NudeButton)` position: absolute; - left: -24px; + inset-inline-start: -24px; flex-shrink: 0; color: ${s("textSecondary")}; margin: 2px; @@ -46,7 +47,14 @@ const StyledCollapsedIcon = styled(CollapsedIcon)<{ opacity 100ms ease, transform 100ms ease, fill 50ms !important; - ${(props) => !props.$expanded && "transform: rotate(-90deg);"}; + + [aria-expanded="false"] & { + transform: rotate(-90deg); + } + + [dir="rtl"] [aria-expanded="false"] & { + transform: rotate(90deg); + } `; // Enables identifying this component within styled components diff --git a/app/components/Sidebar/components/DocumentLink.tsx b/app/components/Sidebar/components/DocumentLink.tsx index 1b1603ceddfe..ee58d7d9f7fb 100644 --- a/app/components/Sidebar/components/DocumentLink.tsx +++ b/app/components/Sidebar/components/DocumentLink.tsx @@ -1,25 +1,21 @@ import type { Location } from "history"; import { observer } from "mobx-react"; -import { PlusIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; -import styled from "styled-components"; import Icon from "@shared/components/Icon"; import type { NavigationNode } from "@shared/types"; import { UserPreference } from "@shared/types"; import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; import { sortNavigationNodes } from "@shared/utils/collections"; -import { DocumentValidation } from "@shared/validations"; import type Collection from "~/models/Collection"; import type Document from "~/models/Document"; +import type GroupMembership from "~/models/GroupMembership"; +import type UserMembership from "~/models/UserMembership"; import type { RefHandle } from "~/components/EditableTitle"; -import EditableTitle from "~/components/EditableTitle"; -import Fade from "~/components/Fade"; -import NudeButton from "~/components/NudeButton"; -import Tooltip from "~/components/Tooltip"; import useBoolean from "~/hooks/useBoolean"; import useCurrentUser from "~/hooks/useCurrentUser"; +import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import DocumentMenu from "~/menus/DocumentMenu"; @@ -29,21 +25,12 @@ import { useDropToReorderDocument, useDropToReparentDocument, } from "../hooks/useDragAndDrop"; +import { useSidebarExpansion } from "./SidebarExpansionContext"; +import DocumentRow from "./DocumentRow"; import DropCursor from "./DropCursor"; -import DropToImport from "./DropToImport"; import Folder from "./Folder"; -import Relative from "./Relative"; import type { SidebarContextType } from "./SidebarContext"; import { useSidebarContext } from "./SidebarContext"; -import SidebarLink from "./SidebarLink"; -import type UserMembership from "~/models/UserMembership"; -import type GroupMembership from "~/models/GroupMembership"; -import { ActionContextProvider } from "~/hooks/useActionContext"; -import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction"; -import SidebarDisclosureContext, { - useSidebarDisclosure, - useSidebarDisclosureState, -} from "./SidebarDisclosureContext"; type Props = { node: NavigationNode; @@ -57,24 +44,22 @@ type Props = { parentId?: string; }; -function InnerDocumentLink( - { - node, - collection, - membership, - activeDocument, - prefetchDocument, - isDraft, - depth, - index, - parentId, - }: Props, - ref: React.RefObject<HTMLAnchorElement> -) { - const { documents, policies } = useStores(); +const DocumentLink = observer(function DocumentLinkInner({ + node, + collection, + membership, + activeDocument, + prefetchDocument, + isDraft, + depth, + index, + parentId, +}: Props) { + const { documents } = useStores(); const { t } = useTranslation(); const history = useHistory(); - const canUpdate = usePolicy(node.id).update; + const can = usePolicy(node.id); + const canUpdate = can.update; const isActiveDocument = activeDocument && activeDocument.id === node.id; const hasChildDocuments = !!node.children.length || activeDocument?.parentDocumentId === node.id; @@ -84,6 +69,14 @@ function InnerDocumentLink( const editableTitleRef = React.useRef<RefHandle>(null); const sidebarContext = useSidebarContext(); const user = useCurrentUser(); + const expansion = useSidebarExpansion(); + const expanded = expansion.isExpanded(node.id); + + React.useEffect(() => { + if (expanded && !hasChildDocuments) { + expansion.collapse(node.id); + } + }, [expansion, expanded, hasChildDocuments, node.id]); React.useEffect(() => { if ( @@ -100,62 +93,33 @@ function InnerDocumentLink( isActiveDocument, ]); - const showChildren = React.useMemo(() => { - if (!hasChildDocuments || !activeDocument) { - return false; - } - - const pathToDocument = - collection?.pathToDocument(activeDocument.id) ?? - membership?.pathToDocument(activeDocument.id); - - return !!( - pathToDocument?.some((entry) => entry.id === node.id) || isActiveDocument - ); - }, [ - hasChildDocuments, - activeDocument, - isActiveDocument, - node, - collection, - membership, - ]); - - const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren); - - // Context-based recursive expand/collapse for descendant DocumentLinks - const { event: disclosureEvent, onDisclosureClick } = - useSidebarDisclosureState(); - - // Subscribe to recursive expand/collapse events from an ancestor - useSidebarDisclosure(setExpanded, setCollapsed); - - React.useEffect(() => { - if (showChildren) { - setExpanded(); - } - }, [setExpanded, showChildren]); - - // when the last child document is removed auto-close the local folder state - React.useEffect(() => { - if (expanded && !hasChildDocuments) { - setCollapsed(); - } - }, [setCollapsed, expanded, hasChildDocuments]); - const handleDisclosureClick = React.useCallback( - (ev: React.MouseEvent<HTMLElement>) => { - const willExpand = !expanded; - if (willExpand) { - setExpanded(); + (ev?: React.MouseEvent<HTMLElement>) => { + if (expanded) { + if (ev?.altKey) { + expansion.collapseDescendants(node); + } else { + expansion.collapse(node.id); + } } else { - setCollapsed(); + if (ev?.altKey) { + expansion.expandDescendants(node); + } else { + expansion.expand(node.id); + } } - onDisclosureClick(willExpand, ev.altKey); }, - [setCollapsed, setExpanded, expanded, onDisclosureClick] + [expansion, expanded, node] ); + const handleExpand = React.useCallback(() => { + expansion.expand(node.id); + }, [expansion, node.id]); + + const handleCollapse = React.useCallback(() => { + expansion.collapse(node.id); + }, [expansion, node.id]); + const handlePrefetch = React.useCallback(() => { void prefetchDocument?.(node.id); }, [prefetchDocument, node]); @@ -172,6 +136,7 @@ function InnerDocumentLink( }, [documents, document] ); + const handleRename = React.useCallback(() => { editableTitleRef.current?.setIsEditing(true); }, []); @@ -206,7 +171,6 @@ function InnerDocumentLink( const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const isMoving = documents.movingDocumentId === node.id; - const can = policies.abilities(node.id); const icon = document?.icon || node.icon || node.emoji; const color = document?.color || node.color; const initial = document?.initial || node.title.charAt(0).toUpperCase(); @@ -214,10 +178,9 @@ function InnerDocumentLink( const iconElement = React.useMemo( () => icon ? <Icon value={icon} color={color} initial={initial} /> : undefined, - [icon, color] + [icon, color, initial] ); - // Draggable const [{ isDragging }, drag] = useDragDocument( node, depth, @@ -225,12 +188,10 @@ function InnerDocumentLink( isEditing ); - // Drop to re-parent const parentRef = React.useRef<HTMLDivElement>(null); const [{ isOverReparent, canDropToReparent }, dropToReparent] = - useDropToReparentDocument(node, setExpanded, parentRef); + useDropToReparentDocument(node, handleExpand, parentRef); - // Drop to reorder const [{ isOverReorder: isOverReorderAbove }, dropToReorderAbove] = useDropToReorderDocument(node, collection, (item) => { if (!collection) { @@ -249,7 +210,7 @@ function InnerDocumentLink( if (!collection) { return; } - if (expanded) { + if (expansion.isExpanded(node.id)) { return { documentId: item.id, collectionId: collection.id, @@ -265,90 +226,61 @@ function InnerDocumentLink( }; }); - const nodeChildren = React.useMemo(() => { - const insertDraftDocument = - activeDocument?.isDraft && - activeDocument?.isActive && - activeDocument?.parentDocumentId === node.id; - - return collection && insertDraftDocument - ? sortNavigationNodes( - [activeDocument?.asNavigationNode, ...node.children], - collection.sort, - false - ) - : node.children; - }, [ - activeDocument?.isActive, - activeDocument?.isDraft, - activeDocument?.parentDocumentId, - activeDocument?.asNavigationNode, - collection, - node, - ]); + const insertDraftChild = !!( + activeDocument?.isDraft && + activeDocument?.isActive && + activeDocument?.parentDocumentId === node.id + ); - const doc = documents.get(node.id); - const title = doc?.title || node.title || t("Untitled"); + const draftNavNode = insertDraftChild + ? activeDocument?.asNavigationNode + : undefined; - const isExpanded = expanded && !isDragging; - const hasChildren = nodeChildren.length > 0; - - const handleKeyDown = React.useCallback( - (ev: React.KeyboardEvent) => { - if (!hasChildren) { - return; - } - if (ev.key === "ArrowRight" && !expanded) { - setExpanded(); - } - if (ev.key === "ArrowLeft" && expanded) { - setCollapsed(); - } - }, - [setExpanded, setCollapsed, hasChildren, expanded] + const nodeChildren = React.useMemo( + () => + collection && draftNavNode + ? sortNavigationNodes( + [draftNavNode, ...node.children], + collection.sort, + false + ) + : node.children, + [draftNavNode, collection, node.children] ); - const newChildTitleRef = React.useRef<RefHandle>(null); - const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] = - useBoolean(); + const title = document?.title || node.title || t("Untitled"); + const hasChildren = nodeChildren.length > 0; const handleNewDoc = React.useCallback( - async (input) => { - try { - newChildTitleRef.current?.setIsEditing(false); - const newDocument = await documents.create( - { - collectionId: collection?.id, - parentDocumentId: node.id, - fullWidth: - doc?.fullWidth ?? - user.getPreference(UserPreference.FullWidthDocuments), - title: input, - data: ProsemirrorHelper.getEmptyDocument(), - }, - { publish: true } - ); - collection?.addDocument(newDocument, node.id); - membership?.addDocument(newDocument, node.id); - - closeAddingNewChild(); - history.push({ - pathname: documentEditPath(newDocument), - state: { sidebarContext }, - }); - } catch (_err) { - newChildTitleRef.current?.setIsEditing(true); - } + async (input: string) => { + const newDocument = await documents.create( + { + collectionId: collection?.id, + parentDocumentId: node.id, + fullWidth: + document?.fullWidth ?? + user.getPreference(UserPreference.FullWidthDocuments), + title: input, + data: ProsemirrorHelper.getEmptyDocument(), + }, + { publish: true } + ); + collection?.addDocument(newDocument, node.id); + membership?.addDocument(newDocument, node.id); + history.push({ + pathname: documentEditPath(newDocument), + state: { sidebarContext }, + }); }, [ documents, collection, + membership, sidebarContext, user, - node, - doc, + node.id, + document, history, - closeAddingNewChild, ] ); @@ -357,160 +289,84 @@ function InnerDocumentLink( onRename: handleRename, }); - const labelElement = React.useMemo( - () => ( - <EditableTitle - title={title} - onSubmit={handleTitleChange} - isEditing={isEditing} - onEditing={setIsEditing} - canUpdate={canUpdate} - maxLength={DocumentValidation.maxTitleLength} - ref={editableTitleRef} + const showMenuActions = !isDraggingAnyDocument; + const menu = + showMenuActions && document ? ( + <DocumentMenu + document={document} + onRename={handleRename} + onOpen={handleMenuOpen} + onClose={handleMenuClose} /> - ), - [title, handleTitleChange, isEditing, setIsEditing, canUpdate] - ); + ) : undefined; + + const cursorBefore = + isDraggingAnyDocument && collection?.isManualSort && index === 0 ? ( + <DropCursor + isActiveDrop={isOverReorderAbove} + innerRef={dropToReorderAbove} + position="top" + /> + ) : undefined; - const menuElement = React.useMemo( - () => - document && !isMoving && !isEditing && !isDraggingAnyDocument ? ( - <Fade> - {can.createChildDocument && ( - <Tooltip content={t("New doc")}> - <NudeButton - aria-label={t("New nested document")} - onClick={(ev) => { - ev.preventDefault(); - setIsAddingNewChild(); - setExpanded(); - }} - > - <PlusIcon /> - </NudeButton> - </Tooltip> - )} - <DocumentMenu - document={document} - onRename={handleRename} - onOpen={handleMenuOpen} - onClose={handleMenuClose} - /> - </Fade> - ) : undefined, - [ - document, - isMoving, - isEditing, - isDraggingAnyDocument, - can.createChildDocument, - t, - setIsAddingNewChild, - setExpanded, - handleRename, - handleMenuOpen, - handleMenuClose, - ] - ); + const cursorAfter = + isDraggingAnyDocument && collection?.isManualSort ? ( + <DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} /> + ) : undefined; return ( - <ActionContextProvider - value={{ - activeModels: document ? [document] : [], - }} + <DocumentRow + documentId={node.id} + document={document} + to={toPath} + depth={depth} + isDraft={isDraft} + scrollIntoViewIfNeeded={sidebarContext === "collections"} + icon={iconElement} + canEdit={canUpdate} + labelText={title} + onTitleChange={handleTitleChange} + editableTitleRef={editableTitleRef} + onEditingChange={setIsEditing} + expanded={expanded && !isDragging} + hasChildren={hasChildren} + onDisclosureClick={handleDisclosureClick} + onExpand={handleExpand} + onCollapse={handleCollapse} + dragRef={drag} + isDragging={isDragging} + isMoving={isMoving} + parentRef={parentRef} + dropToReparentRef={dropToReparent} + isActiveDropTarget={isOverReparent && canDropToReparent} + cursorBefore={cursorBefore} + cursorAfter={cursorAfter} + menu={menu} + menuOpen={menuOpen} + canCreateChild={showMenuActions && can.createChildDocument} + onCreateChild={handleNewDoc} + contextAction={contextMenuAction} + isActiveOverride={isActiveCheck} + onClickIntent={handlePrefetch} > - <Relative ref={parentRef}> - {isDraggingAnyDocument && collection?.isManualSort && index === 0 && ( - <DropCursor - isActiveDrop={isOverReorderAbove} - innerRef={dropToReorderAbove} - position="top" + <Folder expanded={expanded && !isDragging}> + {nodeChildren.map((childNode, childIndex) => ( + <DocumentLink + key={childNode.id} + collection={collection} + membership={membership} + node={childNode} + activeDocument={activeDocument} + prefetchDocument={prefetchDocument} + isDraft={childNode.isDraft} + depth={depth + 1} + index={childIndex} + parentId={node.id} /> - )} - <Draggable - key={node.id} - ref={drag} - $isDragging={isDragging} - $isMoving={isMoving} - onKeyDown={handleKeyDown} - > - <div ref={dropToReparent}> - <DropToImport documentId={node.id}> - <SidebarLink - // @ts-expect-error react-router type is wrong, string component is fine. - component={isEditing ? "div" : undefined} - expanded={hasChildren ? isExpanded : undefined} - onDisclosureClick={handleDisclosureClick} - onClickIntent={handlePrefetch} - contextAction={contextMenuAction} - to={toPath} - icon={iconElement} - label={labelElement} - ellipsis={!isEditing} - isActive={isActiveCheck} - isActiveDrop={isOverReparent && canDropToReparent} - depth={depth} - exact={false} - $showActions={menuOpen} - scrollIntoViewIfNeeded={sidebarContext === "collections"} - isDraft={isDraft} - ref={ref} - menu={menuElement} - /> - </DropToImport> - </div> - </Draggable> - {isDraggingAnyDocument && collection?.isManualSort && ( - <DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} /> - )} - </Relative> - {isAddingNewChild && ( - <SidebarLink - isActive={() => true} - depth={depth + 1} - ellipsis={false} - label={ - <EditableTitle - title="" - canUpdate - isEditing - placeholder={`${t("New doc")}…`} - onCancel={closeAddingNewChild} - onSubmit={handleNewDoc} - maxLength={DocumentValidation.maxTitleLength} - ref={newChildTitleRef} - /> - } - /> - )} - <SidebarDisclosureContext.Provider value={disclosureEvent}> - <Folder expanded={expanded && !isDragging}> - {nodeChildren.map((childNode, childIndex) => ( - <DocumentLink - key={childNode.id} - collection={collection} - membership={membership} - node={childNode} - activeDocument={activeDocument} - prefetchDocument={prefetchDocument} - isDraft={childNode.isDraft} - depth={depth + 1} - index={childIndex} - parentId={node.id} - /> - ))} - </Folder> - </SidebarDisclosureContext.Provider> - </ActionContextProvider> + ))} + </Folder> + </DocumentRow> ); -} - -const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>` - transition: opacity 250ms ease; - opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.1 : 1)}; - pointer-events: ${(props) => (props.$isMoving ? "none" : "inherit")}; -`; - -const DocumentLink = observer(React.forwardRef(InnerDocumentLink)); +}); export default DocumentLink; diff --git a/app/components/Sidebar/components/DocumentRow.tsx b/app/components/Sidebar/components/DocumentRow.tsx new file mode 100644 index 000000000000..0f452a42a2c7 --- /dev/null +++ b/app/components/Sidebar/components/DocumentRow.tsx @@ -0,0 +1,336 @@ +import type { Location, LocationDescriptor } from "history"; +import { observer } from "mobx-react"; +import { PlusIcon } from "outline-icons"; +import * as React from "react"; +import type { ConnectDragSource } from "react-dnd"; +import { useTranslation } from "react-i18next"; +import type { match } from "react-router"; +import styled from "styled-components"; +import { DocumentValidation } from "@shared/validations"; +import type Document from "~/models/Document"; +import EditableTitle, { type RefHandle } from "~/components/EditableTitle"; +import Fade from "~/components/Fade"; +import NudeButton from "~/components/NudeButton"; +import Tooltip from "~/components/Tooltip"; +import useBoolean from "~/hooks/useBoolean"; +import { ActionContextProvider } from "~/hooks/useActionContext"; +import DropToImport from "./DropToImport"; +import Relative from "./Relative"; +import SidebarLink from "./SidebarLink"; +import type { SidebarContextType } from "./SidebarContext"; +import { useSidebarContext } from "./SidebarContext"; +import type { ActionWithChildren } from "~/types"; + +export type DocumentRowProps = { + /** Document identifier for policy, prefetch and import. */ + documentId: string; + /** Loaded document; used for editing title and active matching. */ + document?: Document; + + /** Navigation target for the row. */ + to: LocationDescriptor; + + /** Indentation depth of the row. */ + depth: number; + /** Applies draft styling around the row. */ + isDraft?: boolean; + /** Scroll this row into view when it becomes the active route. */ + scrollIntoViewIfNeeded?: boolean; + + /** Icon displayed to the left of the label. */ + icon?: React.ReactNode; + /** Displays a small unread badge to the right of the label. */ + unreadBadge?: boolean; + + /** Whether inline title updates are allowed. */ + canEdit?: boolean; + /** Static label content; when provided, it is rendered in preference to `labelText`. */ + label?: React.ReactNode; + /** Label as a text string, for editing. */ + labelText?: string; + /** Submit handler when title updates are allowed. */ + onTitleChange?: (value: string) => Promise<void>; + /** Forwarded ref to the `EditableTitle` instance when it is rendered. */ + editableTitleRef?: React.Ref<RefHandle>; + /** Notifies the container when the rendered inline title enters or exits editing mode. */ + onEditingChange?: (editing: boolean) => void; + + /** Whether the row is expanded. */ + expanded: boolean; + /** Whether the row has any descendants (controls whether the disclosure renders). */ + hasChildren: boolean; + /** Called when the disclosure caret or Alt+click toggles expansion. */ + onDisclosureClick: (ev?: React.MouseEvent<HTMLElement>) => void; + /** Imperative expand, used by the "+" button and ArrowRight keydown. */ + onExpand?: () => void; + /** Imperative collapse, used by ArrowLeft keydown. */ + onCollapse?: () => void; + + /** Drag source ref from the container's drag hook. */ + dragRef?: ConnectDragSource; + /** Whether the row is being dragged. */ + isDragging?: boolean; + /** Whether the row's document is being moved. */ + isMoving?: boolean; + + /** Ref to the outer Relative element; some drop hooks need to read it. */ + parentRef?: React.Ref<HTMLDivElement>; + /** Ref for the row's reparent drop target. */ + dropToReparentRef?: React.Ref<HTMLDivElement>; + /** Whether the row is an active drop target (visual highlight). */ + isActiveDropTarget?: boolean; + + /** Cursor element rendered above the row. */ + cursorBefore?: React.ReactNode; + /** Cursor element rendered below the row. */ + cursorAfter?: React.ReactNode; + + /** Menu content rendered by the container. */ + menu?: React.ReactNode; + /** Whether the menu's action slot is visible (e.g. while the menu is open). */ + menuOpen?: boolean; + + /** When true, the "+" new-child button is rendered in the menu slot. */ + canCreateChild?: boolean; + /** Submit handler for the inline new-child title input. */ + onCreateChild?: (title: string) => Promise<void>; + /** Depth of the inline new-child SidebarLink. Defaults to `depth + 1`. */ + newChildDepth?: number; + + /** Context menu action for the row. */ + contextAction?: ActionWithChildren; + + /** Optional override for the active-match function. */ + isActiveOverride?: ( + match: match | null, + location: Location<{ sidebarContext?: SidebarContextType }> + ) => boolean; + + /** Content rendered after the row (e.g. a Folder of nested child rows). */ + children?: React.ReactNode; + + /** Called on click intent for prefetching. */ + onClickIntent?: () => void; +}; + +function DocumentRow({ + documentId, + document, + to, + depth, + isDraft, + scrollIntoViewIfNeeded, + icon, + unreadBadge, + label, + canEdit, + labelText, + onTitleChange, + editableTitleRef, + onEditingChange, + expanded, + hasChildren, + onDisclosureClick, + onExpand, + onCollapse, + dragRef, + isDragging, + isMoving, + parentRef, + dropToReparentRef, + isActiveDropTarget, + cursorBefore, + cursorAfter, + menu, + menuOpen, + canCreateChild, + onCreateChild, + newChildDepth, + contextAction, + isActiveOverride, + children, + onClickIntent, +}: DocumentRowProps) { + const { t } = useTranslation(); + const sidebarContext = useSidebarContext(); + const [isEditing, setIsEditingState] = React.useState(false); + const setIsEditing = React.useCallback( + (editing: boolean) => { + setIsEditingState(editing); + onEditingChange?.(editing); + }, + [onEditingChange] + ); + const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] = + useBoolean(); + const newChildTitleRef = React.useRef<RefHandle>(null); + + const handleKeyDown = React.useCallback( + (ev: React.KeyboardEvent) => { + if (!hasChildren) { + return; + } + if (ev.key === "ArrowRight" && !expanded) { + onExpand?.(); + } + if (ev.key === "ArrowLeft" && expanded) { + onCollapse?.(); + } + }, + [hasChildren, expanded, onExpand, onCollapse] + ); + + const handleAddChild = React.useCallback( + (ev: React.MouseEvent<HTMLButtonElement>) => { + ev.preventDefault(); + setIsAddingNewChild(); + onExpand?.(); + }, + [setIsAddingNewChild, onExpand] + ); + + const handleNewChildSubmit = React.useCallback( + async (value: string) => { + if (!onCreateChild) { + return; + } + try { + newChildTitleRef.current?.setIsEditing(false); + await onCreateChild(value); + closeAddingNewChild(); + } catch (_err) { + newChildTitleRef.current?.setIsEditing(true); + } + }, + [onCreateChild, closeAddingNewChild] + ); + + const labelElement = + label ?? + (labelText !== undefined ? ( + <EditableTitle + title={labelText} + onSubmit={onTitleChange ?? (async () => undefined)} + isEditing={isEditing} + onEditing={setIsEditing} + canUpdate={!!canEdit} + maxLength={DocumentValidation.maxTitleLength} + ref={editableTitleRef} + /> + ) : null); + + const hasMenuContent = Boolean(menu) || canCreateChild; + const menuVisible = hasMenuContent && !isEditing && !isDragging && !isMoving; + const menuElement = menuVisible ? ( + <Fade> + {canCreateChild && ( + <Tooltip content={t("New doc")}> + <NudeButton + aria-label={t("New nested document")} + onClick={handleAddChild} + > + <PlusIcon /> + </NudeButton> + </Tooltip> + )} + {menu} + </Fade> + ) : undefined; + + const defaultIsActive = React.useCallback( + ( + m: match | null, + location: Location<{ sidebarContext?: SidebarContextType }> + ) => { + if (sidebarContext !== location.state?.sidebarContext) { + return false; + } + return (document && location.pathname.endsWith(document.urlId)) || !!m; + }, + [sidebarContext, document] + ); + + const sidebarLinkElement = ( + <SidebarLink + // @ts-expect-error react-router type is wrong, string component is fine. + component={isEditing ? "div" : undefined} + depth={depth} + to={to} + expanded={hasChildren && !isDragging ? expanded : undefined} + onDisclosureClick={onDisclosureClick} + onClickIntent={onClickIntent} + contextAction={contextAction} + icon={icon} + isActive={isActiveOverride ?? defaultIsActive} + isActiveDrop={isActiveDropTarget} + label={labelElement} + ellipsis={!isEditing} + exact={false} + scrollIntoViewIfNeeded={scrollIntoViewIfNeeded} + isDraft={isDraft} + unreadBadge={unreadBadge} + $showActions={menuOpen} + menu={menuElement} + /> + ); + + const withImport = documentId ? ( + <DropToImport documentId={documentId}>{sidebarLinkElement}</DropToImport> + ) : ( + sidebarLinkElement + ); + + return ( + <ActionContextProvider + value={{ + activeModels: document ? [document] : [], + }} + > + <Relative ref={parentRef}> + {cursorBefore} + <Draggable + key={documentId} + ref={dragRef} + $isDragging={isDragging} + $isMoving={isMoving} + onKeyDown={handleKeyDown} + > + {dropToReparentRef ? ( + <div ref={dropToReparentRef}>{withImport}</div> + ) : ( + withImport + )} + </Draggable> + {cursorAfter} + </Relative> + {isAddingNewChild && onCreateChild && ( + <SidebarLink + isActive={() => true} + depth={newChildDepth ?? depth + 1} + ellipsis={false} + label={ + <EditableTitle + title="" + canUpdate + isEditing + placeholder={`${t("New doc")}…`} + onCancel={closeAddingNewChild} + onSubmit={handleNewChildSubmit} + maxLength={DocumentValidation.maxTitleLength} + ref={newChildTitleRef} + /> + } + /> + )} + {children} + </ActionContextProvider> + ); +} + +const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>` + transition: opacity 250ms ease; + opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.1 : 1)}; + pointer-events: ${(props) => (props.$isMoving ? "none" : "inherit")}; +`; + +export default observer(DocumentRow); diff --git a/app/components/Sidebar/components/GroupLink.tsx b/app/components/Sidebar/components/GroupLink.tsx index 35cf4db061c3..2cd4b7085623 100644 --- a/app/components/Sidebar/components/GroupLink.tsx +++ b/app/components/Sidebar/components/GroupLink.tsx @@ -61,7 +61,7 @@ const GroupLink: React.FC<Props> = ({ group }) => { <SharedWithMeLink key={membership.id} membership={membership} - depth={1} + depth={2} /> ))} </Folder> diff --git a/app/components/Sidebar/components/Header.tsx b/app/components/Sidebar/components/Header.tsx index aaf7d057e69a..fed0f94b7ba9 100644 --- a/app/components/Sidebar/components/Header.tsx +++ b/app/components/Sidebar/components/Header.tsx @@ -75,7 +75,8 @@ const Button = styled.button` position: relative; letter-spacing: 0.03em; margin: 0; - padding: 4px 2px 4px 12px; + padding-block: 4px; + padding-inline: 12px 2px; border: 0; background: none; border-radius: 4px; @@ -98,6 +99,10 @@ const Disclosure = styled(CollapsedIcon)<{ $expanded?: boolean }>` fill 50ms !important; ${(props) => !props.$expanded && "transform: rotate(-90deg);"}; opacity: 0; + + [dir="rtl"] & { + ${(props) => !props.$expanded && "transform: rotate(90deg);"}; + } `; const H3 = styled.h3` diff --git a/app/components/Sidebar/components/HistoryNavigation.tsx b/app/components/Sidebar/components/HistoryNavigation.tsx index 58519b407ad5..a5e1e86554a0 100644 --- a/app/components/Sidebar/components/HistoryNavigation.tsx +++ b/app/components/Sidebar/components/HistoryNavigation.tsx @@ -1,82 +1,138 @@ -import { ArrowIcon } from "outline-icons"; +import { ArrowIcon, ClockIcon } from "outline-icons"; +import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { s } from "@shared/styles"; -import { isMac } from "@shared/utils/browser"; +import { createActionGroup } from "~/actions"; +import { DropdownMenu } from "~/components/Menu/DropdownMenu"; import Flex from "~/components/Flex"; import NudeButton from "~/components/NudeButton"; import Tooltip from "~/components/Tooltip"; -import useKeyDown from "~/hooks/useKeyDown"; +import useRecentDocumentActions from "~/components/CommandBar/useRecentDocumentActions"; +import { useMenuAction } from "~/hooks/useMenuAction"; +import useStores from "~/hooks/useStores"; import Desktop from "~/utils/Desktop"; +const RECENT_DOCUMENTS_LIMIT = 10; + function HistoryNavigation(props: React.ComponentProps<typeof Flex>) { const { t } = useTranslation(); - const [back, setBack] = React.useState(false); - const [forward, setForward] = React.useState(false); - - useKeyDown( - (event) => - isMac - ? event.metaKey && event.key === "[" - : event.altKey && event.key === "ArrowLeft", - () => { - setBack(true); - setTimeout(() => setBack(false), 100); - } + const { documents } = useStores(); + const [canGoBack, setCanGoBack] = React.useState(false); + const [canGoForward, setCanGoForward] = React.useState(false); + const [supported, setSupported] = React.useState(false); + + const recentActions = useRecentDocumentActions(RECENT_DOCUMENTS_LIMIT); + const menuActions = React.useMemo( + () => [ + createActionGroup({ + name: t("Recent"), + actions: recentActions, + }), + ], + [t, recentActions] ); + const menuAction = useMenuAction(menuActions); - useKeyDown( - (event) => - isMac - ? event.metaKey && event.key === "]" - : event.altKey && event.key === "ArrowRight", - () => { - setForward(true); - setTimeout(() => setForward(false), 100); + const handleOpen = React.useCallback(() => { + void documents.fetchRecentlyViewed({ limit: RECENT_DOCUMENTS_LIMIT }); + }, [documents]); + + React.useEffect(() => { + if (!(Desktop.bridge && "onNavigationStateChanged" in Desktop.bridge)) { + return; } - ); + setSupported(true); + return Desktop.bridge.onNavigationStateChanged((state) => { + setCanGoBack(state.canGoBack); + setCanGoForward(state.canGoForward); + }); + }, []); - if (!Desktop.isMacApp()) { + if (!Desktop.isMacApp() || !supported) { return null; } return ( <Navigation gap={4} {...props}> - <Tooltip content={t("Go back")}> - <NudeButton onClick={() => Desktop.bridge?.goBack()}> - <Back $active={back} /> + <Tooltip content={t("Go back")} disabled={!canGoBack}> + <NudeButton + aria-label={t("Go back")} + disabled={!canGoBack} + onClick={() => Desktop.bridge?.goBack()} + > + <Back $enabled={canGoBack} /> </NudeButton> </Tooltip> - <Tooltip content={t("Go forward")}> - <NudeButton onClick={() => Desktop.bridge?.goForward()}> - <Forward $active={forward} /> + <Tooltip content={t("Go forward")} disabled={!canGoForward}> + <NudeButton + aria-label={t("Go forward")} + disabled={!canGoForward} + onClick={() => Desktop.bridge?.goForward()} + > + <Forward $enabled={canGoForward} /> </NudeButton> </Tooltip> + <Tooltip content={t("History")}> + <DropdownMenu + action={menuAction} + ariaLabel={t("History")} + onOpen={handleOpen} + > + <NudeButton aria-label={t("History")}> + <StyledClockIcon /> + </NudeButton> + </DropdownMenu> + </Tooltip> </Navigation> ); } const Navigation = styled(Flex)` position: absolute; - right: 12px; + inset-inline-end: 12px; top: 14px; + + button { + cursor: default; + } `; -const Forward = styled(ArrowIcon)<{ $active: boolean }>` +const Forward = styled(ArrowIcon)<{ $enabled: boolean }>` color: ${s("textTertiary")}; - opacity: ${(props) => (props.$active ? 1 : 0.5)}; + opacity: ${(props) => (props.$enabled ? 0.5 : 0.15)}; transition: color 100ms ease-in-out; &:active, &:hover { - opacity: 1; + opacity: ${(props) => (props.$enabled ? 1 : 0.15)}; + } + + [dir="rtl"] & { + transform: rotate(180deg); } `; const Back = styled(Forward)` transform: rotate(180deg); flex-shrink: 0; + + [dir="rtl"] & { + transform: rotate(0deg); + } +`; + +const StyledClockIcon = styled(ClockIcon)` + color: ${s("textTertiary")}; + opacity: 0.5; + transition: color 100ms ease-in-out; + + &:active, + &:hover, + [data-state="open"] & { + opacity: 1; + } `; -export default HistoryNavigation; +export default observer(HistoryNavigation); diff --git a/app/components/Sidebar/components/NavLink.tsx b/app/components/Sidebar/components/NavLink.tsx index 882cd04daf5d..903dbbe714c6 100644 --- a/app/components/Sidebar/components/NavLink.tsx +++ b/app/components/Sidebar/components/NavLink.tsx @@ -141,10 +141,6 @@ const NavLink = ({ (event: React.MouseEvent<HTMLAnchorElement>) => { onClick?.(event); - if (isActive && !event.defaultPrevented) { - onActiveClick?.(event); - } - if (shouldFastClick(event)) { event.currentTarget.focus(); @@ -157,7 +153,7 @@ const NavLink = ({ }); } }, - [onClick, navigateTo, isActive, shouldFastClick] + [onClick, navigateTo, shouldFastClick] ); const handleClick = React.useCallback( @@ -170,8 +166,15 @@ const NavLink = ({ ) { event.preventDefault(); } + + // Fire onActiveClick on click rather than mousedown so that the native + // HTML5 drag gesture can initiate from an active row without being + // blocked by a preventDefault on mousedown. + if (isActive) { + onActiveClick?.(event); + } }, - [isActive] + [isActive, onActiveClick] ); React.useEffect(() => { diff --git a/app/components/Sidebar/components/ResizeBorder.ts b/app/components/Sidebar/components/ResizeBorder.ts index 653ddf046862..913d96befb14 100644 --- a/app/components/Sidebar/components/ResizeBorder.ts +++ b/app/components/Sidebar/components/ResizeBorder.ts @@ -6,8 +6,8 @@ const ResizeBorder = styled.div<{ dir?: "left" | "right" }>` position: absolute; top: 0; bottom: 0; - right: ${(props) => (props.dir !== "right" ? "-1px" : "auto")}; - left: ${(props) => (props.dir === "right" ? "-1px" : "auto")}; + inset-inline-end: ${(props) => (props.dir !== "right" ? "-1px" : "auto")}; + inset-inline-start: ${(props) => (props.dir === "right" ? "-1px" : "auto")}; width: 2px; cursor: col-resize; ${undraggableOnDesktop()} @@ -23,7 +23,7 @@ const ResizeBorder = styled.div<{ dir?: "left" | "right" }>` position: absolute; top: 0; bottom: 0; - right: -4px; + inset-inline-end: -4px; width: 10px; ${undraggableOnDesktop()} } diff --git a/app/components/Sidebar/components/SharedDocumentLink.tsx b/app/components/Sidebar/components/SharedDocumentLink.tsx index 1cf7948c90d6..9018d3a1934e 100644 --- a/app/components/Sidebar/components/SharedDocumentLink.tsx +++ b/app/components/Sidebar/components/SharedDocumentLink.tsx @@ -1,4 +1,3 @@ -import includes from "lodash/includes"; import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; @@ -8,11 +7,7 @@ import type Collection from "~/models/Collection"; import type Document from "~/models/Document"; import useStores from "~/hooks/useStores"; import { sharedModelPath } from "~/utils/routeHelpers"; -import { descendants } from "@shared/utils/tree"; -import SidebarDisclosureContext, { - useSidebarDisclosure, - useSidebarDisclosureState, -} from "./SidebarDisclosureContext"; +import { useSidebarExpansion } from "./SidebarExpansionContext"; import SidebarLink from "./SidebarLink"; type Props = { @@ -43,59 +38,46 @@ function DocumentLink( ) { const { documents } = useStores(); const { t } = useTranslation(); + const expansion = useSidebarExpansion(); const isActiveDocument = activeDocumentId === node.id; const hasChildDocuments = !!node.children.length || activeDocument?.parentDocumentId === node.id; const document = documents.get(node.id); - const showChildren = React.useMemo( - () => - !!( - hasChildDocuments && - ((activeDocumentId && - includes( - descendants(node).map((n) => n.id), - activeDocumentId - )) || - isActiveDocument || - depth <= 1) - ), - [hasChildDocuments, activeDocumentId, isActiveDocument, depth, node] - ); - - const [expanded, setExpanded] = React.useState(showChildren); - - const { event: disclosureEvent, onDisclosureClick } = - useSidebarDisclosureState(); - - const handleExpand = React.useCallback(() => setExpanded(true), []); - const handleCollapse = React.useCallback(() => setExpanded(false), []); - - useSidebarDisclosure(handleExpand, handleCollapse); + // Auto-expand top-level nodes (depth <= 1) on initial render React.useEffect(() => { - if (showChildren) { - setExpanded(showChildren); + if (hasChildDocuments && depth <= 1 && !expansion.isExpanded(node.id)) { + expansion.expand(node.id); } - }, [showChildren]); + }, [expansion, node.id, hasChildDocuments, depth]); + + const expanded = expansion.isExpanded(node.id); const handleDisclosureClick = React.useCallback( (ev: React.SyntheticEvent) => { ev.preventDefault(); ev.stopPropagation(); - const willExpand = !expanded; - setExpanded(willExpand); - const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey; - onDisclosureClick(willExpand, !!altKey); + if (expanded) { + const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey; + if (altKey) { + expansion.collapseDescendants(node); + } else { + expansion.collapse(node.id); + } + } else { + const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey; + if (altKey) { + expansion.expandDescendants(node); + } else { + expansion.expand(node.id); + } + } }, - [expanded, onDisclosureClick] + [expanded, expansion, node] ); - // since we don't have access to the collection sort here, we just put any - // drafts at the front of the list. this is slightly inconsistent with the - // logged-in behavior, but it's probably better to emphasize the draft state - // of the document in a shared context const nodeChildren = React.useMemo(() => { if ( activeDocument?.isDraft && @@ -148,24 +130,22 @@ function DocumentLink( ref={ref} isActive={() => !!isActiveDocument} /> - <SidebarDisclosureContext.Provider value={disclosureEvent}> - {expanded && - nodeChildren.map((childNode, index) => ( - <SharedDocumentLink - shareId={shareId} - key={childNode.id} - collection={collection} - node={childNode} - activeDocumentId={activeDocumentId} - activeDocument={activeDocument} - prefetchDocument={prefetchDocument} - isDraft={childNode.isDraft} - depth={depth + 1} - index={index} - parentId={node.id} - /> - ))} - </SidebarDisclosureContext.Provider> + {expanded && + nodeChildren.map((childNode, index) => ( + <SharedDocumentLink + shareId={shareId} + key={childNode.id} + collection={collection} + node={childNode} + activeDocumentId={activeDocumentId} + activeDocument={activeDocument} + prefetchDocument={prefetchDocument} + isDraft={childNode.isDraft} + depth={depth + 1} + index={index} + parentId={node.id} + /> + ))} </> ); } diff --git a/app/components/Sidebar/components/SharedWithMeLink.tsx b/app/components/Sidebar/components/SharedWithMeLink.tsx index 9704813936c2..f1a53bce5c6e 100644 --- a/app/components/Sidebar/components/SharedWithMeLink.tsx +++ b/app/components/Sidebar/components/SharedWithMeLink.tsx @@ -2,12 +2,10 @@ import fractionalIndex from "fractional-index"; import type { Location } from "history"; import { observer } from "mobx-react"; import * as React from "react"; -import styled from "styled-components"; import { IconType, NotificationEventType } from "@shared/types"; import { determineIconType } from "@shared/utils/icon"; import type GroupMembership from "~/models/GroupMembership"; import UserMembership from "~/models/UserMembership"; -import Fade from "~/components/Fade"; import useBoolean from "~/hooks/useBoolean"; import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext"; import useStores from "~/hooks/useStores"; @@ -17,17 +15,19 @@ import { useDropToReorderUserMembership, useDropToReparentDocument, } from "../hooks/useDragAndDrop"; +import SidebarExpansionContext, { + useSidebarExpansionState, +} from "./SidebarExpansionContext"; import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon"; import DocumentLink from "./DocumentLink"; +import DocumentRow from "./DocumentRow"; import DropCursor from "./DropCursor"; import Folder from "./Folder"; -import Relative from "./Relative"; import SidebarDisclosureContext, { useSidebarDisclosure, useSidebarDisclosureState, } from "./SidebarDisclosureContext"; import { useSidebarContext, type SidebarContextType } from "./SidebarContext"; -import SidebarLink from "./SidebarLink"; type Props = { membership: UserMembership | GroupMembership; @@ -44,6 +44,11 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) { const sidebarContext = useSidebarContext(); const document = documentId ? documents.get(documentId) : undefined; + const membershipDocuments = membership.documents; + const expansion = useSidebarExpansionState( + membershipDocuments, + ui.activeDocumentId + ); const isActiveDocumentInPath = ui.activeDocumentId ? membership.pathToDocument(ui.activeDocumentId).length > 0 : false; @@ -55,7 +60,6 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) { const { event: disclosureEvent, onDisclosureClick } = useSidebarDisclosureState(); - // Subscribe to recursive expand/collapse events from an ancestor (e.g. GroupLink) useSidebarDisclosure(setExpanded, setCollapsed); React.useEffect(() => { @@ -74,7 +78,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) { void documents.fetch(documentId); void membership.fetchDocuments(); } - }, [documentId, documents]); + }, [documentId, documents, membership]); React.useEffect(() => { if (isActiveDocument && membership.documentId) { @@ -83,18 +87,31 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) { }, [fetchChildDocuments, isActiveDocument, membership.documentId]); const handleDisclosureClick = React.useCallback( - (ev: React.MouseEvent<HTMLButtonElement>) => { - ev.preventDefault(); - ev.stopPropagation(); + (ev?: React.MouseEvent<HTMLElement>) => { + ev?.preventDefault(); + ev?.stopPropagation(); const willExpand = !expanded; if (willExpand) { setExpanded(); + if (ev?.altKey && membershipDocuments) { + expansion.expandAll(membershipDocuments); + } } else { setCollapsed(); + if (ev?.altKey) { + expansion.collapseAll(); + } } - onDisclosureClick(willExpand, ev.altKey); + onDisclosureClick(willExpand, !!ev?.altKey); }, - [expanded, setExpanded, setCollapsed, onDisclosureClick] + [ + expanded, + setExpanded, + setCollapsed, + onDisclosureClick, + expansion, + membershipDocuments, + ] ); const parentRef = React.useRef<HTMLDivElement>(null); @@ -118,75 +135,70 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) { const [reorderProps, dropToReorderRef] = useDropToReorderUserMembership(getIndex); + const isActive = React.useCallback( + (match, location: Location<{ sidebarContext?: SidebarContextType }>) => + !!match && location.state?.sidebarContext === sidebarContext, + [sidebarContext] + ); + const displayChildDocuments = expanded && !isDragging; - if (document) { - const { icon: docIcon } = document; - const label = - determineIconType(docIcon) === IconType.Emoji - ? document.title.replace(docIcon!, "") - : document.titleWithDefault; - const collection = document.collectionId - ? collections.get(document.collectionId) - : undefined; - - const childDocuments = membership.documents ?? []; - - return ( - <> - <Relative ref={parentRef}> - <Draggable - key={membership.id} - ref={draggableRef} - $isDragging={isDragging} - > - <div ref={dropToReparent}> - <SidebarLink - isActiveDrop={isOverReparent && canDropToReparent} - depth={depth} - to={{ - pathname: document.path, - state: { sidebarContext }, - }} - expanded={ - childDocuments.length > 0 && !isDragging - ? expanded - : undefined - } - onDisclosureClick={handleDisclosureClick} - icon={icon} - isActive={( - match, - location: Location<{ sidebarContext?: SidebarContextType }> - ) => - !!match && location.state?.sidebarContext === sidebarContext - } - label={label} - exact={false} - unreadBadge={ - document.unreadNotifications.filter( - (notification) => - notification.event === - NotificationEventType.AddUserToDocument - ).length > 0 - } - $showActions={menuOpen} - menu={ - document && !isDragging ? ( - <Fade> - <DocumentMenu - document={document} - onOpen={handleMenuOpen} - onClose={handleMenuClose} - /> - </Fade> - ) : undefined - } - /> - </div> - </Draggable> - </Relative> - <SidebarDisclosureContext.Provider value={disclosureEvent}> + if (!document) { + return null; + } + + const { icon: docIcon } = document; + const label = + determineIconType(docIcon) === IconType.Emoji + ? document.title.replace(docIcon!, "") + : document.titleWithDefault; + const collection = document.collectionId + ? collections.get(document.collectionId) + : undefined; + + const childDocuments = membershipDocuments ?? []; + const hasChildren = childDocuments.length > 0; + + const unreadBadge = + document.unreadNotifications.filter( + (notification) => + notification.event === NotificationEventType.AddUserToDocument + ).length > 0; + + const menu = !isDragging ? ( + <DocumentMenu + document={document} + onOpen={handleMenuOpen} + onClose={handleMenuClose} + /> + ) : undefined; + + return ( + <DocumentRow + documentId={documentId ?? ""} + document={document} + to={{ pathname: document.path, state: { sidebarContext } }} + depth={depth} + icon={icon} + canEdit={false} + label={label} + unreadBadge={unreadBadge} + expanded={expanded && !isDragging} + hasChildren={hasChildren} + onDisclosureClick={handleDisclosureClick} + onExpand={setExpanded} + onCollapse={setCollapsed} + dragRef={draggableRef} + isDragging={isDragging} + parentRef={parentRef} + dropToReparentRef={dropToReparent} + isActiveDropTarget={isOverReparent && canDropToReparent} + menu={menu} + menuOpen={menuOpen} + isActiveOverride={isActive} + > + <SidebarDisclosureContext.Provider value={disclosureEvent}> + <SidebarExpansionContext.Provider value={expansion}> <Folder expanded={displayChildDocuments}> {childDocuments.map((childNode, index) => ( <DocumentLink @@ -201,24 +213,16 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) { /> ))} </Folder> - </SidebarDisclosureContext.Provider> - {reorderProps.isDragging && ( - <DropCursor - isActiveDrop={reorderProps.isOverCursor} - innerRef={dropToReorderRef} - /> - )} - </> - ); - } - - return null; + </SidebarExpansionContext.Provider> + </SidebarDisclosureContext.Provider> + {reorderProps.isDragging && ( + <DropCursor + isActiveDrop={reorderProps.isOverCursor} + innerRef={dropToReorderRef} + /> + )} + </DocumentRow> + ); } -const Draggable = styled.div<{ $isDragging?: boolean }>` - position: relative; - transition: opacity 250ms ease; - opacity: ${(props) => (props.$isDragging ? 0.1 : 1)}; -`; - export default observer(SharedWithMeLink); diff --git a/app/components/Sidebar/components/SidebarButton.tsx b/app/components/Sidebar/components/SidebarButton.tsx index c83f4f451de7..908f818bc72c 100644 --- a/app/components/Sidebar/components/SidebarButton.tsx +++ b/app/components/Sidebar/components/SidebarButton.tsx @@ -2,8 +2,8 @@ import { MoreIcon } from "outline-icons"; import { observer } from "mobx-react"; import * as React from "react"; import styled from "styled-components"; +import breakpoint from "styled-components-breakpoint"; import { extraArea, hover, s } from "@shared/styles"; -import { isMobile } from "@shared/utils/browser"; import Flex from "~/components/Flex"; import Text from "~/components/Text"; import { draggableOnDesktop, undraggableOnDesktop } from "~/styles"; @@ -89,41 +89,39 @@ const Button = styled(Flex)<{ flex: 1; color: ${s("textTertiary")}; align-items: center; - padding: ${isMobile() ? 12 : 4}px 4px; + padding: 12px; font-size: 15px; font-weight: 500; border-radius: 4px; border: 0; - margin: ${(props) => (!isMobile() && props.$position === "top" ? 16 : 8)}px 0; + margin: 8px; background: none; flex-shrink: 0; -webkit-appearance: none; text-decoration: none; - text-align: left; + text-align: start; user-select: none; position: relative; ${undraggableOnDesktop()} ${extraArea(4)} + ${breakpoint("tablet")` + padding: 8px; + `} &:not(:disabled) { + &: ${hover} { + background: ${s("sidebarHoverBackground")}; + } + &:active, - &:${hover}, &[aria-expanded="true"] { color: ${s("sidebarText")}; background: ${s("sidebarActiveBackground")}; cursor: var(--pointer); } } - - &:last-child { - margin-right: 8px; - } - - &:first-child { - margin-left: 8px; - } `; export default SidebarButton; diff --git a/app/components/Sidebar/components/SidebarExpansionContext.ts b/app/components/Sidebar/components/SidebarExpansionContext.ts new file mode 100644 index 000000000000..daec363055a1 --- /dev/null +++ b/app/components/Sidebar/components/SidebarExpansionContext.ts @@ -0,0 +1,200 @@ +import { action, observable } from "mobx"; +import { createContext, useContext, useEffect, useState } from "react"; +import type { NavigationNode } from "@shared/types"; + +/** + * Computes the set of node IDs along the path from any node in `roots` down + * to a node with `targetId`, inclusive of both endpoints. Returns an empty + * array when no path exists. + * + * @param roots the top-level navigation nodes to search through. + * @param targetId the id of the target document. + * @returns array of ancestor IDs (inclusive of the target). + */ +function computeAncestorPath( + roots: NavigationNode[], + targetId: string +): string[] { + const stack: string[] = []; + let found = false; + const search = (nodes: NavigationNode[]): boolean => { + for (const node of nodes) { + stack.push(node.id); + if (node.id === targetId) { + found = true; + return true; + } + if (node.children.length && search(node.children)) { + return true; + } + stack.pop(); + } + return false; + }; + search(roots); + return found ? stack : []; +} + +/** + * Manages the set of expanded node IDs for a sidebar document tree. + * + * Uses a MobX ObservableSet so that individual `observer`-wrapped + * DocumentLinks only re-render when their own node's membership in the set + * changes, rather than on every expansion toggle anywhere in the tree. + */ +export class SidebarExpansionState { + @observable + expandedIds = new Set<string>(); + + /** + * Whether a given node is currently expanded. + * + * @param nodeId the id of the node to check. + * @returns true if the node is expanded. + */ + isExpanded(nodeId: string): boolean { + return this.expandedIds.has(nodeId); + } + + /** + * Expand a single node. + * + * @param nodeId the id of the node to expand. + */ + @action + expand(nodeId: string): void { + this.expandedIds.add(nodeId); + } + + /** + * Collapse a single node. + * + * @param nodeId the id of the node to collapse. + */ + @action + collapse(nodeId: string): void { + this.expandedIds.delete(nodeId); + } + + /** + * Expand a node and all of its descendants recursively. + * + * @param node the root NavigationNode to expand. + */ + @action + expandDescendants(node: NavigationNode): void { + const walk = (n: NavigationNode) => { + this.expandedIds.add(n.id); + for (const child of n.children) { + walk(child); + } + }; + walk(node); + } + + /** + * Collapse a node and all of its descendants recursively. + * + * @param node the root NavigationNode to collapse. + */ + @action + collapseDescendants(node: NavigationNode): void { + const walk = (n: NavigationNode) => { + this.expandedIds.delete(n.id); + for (const child of n.children) { + walk(child); + } + }; + walk(node); + } + + /** + * Expand all nodes along a path (e.g. ancestors of the active document). + * + * @param ids the node IDs to expand. + */ + @action + expandPath(ids: Iterable<string>): void { + for (const id of ids) { + this.expandedIds.add(id); + } + } + + /** + * Expand every node in the given roots, recursively. + * + * @param roots the top-level navigation nodes. + */ + @action + expandAll(roots: NavigationNode[]): void { + const walk = (nodes: NavigationNode[]) => { + for (const node of nodes) { + this.expandedIds.add(node.id); + walk(node.children); + } + }; + walk(roots); + } + + /** + * Collapse every node by clearing the set. + */ + @action + collapseAll(): void { + this.expandedIds.clear(); + } +} + +/** + * Context for providing a SidebarExpansionState to descendant sidebar + * components. Each document tree root (collection, starred doc, shared + * membership) creates its own instance so expansion state is scoped. + */ +const SidebarExpansionContext = createContext<SidebarExpansionState | null>( + null +); + +/** + * Hook to consume the nearest SidebarExpansionState from context. + * + * @returns the expansion state instance. + */ +export function useSidebarExpansion(): SidebarExpansionState { + const ctx = useContext(SidebarExpansionContext); + if (!ctx) { + throw new Error( + "useSidebarExpansion must be used within a SidebarExpansionContext.Provider" + ); + } + return ctx; +} + +/** + * Hook that creates a SidebarExpansionState and auto-expands the path + * to the active document whenever it changes. Returns the state instance + * to be provided via SidebarExpansionContext.Provider. + * + * @param roots the top-level navigation nodes (e.g. collection documents). + * @param activeDocumentId the currently active document ID. + * @returns the expansion state instance. + */ +export function useSidebarExpansionState( + roots: NavigationNode[] | undefined, + activeDocumentId: string | undefined +): SidebarExpansionState { + const [state] = useState(() => new SidebarExpansionState()); + + useEffect(() => { + if (!roots || !activeDocumentId) { + return; + } + const path = computeAncestorPath(roots, activeDocumentId); + if (path.length > 0) { + state.expandPath(path); + } + }, [state, roots, activeDocumentId]); + + return state; +} + +export default SidebarExpansionContext; diff --git a/app/components/Sidebar/components/SidebarLink.tsx b/app/components/Sidebar/components/SidebarLink.tsx index 3ec6b37c280b..2381c57c27fb 100644 --- a/app/components/Sidebar/components/SidebarLink.tsx +++ b/app/components/Sidebar/components/SidebarLink.tsx @@ -65,8 +65,10 @@ const activeDropStyle = { fontWeight: 600, }; -const preventDefault = (ev: React.MouseEvent) => { - ev.preventDefault(); +// Prevents the parent NavLink's mousedown handler from firing (which would +// navigate or toggle), without calling preventDefault — that would block the +// native HTML5 drag from initiating on the draggable row. +const stopPropagation = (ev: React.MouseEvent) => { ev.stopPropagation(); }; @@ -102,15 +104,15 @@ function SidebarLink( const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent); const style = React.useMemo( () => ({ - paddingLeft: `${(depth || 0) * 16 + (icon ? -8 : 12)}px`, - paddingRight: unreadBadge ? "32px" : undefined, + paddingInlineStart: `${(depth || 0) * 16 + (icon ? -8 : 12)}px`, + paddingInlineEnd: unreadBadge ? "32px" : undefined, }), [depth, icon, unreadBadge] ); const unreadStyle = React.useMemo( () => ({ - right: -20, + insetInlineEnd: -20, }), [] ); @@ -130,7 +132,7 @@ function SidebarLink( onClick(ev); } }, - [onClick, disabled, expanded] + [onClick, disabled] ); const handleDisclosureClick = React.useCallback( @@ -147,6 +149,49 @@ function SidebarLink( const DisclosureComponent = icon ? HiddenDisclosure : Disclosure; + const innerContent = ( + <> + <ContextMenu action={contextAction} ariaLabel={t("Link options")}> + <Content> + {hasDisclosure && ( + <DisclosureComponent + expanded={expanded} + onClick={handleDisclosureClick} + onMouseDown={stopPropagation} + tabIndex={-1} + /> + )} + {icon && <IconWrapper aria-hidden>{icon}</IconWrapper>} + <Label $ellipsis={ellipsis}>{label}</Label> + {unreadBadge && <UnreadBadge style={unreadStyle} />} + </Content> + </ContextMenu> + {menu && <Actions $showActions={$showActions}>{menu}</Actions>} + </> + ); + + if (!to) { + return ( + <Link + as={href ? "a" : "button"} + $isActiveDrop={isActiveDrop} + $isDraft={isDraft} + $disabled={disabled} + style={active ? activeStyle : style} + onClick={handleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + onDragEnter={handleMouseEnter} + href={href} + className={className} + ref={ref} + {...rest} + > + {innerContent} + </Link> + ); + } + return ( <Link $isActiveDrop={isActiveDrop} @@ -159,38 +204,22 @@ function SidebarLink( onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onDragEnter={handleMouseEnter} - // @ts-expect-error exact does not exist on div exact={exact !== false} - to={to} - as={to ? undefined : href ? "a" : "div"} + to={to!} href={href} className={className} + // @ts-expect-error spread props cause overload mismatch with styled NavLink ref={ref} {...rest} > - <ContextMenu action={contextAction} ariaLabel={t("Link options")}> - <Content> - {hasDisclosure && ( - <DisclosureComponent - expanded={expanded} - onClick={preventDefault} - onPointerDown={handleDisclosureClick} - tabIndex={-1} - /> - )} - {icon && <IconWrapper>{icon}</IconWrapper>} - <Label $ellipsis={ellipsis}>{label}</Label> - {unreadBadge && <UnreadBadge style={unreadStyle} />} - </Content> - </ContextMenu> - {menu && <Actions $showActions={$showActions}>{menu}</Actions>} + {innerContent} </Link> ); } // accounts for whitespace around icon export const IconWrapper = styled.span` - margin-left: -4px; + margin-inline-start: -4px; height: 24px; overflow: hidden; flex-shrink: 0; @@ -210,7 +239,7 @@ const Actions = styled(EventBoundary)<{ $showActions?: boolean }>` visibility: ${(props) => (props.$showActions ? "visible" : "hidden")}; position: absolute; top: 3px; - right: 4px; + inset-inline-end: 4px; gap: 4px; color: ${s("textTertiary")}; transition: opacity 50ms; @@ -234,10 +263,10 @@ const Actions = styled(EventBoundary)<{ $showActions?: boolean }>` const HiddenDisclosure = styled(Disclosure)` position: inherit; - left: initial; + inset-inline-start: initial; display: none; - margin-left: -2px; - margin-right: 6px; + margin-inline-start: -2px; + margin-inline-end: 6px; `; const Link = styled(NavLink)<{ @@ -273,6 +302,8 @@ const Link = styled(NavLink)<{ font-size: 16px; cursor: var(--pointer); overflow: hidden; + border: 0; + width: 100%; ${undraggableOnDesktop()} ${(props) => @@ -288,10 +319,7 @@ const Link = styled(NavLink)<{ &:after { content: ""; position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; + inset: 0; pointer-events: none; border-radius: 4px; border: 1.5px dashed ${props.theme.sidebarDraftBorder}; @@ -315,7 +343,8 @@ const Link = styled(NavLink)<{ } ${breakpoint("tablet")` - padding: 3px 8px 3px 12px; + padding-block: 3px; + padding-inline: 12px 8px; font-size: 14px; `} @@ -350,8 +379,10 @@ const Label = styled.div<{ $ellipsis: boolean }>` position: relative; width: 100%; line-height: 24px; - margin-left: 2px; + margin-inline-start: 2px; min-width: 0; + text-align: start; + ${(props) => props.$ellipsis && ellipsis()} * { diff --git a/app/components/Sidebar/components/StarredLink.tsx b/app/components/Sidebar/components/StarredLink.tsx index 7ff043d841be..02cc3438c1fe 100644 --- a/app/components/Sidebar/components/StarredLink.tsx +++ b/app/components/Sidebar/components/StarredLink.tsx @@ -1,36 +1,49 @@ import fractionalIndex from "fractional-index"; import type { Location } from "history"; import { observer } from "mobx-react"; -import { StarredIcon } from "outline-icons"; import * as React from "react"; import { useEffect, useState } from "react"; -import styled, { useTheme } from "styled-components"; +import { useHistory } from "react-router-dom"; +import styled from "styled-components"; +import { UserPreference } from "@shared/types"; +import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; +import type Collection from "~/models/Collection"; +import type Document from "~/models/Document"; import type Star from "~/models/Star"; -import Fade from "~/components/Fade"; +import type { RefHandle } from "~/components/EditableTitle"; import useBoolean from "~/hooks/useBoolean"; +import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction"; +import useCurrentUser from "~/hooks/useCurrentUser"; +import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction"; import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext"; +import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; +import CollectionMenu from "~/menus/CollectionMenu"; import DocumentMenu from "~/menus/DocumentMenu"; +import { documentEditPath } from "~/utils/routeHelpers"; import { useDragStar, + useDropToChangeCollection, useDropToCreateStar, useDropToReorderStar, } from "../hooks/useDragAndDrop"; import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon"; -import CollectionLink from "./CollectionLink"; +import SidebarExpansionContext, { + useSidebarExpansionState, +} from "./SidebarExpansionContext"; +import CollectionLinkChildren from "./CollectionLinkChildren"; +import CollectionRow from "./CollectionRow"; import DocumentLink from "./DocumentLink"; -import SidebarDisclosureContext, { - useSidebarDisclosureState, -} from "./SidebarDisclosureContext"; +import DocumentRow from "./DocumentRow"; import DropCursor from "./DropCursor"; import Folder from "./Folder"; import Relative from "./Relative"; import type { SidebarContextType } from "./SidebarContext"; import SidebarContext, { starredSidebarContext } from "./SidebarContext"; -import SidebarLink from "./SidebarLink"; -import { ActionContextProvider } from "~/hooks/useActionContext"; -import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction"; -import { type ConnectDragSource } from "react-dnd"; +import SidebarDisclosureContext, { + useSidebarDisclosure, + useSidebarDisclosureState, +} from "./SidebarDisclosureContext"; type Props = { star: Star; @@ -38,152 +51,309 @@ type Props = { type StarredDocumentLinkProps = { star: Star; - documentId: string; + document: Document; expanded: boolean; sidebarContext: SidebarContextType; - isDragging: boolean; - handleDisclosureClick: React.MouseEventHandler<HTMLElement>; + handleDisclosureClick: (ev?: React.MouseEvent<HTMLElement>) => void; handlePrefetch: () => void; + onExpand: () => void; + onCollapse: () => void; icon: React.ReactNode; - label: React.ReactNode; menuOpen: boolean; handleMenuOpen: () => void; handleMenuClose: () => void; - draggableRef: ConnectDragSource; cursor: React.ReactNode; }; type StarredCollectionLinkProps = { star: Star; - collection: any; + collection: Collection; expanded: boolean; sidebarContext: SidebarContextType; - isDragging: boolean; - handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void; - draggableRef: ConnectDragSource; + handleDisclosureClick: (ev?: React.MouseEvent<HTMLElement>) => void; cursor: React.ReactNode; - displayChildDocuments: boolean; - reorderStarProps: any; + isDraggingAnyStar: boolean; }; const StarredDocumentLink = observer(function StarredDocumentLink({ star, - documentId, + document, expanded, sidebarContext, - isDragging, handleDisclosureClick, handlePrefetch, + onExpand, + onCollapse, icon, - label, menuOpen, handleMenuOpen, handleMenuClose, - draggableRef, cursor, }: StarredDocumentLinkProps) { + const history = useHistory(); + const user = useCurrentUser(); const { collections, documents } = useStores(); + const can = usePolicy(document); + const editableTitleRef = React.useRef<RefHandle>(null); + const [{ isDragging }, draggableRef] = useDragStar(star); - const document = documents.get(documentId); - - const documentCollection = document?.collectionId + const documentCollection = document.collectionId ? collections.get(document.collectionId) : undefined; const childDocuments = documentCollection - ? documentCollection.getChildrenForDocument(documentId) + ? documentCollection.getChildrenForDocument(document.id) : []; const hasChildDocuments = childDocuments.length > 0; const displayChildDocuments = expanded && !isDragging; - const contextMenuAction = useDocumentMenuAction({ documentId }); + const expansion = useSidebarExpansionState( + childDocuments, + documents.active?.id + ); - if (!document) { - return null; - } + const handleCascadeExpand = React.useCallback(() => { + if (childDocuments.length) { + expansion.expandAll(childDocuments); + } + }, [expansion, childDocuments]); + + const handleCascadeCollapse = React.useCallback(() => { + expansion.collapseAll(); + }, [expansion]); + + useSidebarDisclosure(handleCascadeExpand, handleCascadeCollapse); + + const handleRename = React.useCallback(() => { + editableTitleRef.current?.setIsEditing(true); + }, []); + + const handleTitleChange = React.useCallback( + async (value: string) => { + if (!document) { + return; + } + await documents.update({ + id: document.id, + title: value, + }); + }, + [documents, document] + ); + + const handleNewDoc = React.useCallback( + async (input: string) => { + if (!document) { + return; + } + const newDocument = await documents.create( + { + collectionId: documentCollection?.id, + parentDocumentId: document.id, + fullWidth: + document.fullWidth ?? + user.getPreference(UserPreference.FullWidthDocuments), + title: input, + data: ProsemirrorHelper.getEmptyDocument(), + }, + { publish: true } + ); + documentCollection?.addDocument(newDocument, document.id); + history.push({ + pathname: documentEditPath(newDocument), + state: { sidebarContext }, + }); + }, + [documents, document, documentCollection, sidebarContext, user, history] + ); + + const contextMenuAction = useDocumentMenuAction({ + documentId: document.id, + onRename: handleRename, + }); + + const isActive = React.useCallback( + (match, location: Location<{ sidebarContext?: SidebarContextType }>) => { + if (location.state?.sidebarContext !== sidebarContext) { + return false; + } + return ( + !!match || (!!document && location.pathname.endsWith(document.urlId)) + ); + }, + [sidebarContext, document] + ); + + const menu = ( + <DocumentMenu + document={document} + onRename={handleRename} + onOpen={handleMenuOpen} + onClose={handleMenuClose} + /> + ); return ( - <ActionContextProvider - value={{ - activeModels: [document], - }} - > - <Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}> - <SidebarLink - depth={0} - to={{ - pathname: document.url, - state: { sidebarContext }, - }} - expanded={hasChildDocuments && !isDragging ? expanded : undefined} - onDisclosureClick={handleDisclosureClick} - onClickIntent={handlePrefetch} - contextAction={contextMenuAction} - icon={icon} - isActive={( - match, - location: Location<{ sidebarContext?: SidebarContextType }> - ) => !!match && location.state?.sidebarContext === sidebarContext} - label={label} - exact={false} - $showActions={menuOpen} - menu={ - document && !isDragging ? ( - <Fade> - <DocumentMenu - document={document} - onOpen={handleMenuOpen} - onClose={handleMenuClose} - /> - </Fade> - ) : undefined - } - /> - </Draggable> - <SidebarContext.Provider value={sidebarContext}> - <Relative> - <Folder expanded={displayChildDocuments}> - {childDocuments.map((node, index) => ( - <DocumentLink - key={node.id} - node={node} - collection={documentCollection} - activeDocument={documents.active} - prefetchDocument={documents.prefetchDocument} - isDraft={node.isDraft} - depth={2} - index={index} - /> - ))} - </Folder> - {cursor} - </Relative> - </SidebarContext.Provider> - </ActionContextProvider> + <Draggable ref={draggableRef} $isDragging={isDragging}> + <DocumentRow + documentId={document.id} + document={document} + to={{ pathname: document.path, state: { sidebarContext } }} + depth={0} + icon={icon} + canEdit={can.update} + labelText={document.titleWithDefault} + onTitleChange={handleTitleChange} + editableTitleRef={editableTitleRef} + expanded={expanded} + hasChildren={hasChildDocuments} + onDisclosureClick={handleDisclosureClick} + onExpand={onExpand} + onCollapse={onCollapse} + isDragging={isDragging} + menu={menu} + menuOpen={menuOpen} + canCreateChild={can.createChildDocument} + onCreateChild={handleNewDoc} + newChildDepth={2} + contextAction={contextMenuAction} + isActiveOverride={isActive} + onClickIntent={handlePrefetch} + > + <SidebarContext.Provider value={sidebarContext}> + <SidebarExpansionContext.Provider value={expansion}> + <Relative> + <Folder expanded={displayChildDocuments}> + {childDocuments.map((node, index) => ( + <DocumentLink + key={node.id} + node={node} + collection={documentCollection} + activeDocument={documents.active} + prefetchDocument={documents.prefetchDocument} + isDraft={node.isDraft} + depth={2} + index={index} + parentId={document.id} + /> + ))} + </Folder> + {cursor} + </Relative> + </SidebarExpansionContext.Provider> + </SidebarContext.Provider> + </DocumentRow> + </Draggable> ); }); const StarredCollectionLink = observer(function StarredCollectionLink({ star, collection, + expanded, sidebarContext, - isDragging, handleDisclosureClick, - draggableRef, cursor, - displayChildDocuments, - reorderStarProps, + isDraggingAnyStar, }: StarredCollectionLinkProps) { const { documents } = useStores(); + const history = useHistory(); + const user = useCurrentUser(); + const can = usePolicy(collection.id); + const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); + const editableTitleRef = React.useRef<RefHandle>(null); + const [{ isDragging }, draggableRef] = useDragStar(star); + const displayChildDocuments = expanded && !isDragging; + + const handleTitleChange = React.useCallback( + async (name: string) => { + await collection.save({ name }); + }, + [collection] + ); + + const handleExpand = React.useCallback(() => { + if (!displayChildDocuments) { + handleDisclosureClick(); + } + }, [displayChildDocuments, handleDisclosureClick]); + + const parentRef = React.useRef<HTMLDivElement>(null); + const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection( + collection, + handleExpand, + parentRef + ); + + const handleRename = React.useCallback(() => { + editableTitleRef.current?.setIsEditing(true); + }, []); + + const handlePrefetch = React.useCallback(() => { + void collection.fetchDocuments(); + }, [collection]); + + const handleNewDoc = React.useCallback( + async (input: string) => { + const newDocument = await documents.create( + { + collectionId: collection.id, + title: input, + fullWidth: user.getPreference(UserPreference.FullWidthDocuments), + data: ProsemirrorHelper.getEmptyDocument(), + }, + { publish: true } + ); + collection?.addDocument(newDocument); + history.push({ + pathname: documentEditPath(newDocument), + state: { sidebarContext }, + }); + }, + [user, sidebarContext, history, collection, documents] + ); + + const contextMenuAction = useCollectionMenuAction({ + collectionId: collection.id, + onRename: handleRename, + }); + + const menu = !isDraggingAnyStar ? ( + <CollectionMenu + collection={collection} + onRename={handleRename} + onOpen={handleMenuOpen} + onClose={handleMenuClose} + /> + ) : undefined; return ( <SidebarContext.Provider value={sidebarContext}> - <Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}> - <CollectionLink + <Draggable ref={draggableRef} $isDragging={isDragging}> + <CollectionRow collection={collection} + to={{ pathname: collection.path, state: { sidebarContext } }} expanded={isDragging ? undefined : displayChildDocuments} - activeDocument={documents.active} onDisclosureClick={handleDisclosureClick} - isDraggingAnyCollection={reorderStarProps.isDragging} - /> + onExpand={handleExpand} + onClickIntent={handlePrefetch} + canEdit={can.update} + labelText={collection.name} + onTitleChange={handleTitleChange} + editableTitleRef={editableTitleRef} + contextAction={contextMenuAction} + menu={menu} + menuOpen={menuOpen} + canCreateChild={!isDraggingAnyStar && can.createDocument} + onCreateChild={handleNewDoc} + parentRef={parentRef} + dropRef={dropRef} + isActiveDropTarget={isOver && canDrop} + > + <CollectionLinkChildren + collection={collection} + expanded={displayChildDocuments} + prefetchDocument={documents.prefetchDocument} + /> + </CollectionRow> </Draggable> <Relative>{cursor}</Relative> </SidebarContext.Provider> @@ -191,11 +361,11 @@ const StarredCollectionLink = observer(function StarredCollectionLink({ }); function StarredLink({ star }: Props) { - const theme = useTheme(); const { ui, collections, documents } = useStores(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const { documentId, collectionId } = star; const collection = collectionId ? collections.get(collectionId) : undefined; + const document = documentId ? documents.get(documentId) : undefined; const locationSidebarContext = useLocationSidebarContext(); const sidebarContext = starredSidebarContext( star.documentId ?? star.collectionId ?? "" @@ -250,6 +420,14 @@ function StarredLink({ star }: Props) { [onDisclosureClick] ); + const handleExpand = React.useCallback(() => { + setExpanded(true); + }, []); + + const handleCollapse = React.useCallback(() => { + setExpanded(false); + }, []); + const handlePrefetch = React.useCallback(() => { if (documentId) { void documents.prefetchDocument(documentId); @@ -265,16 +443,10 @@ function StarredLink({ star }: Props) { const next = star?.next(); return fractionalIndex(star?.index || null, next?.index || null); }; - const { label, icon } = useSidebarLabelAndIcon( - star, - <StarredIcon color={theme.yellow} /> - ); - const [{ isDragging }, draggableRef] = useDragStar(star); + const { icon } = useSidebarLabelAndIcon(star); const [reorderStarProps, dropToReorderRef] = useDropToReorderStar(getIndex); const [createStarProps, dropToStarRef] = useDropToCreateStar(getIndex); - const displayChildDocuments = expanded && !isDragging; - const cursor = ( <> {reorderStarProps.isDragging && ( @@ -292,23 +464,22 @@ function StarredLink({ star }: Props) { </> ); - if (documentId) { + if (document) { return ( <SidebarDisclosureContext.Provider value={disclosureEvent}> <StarredDocumentLink star={star} - documentId={documentId} + document={document} expanded={expanded} sidebarContext={sidebarContext} - isDragging={isDragging} handleDisclosureClick={handleDisclosureClick} handlePrefetch={handlePrefetch} + onExpand={handleExpand} + onCollapse={handleCollapse} icon={icon} - label={label} menuOpen={menuOpen} handleMenuOpen={handleMenuOpen} handleMenuClose={handleMenuClose} - draggableRef={draggableRef} cursor={cursor} /> </SidebarDisclosureContext.Provider> @@ -323,12 +494,9 @@ function StarredLink({ star }: Props) { collection={collection} expanded={expanded} sidebarContext={sidebarContext} - isDragging={isDragging} handleDisclosureClick={handleDisclosureClick} - draggableRef={draggableRef} cursor={cursor} - displayChildDocuments={displayChildDocuments} - reorderStarProps={reorderStarProps} + isDraggingAnyStar={reorderStarProps.isDragging} /> </SidebarDisclosureContext.Provider> ); diff --git a/app/components/Sidebar/components/ToggleButton.tsx b/app/components/Sidebar/components/ToggleButton.tsx index b782d4aa0bfb..47ed4135c6c3 100644 --- a/app/components/Sidebar/components/ToggleButton.tsx +++ b/app/components/Sidebar/components/ToggleButton.tsx @@ -10,6 +10,10 @@ const ToggleButton = styled(SidebarButton)` &:active { opacity: 1; } + + [dir="rtl"] & svg { + transform: scaleX(-1); + } `; export default ToggleButton; diff --git a/app/components/Sidebar/components/Version.tsx b/app/components/Sidebar/components/Version.tsx index 0d77a98dd47e..66f9a8affd8d 100644 --- a/app/components/Sidebar/components/Version.tsx +++ b/app/components/Sidebar/components/Version.tsx @@ -54,5 +54,5 @@ export default function Version() { } const LilBadge = styled(Badge)` - margin-left: 0; + margin-inline-start: 0; `; diff --git a/app/components/Sidebar/hooks/useCollectionDocuments.ts b/app/components/Sidebar/hooks/useCollectionDocuments.ts index 9574db3ef853..a886d4f15858 100644 --- a/app/components/Sidebar/hooks/useCollectionDocuments.ts +++ b/app/components/Sidebar/hooks/useCollectionDocuments.ts @@ -7,38 +7,32 @@ export default function useCollectionDocuments( collection: Collection | undefined, activeDocument: Document | undefined ) { - const insertDraftDocument = useMemo( - () => - activeDocument && - activeDocument.isActive && - activeDocument.isDraft && - activeDocument.collectionId === collection?.id && - !activeDocument.parentDocumentId, - [ - activeDocument?.isActive, - activeDocument?.isDraft, - activeDocument?.collectionId, - activeDocument?.parentDocumentId, - collection?.id, - ] + const insertDraftDocument = !!( + activeDocument && + activeDocument.isActive && + activeDocument.isDraft && + activeDocument.collectionId === collection?.id && + !activeDocument.parentDocumentId ); + // Only subscribe to asNavigationNode when we actually need to insert a draft + // into the sorted list. This avoids every CollectionLinkChildren observer + // re-rendering on every title keystroke. + const draftNavNode = insertDraftDocument + ? activeDocument?.asNavigationNode + : undefined; + return useMemo(() => { if (!collection?.sortedDocuments) { return undefined; } - return insertDraftDocument && activeDocument + return draftNavNode ? sortNavigationNodes( - [activeDocument.asNavigationNode, ...collection.sortedDocuments], + [draftNavNode, ...collection.sortedDocuments], collection.sort, false ) : collection.sortedDocuments; - }, [ - insertDraftDocument, - activeDocument?.asNavigationNode, - collection?.sortedDocuments, - collection?.sort, - ]); + }, [draftNavNode, collection?.sortedDocuments, collection?.sort]); } diff --git a/app/components/Sidebar/hooks/useDragAndDrop.tsx b/app/components/Sidebar/hooks/useDragAndDrop.tsx index 61ff400f2ed6..558132a05a2d 100644 --- a/app/components/Sidebar/hooks/useDragAndDrop.tsx +++ b/app/components/Sidebar/hooks/useDragAndDrop.tsx @@ -1,12 +1,10 @@ import fractionalIndex from "fractional-index"; -import { StarredIcon } from "outline-icons"; import * as React from "react"; import type { ConnectDragSource } from "react-dnd"; import { useDrag, useDrop } from "react-dnd"; import { getEmptyImage } from "react-dnd-html5-backend"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { useTheme } from "styled-components"; import Icon from "@shared/components/Icon"; import type { NavigationNode } from "@shared/types"; import type Collection from "~/models/Collection"; @@ -68,11 +66,8 @@ export function useDragStar( star: Star ): [{ isDragging: boolean }, ConnectDragSource] { const id = star.id; - const theme = useTheme(); - const { label: title, icon } = useSidebarLabelAndIcon( - star, - <StarredIcon color={theme.yellow} /> - ); + const { label: title, icon } = useSidebarLabelAndIcon(star); + const [{ isDragging }, draggableRef, preview] = useDrag({ type: "star", item: () => ({ id, title, icon }), @@ -495,21 +490,12 @@ export function useDragMembership( const id = membership.id; const { label: title, icon } = useSidebarLabelAndIcon(membership); - const [{ isDragging }, draggableRef, preview] = useDrag< - DragObject, - Promise<void>, - { isDragging: boolean } - >({ + const [{ isDragging }, draggableRef, preview] = useDrag({ type: membership instanceof UserMembership ? "userMembership" : "groupMembership", - item: () => - ({ - id, - title, - icon, - }) as DragObject, + item: () => ({ id, title, icon }), collect: (monitor) => ({ isDragging: !!monitor.isDragging(), }), diff --git a/app/components/Sidebar/hooks/useSidebarLabelAndIcon.tsx b/app/components/Sidebar/hooks/useSidebarLabelAndIcon.tsx index e95884ce8fb0..16f95d94b59b 100644 --- a/app/components/Sidebar/hooks/useSidebarLabelAndIcon.tsx +++ b/app/components/Sidebar/hooks/useSidebarLabelAndIcon.tsx @@ -1,4 +1,4 @@ -import { DocumentIcon } from "outline-icons"; +import { DocumentIcon, QuestionMarkIcon } from "outline-icons"; import * as React from "react"; import Icon from "@shared/components/Icon"; import CollectionIcon from "~/components/Icons/CollectionIcon"; @@ -7,14 +7,16 @@ import useStores from "~/hooks/useStores"; interface SidebarItem { documentId?: string; collectionId?: string; + groupId?: string; } -export function useSidebarLabelAndIcon( - { documentId, collectionId }: SidebarItem, - defaultIcon?: React.ReactNode -) { +export function useSidebarLabelAndIcon({ + documentId, + collectionId, + groupId, +}: SidebarItem) { const { collections, documents } = useStores(); - const icon = defaultIcon ?? <DocumentIcon />; + const icon = <QuestionMarkIcon />; if (documentId) { const document = documents.get(documentId); @@ -27,8 +29,8 @@ export function useSidebarLabelAndIcon( initial={document.initial} color={document.color ?? undefined} /> - ) : ( - icon + ) : groupId ? null : ( + <DocumentIcon outline={document.isDraft} /> ), }; } diff --git a/app/components/SortableTable.tsx b/app/components/SortableTable.tsx index 340268e34d8f..d56796acf975 100644 --- a/app/components/SortableTable.tsx +++ b/app/components/SortableTable.tsx @@ -1,7 +1,6 @@ import type { ColumnSort } from "@tanstack/react-table"; import { useCallback } from "react"; -import { useHistory, useLocation } from "react-router-dom"; -import useQuery from "~/hooks/useQuery"; +import { useHistory } from "react-router-dom"; import lazyWithRetry from "~/utils/lazyWithRetry"; import type { Props as TableProps } from "./Table"; @@ -10,21 +9,21 @@ const Table = lazyWithRetry(() => import("~/components/Table")); export type Props<T> = Omit<TableProps<T>, "onChangeSort">; export function SortableTable<T>(props: Props<T>) { - const location = useLocation(); const history = useHistory(); - const params = useQuery(); const handleChangeSort = useCallback( (sort: ColumnSort) => { + const { pathname, search } = history.location; + const params = new URLSearchParams(search); params.set("sort", sort.id); params.set("direction", sort.desc ? "desc" : "asc"); history.replace({ - pathname: location.pathname, + pathname, search: params.toString(), }); }, - [params, history, location.pathname] + [history] ); return <Table onChangeSort={handleChangeSort} {...props} />; diff --git a/app/components/Switch.tsx b/app/components/Switch.tsx index 9bd3f53bd63b..0f7fba55eaae 100644 --- a/app/components/Switch.tsx +++ b/app/components/Switch.tsx @@ -1,4 +1,5 @@ import * as RadixSwitch from "@radix-ui/react-switch"; +import { darken } from "polished"; import * as React from "react"; import styled from "styled-components"; import { s } from "@shared/styles"; @@ -126,6 +127,32 @@ const Label = styled.label<{ ${(props) => (props.disabled ? `opacity: 0.75;` : "")} `; +const HOVER_EXTRA = 3; + +const StyledSwitchThumb = styled(RadixSwitch.Thumb)<{ + width: number; + height: number; +}>` + display: block; + width: ${(props) => props.height - 8}px; + height: ${(props) => props.height - 8}px; + background-color: white; + border-radius: ${(props) => (props.height - 8) / 2}px; + transition: + transform 0.2s, + width 0.2s; + transform: translateX(0); + will-change: transform, width; + + &[data-state="checked"] { + transform: translateX(${(props) => props.width - props.height}px); + } + + [dir="rtl"] &[data-state="checked"] { + transform: translateX(${(props) => -(props.width - props.height)}px); + } +`; + const StyledSwitchRoot = styled(RadixSwitch.Root)<{ width: number; height: number; @@ -137,7 +164,7 @@ const StyledSwitchRoot = styled(RadixSwitch.Root)<{ border-radius: ${(props) => props.height}px; border: none; cursor: var(--pointer); - transition: background-color 0.4s; + transition: background-color 0.2s; padding: 0 4px; flex-shrink: 0; @@ -150,27 +177,35 @@ const StyledSwitchRoot = styled(RadixSwitch.Root)<{ background-color: ${s("accent")}; } + &:active:not(:disabled) { + background-color: ${(props) => darken(0.1, props.theme.slate)}; + } + + &:active:not(:disabled)[data-state="checked"] { + background-color: ${(props) => darken(0.1, props.theme.accent)}; + } + &:disabled { opacity: 0.75; cursor: default; } -`; -const StyledSwitchThumb = styled(RadixSwitch.Thumb)<{ - width: number; - height: number; -}>` - display: block; - width: ${(props) => props.height - 8}px; - height: ${(props) => props.height - 8}px; - background-color: white; - border-radius: 50%; - transition: transform 0.4s; - transform: translateX(0); - will-change: transform; + &:hover:not(:disabled) ${StyledSwitchThumb} { + width: ${(props) => props.height - 8 + HOVER_EXTRA}px; + } - &[data-state="checked"] { - transform: translateX(${(props) => props.width - props.height}px); + &:hover:not(:disabled)[data-state="checked"] ${StyledSwitchThumb} { + transform: translateX( + ${(props) => props.width - props.height - HOVER_EXTRA}px + ); + } + + [dir="rtl"] + &:hover:not(:disabled)[data-state="checked"] + ${StyledSwitchThumb} { + transform: translateX( + ${(props) => -(props.width - props.height - HOVER_EXTRA)}px + ); } `; diff --git a/app/components/Tab.tsx b/app/components/Tab.tsx index 4dc208743d5b..6d872fddc1e7 100644 --- a/app/components/Tab.tsx +++ b/app/components/Tab.tsx @@ -3,7 +3,8 @@ import type { LocationDescriptor } from "history"; import isEqual from "lodash/isEqual"; import queryString from "query-string"; import * as React from "react"; -import styled, { useTheme } from "styled-components"; +import styled, { css, useTheme } from "styled-components"; +import breakpoint from "styled-components-breakpoint"; import { s, hover } from "@shared/styles"; import NavLink from "~/components/NavLink"; @@ -46,7 +47,7 @@ interface ButtonProps extends BaseProps { type Props = LinkProps | ButtonProps; -const tabStyles = ` +const tabStyles = css` position: relative; display: inline-flex; align-items: center; @@ -54,8 +55,11 @@ const tabStyles = ` font-size: 14px; cursor: var(--pointer); user-select: none; - margin-right: 24px; - padding: 6px 0; + padding: 12px 0; + + ${breakpoint("tablet")` + padding: 6px 0; + `}; `; const TabLink = styled(NavLink)` diff --git a/app/components/Tabs.tsx b/app/components/Tabs.tsx index 29eb131ef6ca..a33097341467 100644 --- a/app/components/Tabs.tsx +++ b/app/components/Tabs.tsx @@ -14,6 +14,10 @@ const Nav = styled.nav<{ $shadowVisible?: boolean }>` -ms-overflow-style: none; scrollbar-width: none; + & > * + * { + margin-inline-start: 24px; + } + &::-webkit-scrollbar { display: none; } @@ -52,7 +56,6 @@ export const Separator = styled.span` border-left: 1px solid ${s("divider")}; position: relative; top: 2px; - margin-right: 24px; margin-top: 6px; `; @@ -61,7 +64,7 @@ type Props = { }; const Tabs: React.FC = ({ children }: Props) => { - const ref = React.useRef<any>(); + const ref = React.useRef<HTMLElement>(null); const [shadowVisible, setShadow] = React.useState(false); const { width } = useWindowSize(); diff --git a/app/components/Template/TemplateEdit.tsx b/app/components/Template/TemplateEdit.tsx deleted file mode 100644 index 388e7e37e16f..000000000000 --- a/app/components/Template/TemplateEdit.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { observer } from "mobx-react"; -import { useCallback } from "react"; -import { toast } from "sonner"; -import { TemplateForm } from "./TemplateForm"; -import type Template from "~/models/Template"; - -type Props = { - template: Template; - onSubmit: () => void; -}; - -export const TemplateEdit = observer(function TemplateEdit_({ - template, - onSubmit, -}: Props) { - const handleSubmit = useCallback(async () => { - try { - await template?.save(); - onSubmit?.(); - } catch (error) { - toast.error(error.message); - } - }, [template, onSubmit]); - - if (!template) { - return null; - } - - return <TemplateForm template={template} handleSubmit={handleSubmit} />; -}); diff --git a/app/components/Template/TemplateNew.tsx b/app/components/Template/TemplateNew.tsx deleted file mode 100644 index 5ee1b030ee9f..000000000000 --- a/app/components/Template/TemplateNew.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { observer } from "mobx-react"; -import { useCallback, useState } from "react"; -import { toast } from "sonner"; -import Template from "~/models/Template"; -import useStores from "~/hooks/useStores"; -import { TemplateForm } from "./TemplateForm"; - -type Props = { - collectionId?: string | null; - onSubmit?: () => void; -}; - -export const TemplateNew = observer(function TemplateNew_({ - collectionId, - onSubmit, -}: Props) { - const { templates } = useStores(); - const [template] = useState( - new Template({ title: "", collectionId }, templates) - ); - - const handleSubmit = useCallback(async () => { - try { - await template.save(); - onSubmit?.(); - } catch (error) { - toast.error(error.message); - } - }, [template, onSubmit]); - - if (!template) { - return null; - } - - return <TemplateForm template={template} handleSubmit={handleSubmit} />; -}); diff --git a/app/components/TemplatizeDialog/Label.tsx b/app/components/TemplatizeDialog/Label.tsx deleted file mode 100644 index 0d52ccbd16d1..000000000000 --- a/app/components/TemplatizeDialog/Label.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from "react"; -import styled from "styled-components"; -import Flex from "~/components/Flex"; - -const Label = ({ icon, value }: { icon: React.ReactNode; value: string }) => ( - <Flex align="center" gap={4}> - <IconWrapper>{icon}</IconWrapper> - {value} - </Flex> -); - -const IconWrapper = styled.span` - display: flex; - justify-content: center; - align-items: center; - height: 24px; - width: 24px; - overflow: hidden; - flex-shrink: 0; -`; - -export default Label; diff --git a/app/components/Theme.tsx b/app/components/Theme.tsx index f5939c1c5484..21cd192c122a 100644 --- a/app/components/Theme.tsx +++ b/app/components/Theme.tsx @@ -1,8 +1,11 @@ +import { DirectionProvider } from "@radix-ui/react-direction"; import { observer } from "mobx-react"; import * as React from "react"; +import { useTranslation } from "react-i18next"; import { ThemeProvider } from "styled-components"; import GlobalStyles from "@shared/styles/globals"; import { TeamPreference, UserPreference } from "@shared/types"; +import { isRTLLanguage } from "@shared/utils/rtl"; import useBuildTheme from "~/hooks/useBuildTheme"; import useStores from "~/hooks/useStores"; @@ -12,11 +15,13 @@ type Props = { const Theme: React.FC = ({ children }: Props) => { const { auth, ui } = useStores(); + const { i18n } = useTranslation(); const theme = useBuildTheme( auth.team?.getPreference(TeamPreference.CustomTheme) || auth.config?.customTheme || undefined ); + const direction = isRTLLanguage(i18n.language) ? "rtl" : "ltr"; React.useEffect(() => { window.dispatchEvent( @@ -27,17 +32,19 @@ const Theme: React.FC = ({ children }: Props) => { }, [ui.resolvedTheme]); return ( - <ThemeProvider theme={theme}> - <> - <GlobalStyles - useCursorPointer={ - // Default to showing the cursor pointer if no user is logged in (public share) - auth.user?.getPreference(UserPreference.UseCursorPointer) ?? true - } - /> - {children} - </> - </ThemeProvider> + <DirectionProvider dir={direction}> + <ThemeProvider theme={theme}> + <> + <GlobalStyles + useCursorPointer={ + // Default to showing the cursor pointer if no user is logged in (public share) + auth.user?.getPreference(UserPreference.UseCursorPointer) ?? true + } + /> + {children} + </> + </ThemeProvider> + </DirectionProvider> ); }; diff --git a/app/components/Toasts.tsx b/app/components/Toasts.tsx index bc8c9755a7fb..2df6f70ae5dd 100644 --- a/app/components/Toasts.tsx +++ b/app/components/Toasts.tsx @@ -4,6 +4,7 @@ import { Toaster, useSonner } from "sonner"; import styled, { useTheme } from "styled-components"; import { useWebHaptics } from "web-haptics/react"; import useStores from "~/hooks/useStores"; +import type { ResolvedTheme } from "~/stores/UiStore"; function Toasts() { const { ui } = useStores(); @@ -26,7 +27,8 @@ function Toasts() { return ( <StyledToaster - theme={ui.resolvedTheme as any} + // @ts-expect-error styled-components overrides sonner's theme prop with DefaultTheme + theme={ui.resolvedTheme as ResolvedTheme} closeButton toastOptions={{ duration: 5000, diff --git a/app/components/Tooltip.tsx b/app/components/Tooltip.tsx index a674be684cbc..b1cebbd0eba0 100644 --- a/app/components/Tooltip.tsx +++ b/app/components/Tooltip.tsx @@ -3,6 +3,7 @@ import { transparentize } from "polished"; import * as React from "react"; import styled, { keyframes } from "styled-components"; import { s, depths } from "@shared/styles"; +import { shortcutSeparator } from "@shared/utils/keyboard"; import useMobile from "~/hooks/useMobile"; import { useTooltipContext } from "./TooltipContext"; @@ -141,13 +142,16 @@ function Tooltip({ {tooltip} {shortcutOnNewline ? <br /> : " "} {typeof shortcut === "string" ? ( - shortcut - .split("+") - .map((key, i) => ( + shortcut.split("+").flatMap((key, i, arr) => { + const el = ( <Shortcut key={`${key}${i}`}> {key.length === 1 ? key.toUpperCase() : key} </Shortcut> - )) + ); + return i < arr.length - 1 && shortcutSeparator + ? [el, shortcutSeparator] + : [el]; + }) ) : ( <Shortcut>{shortcut}</Shortcut> )} diff --git a/app/components/UserDialogs.tsx b/app/components/UserDialogs.tsx index 6d3381142ae5..1d568bd6547e 100644 --- a/app/components/UserDialogs.tsx +++ b/app/components/UserDialogs.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; import { toast } from "sonner"; import { UserRole } from "@shared/types"; +import { UserValidation } from "@shared/validations"; import type User from "~/models/User"; import ConfirmationDialog from "~/components/ConfirmationDialog"; import Input from "~/components/Input"; @@ -131,6 +132,8 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) { onChange={handleChange} error={!name ? t("Name can't be empty") : undefined} value={name} + maxLength={UserValidation.maxNameLength} + showCharacterCount autoSelect required flex @@ -192,6 +195,8 @@ export function UserChangeEmailDialog({ user, onSubmit }: Props) { onChange={handleChange} error={!email ? t("Email can't be empty") : error} value={email} + maxLength={UserValidation.maxEmailLength} + showCharacterCount autoSelect required flex diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index 820442978cd8..06cecab32a2e 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -1,11 +1,10 @@ import * as Sentry from "@sentry/react"; import invariant from "invariant"; import find from "lodash/find"; -import { action, observable } from "mobx"; +import { action } from "mobx"; import { observer } from "mobx-react"; -import { createContext, Component } from "react"; -import type { WithTranslation } from "react-i18next"; -import { withTranslation } from "react-i18next"; +import { createContext, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import type { Socket } from "socket.io-client"; import { io } from "socket.io-client"; import { toast } from "sonner"; @@ -31,7 +30,7 @@ import type Subscription from "~/models/Subscription"; import type Team from "~/models/Team"; import type User from "~/models/User"; import type UserMembership from "~/models/UserMembership"; -import withStores from "~/components/withStores"; +import useStores from "~/hooks/useStores"; import type { PartialExcept, WebsocketCollectionUpdateIndexEvent, @@ -40,6 +39,7 @@ import type { WebsocketEntityDeletedEvent, } from "~/types"; import { AuthorizationError, NotFoundError } from "~/utils/errors"; +import Logger from "~/utils/Logger"; import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility"; type SocketWithAuthentication = Socket & { @@ -50,96 +50,65 @@ export const WebsocketContext = createContext<SocketWithAuthentication | null>( null ); -type Props = WithTranslation & RootStore; - -@observer -class WebsocketProvider extends Component<Props> { - @observable - socket: SocketWithAuthentication | null; - - componentDidMount() { - this.createConnection(); - document.addEventListener(getVisibilityListener(), this.checkConnection); - } - - componentWillUnmount() { - if (this.socket) { - this.socket.authenticated = false; - this.socket.disconnect(); - } - - document.removeEventListener(getVisibilityListener(), this.checkConnection); +function invalidateChildPolicies( + documentId: string, + { documents, policies }: Pick<RootStore, "documents" | "policies"> +) { + const document = documents.get(documentId); + if (document) { + document.childDocuments.forEach((childDocument) => { + policies.remove(childDocument.id); + }); } +} - checkConnection = () => { - if (this.socket?.disconnected && getPageVisible()) { - // null-ifying this reference is important, do not remove. Without it - // references to old sockets are potentially held in context - this.socket.close(); - this.socket = null; - this.createConnection(); - } - }; - - createConnection = () => { - this.socket = io(window.location.origin, { - path: "/realtime", - transports: ["websocket"], - reconnectionDelay: 1000, - reconnectionDelayMax: 30000, - withCredentials: true, - }); - invariant(this.socket, "Socket should be defined"); - - this.socket.authenticated = false; - const { - auth, - documents, - collections, - groups, - groupUsers, - groupMemberships, - pins, - stars, - memberships, - users, - userMemberships, - policies, - comments, - subscriptions, - fileOperations, - notifications, - imports, - } = this.props; - - const currentUserId = auth?.user?.id; +function useConnectionHandlers() { + const { auth } = useStores(); + return (socket: SocketWithAuthentication) => { // on reconnection, reset the transports option, as the Websocket // connection may have failed (caused by proxy, firewall, browser, ...) - this.socket.io.on("reconnect_attempt", () => { - if (this.socket) { - this.socket.io.opts.transports = auth?.team?.domain + socket.io.on("reconnect_attempt", () => { + if (socket) { + socket.io.opts.transports = auth?.team?.domain ? ["websocket"] : ["websocket", "polling"]; } }); - this.socket.on("authenticated", () => { - if (this.socket) { - this.socket.authenticated = true; + socket.on("authenticated", () => { + if (socket) { + socket.authenticated = true; } }); - this.socket.on("unauthorized", (err: Error) => { - if (this.socket) { - this.socket.authenticated = false; + socket.on("unauthorized", (err: unknown) => { + if (socket) { + socket.authenticated = false; + } + + const message = + err instanceof Error + ? err.message + : typeof err === "object" && err !== null && "message" in err + ? String((err as { message: unknown }).message) + : "Socket unauthorized"; + + toast.error(message); + + if (err instanceof Error) { + Sentry.captureException(err); + } else { + Sentry.captureException(new Error(message), { + extra: { + unauthorizedPayload: err, + }, + }); } - toast.error(err.message); - throw err; }); // add a listener for all events that logs a sentry breadcrumb - this.socket.onAny((event: string, data: Record<string, unknown>) => { + socket.onAny((event: string, data: Record<string, unknown>) => { Sentry.addBreadcrumb({ category: "websocket", message: `Received event: ${event}`, @@ -147,7 +116,23 @@ class WebsocketProvider extends Component<Props> { }); }); - this.socket.on( + // received a message from the API server that we should request + // to join or leave a specific room. Forward that to the ws server. + socket.on("join", (event) => { + socket.emit("join", event); + }); + + socket.on("leave", (event) => { + socket.emit("leave", event); + }); + }; +} + +function useEntityHandlers() { + const { documents, collections, policies, memberships } = useStores(); + + return (socket: SocketWithAuthentication) => { + socket.on( "entities", action(async (event: WebsocketEntitiesEvent) => { if (event.documentIds) { @@ -243,8 +228,24 @@ class WebsocketProvider extends Component<Props> { } }) ); + }; +} + +function useDocumentHandlers() { + const { + auth, + documents, + collections, + policies, + userMemberships, + groupMemberships, + groups, + } = useStores(); + + return (socket: SocketWithAuthentication) => { + const currentUserId = auth?.user?.id; - this.socket.on( + socket.on( "documents.update", action((event: PartialExcept<Document, "id" | "title" | "url">) => { documents.add(event); @@ -256,7 +257,7 @@ class WebsocketProvider extends Component<Props> { }) ); - this.socket.on( + socket.on( "documents.unpublish", action( (event: { @@ -284,7 +285,7 @@ class WebsocketProvider extends Component<Props> { ) ); - this.socket.on( + socket.on( "documents.archive", action((event: PartialExcept<Document, "id">) => { const model = documents.add(event); @@ -297,7 +298,7 @@ class WebsocketProvider extends Component<Props> { }) ); - this.socket.on( + socket.on( "documents.delete", action((event: PartialExcept<Document, "id">) => { documents.add(event); @@ -314,47 +315,39 @@ class WebsocketProvider extends Component<Props> { }) ); - this.socket.on( + socket.on( "documents.permanent_delete", (event: WebsocketEntityDeletedEvent) => { documents.remove(event.modelId); } ); - this.socket.on( + socket.on( "documents.add_user", async (event: PartialExcept<UserMembership, "id">) => { userMemberships.add(event); - // Any existing child policies are now invalid if (event.userId === currentUserId) { - const document = documents.get(event.documentId!); - if (document) { - document.childDocuments.forEach((childDocument) => { - policies.remove(childDocument.id); - }); - } + invalidateChildPolicies(event.documentId!, { documents, policies }); } - await documents.fetch(event.documentId!, { - force: event.userId === currentUserId, - }); + try { + await documents.fetch(event.documentId!, { + force: event.userId === currentUserId, + }); + } catch (err) { + Logger.error("Failed to fetch document after add_user", err); + } } ); - this.socket.on( + socket.on( "documents.remove_user", (event: PartialExcept<UserMembership, "id">) => { userMemberships.remove(event.id); - // Any existing child policies are now invalid if (event.userId === currentUserId) { - const document = documents.get(event.documentId!); - if (document) { - document.childDocuments.forEach((childDocument) => { - policies.remove(childDocument.id); - }); - } + invalidateChildPolicies(event.documentId!, { documents, policies }); } const policy = policies.get(event.documentId!); @@ -364,119 +357,56 @@ class WebsocketProvider extends Component<Props> { } ); - this.socket.on( + socket.on( "documents.add_group", (event: PartialExcept<GroupMembership, "id">) => { groupMemberships.add(event); const group = groups.get(event.groupId!); - // Any existing child policies are now invalid if (currentUserId && group?.users.some((u) => u.id === currentUserId)) { - const document = documents.get(event.documentId!); - if (document) { - document.childDocuments.forEach((childDocument) => { - policies.remove(childDocument.id); - }); - } + invalidateChildPolicies(event.documentId!, { documents, policies }); } } ); - this.socket.on( + socket.on( "documents.remove_group", (event: PartialExcept<GroupMembership, "id">) => { groupMemberships.remove(event.id); } ); + }; +} - this.socket.on("comments.create", (event: PartialExcept<Comment, "id">) => { - comments.add(event); - }); - - this.socket.on("comments.update", (event: PartialExcept<Comment, "id">) => { - const comment = comments.get(event.id); - - // Existing policy becomes invalid when the resolution status has changed and we don't have the latest version. - if (comment?.resolvedAt !== event.resolvedAt) { - policies.remove(event.id); - } - - comments.add(event); - }); - - this.socket.on("comments.delete", (event: WebsocketEntityDeletedEvent) => { - comments.remove(event.modelId); - }); - - this.socket.on( - "comments.add_reaction", - (event: WebsocketCommentReactionEvent) => { - const comment = comments.get(event.commentId); - comment?.updateReaction({ - type: "add", - emoji: event.emoji, - user: event.user, - }); - } - ); - - this.socket.on( - "comments.remove_reaction", - (event: WebsocketCommentReactionEvent) => { - const comment = comments.get(event.commentId); - comment?.updateReaction({ - type: "remove", - emoji: event.emoji, - user: event.user, - }); - } - ); - - this.socket.on("groups.create", (event: PartialExcept<Group, "id">) => { - groups.add(event); - }); - - this.socket.on("groups.update", (event: PartialExcept<Group, "id">) => { - groups.add(event); - }); - - this.socket.on("groups.delete", (event: WebsocketEntityDeletedEvent) => { - groups.remove(event.modelId); - }); - - this.socket.on( - "groups.add_user", - (event: PartialExcept<GroupUser, "id">) => { - groupUsers.add(event); - } - ); - - this.socket.on( - "groups.remove_user", - (event: PartialExcept<GroupUser, "id">) => { - groupUsers.removeAll({ - groupId: event.groupId, - userId: event.userId, - }); - } - ); +function useCollectionHandlers() { + const { + auth, + collections, + documents, + policies, + memberships, + groupMemberships, + } = useStores(); + + return (socket: SocketWithAuthentication) => { + const currentUserId = auth?.user?.id; - this.socket.on( + socket.on( "collections.create", (event: PartialExcept<Collection, "id">) => { collections.add(event); } ); - this.socket.on( + socket.on( "collections.update", (event: PartialExcept<Collection, "id">) => { collections.add(event); } ); - this.socket.on( + socket.on( "collections.delete", action((event: WebsocketEntityDeletedEvent) => { const collectionId = event.modelId; @@ -496,13 +426,17 @@ class WebsocketProvider extends Component<Props> { }) ); - this.socket.on( + socket.on( "collections.archive", async (event: PartialExcept<Collection, "id">) => { const collectionId = event.id; // Fetch collection to update policies - await collections.fetch(collectionId, { force: true }); + try { + await collections.fetch(collectionId, { force: true }); + } catch (err) { + Logger.error("Failed to fetch collection after archive", err); + } documents.unarchivedInCollection(collectionId).forEach( action((doc) => { @@ -518,7 +452,7 @@ class WebsocketProvider extends Component<Props> { } ); - this.socket.on( + socket.on( "collections.restore", async (event: PartialExcept<Collection, "id">) => { const collectionId = event.id; @@ -534,11 +468,145 @@ class WebsocketProvider extends Component<Props> { ); // Fetch collection to update policies - await collections.fetch(collectionId, { force: true }); + try { + await collections.fetch(collectionId, { force: true }); + } catch (err) { + Logger.error("Failed to fetch collection after restore", err); + } + } + ); + + socket.on("collections.add_user", async (event: Membership) => { + memberships.add(event); + try { + await collections.fetch(event.collectionId, { + force: event.userId === currentUserId, + }); + } catch (err) { + Logger.error("Failed to fetch collection after add_user", err); + } + }); + + socket.on("collections.remove_user", (event: Membership) => { + memberships.remove(event.id); + + const policy = policies.get(event.collectionId); + if (policy && policy.abilities.read === false) { + collections.remove(event.collectionId); + } + }); + + socket.on("collections.add_group", async (event: GroupMembership) => { + groupMemberships.add(event); + try { + await collections.fetch(event.collectionId!); + } catch (err) { + Logger.error("Failed to fetch collection after add_group", err); + } + }); + + socket.on("collections.remove_group", async (event: GroupMembership) => { + groupMemberships.remove(event.id); + + const policy = policies.get(event.collectionId!); + if (policy && policy.abilities.read === false) { + collections.remove(event.collectionId!); + } + }); + + socket.on( + "collections.update_index", + action((event: WebsocketCollectionUpdateIndexEvent) => { + const collection = collections.get(event.collectionId); + collection?.updateIndex(event.index); + }) + ); + }; +} + +function useCommentHandlers() { + const { comments, policies } = useStores(); + + return (socket: SocketWithAuthentication) => { + socket.on("comments.create", (event: PartialExcept<Comment, "id">) => { + comments.add(event); + }); + + socket.on("comments.update", (event: PartialExcept<Comment, "id">) => { + const comment = comments.get(event.id); + + // Existing policy becomes invalid when the resolution status has changed and we don't have the latest version. + if (comment?.resolvedAt !== event.resolvedAt) { + policies.remove(event.id); + } + + comments.add(event); + }); + + socket.on("comments.delete", (event: WebsocketEntityDeletedEvent) => { + comments.remove(event.modelId); + }); + + socket.on( + "comments.add_reaction", + (event: WebsocketCommentReactionEvent) => { + const comment = comments.get(event.commentId); + comment?.updateReaction({ + type: "add", + emoji: event.emoji, + user: event.user, + }); + } + ); + + socket.on( + "comments.remove_reaction", + (event: WebsocketCommentReactionEvent) => { + const comment = comments.get(event.commentId); + comment?.updateReaction({ + type: "remove", + emoji: event.emoji, + user: event.user, + }); } ); + }; +} + +function useGroupHandlers() { + const { groups, groupUsers } = useStores(); + + return (socket: SocketWithAuthentication) => { + socket.on("groups.create", (event: PartialExcept<Group, "id">) => { + groups.add(event); + }); + + socket.on("groups.update", (event: PartialExcept<Group, "id">) => { + groups.add(event); + }); + + socket.on("groups.delete", (event: WebsocketEntityDeletedEvent) => { + groups.remove(event.modelId); + }); + + socket.on("groups.add_user", (event: PartialExcept<GroupUser, "id">) => { + groupUsers.add(event); + }); + + socket.on("groups.remove_user", (event: PartialExcept<GroupUser, "id">) => { + groupUsers.removeAll({ + groupId: event.groupId, + userId: event.userId, + }); + }); + }; +} - this.socket.on("teams.update", (event: PartialExcept<Team, "id">) => { +function useTeamHandlers() { + const { auth, documents, policies } = useStores(); + + return (socket: SocketWithAuthentication) => { + socket.on("teams.update", (event: PartialExcept<Team, "id">) => { if ("sharing" in event && event.sharing !== auth.team?.sharing) { documents.all.forEach((document) => { policies.remove(document.id); @@ -547,94 +615,122 @@ class WebsocketProvider extends Component<Props> { auth.team?.updateData(event); }); + }; +} + +function useUserHandlers() { + const { auth, users, userMemberships, documents, collections, policies } = + useStores(); + + return (socket: SocketWithAuthentication) => { + socket.on("users.update", (event: PartialExcept<User, "id">) => { + users.add(event); + }); + + socket.on("users.demote", async (event: PartialExcept<User, "id">) => { + if (event.id === auth.user?.id) { + documents.all.forEach((document) => policies.remove(document.id)); + try { + await collections.fetchAll(); + } catch (err) { + Logger.error("Failed to fetch collections after demote", err); + } + } + }); + + socket.on("users.delete", (event: WebsocketEntityDeletedEvent) => { + users.remove(event.modelId); + }); + + socket.on( + "userMemberships.update", + async (event: PartialExcept<UserMembership, "id">) => { + userMemberships.add(event); + } + ); + }; +} - this.socket.on( +function useNotificationHandlers() { + const { notifications, subscriptions } = useStores(); + + return (socket: SocketWithAuthentication) => { + socket.on( "notifications.create", (event: PartialExcept<Notification, "id">) => { notifications.add(event); } ); - this.socket.on( + socket.on( "notifications.update", (event: PartialExcept<Notification, "id">) => { notifications.add(event); } ); - this.socket.on("pins.create", (event: PartialExcept<Pin, "id">) => { + socket.on( + "subscriptions.create", + (event: PartialExcept<Subscription, "id">) => { + subscriptions.add(event); + } + ); + + socket.on("subscriptions.delete", (event: WebsocketEntityDeletedEvent) => { + subscriptions.remove(event.modelId); + }); + }; +} + +function usePinHandlers() { + const { pins } = useStores(); + + return (socket: SocketWithAuthentication) => { + socket.on("pins.create", (event: PartialExcept<Pin, "id">) => { pins.add(event); }); - this.socket.on("pins.update", (event: PartialExcept<Pin, "id">) => { + socket.on("pins.update", (event: PartialExcept<Pin, "id">) => { pins.add(event); }); - this.socket.on("pins.delete", (event: WebsocketEntityDeletedEvent) => { + socket.on("pins.delete", (event: WebsocketEntityDeletedEvent) => { pins.remove(event.modelId); }); + }; +} - this.socket.on("stars.create", (event: PartialExcept<Star, "id">) => { +function useStarHandlers() { + const { stars } = useStores(); + + return (socket: SocketWithAuthentication) => { + socket.on("stars.create", (event: PartialExcept<Star, "id">) => { stars.add(event); }); - this.socket.on("stars.update", (event: PartialExcept<Star, "id">) => { + socket.on("stars.update", (event: PartialExcept<Star, "id">) => { stars.add(event); }); - this.socket.on("stars.delete", (event: WebsocketEntityDeletedEvent) => { + socket.on("stars.delete", (event: WebsocketEntityDeletedEvent) => { stars.remove(event.modelId); }); + }; +} - this.socket.on("collections.add_user", async (event: Membership) => { - memberships.add(event); - await collections.fetch(event.collectionId, { - force: event.userId === currentUserId, - }); - }); - - this.socket.on("collections.remove_user", (event: Membership) => { - memberships.remove(event.id); - - const policy = policies.get(event.collectionId); - if (policy && policy.abilities.read === false) { - collections.remove(event.collectionId); - } - }); - - this.socket.on("collections.add_group", async (event: GroupMembership) => { - groupMemberships.add(event); - await collections.fetch(event.collectionId!); - }); - - this.socket.on( - "collections.remove_group", - async (event: GroupMembership) => { - groupMemberships.remove(event.id); - - const policy = policies.get(event.collectionId!); - if (policy && policy.abilities.read === false) { - collections.remove(event.collectionId!); - } - } - ); - - this.socket.on( - "collections.update_index", - action((event: WebsocketCollectionUpdateIndexEvent) => { - const collection = collections.get(event.collectionId); - collection?.updateIndex(event.index); - }) - ); +function useImportHandlers() { + const { auth, fileOperations, imports } = useStores(); + const { t } = useTranslation(); - this.socket.on( + return (socket: SocketWithAuthentication) => { + socket.on( "fileOperations.create", (event: PartialExcept<FileOperation, "id">) => { fileOperations.add(event); } ); - this.socket.on( + socket.on( "fileOperations.update", (event: PartialExcept<FileOperation, "id">) => { fileOperations.add(event); @@ -645,17 +741,17 @@ class WebsocketProvider extends Component<Props> { event.user?.id === auth.user?.id ) { toast.success(event.name, { - description: this.props.t("Your import completed"), + description: t("Your import completed"), }); } } ); - this.socket.on("imports.create", (event: PartialExcept<Import, "id">) => { + socket.on("imports.create", (event: PartialExcept<Import, "id">) => { imports.add(event); }); - this.socket.on("imports.update", (event: PartialExcept<Import, "id">) => { + socket.on("imports.update", (event: PartialExcept<Import, "id">) => { imports.add(event); if ( @@ -663,67 +759,89 @@ class WebsocketProvider extends Component<Props> { event.createdBy?.id === auth.user?.id ) { toast.success(event.name, { - description: this.props.t("Your import completed"), + description: t("Your import completed"), }); } }); + }; +} - this.socket.on( - "subscriptions.create", - (event: PartialExcept<Subscription, "id">) => { - subscriptions.add(event); - } - ); - - this.socket.on( - "subscriptions.delete", - (event: WebsocketEntityDeletedEvent) => { - subscriptions.remove(event.modelId); - } - ); - - this.socket.on("users.update", (event: PartialExcept<User, "id">) => { - users.add(event); - }); +function WebsocketProvider({ children }: React.PropsWithChildren<object>) { + const [socket, setSocket] = useState<SocketWithAuthentication | null>(null); + + const registerConnectionHandlers = useConnectionHandlers(); + const registerEntityHandlers = useEntityHandlers(); + const registerDocumentHandlers = useDocumentHandlers(); + const registerCollectionHandlers = useCollectionHandlers(); + const registerCommentHandlers = useCommentHandlers(); + const registerGroupHandlers = useGroupHandlers(); + const registerTeamHandlers = useTeamHandlers(); + const registerUserHandlers = useUserHandlers(); + const registerNotificationHandlers = useNotificationHandlers(); + const registerPinHandlers = usePinHandlers(); + const registerStarHandlers = useStarHandlers(); + const registerImportHandlers = useImportHandlers(); + + useEffect(() => { + let currentSocket: SocketWithAuthentication | null = null; + + function createConnection() { + currentSocket = io(window.location.origin, { + path: "/realtime", + transports: ["websocket"], + reconnectionDelay: 1000, + reconnectionDelayMax: 30000, + withCredentials: true, + }); + invariant(currentSocket, "Socket should be defined"); + + currentSocket.authenticated = false; + + registerConnectionHandlers(currentSocket); + registerEntityHandlers(currentSocket); + registerDocumentHandlers(currentSocket); + registerCollectionHandlers(currentSocket); + registerCommentHandlers(currentSocket); + registerGroupHandlers(currentSocket); + registerTeamHandlers(currentSocket); + registerUserHandlers(currentSocket); + registerNotificationHandlers(currentSocket); + registerPinHandlers(currentSocket); + registerStarHandlers(currentSocket); + registerImportHandlers(currentSocket); + + setSocket(currentSocket); + } - this.socket.on("users.demote", async (event: PartialExcept<User, "id">) => { - if (event.id === auth.user?.id) { - documents.all.forEach((document) => policies.remove(document.id)); - await collections.fetchAll(); + function checkConnection() { + if (currentSocket?.disconnected && getPageVisible()) { + // null-ifying this reference is important, do not remove. Without it + // references to old sockets are potentially held in context + currentSocket.close(); + currentSocket = null; + setSocket(null); + createConnection(); } - }); + } - this.socket.on("users.delete", (event: WebsocketEntityDeletedEvent) => { - users.remove(event.modelId); - }); + createConnection(); + document.addEventListener(getVisibilityListener(), checkConnection); - this.socket.on( - "userMemberships.update", - async (event: PartialExcept<UserMembership, "id">) => { - userMemberships.add(event); + return () => { + if (currentSocket) { + currentSocket.authenticated = false; + currentSocket.disconnect(); } - ); - - // received a message from the API server that we should request - // to join a specific room. Forward that to the ws server. - this.socket.on("join", (event) => { - this.socket?.emit("join", event); - }); - - // received a message from the API server that we should request - // to leave a specific room. Forward that to the ws server. - this.socket.on("leave", (event) => { - this.socket?.emit("leave", event); - }); - }; - - render() { - return ( - <WebsocketContext.Provider value={this.socket}> - {this.props.children} - </WebsocketContext.Provider> - ); - } + document.removeEventListener(getVisibilityListener(), checkConnection); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <WebsocketContext.Provider value={socket}> + {children} + </WebsocketContext.Provider> + ); } -export default withTranslation()(withStores(WebsocketProvider)); +export default observer(WebsocketProvider); diff --git a/app/components/primitives/InputSelect.tsx b/app/components/primitives/InputSelect.tsx index edbd711ca96e..dbafe63786eb 100644 --- a/app/components/primitives/InputSelect.tsx +++ b/app/components/primitives/InputSelect.tsx @@ -1,6 +1,7 @@ import * as InputSelectPrimitive from "@radix-ui/react-select"; import * as React from "react"; import styled from "styled-components"; +import Text from "@shared/components/Text"; import { depths, s } from "@shared/styles"; import type { Props as ButtonProps } from "~/components/Button"; import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations"; @@ -22,19 +23,27 @@ export type TriggerButtonProps = { className?: string; } & Pick<ButtonProps<unknown>, "borderOnHover">; -type InputSelectTriggerProps = { placeholder: string } & TriggerButtonProps & +type InputSelectTriggerProps = { + placeholder: string; + /** When provided, overrides the selected value rendered inside the trigger. */ + displayValue?: React.ReactNode; +} & TriggerButtonProps & React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Trigger>; const InputSelectTrigger = React.forwardRef< React.ElementRef<typeof InputSelectPrimitive.Trigger>, InputSelectTriggerProps >((props, ref) => { - const { placeholder, children, nude, ...buttonProps } = props; + const { placeholder, children, nude, displayValue, ...buttonProps } = props; return ( <InputSelectPrimitive.Trigger ref={ref} asChild> <SelectButton neutral disclosure $nude={nude} {...buttonProps}> - <InputSelectPrimitive.Value placeholder={placeholder} /> + {displayValue !== undefined ? ( + <>{displayValue}</> + ) : ( + <InputSelectPrimitive.Value placeholder={placeholder} /> + )} </SelectButton> </InputSelectPrimitive.Trigger> ); @@ -102,6 +111,31 @@ const Separator = styled.hr` margin: 6px 0; `; +/** Non-selectable heading rendered to group options in the menu. */ +const InputSelectHeading = React.forwardRef< + HTMLSpanElement, + { children?: React.ReactNode } +>(({ children }, ref) => ( + <InputSelectPrimitive.Group> + <InputSelectPrimitive.Label asChild> + <Heading ref={ref}>{children}</Heading> + </InputSelectPrimitive.Label> + </InputSelectPrimitive.Group> +)); +InputSelectHeading.displayName = "InputSelectHeading"; + +const Heading = styled(Text).attrs({ + type: "tertiary", + size: "xsmall", + weight: "bold", +})` + display: block; + padding-block: 8px 4px; + padding-inline: 8px; + text-transform: uppercase; + letter-spacing: 0.04em; +`; + /** Styled components. */ const StyledContent = styled(InputSelectPrimitive.Content)` z-index: ${depths.menu}; @@ -135,4 +169,5 @@ export { InputSelectContent, InputSelectItem, InputSelectSeparator, + InputSelectHeading, }; diff --git a/app/components/primitives/Menu/index.tsx b/app/components/primitives/Menu/index.tsx index a49721c7beac..7677eea64444 100644 --- a/app/components/primitives/Menu/index.tsx +++ b/app/components/primitives/Menu/index.tsx @@ -5,6 +5,7 @@ import type { LocationDescriptor } from "history"; import * as React from "react"; import Tooltip from "~/components/Tooltip"; import { CheckmarkIcon } from "outline-icons"; +import { normalizeKeyDisplay, shortcutSeparator } from "@shared/utils/keyboard"; import { useMenuContext } from "./MenuContext"; type MenuProps = React.ComponentPropsWithoutRef< @@ -71,8 +72,7 @@ type ContentProps = React.ComponentPropsWithoutRef< React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>; const MenuContent = React.forwardRef< - | React.ElementRef<typeof DropdownMenuPrimitive.Content> - | React.ElementRef<typeof ContextMenuPrimitive.Content>, + React.ElementRef<typeof DropdownMenuPrimitive.Content>, ContentProps >((props, ref) => { const { variant } = useMenuContext(); @@ -119,8 +119,7 @@ type SubMenuTriggerProps = BaseItemProps & React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger>; const SubMenuTrigger = React.forwardRef< - | React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger> - | React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, + React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, SubMenuTriggerProps >((props, ref) => { const { variant } = useMenuContext(); @@ -149,8 +148,7 @@ type SubMenuContentProps = React.ComponentPropsWithoutRef< React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>; const SubMenuContent = React.forwardRef< - | React.ElementRef<typeof DropdownMenuPrimitive.SubContent> - | React.ElementRef<typeof ContextMenuPrimitive.SubContent>, + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, SubMenuContentProps >((props, ref) => { const { variant } = useMenuContext(); @@ -202,8 +200,7 @@ type MenuGroupProps = { >; const MenuGroup = React.forwardRef< - | React.ElementRef<typeof DropdownMenuPrimitive.Group> - | React.ElementRef<typeof ContextMenuPrimitive.Group>, + React.ElementRef<typeof DropdownMenuPrimitive.Group>, MenuGroupProps >((props, ref) => { const { variant } = useMenuContext(); @@ -227,8 +224,38 @@ type BaseItemProps = { label: string; icon?: React.ReactElement; disabled?: boolean; + shortcut?: string[]; }; +/** + * Renders a keyboard shortcut as formatted key symbols. + * + * @param shortcut - array of key strings (e.g. ["Meta+Shift+l"]). + * @returns rendered shortcut element or null. + */ +function MenuItemShortcut({ shortcut }: { shortcut?: string[] }) { + if (!shortcut?.length) { + return null; + } + + return ( + <Components.MenuShortcut> + {shortcut.map((sc, scIndex) => + sc.split("+").flatMap((key, keyIndex, arr) => { + const el = ( + <span key={`${scIndex}-${keyIndex}`}> + {normalizeKeyDisplay(key, true)} + </span> + ); + return keyIndex < arr.length - 1 && shortcutSeparator + ? [el, shortcutSeparator] + : [el]; + }) + )} + </Components.MenuShortcut> + ); +} + type MenuButtonProps = BaseItemProps & { onClick: (event: React.MouseEvent<HTMLButtonElement>) => void; tooltip?: React.ReactChild; @@ -244,8 +271,7 @@ type MenuButtonProps = BaseItemProps & { >; const MenuButton = React.forwardRef< - | React.ElementRef<typeof DropdownMenuPrimitive.Item> - | React.ElementRef<typeof ContextMenuPrimitive.Item>, + React.ElementRef<typeof DropdownMenuPrimitive.Item>, MenuButtonProps >((props, ref) => { const { variant } = useMenuContext(); @@ -256,6 +282,7 @@ const MenuButton = React.forwardRef< disabled, selected, dangerous, + shortcut, onClick, ...rest } = props; @@ -279,6 +306,7 @@ const MenuButton = React.forwardRef< {selected ? <CheckmarkIcon size={18} /> : null} </Components.SelectedIconWrapper> )} + <MenuItemShortcut shortcut={shortcut} /> </Components.MenuButton> </Item> ); @@ -305,12 +333,11 @@ type MenuInternalLinkProps = BaseItemProps & { >; const MenuInternalLink = React.forwardRef< - | React.ElementRef<typeof DropdownMenuPrimitive.Item> - | React.ElementRef<typeof ContextMenuPrimitive.Item>, + React.ElementRef<typeof DropdownMenuPrimitive.Item>, MenuInternalLinkProps >((props, ref) => { const { variant } = useMenuContext(); - const { label, icon, disabled, to, ...rest } = props; + const { label, icon, disabled, shortcut, to, ...rest } = props; const Item = variant === "dropdown" @@ -322,6 +349,7 @@ const MenuInternalLink = React.forwardRef< <Components.MenuInternalLink to={to} disabled={disabled}> {icon} <Components.MenuLabel>{label}</Components.MenuLabel> + <MenuItemShortcut shortcut={shortcut} /> </Components.MenuInternalLink> </Item> ); @@ -341,12 +369,11 @@ type MenuExternalLinkProps = BaseItemProps & { >; const MenuExternalLink = React.forwardRef< - | React.ElementRef<typeof DropdownMenuPrimitive.Item> - | React.ElementRef<typeof ContextMenuPrimitive.Item>, + React.ElementRef<typeof DropdownMenuPrimitive.Item>, MenuExternalLinkProps >((props, ref) => { const { variant } = useMenuContext(); - const { label, icon, disabled, href, target, ...rest } = props; + const { label, icon, disabled, shortcut, href, target, ...rest } = props; const Item = variant === "dropdown" @@ -362,6 +389,7 @@ const MenuExternalLink = React.forwardRef< > {icon} <Components.MenuLabel>{label}</Components.MenuLabel> + <MenuItemShortcut shortcut={shortcut} /> </Components.MenuExternalLink> </Item> ); @@ -374,8 +402,7 @@ type MenuSeparatorProps = React.ComponentPropsWithoutRef< React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>; const MenuSeparator = React.forwardRef< - | React.ElementRef<typeof DropdownMenuPrimitive.Separator> - | React.ElementRef<typeof ContextMenuPrimitive.Separator>, + React.ElementRef<typeof DropdownMenuPrimitive.Separator>, MenuSeparatorProps >((props, ref) => { const { variant } = useMenuContext(); @@ -399,8 +426,7 @@ type MenuLabelProps = React.ComponentPropsWithoutRef< React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label>; const MenuLabel = React.forwardRef< - | React.ElementRef<typeof DropdownMenuPrimitive.Label> - | React.ElementRef<typeof ContextMenuPrimitive.Label>, + React.ElementRef<typeof DropdownMenuPrimitive.Label>, MenuLabelProps >((props, ref) => { const { variant } = useMenuContext(); diff --git a/app/components/primitives/Popover.tsx b/app/components/primitives/Popover.tsx index cca40e4947b3..c1b4510127a3 100644 --- a/app/components/primitives/Popover.tsx +++ b/app/components/primitives/Popover.tsx @@ -44,7 +44,7 @@ const PopoverContent = React.forwardRef< const timeoutRef = React.useRef<NodeJS.Timeout>(); const container = usePortalContext(); const { - width = 380, + width, minWidth, minHeight, scrollable = true, @@ -53,6 +53,7 @@ const PopoverContent = React.forwardRef< children, ...rest } = props; + const resolvedWidth = width ?? (minWidth ? undefined : 380); const enablePointerEvents = React.useCallback(() => { if (timeoutRef.current) { @@ -78,7 +79,7 @@ const PopoverContent = React.forwardRef< <StyledContent ref={mergeRefs([ref, forwardedRef])} sideOffset={sideOffset} - $width={width} + $width={resolvedWidth} $minWidth={minWidth} $minHeight={minHeight} $scrollable={scrollable} diff --git a/app/components/primitives/components/InputSelect.tsx b/app/components/primitives/components/InputSelect.tsx index 5fea3a66b325..9ec1afaf3369 100644 --- a/app/components/primitives/components/InputSelect.tsx +++ b/app/components/primitives/components/InputSelect.tsx @@ -73,13 +73,13 @@ export const SelectButton = styled(Button)<{ $nude?: boolean }>` ${Inner} { line-height: 28px; - padding-left: 12px; - padding-right: 4px; + padding-inline-start: 12px; + padding-inline-end: 4px; } svg { justify-self: flex-end; - margin-left: auto; + margin-inline-start: auto; } &[data-placeholder=""] { @@ -132,7 +132,7 @@ const ItemContainer = styled(Flex)` ${breakpoint("tablet")` font-size: 14px; padding: 4px; - padding-left: 8px; + padding-inline-start: 8px; `} `; diff --git a/app/components/primitives/components/Menu.tsx b/app/components/primitives/components/Menu.tsx index c60a021a341e..e509f74088c7 100644 --- a/app/components/primitives/components/Menu.tsx +++ b/app/components/primitives/components/Menu.tsx @@ -16,7 +16,7 @@ type BaseMenuItemProps = { const BaseMenuItemCSS = css<BaseMenuItemProps>` position: relative; display: flex; - justify-content: left; + justify-content: flex-start; align-items: center; width: 100%; min-height: 32px; @@ -129,20 +129,25 @@ export const MenuHeader = styled.h3` color: ${s("sidebarText")}; letter-spacing: 0.04em; margin: 1em 12px 0.5em; + user-select: none; `; export const MenuDisclosure = styled(ExpandedIcon)` transform: rotate(270deg); position: absolute; - right: 8px; + inset-inline-end: 8px; color: ${s("textTertiary")}; + + [dir="rtl"] & { + transform: rotate(90deg); + } `; export const MenuIconWrapper = styled.span` width: 24px; height: 24px; - margin-right: 6px; - margin-left: -4px; + margin-inline-end: 6px; + margin-inline-start: -4px; color: ${s("textSecondary")}; flex-shrink: 0; display: flex; @@ -153,7 +158,7 @@ export const MenuIconWrapper = styled.span` export const SelectedIconWrapper = styled.span` width: 24px; height: 24px; - margin-right: -6px; + margin-inline-end: -6px; color: ${s("textSecondary")}; flex-shrink: 0; display: flex; @@ -161,13 +166,24 @@ export const SelectedIconWrapper = styled.span` justify-content: center; `; +export const MenuShortcut = styled.span` + display: flex; + align-items: center; + gap: 2px; + font-size: 12px; + color: currentColor; + opacity: 0.5; + margin-inline-start: 16px; + flex-shrink: 0; +`; + export const MenuContent = styled(Scrollable)<{ maxHeightVar: string; transformOriginVar: string; }>` z-index: ${depths.menu}; min-width: 180px; - max-width: 276px; + max-width: 320px; min-height: 44px; max-height: ${({ maxHeightVar }) => `min(85vh, var(${maxHeightVar}))`}; font-weight: normal; diff --git a/app/components/withStores.tsx b/app/components/withStores.tsx deleted file mode 100644 index 14ee1c788c94..000000000000 --- a/app/components/withStores.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import hoistNonReactStatics from "hoist-non-react-statics"; -import * as React from "react"; -import type RootStore from "~/stores/RootStore"; -import useStores from "~/hooks/useStores"; - -type StoreProps = keyof RootStore; - -function withStores< - P extends React.ComponentType<ResolvedProps & RootStore>, - ResolvedProps = JSX.LibraryManagedAttributes< - P, - Omit<React.ComponentProps<P>, StoreProps> - >, ->(WrappedComponent: P): React.FC<ResolvedProps> { - const ComponentWithStore = (props: ResolvedProps) => { - const stores = useStores(); - return <WrappedComponent {...(props as any)} {...stores} />; - }; - - ComponentWithStore.displayName = `WithStores(${ - WrappedComponent.name || WrappedComponent.displayName - })`; - - /** - * https://reactjs.org/docs/higher-order-components.html#static-methods-must-be-copied-over - */ - hoistNonReactStatics(ComponentWithStore, WrappedComponent); - - return ComponentWithStore; -} - -export default withStores; diff --git a/app/editor/components/ComponentView.tsx b/app/editor/components/ComponentView.tsx index 4177f547f8a6..0132ff907195 100644 --- a/app/editor/components/ComponentView.tsx +++ b/app/editor/components/ComponentView.tsx @@ -119,10 +119,12 @@ export default class ComponentView { // Apply classes from inline decorations. this.decorations.forEach((decoration) => { // For inline decorations, attrs contain the class property. - const attrs = (decoration as any).type?.attrs; + const attrs = ( + decoration as Decoration & { type?: { attrs?: { class?: string } } } + ).type?.attrs; if (attrs?.class) { const classes = attrs.class.split(" "); - classes.forEach((className: string) => { + classes.forEach((className) => { if (className && this.dom) { this.dom.classList.add(className); } diff --git a/app/editor/components/FindAndReplace.tsx b/app/editor/components/FindAndReplace.tsx index 76125c584ed8..f3a19102a0fd 100644 --- a/app/editor/components/FindAndReplace.tsx +++ b/app/editor/components/FindAndReplace.tsx @@ -367,7 +367,11 @@ export default function FindAndReplace({ return ( <Popover open={localOpen} onOpenChange={setLocalOpen}> <PopoverTrigger> - <span style={style} /> + <button + type="button" + aria-label={t("Find and replace")} + style={{ ...style, background: "none", border: 0, padding: 0 }} + /> </PopoverTrigger> <PopoverContent aria-label={t("Find and replace")} @@ -443,7 +447,7 @@ export default function FindAndReplace({ </Flex> <ResizingHeightContainer> {showReplace && !readOnly && ( - <HStack> + <HStack align="flex-start"> <StyledInput maxLength={255} value={replaceTerm} diff --git a/app/editor/components/LinkEditor.tsx b/app/editor/components/LinkEditor.tsx index d6dd4b0c8b38..cbfadd42329f 100644 --- a/app/editor/components/LinkEditor.tsx +++ b/app/editor/components/LinkEditor.tsx @@ -102,7 +102,7 @@ const LinkEditor: React.FC<Props> = ({ const openLink = React.useCallback(() => { commands["openLink"](); - }, []); + }, [commands]); const removeLink = React.useCallback(() => { commands["removeLink"](); @@ -144,7 +144,11 @@ const LinkEditor: React.FC<Props> = ({ if (selectedIndex >= 0 && results[selectedIndex]) { const selectedDoc = results[selectedIndex]; - !mark ? addLink(selectedDoc.url) : updateLink(selectedDoc.url); + if (!mark) { + addLink(selectedDoc.url); + } else { + updateLink(selectedDoc.url); + } } else if (!trimmedQuery) { removeLink(); } else if (!mark) { @@ -238,7 +242,11 @@ const LinkEditor: React.FC<Props> = ({ {results.map((doc, index) => ( <SuggestionsMenuItem onClick={() => { - !mark ? addLink(doc.path) : updateLink(doc.path); + if (!mark) { + addLink(doc.path); + } else { + updateLink(doc.path); + } }} onPointerMove={() => setSelectedIndex(index)} selected={index === selectedIndex} diff --git a/app/editor/components/MediaDimension.tsx b/app/editor/components/MediaDimension.tsx index b53afa9a13b6..1c22cc4ae681 100644 --- a/app/editor/components/MediaDimension.tsx +++ b/app/editor/components/MediaDimension.tsx @@ -68,7 +68,10 @@ export function MediaDimension() { const isOutsideBounds = useCallback( (type: "width" | "height", value: number) => { - const bounds = boundsRef.current!; + const bounds = boundsRef.current; + if (!bounds) { + return false; + } return value < bounds[type].min || value > bounds[type].max; }, [] @@ -128,12 +131,12 @@ export function MediaDimension() { localWidthAsNumber && isOutsideBounds("width", localWidthAsNumber)); // check width bounds here since 'onChange' error checker is debounced. - if (isUnchanged || isError) { + if (isUnchanged || isError || !boundsRef.current) { reset(); return; } - const maxWidth = boundsRef.current!.width.max; + const maxWidth = boundsRef.current.width.max; // For images resized to the full width of the editor, natural width will be shown in the toolbar. // So, we constrain it here for computing aspect ratio. const constrainedWidth = Math.min(width, maxWidth); @@ -163,7 +166,16 @@ export function MediaDimension() { height: finalHeight, }); } - }, [commands, width, height, localDimension, nodeType, error, reset]); + }, [ + commands, + width, + height, + localDimension, + nodeType, + error, + reset, + isOutsideBounds, + ]); const handleKeyDown = useCallback( (e: React.KeyboardEvent<HTMLInputElement>) => { diff --git a/app/editor/components/MediaLinkEditor.tsx b/app/editor/components/MediaLinkEditor.tsx index bdc9228b6295..248626f2291d 100644 --- a/app/editor/components/MediaLinkEditor.tsx +++ b/app/editor/components/MediaLinkEditor.tsx @@ -61,7 +61,7 @@ export function MediaLinkEditor({ const { state, dispatch } = view; dispatch(state.tr.deleteSelection()); onLinkRemove(); - }, [view]); + }, [view, onLinkRemove]); const update = useCallback(() => { const { state } = view; @@ -74,7 +74,7 @@ export function MediaLinkEditor({ view.dispatch(tr); moveSelectionToEnd(); onLinkUpdate(); - }, [localUrl, node, view, moveSelectionToEnd]); + }, [localUrl, node, view, moveSelectionToEnd, onLinkUpdate]); useOnClickOutside(wrapperRef, onClickOutside); @@ -99,7 +99,7 @@ export function MediaLinkEditor({ } } }, - [update, moveSelectionToEnd] + [update, moveSelectionToEnd, onEscape] ); if (!node) { diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx index 3b27fcc66017..f8026bdf8e45 100644 --- a/app/editor/components/MentionMenu.tsx +++ b/app/editor/components/MentionMenu.tsx @@ -69,7 +69,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) { res.data.collections.map(collections.add); res.data.groups.map(groups.add); }); - }, [search, documents, users, collections]) + }, [search, documents, users, collections, groups, maxResultsInSection]) ); useEffect(() => { diff --git a/app/editor/components/PasteMenu.tsx b/app/editor/components/PasteMenu.tsx index fb032d6dca9a..92c1220fd81f 100644 --- a/app/editor/components/PasteMenu.tsx +++ b/app/editor/components/PasteMenu.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import type { EmbedDescriptor } from "@shared/editor/embeds"; import type { MenuItem } from "@shared/editor/types"; import { MentionType } from "@shared/types"; -import { isUrl } from "@shared/utils/urls"; +import { isInternalUrl, isUrl } from "@shared/utils/urls"; import type Integration from "~/models/Integration"; import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; @@ -67,6 +67,7 @@ function useItems({ const singleUrl = typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null; + const isInternal = singleUrl ? isInternalUrl(singleUrl) : false; const matchedEmbed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null; @@ -74,7 +75,7 @@ function useItems({ // Check embeddability for single URL useEffect(() => { - if (!singleUrl || !embed) { + if (!singleUrl || !embed || isInternal) { setEmbedCheck({ loading: false }); return; } @@ -101,7 +102,7 @@ function useItems({ return () => { cancelled = true; }; - }, [singleUrl, embed]); + }, [singleUrl, embed, isInternal]); // single item is pasted. if (typeof pastedText === "string") { @@ -143,8 +144,10 @@ function useItems({ name: "embed", title: t("Embed"), subtitle: - embedCheck.embeddable === false ? t("Not supported") : undefined, - disabled: embedCheck.loading || !embedCheck.embeddable, + embedCheck.embeddable === false || isInternal + ? t("Not supported") + : undefined, + disabled: isInternal || embedCheck.loading || !embedCheck.embeddable, icon: embed?.icon, keywords: embed?.keywords, }, diff --git a/app/editor/components/SuggestionsMenu.tsx b/app/editor/components/SuggestionsMenu.tsx index 23beaa78b4bd..93f180ef97d6 100644 --- a/app/editor/components/SuggestionsMenu.tsx +++ b/app/editor/components/SuggestionsMenu.tsx @@ -38,7 +38,7 @@ export type Props<T extends MenuItem = MenuItem> = { rtl: boolean; isActive: boolean; search: string; - trigger: string; + trigger: string | string[]; uploadFile?: (file: File) => Promise<string>; onFileUploadStart?: () => void; onFileUploadStop?: () => void; @@ -160,11 +160,15 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) { const { state, dispatch } = view; const selection = isMobile && selectionRef.current ? selectionRef.current : state.selection; + const triggers = Array.isArray(props.trigger) + ? props.trigger + : [props.trigger]; + const triggerLength = triggers[0].length; const poss = state.doc.cut( - selection.from - (props.search ?? "").length - props.trigger.length, + selection.from - (props.search ?? "").length - triggerLength, selection.from ); - const trimTrigger = poss.textContent.startsWith(props.trigger); + const trimTrigger = triggers.some((t) => poss.textContent.startsWith(t)); if (!props.search && !trimTrigger) { return; @@ -178,12 +182,12 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) { 0, selection.from - (props.search ?? "").length - - (trimTrigger ? props.trigger.length : 0) + (trimTrigger ? triggerLength : 0) ), selection.to ) ); - }, [props.search, props.trigger, view]); + }, [props.search, props.trigger, view, isMobile]); const restoreSelection = React.useCallback(() => { if (!isMobile) { @@ -341,13 +345,13 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) { } }; - const triggerFilePick = (accept: string, attrs?: Record<string, any>) => { + const triggerFilePick = (accept: string, attrs?: Record<string, unknown>) => { if (inputRef.current) { if (accept) { inputRef.current.accept = accept; } if (attrs) { - inputRef.current.dataset.attrs = attrs ? JSON.stringify(attrs) : ""; + inputRef.current.dataset.attrs = JSON.stringify(attrs); } inputRef.current.click(); } @@ -883,7 +887,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) { onPointerMove={handlePointerMove} onPointerDown={handlePointerDown} > - {props.renderMenuItem(item as any, index, { + {props.renderMenuItem(item as unknown as T, index, { selected: index === selectedIndex, disclosure: hasChildren, onClick: handleOnClick, @@ -909,7 +913,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) { <> <Drawer open={isActive} onOpenChange={handleOpenChange}> <DrawerContent aria-describedby={undefined}> - <DrawerTitle hidden>{props.trigger}</DrawerTitle> + <DrawerTitle hidden> + {Array.isArray(props.trigger) ? props.trigger[0] : props.trigger} + </DrawerTitle> <MobileScrollable hiddenScrollbars> {insertItem ? ( <LinkInputWrapper> @@ -1047,7 +1053,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) { key={`sub-${childIndex}-${child.name}`} onPointerMove={handleChildPointerMove} > - {props.renderMenuItem(child as any, childIndex, { + {props.renderMenuItem(child as unknown as T, childIndex, { selected: childIndex === submenu.selectedIndex, onClick: handleChildClick, })} diff --git a/app/editor/extensions/BlockMenu.tsx b/app/editor/extensions/BlockMenu.tsx index a1551ad19ff8..59f3561c5d9e 100644 --- a/app/editor/extensions/BlockMenu.tsx +++ b/app/editor/extensions/BlockMenu.tsx @@ -6,10 +6,20 @@ import ReactDOM from "react-dom"; import type { WidgetProps } from "@shared/editor/lib/Extension"; import { PlaceholderPlugin } from "@shared/editor/plugins/PlaceholderPlugin"; import { findParentNode } from "@shared/editor/queries/findParentNode"; +import type { Dictionary } from "~/hooks/useDictionary"; +import type { SuggestionOptions } from "~/editor/extensions/Suggestion"; import Suggestion from "~/editor/extensions/Suggestion"; import BlockMenu from "../components/BlockMenu"; -export default class BlockMenuExtension extends Suggestion { +/** + * Options for the BlockMenu extension. + */ +type BlockMenuOptions = SuggestionOptions & { + /** A dictionary of translated strings used in the editor. */ + dictionary: Dictionary; +}; + +export default class BlockMenuExtension extends Suggestion<BlockMenuOptions> { get defaultOptions() { return { trigger: "/", diff --git a/app/editor/extensions/FindAndReplace.tsx b/app/editor/extensions/FindAndReplace.tsx index 884785042ba3..db12f404ab50 100644 --- a/app/editor/extensions/FindAndReplace.tsx +++ b/app/editor/extensions/FindAndReplace.tsx @@ -11,24 +11,47 @@ import Extension from "@shared/editor/lib/Extension"; import { Action, toggleFoldPluginKey } from "@shared/editor/nodes/ToggleBlock"; import { isToggleBlock } from "@shared/editor/queries/toggleBlock"; import { ancestors } from "@shared/editor/utils"; +import isTextInput from "~/utils/isTextInput"; import FindAndReplace from "../components/FindAndReplace"; const pluginKey = new PluginKey("find-and-replace"); - -export default class FindAndReplaceExtension extends Extension { +const supportsHighlightAPI = + typeof CSS !== "undefined" && CSS.highlights !== undefined; + +/** + * Options for the FindAndReplace extension. + */ +type FindAndReplaceOptions = { + /** Whether the search should be case sensitive by default. */ + caseSensitive: boolean; + /** Whether the search query should be interpreted as a regular expression by default. */ + regexEnabled: boolean; +}; + +export default class FindAndReplaceExtension extends Extension<FindAndReplaceOptions> { public get name() { return "find-and-replace"; } - public get defaultOptions() { + public get defaultOptions(): FindAndReplaceOptions { return { - resultClassName: "find-result", - resultCurrentClassName: "current-result", caseSensitive: false, regexEnabled: false, }; } + keys(): Record<string, Command> { + return { + Escape: () => { + if (!this.searchTerm) { + return false; + } + this.handleEscape(); + return true; + }, + }; + } + public commands() { return { /** @@ -82,20 +105,6 @@ export default class FindAndReplaceExtension extends Extension { }; } - private get decorations() { - return this.results.map((deco, index) => { - const decorationType = - deco.type === "node" ? Decoration.node : Decoration.inline; - return decorationType(deco.from, deco.to, { - class: - this.options.resultClassName + - (this.currentResultIndex === index - ? ` ${this.options.resultCurrentClassName}` - : ""), - }); - }); - } - public replace(replace: string): Command { return (state, dispatch) => { // Redo the search to ensure we have the latest results, the document may @@ -165,6 +174,8 @@ export default class FindAndReplaceExtension extends Extension { return (state, dispatch) => { this.searchTerm = ""; this.currentResultIndex = 0; + this.results = []; + this.clearHighlights(); dispatch?.(state.tr.setMeta(pluginKey, {})); return true; @@ -209,14 +220,25 @@ export default class FindAndReplaceExtension extends Extension { } private scrollToCurrentMatch() { - const element = window.document.querySelector( - `.${this.options.resultCurrentClassName}` - ); - if (element) { - scrollIntoView(element, { - scrollMode: "if-needed", - block: "center", - }); + if (supportsHighlightAPI) { + if (this.currentHighlightRange) { + const node = this.currentHighlightRange.startContainer; + const element = node instanceof Element ? node : node.parentElement; + if (element) { + scrollIntoView(element, { + scrollMode: "if-needed", + block: "center", + }); + } + } + } else { + const element = window.document.querySelector(".current-result"); + if (element) { + scrollIntoView(element, { + scrollMode: "if-needed", + block: "center", + }); + } } } @@ -384,13 +406,123 @@ export default class FindAndReplaceExtension extends Extension { }); } - private createDeco(doc: Node) { + /** + * Build ProseMirror decorations from search results (fallback for browsers + * without CSS Custom Highlight API support). + */ + private get decorations() { + return this.results.map((deco, index) => { + const attrs = { + class: + "find-result" + + (this.currentResultIndex === index ? " current-result" : ""), + }; + return deco.type === "node" + ? Decoration.node(deco.from, deco.to, attrs) + : Decoration.inline(deco.from, deco.to, attrs); + }); + } + + /** + * Create a DecorationSet from the current search results. + */ + private createDecorationSet(doc: Node) { this.search(doc); - return this.decorations - ? DecorationSet.create(doc, this.decorations) + const decos = this.decorations; + return decos.length + ? DecorationSet.create(doc, decos) : DecorationSet.empty; } + /** + * Update CSS Custom Highlight API highlights based on current search results. + */ + private updateHighlights() { + const view = this.editor?.view; + if (!view || !this.results.length || !this.searchTerm) { + this.clearHighlights(); + return; + } + + const allRanges: StaticRange[] = []; + const currentRanges: StaticRange[] = []; + this.currentHighlightRange = undefined; + + for (let i = 0; i < this.results.length; i++) { + const result = this.results[i]; + try { + const from = view.domAtPos(result.from); + const to = view.domAtPos(result.to); + const range = new StaticRange({ + startContainer: from.node, + startOffset: from.offset, + endContainer: to.node, + endOffset: to.offset, + }); + allRanges.push(range); + + if (i === this.currentResultIndex) { + currentRanges.push(range); + this.currentHighlightRange = range; + } + } catch { + // Position may not be in the visible DOM (e.g. inside folded toggle) + } + } + + CSS.highlights.set("search-results", new Highlight(...allRanges)); + if (currentRanges.length) { + CSS.highlights.set( + "search-results-current", + new Highlight(...currentRanges) + ); + } else { + CSS.highlights.delete("search-results-current"); + } + } + + private clearHighlights() { + if (!supportsHighlightAPI) { + return; + } + CSS.highlights.delete("search-results"); + CSS.highlights.delete("search-results-current"); + this.currentHighlightRange = undefined; + } + + private handleEscape = () => { + const params = new URLSearchParams(window.location.search); + if (params.has("q")) { + params.delete("q"); + const search = params.toString(); + window.history.replaceState( + window.history.state, + "", + window.location.pathname + (search ? `?${search}` : "") + ); + } + + const view = this.editor?.view; + if (view) { + this.clear()(view.state, view.dispatch); + } + }; + + private handleDocumentKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || !this.searchTerm) { + return; + } + if (event.defaultPrevented) { + return; + } + if (isTextInput(event.target as HTMLElement)) { + return; + } + this.handleEscape(); + }; + + private currentHighlightRange?: StaticRange; + get allowInReadOnly() { return true; } @@ -400,35 +532,107 @@ export default class FindAndReplaceExtension extends Extension { } get plugins() { - return [ - new Plugin({ - key: pluginKey, - state: { - init: () => DecorationSet.empty, - apply: (tr, decorationSet) => { - const action = tr.getMeta(pluginKey); - - if (action) { - if (action.open) { - this.open = true; - } - return this.createDeco(tr.doc); - } + const highlightPlugin = supportsHighlightAPI + ? this.highlightAPIPlugin + : this.decorationPlugin; + return [highlightPlugin, this.escapeListenerPlugin]; + } + + /** + * Plugin that listens for Escape at the document level so the search + * highlight can be cleared even when the editor is not focused. + */ + private get escapeListenerPlugin() { + return new Plugin({ + view: () => { + document.addEventListener("keydown", this.handleDocumentKeyDown); + return { + destroy: () => { + document.removeEventListener("keydown", this.handleDocumentKeyDown); + }, + }; + }, + }); + } - if (tr.docChanged) { - return decorationSet.map(tr.mapping, tr.doc); + /** Plugin using the CSS Custom Highlight API (no DOM modifications). */ + private get highlightAPIPlugin() { + return new Plugin({ + key: pluginKey, + state: { + init: () => 0, + apply: (tr, generation) => { + const action = tr.getMeta(pluginKey); + + if (action) { + if (action.open) { + this.open = true; } + this.search(tr.doc); + return generation + 1; + } - return decorationSet; - }, + if (tr.docChanged && this.searchTerm) { + this.search(tr.doc); + return generation + 1; + } + + // Toggle fold/unfold changes DOM visibility without changing the doc, + // so we need to rebuild highlight ranges for newly visible matches. + if (tr.getMeta(toggleFoldPluginKey) && this.searchTerm) { + return generation + 1; + } + + return generation; }, - props: { - decorations(state) { - return this.getState(state); + }, + view: () => { + let lastGeneration = 0; + return { + update: (view) => { + const generation = pluginKey.getState(view.state) as number; + if (generation !== lastGeneration) { + lastGeneration = generation; + this.updateHighlights(); + } + }, + destroy: () => { + this.clearHighlights(); }, + }; + }, + }); + } + + /** Fallback plugin using ProseMirror decorations. */ + private get decorationPlugin() { + return new Plugin({ + key: pluginKey, + state: { + init: () => DecorationSet.empty, + apply: (tr, decorationSet) => { + const action = tr.getMeta(pluginKey); + + if (action) { + if (action.open) { + this.open = true; + } + return this.createDecorationSet(tr.doc); + } + + if (tr.docChanged) { + return decorationSet.map(tr.mapping, tr.doc); + } + + return decorationSet; }, - }), - ]; + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); } public widget = ({ readOnly }: WidgetProps) => ( diff --git a/app/editor/extensions/HoverPreviews.tsx b/app/editor/extensions/HoverPreviews.tsx index a485ea067a17..d3ecb101b9ad 100644 --- a/app/editor/extensions/HoverPreviews.tsx +++ b/app/editor/extensions/HoverPreviews.tsx @@ -7,12 +7,15 @@ import stores from "~/stores"; import HoverPreview from "~/components/HoverPreview"; import env from "~/env"; +/** + * Options for the HoverPreviews extension. + */ interface HoverPreviewsOptions { - /** Delay before the target is considered "hovered" and callback is triggered. */ + /** Delay in milliseconds before the target is considered "hovered" and the preview is shown. */ delay: number; } -export default class HoverPreviews extends Extension { +export default class HoverPreviews extends Extension<HoverPreviewsOptions> { state: { activeLinkElement: HTMLElement | null; unfurlId: string | null; diff --git a/app/editor/extensions/MentionMenu.tsx b/app/editor/extensions/MentionMenu.tsx index 063d640eafb6..50a7f3a28482 100644 --- a/app/editor/extensions/MentionMenu.tsx +++ b/app/editor/extensions/MentionMenu.tsx @@ -6,7 +6,7 @@ import MentionMenu from "../components/MentionMenu"; export default class MentionMenuExtension extends Suggestion { get defaultOptions() { return { - trigger: "@", + trigger: ["@", "\uff20"], allowSpaces: true, requireSearchTerm: false, enabledInCode: false, diff --git a/app/editor/extensions/Multiplayer.ts b/app/editor/extensions/Multiplayer.ts index 87c469b509da..27e3b5718cde 100644 --- a/app/editor/extensions/Multiplayer.ts +++ b/app/editor/extensions/Multiplayer.ts @@ -1,3 +1,4 @@ +import type { HocuspocusProvider } from "@hocuspocus/provider"; import isEqual from "lodash/isEqual"; import { Plugin } from "prosemirror-state"; import { @@ -20,7 +21,19 @@ type UserAwareness = { head: object; }; -export default class Multiplayer extends Extension { +/** + * Options for the Multiplayer extension. + */ +type MultiplayerOptions = { + /** The local user, used for cursor presence and the persistent user/client mapping. */ + user: { id: string; color: string }; + /** The Hocuspocus provider used for awareness and document sync. */ + provider: HocuspocusProvider; + /** The shared Yjs document this editor is bound to. */ + document: Y.Doc; +}; + +export default class Multiplayer extends Extension<MultiplayerOptions> { get name() { return "multiplayer"; } diff --git a/app/editor/extensions/SelectionToolbar.tsx b/app/editor/extensions/SelectionToolbar.tsx index 8ac623588572..be4c63e47087 100644 --- a/app/editor/extensions/SelectionToolbar.tsx +++ b/app/editor/extensions/SelectionToolbar.tsx @@ -1,4 +1,3 @@ -import some from "lodash/some"; import { action, observable } from "mobx"; import type { EditorState, Selection } from "prosemirror-state"; import { NodeSelection, Plugin, TextSelection } from "prosemirror-state"; @@ -82,12 +81,12 @@ export default class SelectionToolbarExtension extends Extension { return false; } - const slice = selection.content(); - const fragment = slice.content; - const nodes = (fragment as any).content; + const fragment = selection.content().content; - if (some(nodes, (n) => n.content.size)) { - return selection; + for (let i = 0; i < fragment.childCount; i++) { + if (fragment.child(i).content.size) { + return selection; + } } return false; diff --git a/app/editor/extensions/SmartText.ts b/app/editor/extensions/SmartText.ts index 6a34831efb3d..29ab5c1505cf 100644 --- a/app/editor/extensions/SmartText.ts +++ b/app/editor/extensions/SmartText.ts @@ -1,9 +1,10 @@ import Extension from "@shared/editor/lib/Extension"; import { InputRule } from "@shared/editor/lib/InputRule"; +import type { UserPreferences } from "@shared/types"; const rightArrow = new InputRule(/->$/, "→"); // Note that the suppression of pipe here prevents conflict with table creation rule. -const emdash = new InputRule(/(?:^|[^\|])(--\s)$/, "— "); +const emdash = new InputRule(/(?:^|[^|])(--\s)$/, "— "); const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½"); const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾"); const copyright = new InputRule(/\(c\)$/, "©️"); @@ -12,20 +13,22 @@ const trademarked = new InputRule(/\(tm\)$/, "™️"); const ellipsis = new InputRule(/\.\.\.$/, "…"); // Double quotes -const openDoubleQuote = new InputRule( - /(?:^|[\s\{\[\(\<'"\u2018\u201C])(")$/, - "“" -); +const openDoubleQuote = new InputRule(/(?:^|[\s{[(<'"\u2018\u201C])(")$/, "“"); const closeDoubleQuote = new InputRule(/^(?!.*`)[\s\S]*(")$/, "”"); // Single quotes -const openSingleQuote = new InputRule( - /(?:^|[\s\{\[\(\<'"\u2018\u201C])(')$/, - "‘" -); +const openSingleQuote = new InputRule(/(?:^|[\s{[(<'"\u2018\u201C])(')$/, "‘"); const closeSingleQuote = new InputRule(/^(?!.*`)[\s\S]*(')$/, "’"); -export default class SmartText extends Extension { +/** + * Options for the SmartText extension. + */ +type SmartTextOptions = { + /** Display preferences for the logged in user, if any. */ + userPreferences?: UserPreferences | null; +}; + +export default class SmartText extends Extension<SmartTextOptions> { get name() { return "smart_text"; } diff --git a/app/editor/extensions/Suggestion.ts b/app/editor/extensions/Suggestion.ts index f7fb76f68bac..6904568bb28d 100644 --- a/app/editor/extensions/Suggestion.ts +++ b/app/editor/extensions/Suggestion.ts @@ -7,21 +7,37 @@ import Extension from "@shared/editor/lib/Extension"; import { SuggestionsMenuPlugin } from "@shared/editor/plugins/SuggestionsMenuPlugin"; import { isInCode } from "@shared/editor/queries/isInCode"; -type Options = { +/** + * Options shared by all suggestion-style extensions (block menu, emoji menu, + * mention menu). + */ +export type SuggestionOptions = { + /** Whether the suggestion menu is allowed to open inside code blocks or inline code. */ enabledInCode: boolean; - trigger: string; + /** Character (or list of characters) that opens the suggestion menu. */ + trigger: string | string[]; + /** Whether spaces are allowed inside the search term. */ allowSpaces: boolean; + /** Whether the menu only opens once at least one character has been typed after the trigger. */ requireSearchTerm: boolean; }; -export default class Suggestion extends Extension { - constructor(options: Options) { +export default class Suggestion< + TOptions extends SuggestionOptions = SuggestionOptions, +> extends Extension<TOptions> { + constructor(options: TOptions) { super(options); + const triggers = Array.isArray(this.options.trigger) + ? this.options.trigger + : [this.options.trigger]; + const triggerPattern = + triggers.length === 1 + ? escapeRegExp(triggers[0]) + : `(?:${triggers.map(escapeRegExp).join("|")})`; + this.openRegex = new RegExp( - `(?:^|\\s|\\()${escapeRegExp( - this.options.trigger - )}(${`[\\p{L}\/\\p{M}\\d${ + `(?:^|\\s|\\(|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${ this.options.allowSpaces ? "\\s{1}" : "" }\\.\\-–_]+`})${this.options.requireSearchTerm ? "" : "?"}$`, "u" @@ -29,9 +45,7 @@ export default class Suggestion extends Extension { } get plugins(): Plugin[] { - return [ - new SuggestionsMenuPlugin(this.options, this.state, this.openRegex), - ]; + return [new SuggestionsMenuPlugin(this.state, this.openRegex)]; } keys() { diff --git a/app/editor/extensions/UpArrowAtStart.ts b/app/editor/extensions/UpArrowAtStart.ts index e3a3de343bd3..34a4201745fd 100644 --- a/app/editor/extensions/UpArrowAtStart.ts +++ b/app/editor/extensions/UpArrowAtStart.ts @@ -28,9 +28,9 @@ export default class UpArrowAtStart extends Extension { const isAtDocStart = $pos.parentOffset === 0 && $pos.depth <= 1; if (isAtDocStart) { - // Call the onUpArrowAtStart callback if it exists - // Cast to any to access the custom prop since it's not in the base Props type - const props = this.editor.props as any; + const props = this.editor.props as { + onUpArrowAtStart?: () => void; + }; if (props.onUpArrowAtStart) { props.onUpArrowAtStart(); return true; diff --git a/app/editor/extensions/index.ts b/app/editor/extensions/index.ts index 5a4b59237622..0b87b7a105e8 100644 --- a/app/editor/extensions/index.ts +++ b/app/editor/extensions/index.ts @@ -1,6 +1,4 @@ -import type Extension from "@shared/editor/lib/Extension"; -import type Mark from "@shared/editor/marks/Mark"; -import type Node from "@shared/editor/nodes/Node"; +import type { AnyExtensionClass } from "@shared/editor/lib/types"; import BlockMenuExtension from "~/editor/extensions/BlockMenu"; import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer"; import DiagramsExtension from "@shared/editor/extensions/Diagrams"; @@ -14,7 +12,7 @@ import PreventTab from "~/editor/extensions/PreventTab"; import SelectionToolbarExtension from "~/editor/extensions/SelectionToolbar"; import SmartText from "~/editor/extensions/SmartText"; -type Nodes = (typeof Node | typeof Mark | typeof Extension)[]; +type Nodes = AnyExtensionClass[]; export const withUIExtensions = (nodes: Nodes) => [ ...nodes, diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 8d9170b379e9..abb547f3c9b0 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -29,16 +29,18 @@ import insertFiles from "@shared/editor/commands/insertFiles"; import Styles from "@shared/editor/components/Styles"; import type { EmbedDescriptor } from "@shared/editor/embeds"; import type { CommandFactory, WidgetProps } from "@shared/editor/lib/Extension"; -import type Extension from "@shared/editor/lib/Extension"; +import type { AnyExtension, AnyExtensionClass } from "@shared/editor/lib/types"; import ExtensionManager from "@shared/editor/lib/ExtensionManager"; import type { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer"; import textBetween from "@shared/editor/lib/textBetween"; -import type Mark from "@shared/editor/marks/Mark"; import { basicExtensions as extensions } from "@shared/editor/nodes"; -import type Node from "@shared/editor/nodes/Node"; import type ReactNode from "@shared/editor/nodes/ReactNode"; import type { ComponentProps } from "@shared/editor/types"; -import type { ProsemirrorData, UserPreferences } from "@shared/types"; +import type { + ProsemirrorData, + ProsemirrorMark, + UserPreferences, +} from "@shared/types"; import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; import EventEmitter from "@shared/utils/events"; import type Document from "~/models/Document"; @@ -71,7 +73,7 @@ export type Props = { /** Placeholder displayed when the editor is empty */ placeholder: string; /** Extensions to load into the editor */ - extensions?: (typeof Node | typeof Mark | typeof Extension | Extension)[]; + extensions?: (AnyExtensionClass | AnyExtension)[]; /** If the editor should be focused on mount */ autoFocus?: boolean; /** The focused comment, if any */ @@ -117,11 +119,22 @@ export type Props = { /** Callback when user uses cancel key combo */ onCancel?: () => void; /** Callback when user changes editor content */ - onChange?: (value: () => any) => void; + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + onChange?: (value: (asString?: boolean, trim?: boolean) => any) => void; /** Callback when a comment mark is clicked */ onClickCommentMark?: (commentId: string) => void; - /** Callback when a comment mark is created */ - onCreateCommentMark?: (commentId: string, userId: string) => void; + /** + * Callback when a comment mark is created. + * + * @param commentId - the id of the comment mark. + * @param userId - the id of the user who created the mark. + * @param options - options for the comment mark creation. + */ + onCreateCommentMark?: ( + commentId: string, + userId: string, + options?: { focus: boolean } + ) => void; /** Callback when a comment mark is removed */ onDeleteCommentMark?: (commentId: string) => void; /** Callback when comments sidebar should be opened */ @@ -443,8 +456,8 @@ export class Editor extends React.PureComponent< schema: this.schema, doc, plugins: [ - ...this.keymaps, ...this.plugins, + ...this.keymaps, anchorPlugin(), dropCursor({ color: this.props.theme.cursor, @@ -745,9 +758,9 @@ export class Editor extends React.PureComponent< } if (isArray(node.attrs?.marks)) { - const existingMarks = node.attrs.marks; + const existingMarks = node.attrs.marks as ProsemirrorMark[]; const updatedMarks = existingMarks.filter( - (mark: any) => mark.attrs.id !== commentId + (mark) => mark.attrs?.id !== commentId ); const attrs = { ...node.attrs, @@ -790,9 +803,9 @@ export class Editor extends React.PureComponent< } if (isArray(node.attrs?.marks)) { - const existingMarks = node.attrs.marks; - const updatedMarks = existingMarks.map((mark: any) => - mark.type === "comment" && mark.attrs.id === commentId + const existingMarks = node.attrs.marks as ProsemirrorMark[]; + const updatedMarks = existingMarks.map((mark) => + mark.type === "comment" && mark.attrs?.id === commentId ? { ...mark, attrs: { ...mark.attrs, ...attrs } } : mark ); @@ -944,7 +957,7 @@ const EditorContainer = styled(Styles)<{ a#comment-${props.focusedCommentId} ~ span.component-image div.image-wrapper { - outline: ${props.theme.commentMarkBackground} solid 2px; + outline: ${props.theme.commentedImageOutlineDark} solid 2px; } `} diff --git a/app/editor/menus/code.tsx b/app/editor/menus/code.tsx index fd1979b66b4b..f3d3826542f7 100644 --- a/app/editor/menus/code.tsx +++ b/app/editor/menus/code.tsx @@ -14,6 +14,7 @@ import { import { isMermaid } from "@shared/editor/lib/isCode"; import type { MenuItem } from "@shared/editor/types"; import type { Dictionary } from "~/hooks/useDictionary"; +import { metaDisplay } from "@shared/utils/keyboard"; export default function codeMenuItems( state: EditorState, @@ -67,6 +68,7 @@ export default function codeMenuItems( name: "edit_mermaid", icon: <EditIcon />, tooltip: dictionary.editDiagram, + shortcut: `${metaDisplay} Enter`, visible: isMermaid(node) && !isEditingMermaid && !readOnly, }, { diff --git a/app/env.ts b/app/env.ts index 6e600caafd3c..6180e8849410 100644 --- a/app/env.ts +++ b/app/env.ts @@ -1,3 +1,4 @@ +// oxlint-disable no-explicit-any -- window.env is a server-injected boundary with mixed value types declare global { interface Window { env: Record<string, any>; diff --git a/app/hooks/useActionContext.tsx b/app/hooks/useActionContext.tsx index 18176015d8ea..bb56a0696c25 100644 --- a/app/hooks/useActionContext.tsx +++ b/app/hooks/useActionContext.tsx @@ -69,15 +69,15 @@ export const ActionContextProvider = observer(function ActionContextProvider_({ activeDocumentId: stores.ui.activeDocumentId ?? undefined, getActiveModels: <T extends Model>( - modelClass: new (...args: any[]) => T + modelClass: new (...args: never[]) => T ): T[] => stores.ui.getActiveModels<T>(modelClass), getActiveModel: <T extends Model>( - modelClass: new (...args: any[]) => T + modelClass: new (...args: never[]) => T ): T | undefined => stores.ui.getActiveModels<T>(modelClass)[0], getActivePolicies: <T extends Model>( - modelClass: new (...args: any[]) => T + modelClass: new (...args: never[]) => T ): Policy[] => stores.ui .getActiveModels<T>(modelClass) @@ -97,7 +97,7 @@ export const ActionContextProvider = observer(function ActionContextProvider_({ // Override model accessors when models are provided in value const getActiveModels = valueModels && valueModels.length > 0 - ? <T extends Model>(modelClass: new (...args: any[]) => T): T[] => { + ? <T extends Model>(modelClass: new (...args: never[]) => T): T[] => { const matching = valueModels.filter( (model): model is T => model instanceof modelClass ); @@ -108,11 +108,11 @@ export const ActionContextProvider = observer(function ActionContextProvider_({ : baseContext.getActiveModels; const getActiveModel = <T extends Model>( - modelClass: new (...args: any[]) => T + modelClass: new (...args: never[]) => T ): T | undefined => getActiveModels(modelClass)[0]; const getActivePolicies = <T extends Model>( - modelClass: new (...args: any[]) => T + modelClass: new (...args: never[]) => T ): Policy[] => getActiveModels(modelClass) .map((node) => stores.policies.get(node.id)) diff --git a/app/hooks/useApiKeyMenuActions.ts b/app/hooks/useApiKeyMenuActions.ts new file mode 100644 index 000000000000..bce7d89eca89 --- /dev/null +++ b/app/hooks/useApiKeyMenuActions.ts @@ -0,0 +1,21 @@ +import { useMemo } from "react"; +import { + copyApiKeyFactory, + revokeApiKeyFactory, +} from "~/actions/definitions/apiKeys"; +import type ApiKey from "~/models/ApiKey"; +import { useMenuAction } from "~/hooks/useMenuAction"; + +/** + * Hook that constructs the action menu for API key operations. + * + * @param apiKey - the API key to build actions for. + * @returns action with children for use in menus. + */ +export function useApiKeyMenuActions(apiKey: ApiKey) { + const actions = useMemo( + () => [copyApiKeyFactory({ apiKey }), revokeApiKeyFactory({ apiKey })], + [apiKey] + ); + return useMenuAction(actions); +} diff --git a/app/hooks/useCollectionMenuAction.tsx b/app/hooks/useCollectionMenuAction.tsx index 71cea2cdf4da..7cc5ffef03ea 100644 --- a/app/hooks/useCollectionMenuAction.tsx +++ b/app/hooks/useCollectionMenuAction.tsx @@ -65,7 +65,7 @@ export function useCollectionMenuAction({ collectionId, onRename }: Props) { ActionSeparator, deleteCollection, ], - [t, can.createDocument, can.update, onRename] + [t, can.update, onRename] ); return useMenuAction(actions); diff --git a/app/hooks/useDictionary.ts b/app/hooks/useDictionary.ts index 7a518e9a9771..649e74e96dae 100644 --- a/app/hooks/useDictionary.ts +++ b/app/hooks/useDictionary.ts @@ -125,6 +125,8 @@ export default function useDictionary() { formattingControls: t("Formatting controls"), distributeColumns: t("Distribute columns"), wrapText: t("Wrap text"), + collapseCode: t("Collapse"), + expandCode: t("Expand"), }), [t] ); diff --git a/app/hooks/useEditorClickHandlers.ts b/app/hooks/useEditorClickHandlers.ts index 9292238502ab..7e791536d2bf 100644 --- a/app/hooks/useEditorClickHandlers.ts +++ b/app/hooks/useEditorClickHandlers.ts @@ -14,7 +14,7 @@ type Params = { export default function useEditorClickHandlers({ shareId }: Params) { const history = useHistory(); - const { documents } = useStores(); + const { documents, ui } = useStores(); const handleClickLink = useCallback( (href: string, event?: MouseEvent) => { // on page hash @@ -81,6 +81,7 @@ export default function useEditorClickHandlers({ shareId }: Params) { !event || (!isModKey(event) && !event.shiftKey && event.button !== 1) ) { + ui.setPresentingDocument(null); history.push(navigateTo, { sidebarContext: "collections" }); // optimistic preference of "collections" } else { window.open(navigateTo, "_blank"); @@ -89,7 +90,7 @@ export default function useEditorClickHandlers({ shareId }: Params) { window.open(href, "_blank"); } }, - [history, shareId] + [history, shareId, documents, ui] ); return { handleClickLink }; diff --git a/app/hooks/useEmojiMenuActions.tsx b/app/hooks/useEmojiMenuActions.tsx index cf3932fec4bb..24ed17a5c5c7 100644 --- a/app/hooks/useEmojiMenuActions.tsx +++ b/app/hooks/useEmojiMenuActions.tsx @@ -1,9 +1,10 @@ import * as React from "react"; -import { TrashIcon } from "outline-icons"; +import { ReplaceIcon, TrashIcon } from "outline-icons"; import { Trans, useTranslation } from "react-i18next"; import { toast } from "sonner"; import type Emoji from "~/models/Emoji"; import ConfirmationDialog from "~/components/ConfirmationDialog"; +import { EmojiReplaceDialog } from "~/components/EmojiDialog/EmojiReplaceDialog"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { createAction } from "~/actions"; @@ -12,7 +13,7 @@ import { useMenuAction } from "~/hooks/useMenuAction"; /** * Hook that constructs the action menu for emoji management operations. - * + * * @param targetEmoji - the emoji to build actions for, or null to skip. * @returns action with children for use in menus, or undefined if emoji is null. */ @@ -21,6 +22,21 @@ export function useEmojiMenuActions(targetEmoji: Emoji | null) { const { dialogs } = useStores(); const can = usePolicy(targetEmoji ?? ({} as Emoji)); + const openReplaceDialog = React.useCallback(() => { + if (!targetEmoji) { + return; + } + dialogs.openModal({ + title: t("Replace image"), + content: ( + <EmojiReplaceDialog + emoji={targetEmoji} + onSubmit={dialogs.closeAllModals} + /> + ), + }); + }, [t, targetEmoji, dialogs]); + const openDeleteDialog = React.useCallback(() => { if (!targetEmoji) { return; @@ -28,27 +44,55 @@ export function useEmojiMenuActions(targetEmoji: Emoji | null) { dialogs.openModal({ title: t("Delete Emoji"), content: ( - <DeleteEmojiDialog emoji={targetEmoji} onSubmit={dialogs.closeAllModals} /> + <DeleteEmojiDialog + emoji={targetEmoji} + onSubmit={dialogs.closeAllModals} + /> ), }); }, [t, targetEmoji, dialogs]); - const actionList = React.useMemo( - () => - !targetEmoji || !can.delete - ? [] - : [ - createAction({ - name: `${t("Delete")}…`, - icon: <TrashIcon />, - section: EmojiSecion, - visible: true, - dangerous: true, - perform: openDeleteDialog, - }), - ], - [t, targetEmoji, can.delete, openDeleteDialog] - ); + const actionList = React.useMemo(() => { + if (!targetEmoji) { + return []; + } + + const actions = []; + + if (can.update) { + actions.push( + createAction({ + name: `${t("Replace")}…`, + icon: <ReplaceIcon />, + section: EmojiSecion, + visible: true, + perform: openReplaceDialog, + }) + ); + } + + if (can.delete) { + actions.push( + createAction({ + name: `${t("Delete")}…`, + icon: <TrashIcon />, + section: EmojiSecion, + visible: true, + dangerous: true, + perform: openDeleteDialog, + }) + ); + } + + return actions; + }, [ + t, + targetEmoji, + can.update, + can.delete, + openReplaceDialog, + openDeleteDialog, + ]); return useMenuAction(actionList); } diff --git a/app/hooks/useImportDocument.ts b/app/hooks/useImportDocument.ts index ac30da9f034e..e07e9fbcd12e 100644 --- a/app/hooks/useImportDocument.ts +++ b/app/hooks/useImportDocument.ts @@ -3,6 +3,7 @@ import { useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; import { toast } from "sonner"; +import { useSidebarContext } from "~/components/Sidebar/components/SidebarContext"; import useStores from "~/hooks/useStores"; import { documentPath } from "~/utils/routeHelpers"; @@ -16,6 +17,7 @@ export default function useImportDocument( isImporting: boolean; } { const { documents } = useStores(); + const sidebarContext = useSidebarContext(); const [isImporting, setImporting] = useState(false); const { t } = useTranslation(); const history = useHistory(); @@ -53,7 +55,10 @@ export default function useImportDocument( }); if (redirect) { - history.push(documentPath(doc)); + history.push({ + pathname: documentPath(doc), + state: { sidebarContext }, + }); } } catch (err) { toast.error(err.message); @@ -68,7 +73,7 @@ export default function useImportDocument( importingLock = false; } }, - [t, documents, history, collectionId, documentId] + [t, documents, history, collectionId, sidebarContext, documentId] ); return { diff --git a/app/hooks/useLastVisitedPath.tsx b/app/hooks/useLastVisitedPath.tsx index 9d2b615e2338..32b9452c36cb 100644 --- a/app/hooks/useLastVisitedPath.tsx +++ b/app/hooks/useLastVisitedPath.tsx @@ -1,6 +1,8 @@ import { useCallback, useRef } from "react"; import { getCookie, removeCookie, setCookie } from "tiny-cookie"; -import usePersistedState, { setPersistedState } from "~/hooks/usePersistedState"; +import usePersistedState, { + setPersistedState, +} from "~/hooks/usePersistedState"; import Logger from "~/utils/Logger"; import history from "~/utils/history"; import { isAllowedLoginRedirect } from "~/utils/urls"; @@ -30,6 +32,14 @@ export function useLastVisitedPath(): [string, (path: string) => void] { return [lastVisitedPath, setPathAsLastVisitedPath] as const; } +/** + * Clear the remembered last visited path so the next login does not reuse a + * stale redirect from a previous user. + */ +export function clearLastVisitedPath(): void { + setPersistedState("lastVisitedPath", "/"); +} + /** * Hook that automatically tracks the current path as the last visited path. * This uses a ref to track the previous path and updates localStorage directly @@ -39,9 +49,12 @@ export function useLastVisitedPath(): [string, (path: string) => void] { */ export function useTrackLastVisitedPath(currentPath: string): void { const prevPathRef = useRef<string>(); - + // Update localStorage directly if path has changed - if (prevPathRef.current !== currentPath && isAllowedLoginRedirect(currentPath)) { + if ( + prevPathRef.current !== currentPath && + isAllowedLoginRedirect(currentPath) + ) { prevPathRef.current = currentPath; setPersistedState("lastVisitedPath", currentPath); } diff --git a/app/hooks/usePaginatedRequest.ts b/app/hooks/usePaginatedRequest.ts index 82f9fa1fd1da..4f24e1a39701 100644 --- a/app/hooks/usePaginatedRequest.ts +++ b/app/hooks/usePaginatedRequest.ts @@ -31,7 +31,7 @@ const DEFAULT_LIMIT = 10; * @returns */ export default function usePaginatedRequest<T = unknown>( - requestFn: (params?: PaginationParams | undefined) => Promise<T[]>, + requestFn: (params?: PaginationParams) => Promise<T[]>, params: PaginationParams = {} ): RequestResponse<T> { const [data, setData] = useState<T[]>(); diff --git a/app/hooks/usePinnedDocuments.ts b/app/hooks/usePinnedDocuments.ts index ed1d5b3c2aac..90a8ccf2dd13 100644 --- a/app/hooks/usePinnedDocuments.ts +++ b/app/hooks/usePinnedDocuments.ts @@ -2,7 +2,7 @@ import { useEffect } from "react"; import usePersistedState from "~/hooks/usePersistedState"; import useStores from "./useStores"; -type UrlId = "home" | string; +type UrlId = string; export const pinsCacheKey = (urlId: UrlId) => `pins-${urlId}`; diff --git a/app/hooks/useQueryNotices.ts b/app/hooks/useQueryNotices.ts index 380cf6de03ae..8a3379f5ec82 100644 --- a/app/hooks/useQueryNotices.ts +++ b/app/hooks/useQueryNotices.ts @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; import { toast } from "sonner"; import { QueryNotices } from "@shared/types"; import useQuery from "./useQuery"; @@ -11,28 +12,44 @@ import useQuery from "./useQuery"; */ export default function useQueryNotices() { const query = useQuery(); + const history = useHistory(); const { t } = useTranslation(); const notice = query.get("notice") as QueryNotices; useEffect(() => { + let message: string | undefined; + switch (notice) { case QueryNotices.UnsubscribeDocument: { - toast.success( - t("Unsubscribed from document", { - type: "success", - }) - ); + message = t("Unsubscribed from document"); break; } case QueryNotices.UnsubscribeCollection: { - toast.success( - t("Unsubscribed from collection", { - type: "success", - }) - ); + message = t("Unsubscribed from collection"); + break; + } + case QueryNotices.Subscribed: { + message = t("Subscription successful"); + break; + } + case QueryNotices.Unsubscribed: { + message = t("Unsubscribed"); break; } default: } - }, [t, notice]); + + if (message) { + // Remove the notice param from the URL to prevent duplicate toasts + const params = new URLSearchParams(window.location.search); + params.delete("notice"); + const search = params.toString(); + history.replace({ + pathname: window.location.pathname, + search: search ? `?${search}` : "", + }); + + setTimeout(() => toast.success(message), 0); + } + }, [t, notice, history]); } diff --git a/app/hooks/useShareBranding.ts b/app/hooks/useShareBranding.ts new file mode 100644 index 000000000000..e09046d363a0 --- /dev/null +++ b/app/hooks/useShareBranding.ts @@ -0,0 +1,33 @@ +import type { PublicTeam } from "@shared/types"; +import { useTeamContext } from "~/components/TeamContext"; +import type Share from "~/models/Share"; +import type Team from "~/models/Team"; + +type ShareBranding = { + displayName: string | null; + displayLogoUrl: string | null; + displayLogoModel: Team | PublicTeam | undefined; + brandingAvailable: boolean; +}; + +/** + * Returns the resolved branding (name + logo) to display for a share, falling + * back to the team's name and avatar when the share has no custom values. + * + * @param share the share to derive branding for. + * @returns the resolved name, logo URL, fallback model, and whether any + * branding is available to display. + */ +export default function useShareBranding(share: Share): ShareBranding { + const team = useTeamContext(); + const displayName = share.title ?? team?.name ?? null; + const displayLogoUrl = share.iconUrl ?? team?.avatarUrl ?? null; + const displayLogoModel = displayLogoUrl ? undefined : team; + + return { + displayName, + displayLogoUrl, + displayLogoModel, + brandingAvailable: !!(displayName || displayLogoUrl), + }; +} diff --git a/app/hooks/useShareDataLoader.ts b/app/hooks/useShareDataLoader.ts new file mode 100644 index 000000000000..6f5246819eb9 --- /dev/null +++ b/app/hooks/useShareDataLoader.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Pagination } from "@shared/constants"; +import type Collection from "~/models/Collection"; +import type Document from "~/models/Document"; +import useStores from "./useStores"; + +type Params = + | { document: Document; collection?: undefined } + | { collection: Collection; document?: undefined }; + +/** + * Hook to preload all data needed by the share popover. Returns a `preload` + * function that can be called on hover so the popover renders instantly. + * + * @param params - the document or collection to load share data for. + * @returns preload function, loading state, and reset function. + */ +export default function useShareDataLoader(params: Params) { + const { shares, userMemberships, groupMemberships, memberships } = + useStores(); + const [loading, setLoading] = useState(false); + const requestedRef = useRef(false); + const requestCountRef = useRef(0); + + const entityId = params.document?.id ?? params.collection?.id; + + // Reset when the entity changes so preload fires for the new target. + useEffect(() => { + requestedRef.current = false; + setLoading(false); + }, [entityId]); + + const preload = useCallback(() => { + if (requestedRef.current) { + return; + } + requestedRef.current = true; + setLoading(true); + + const thisRequest = ++requestCountRef.current; + const promises: Promise<unknown>[] = []; + + if (params.document) { + const doc = params.document; + promises.push( + shares.fetchOne({ documentId: doc.id }), + userMemberships.fetchDocumentMemberships({ + id: doc.id, + limit: Pagination.defaultLimit, + }), + groupMemberships.fetchAll({ documentId: doc.id }) + ); + } else { + const col = params.collection; + promises.push( + shares.fetchOne({ collectionId: col.id }), + memberships.fetchAll({ id: col.id }), + groupMemberships.fetchAll({ collectionId: col.id }) + ); + } + + void Promise.all(promises).finally(() => { + if (requestCountRef.current === thisRequest) { + setLoading(false); + } + }); + }, [ + params.document, + params.collection, + shares, + userMemberships, + groupMemberships, + memberships, + ]); + + const reset = useCallback(() => { + requestedRef.current = false; + }, []); + + return { preload, loading, reset }; +} diff --git a/app/hooks/useShareMenuActions.tsx b/app/hooks/useShareMenuActions.tsx index 885333383186..8849d6a53217 100644 --- a/app/hooks/useShareMenuActions.tsx +++ b/app/hooks/useShareMenuActions.tsx @@ -11,7 +11,7 @@ import { useMenuAction } from "~/hooks/useMenuAction"; /** * Hook that constructs the action menu for share management operations. - * + * * @param targetShare - the share to build actions for, or null to skip. * @returns action with children for use in menus, or undefined if share is null. */ diff --git a/app/hooks/useStores.ts b/app/hooks/useStores.ts index 71cc0253684b..b02ddae7d597 100644 --- a/app/hooks/useStores.ts +++ b/app/hooks/useStores.ts @@ -1,12 +1,15 @@ import { MobXProviderContext } from "mobx-react"; import { useContext } from "react"; -import type RootStore from "~/stores"; +import type RootStore from "~/stores/RootStore"; /** * Hook to access the MobX stores from the React context. * - * @returns The root store containing all application stores + * @returns The root store containing all application stores. */ -export default function useStores() { - return useContext(MobXProviderContext) as typeof RootStore; +export default function useStores(): RootStore { + const { rootStore } = useContext(MobXProviderContext) as { + rootStore: RootStore; + }; + return rootStore; } diff --git a/app/hooks/useThrottledCallback.ts b/app/hooks/useThrottledCallback.ts index 4d1beb80e275..9797f7337a7f 100644 --- a/app/hooks/useThrottledCallback.ts +++ b/app/hooks/useThrottledCallback.ts @@ -20,7 +20,9 @@ const defaultOptions: ThrottleSettings = { * @param dependencies The dependencies to watch for changes * @param options The throttle options */ -export default function useThrottledCallback<T extends (...args: any[]) => any>( +export default function useThrottledCallback< + T extends (...args: never[]) => unknown, +>( fn: T, wait = 250, dependencies: React.DependencyList = [], diff --git a/app/hooks/useUnmount.ts b/app/hooks/useUnmount.ts index 8cf1f5cc4ed1..78260a5cf499 100644 --- a/app/hooks/useUnmount.ts +++ b/app/hooks/useUnmount.ts @@ -5,7 +5,7 @@ import { useRef, useEffect } from "react"; * * @param callback Function to be called on component unmount */ -const useUnmount = (callback: (...args: Array<any>) => any) => { +const useUnmount = (callback: () => void) => { const ref = useRef(callback); ref.current = callback; diff --git a/app/hooks/useUserMenuActions.tsx b/app/hooks/useUserMenuActions.tsx index 462a176e6fe1..bd1b633ffebb 100644 --- a/app/hooks/useUserMenuActions.tsx +++ b/app/hooks/useUserMenuActions.tsx @@ -24,7 +24,7 @@ import { /** * Hook that constructs the action menu for user management operations. - * + * * @param targetUser - the user to build actions for, or null to skip. * @returns action with children for use in menus, or undefined if user is null. */ diff --git a/app/hooks/useViewportHeight.ts b/app/hooks/useViewportHeight.ts deleted file mode 100644 index 3915de50a697..000000000000 --- a/app/hooks/useViewportHeight.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useLayoutEffect, useState } from "react"; - -/** - * Hook to get the current viewport height, accounting for mobile virtual keyboards. - * Uses the VisualViewport API when available, falling back to window.innerHeight. - * - * @returns The current viewport height in pixels - */ -export default function useViewportHeight(): number | void { - // https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility - // Note: No support in Firefox at time of writing, however this mainly exists - // for virtual keyboards on mobile devices, so that's okay. - const [height, setHeight] = useState<number>( - () => window.visualViewport?.height || window.innerHeight - ); - - useLayoutEffect(() => { - const handleResize = () => { - setHeight(() => window.visualViewport?.height || window.innerHeight); - }; - - window.visualViewport?.addEventListener("resize", handleResize); - - return () => { - window.visualViewport?.removeEventListener("resize", handleResize); - }; - }, []); - - return height; -} diff --git a/app/index.tsx b/app/index.tsx index b9ca18abe809..041bbfccbc41 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -57,7 +57,7 @@ if (element) { const App = () => ( <StrictMode> <HelmetProvider> - <Provider {...stores}> + <Provider rootStore={stores}> <Analytics> <Router history={history}> <Theme> @@ -98,8 +98,7 @@ window.addEventListener("load", async () => { if (!env.GOOGLE_ANALYTICS_ID || !window.ga) { return; } - // https://github.com/googleanalytics/autotrack/issues/137#issuecomment-305890099 - await import("autotrack/autotrack.js"); + await import("~/utils/autotrack"); window.ga("require", "outboundLinkTracker"); window.ga("require", "urlChangeTracker"); window.ga("require", "eventTracker", { diff --git a/app/menus/ApiKeyMenu.tsx b/app/menus/ApiKeyMenu.tsx index 9ea02d615da7..616bf193c37c 100644 --- a/app/menus/ApiKeyMenu.tsx +++ b/app/menus/ApiKeyMenu.tsx @@ -1,11 +1,9 @@ import { observer } from "mobx-react"; -import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import type ApiKey from "~/models/ApiKey"; import { DropdownMenu } from "~/components/Menu/DropdownMenu"; import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton"; -import { revokeApiKeyFactory } from "~/actions/definitions/apiKeys"; -import { useMenuAction } from "~/hooks/useMenuAction"; +import { useApiKeyMenuActions } from "~/hooks/useApiKeyMenuActions"; type Props = { /** The apiKey to associate with the menu */ @@ -14,11 +12,10 @@ type Props = { function ApiKeyMenu({ apiKey }: Props) { const { t } = useTranslation(); - const actions = useMemo(() => [revokeApiKeyFactory({ apiKey })], [apiKey]); - const rootAction = useMenuAction(actions); + const rootAction = useApiKeyMenuActions(apiKey); return ( - <DropdownMenu action={rootAction} ariaLabel={t("API key")}> + <DropdownMenu action={rootAction} align="end" ariaLabel={t("API key")}> <OverflowMenuButton /> </DropdownMenu> ); diff --git a/app/menus/TableOfContentsMenu.tsx b/app/menus/TableOfContentsMenu.tsx index 4adf176f47c0..07a3b8972136 100644 --- a/app/menus/TableOfContentsMenu.tsx +++ b/app/menus/TableOfContentsMenu.tsx @@ -72,7 +72,12 @@ function TableOfContentsMenu() { return ( <DropdownMenu action={rootAction} ariaLabel={t("Table of contents")}> - <Button icon={<TableOfContentsIcon />} borderOnHover neutral /> + <Button + icon={<TableOfContentsIcon />} + aria-label={t("Table of contents")} + borderOnHover + neutral + /> </DropdownMenu> ); } diff --git a/app/menus/separator.ts b/app/menus/separator.ts deleted file mode 100644 index 975da5a450c9..000000000000 --- a/app/menus/separator.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { MenuSeparator } from "~/types"; - -export default function separator(): MenuSeparator { - return { - type: "separator", - }; -} diff --git a/app/models/ApiKey.ts b/app/models/ApiKey.ts index 8a3e5dd4e6de..be39c7e73f5a 100644 --- a/app/models/ApiKey.ts +++ b/app/models/ApiKey.ts @@ -4,8 +4,9 @@ import Model from "./base/Model"; import Field from "./decorators/Field"; import User from "./User"; import Relation from "./decorators/Relation"; +import type { Searchable } from "./interfaces/Searchable"; -class ApiKey extends Model { +class ApiKey extends Model implements Searchable { static modelName = "ApiKey"; /** The human-readable name of this API key */ @@ -53,6 +54,16 @@ class ApiKey extends Model { } return `ol...${this.last4}`; } + + @computed + get searchContent(): string[] { + return [this.name, this.obfuscatedValue].filter(Boolean); + } + + @computed + get searchSuppressed(): boolean { + return false; + } } export default ApiKey; diff --git a/app/models/AuthenticationProvider.ts b/app/models/AuthenticationProvider.ts index cdc68ec6c556..2c1c5d804d63 100644 --- a/app/models/AuthenticationProvider.ts +++ b/app/models/AuthenticationProvider.ts @@ -37,11 +37,12 @@ class AuthenticationProvider extends Model { @AfterDelete static afterDelete(model: AuthenticationProvider) { // Restore a placeholder record to allow re-connection - return (model.store as AuthenticationProvidersStore).add({ - ...model, - isEnabled: false, - isConnected: false, - }); + return (model.store as AuthenticationProvidersStore).add( + Object.assign({}, model, { + isEnabled: false, + isConnected: false, + }) + ); } } diff --git a/app/models/Collection.ts b/app/models/Collection.ts index e5e1854a33db..1f8ab54d910e 100644 --- a/app/models/Collection.ts +++ b/app/models/Collection.ts @@ -67,6 +67,11 @@ export default class Collection extends ParanoidModel { direction: "asc" | "desc"; }; + /** The minimum permission level required to manage templates in this collection. */ + @Field + @observable + templateManagement: CollectionPermission; + /** * Whether commenting is enabled for the collection. */ @@ -313,13 +318,6 @@ export default class Collection extends ParanoidModel { this.index = index; } - @action - share = async () => - this.store.rootStore.shares.create({ - type: "collection", - collectionId: this.id, - }); - getChildrenForDocument(documentId: string) { let result: NavigationNode[] = []; diff --git a/app/models/Document.ts b/app/models/Document.ts index 3d35d3394913..13362d64f6c4 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -38,7 +38,7 @@ type SaveOptions = JSONObject & { export default class Document extends ArchivableModel implements Searchable { static modelName = "Document"; - constructor(fields: Record<string, any>, store: DocumentsStore) { + constructor(fields: Record<string, unknown>, store: DocumentsStore) { super(fields, store); this.embedsDisabled = Storage.get(`embedsDisabled-${this.id}`) ?? false; @@ -183,7 +183,7 @@ export default class Document extends ArchivableModel implements Searchable { parentDocument?: Document; @observable - collaboratorIds: string[]; + collaboratorIds: string[] = []; @Relation(() => User) createdBy: User | undefined; @@ -460,13 +460,6 @@ export default class Document extends ArchivableModel implements Searchable { } } - @action - share = async () => - this.store.rootStore.shares.create({ - type: "document", - documentId: this.id, - }); - archive = () => this.store.archive(this); restore = (options?: { revisionId?: string; collectionId?: string }) => @@ -522,7 +515,7 @@ export default class Document extends ArchivableModel implements Searchable { subscribe = () => this.store.subscribe(this); /** - * Unsubscribes the current user to this document. + * Unsubscribes the current user from this document. * * @returns A promise that resolves when the subscription is destroyed. */ @@ -577,7 +570,7 @@ export default class Document extends ArchivableModel implements Searchable { ); // if saving is successful set the new values on the model itself - set(this, { ...params, ...model }); + set(this, Object.assign({}, params, model)); this.persistedAttributes = this.toAPI(); diff --git a/app/models/Share.ts b/app/models/Share.ts index 4c8903d04d73..4b3d5242d037 100644 --- a/app/models/Share.ts +++ b/app/models/Share.ts @@ -70,6 +70,10 @@ class Share extends Model implements Searchable { @observable allowIndexing: boolean; + @Field + @observable + allowSubscriptions: boolean; + @Field @observable showLastUpdated: boolean; @@ -78,6 +82,16 @@ class Share extends Model implements Searchable { @observable showTOC: boolean; + /** Custom branding title to display on the shared page, supersedes team name. */ + @Field + @observable + title: string | null; + + /** Custom branding icon URL to display on the shared page, supersedes team avatar. */ + @Field + @observable + iconUrl: string | null; + @observable views: number; @@ -85,11 +99,6 @@ class Share extends Model implements Searchable { @Relation(() => User, { onDelete: "null" }) createdBy: User; - @computed - get title(): string { - return this.sourceTitle ?? this.documentTitle; - } - @computed get sourcePathWithFallback(): string { return this.sourcePath ?? this.documentUrl; @@ -97,7 +106,7 @@ class Share extends Model implements Searchable { @computed get searchContent(): string[] { - return [this.title]; + return [this.sourceTitle ?? this.documentTitle]; } @computed diff --git a/app/models/Team.ts b/app/models/Team.ts index 47363c7f6014..0d142fc6ee4d 100644 --- a/app/models/Team.ts +++ b/app/models/Team.ts @@ -64,6 +64,10 @@ class Team extends Model { @observable defaultUserRole: UserRole; + @Field + @observable + guidanceMCP: string | null; + @Field @observable preferences: TeamPreferences | null; diff --git a/app/models/User.ts b/app/models/User.ts index 4a11296dbaad..63fbc77e3afa 100644 --- a/app/models/User.ts +++ b/app/models/User.ts @@ -58,7 +58,23 @@ class User extends ParanoidModel implements Searchable { role: UserRole; @observable - lastActiveAt: string; + protected _lastActiveAt: string; + + /** + * The last time the user was active. For the currently signed-in user, this + * always returns the current date so they always appear as recently active. + */ + @computed + get lastActiveAt(): string { + if (this.store.rootStore.auth?.currentUserId === this.id) { + return new Date(now(60000)).toISOString(); + } + return this._lastActiveAt; + } + + set lastActiveAt(value: string) { + this._lastActiveAt = value; + } @observable isSuspended: boolean; diff --git a/app/models/WebhookSubscription.ts b/app/models/WebhookSubscription.ts index d2e1d64a8710..14b47d912e20 100644 --- a/app/models/WebhookSubscription.ts +++ b/app/models/WebhookSubscription.ts @@ -1,8 +1,11 @@ -import { observable } from "mobx"; +import { computed, observable } from "mobx"; import Model from "./base/Model"; import Field from "./decorators/Field"; +import Relation from "./decorators/Relation"; +import type { Searchable } from "./interfaces/Searchable"; +import User from "./User"; -class WebhookSubscription extends Model { +class WebhookSubscription extends Model implements Searchable { static modelName = "WebhookSubscription"; @Field @@ -24,6 +27,23 @@ class WebhookSubscription extends Model { @Field @observable events: string[]; + + /** The user who created this webhook subscription. */ + @Relation(() => User) + createdBy?: User; + + /** The user ID that created this webhook subscription. */ + createdById: string; + + @computed + get searchContent(): string[] { + return [this.name, this.url, ...(this.events ?? [])].filter(Boolean); + } + + @computed + get searchSuppressed(): boolean { + return false; + } } export default WebhookSubscription; diff --git a/app/models/base/Model.ts b/app/models/base/Model.ts index 67f80f2037e9..d683a24d1fba 100644 --- a/app/models/base/Model.ts +++ b/app/models/base/Model.ts @@ -28,7 +28,7 @@ export default abstract class Model { store: Store<Model>; - constructor(fields: Record<string, any>, store: Store<Model>) { + constructor(fields: Record<string, unknown>, store: Store<Model>) { this.store = store; this.updateData(fields); this.isNew = !this.id; @@ -43,7 +43,7 @@ export default abstract class Model { async loadRelations( this: Model, options: { withoutPolicies?: boolean } = {} - ): Promise<any> { + ): Promise<unknown> { // this is to ensure that multiple loads don’t happen in parallel if (this.loadingRelations) { return this.loadingRelations; @@ -90,7 +90,7 @@ export default abstract class Model { * @returns A promise that resolves with the updated model */ save = async ( - params?: Record<string, any>, + params?: Record<string, unknown>, options?: Record<string, string | boolean | number | undefined> ): Promise<Model> => { const isNew = this.isNew; @@ -120,7 +120,7 @@ export default abstract class Model { ); // if saving is successful set the new values on the model itself - this.updateData({ ...params, ...model }); + this.updateData(Object.assign({}, params, model)); if (isNew) { LifecycleManager.executeHooks(this.constructor, "afterCreate", this); @@ -134,7 +134,7 @@ export default abstract class Model { } }; - updateData = action((data: Partial<Model>) => { + updateData = action((data: Record<string, unknown>) => { if (this.initialized) { LifecycleManager.executeHooks(this.constructor, "beforeChange", this); } @@ -197,7 +197,7 @@ export default abstract class Model { * * @returns A plain object representation of the model */ - toAPI = (): Record<string, any> => { + toAPI = (): Partial<Model> => { const fields = getFieldsForModel(this); return pick(this, fields); }; @@ -247,7 +247,7 @@ export default abstract class Model { protected persistedAttributes: Partial<Model> = {}; /** A promise that resolves when all relations have been loaded. */ - private loadingRelations: Promise<any[]> | undefined; + private loadingRelations: Promise<unknown[]> | undefined; /** A boolean representing if the constructor has been called. */ private initialized = false; diff --git a/app/models/decorators/Field.ts b/app/models/decorators/Field.ts index 28c3e43e4a8f..7fc4740d8e9d 100644 --- a/app/models/decorators/Field.ts +++ b/app/models/decorators/Field.ts @@ -12,9 +12,9 @@ export const getFieldsForModel = <T extends Model>(target: T) => * @param target * @param propertyKey */ -const Field = <T>(target: any, propertyKey: keyof T) => { +const Field = (target: Model, propertyKey: string | symbol) => { const className = target.constructor.name; - fields.set(className, [...(fields.get(className) || []), propertyKey]); + fields.set(className, [...(fields.get(className) ?? []), propertyKey]); }; export default Field; diff --git a/app/models/decorators/Lifecycle.ts b/app/models/decorators/Lifecycle.ts index 83db79d71917..15d58b8ee1ee 100644 --- a/app/models/decorators/Lifecycle.ts +++ b/app/models/decorators/Lifecycle.ts @@ -1,24 +1,32 @@ +type ModelClass = { readonly name: string }; +type Hook = (...args: unknown[]) => unknown; + export class LifecycleManager { - private static hooks = new Map(); + private static hooks = new Map<string, Map<string, string[]>>(); - public static getHooks(target: any, lifecycle: string) { + public static getHooks(target: ModelClass, lifecycle: string): string[] { const key = `lifecycle:${lifecycle}`; const modelHooks = this.hooks.get(target.name); - return modelHooks?.get(key) || []; + return modelHooks?.get(key) ?? []; } - public static executeHooks(target: any, lifecycle: string, ...args: any[]) { + public static executeHooks( + target: ModelClass, + lifecycle: string, + ...args: unknown[] + ): void { const hooks = this.getHooks(target, lifecycle); - hooks.forEach((hook: keyof typeof target) => { - target[hook](...args); + hooks.forEach((hook) => { + const fn = (target as unknown as Record<string, Hook>)[hook]; + fn(...args); }); } public static registerHook( - target: any, + target: ModelClass, propertyKey: string, lifecycle: string - ) { + ): void { const key = `lifecycle:${lifecycle}`; let modelHooks = this.hooks.get(target.name); @@ -37,42 +45,42 @@ export class LifecycleManager { } } -export function BeforeCreate(target: any, propertyKey: string) { +export function BeforeCreate(target: ModelClass, propertyKey: string) { LifecycleManager.registerHook(target, propertyKey, "beforeCreate"); } -export function AfterCreate(target: any, propertyKey: string) { +export function AfterCreate(target: ModelClass, propertyKey: string) { LifecycleManager.registerHook(target, propertyKey, "afterCreate"); } -export function BeforeUpdate(target: any, propertyKey: string) { +export function BeforeUpdate(target: ModelClass, propertyKey: string) { LifecycleManager.registerHook(target, propertyKey, "beforeUpdate"); } -export function AfterUpdate(target: any, propertyKey: string) { +export function AfterUpdate(target: ModelClass, propertyKey: string) { LifecycleManager.registerHook(target, propertyKey, "afterUpdate"); } -export function BeforeChange(target: any, propertyKey: string) { +export function BeforeChange(target: ModelClass, propertyKey: string) { LifecycleManager.registerHook(target, propertyKey, "beforeChange"); } -export function AfterChange(target: any, propertyKey: string) { +export function AfterChange(target: ModelClass, propertyKey: string) { LifecycleManager.registerHook(target, propertyKey, "afterChange"); } -export function BeforeRemove(target: any, propertyKey: string) { +export function BeforeRemove(target: ModelClass, propertyKey: string) { LifecycleManager.registerHook(target, propertyKey, "beforeRemove"); } -export function AfterRemove(target: any, propertyKey: string) { +export function AfterRemove(target: ModelClass, propertyKey: string) { LifecycleManager.registerHook(target, propertyKey, "afterRemove"); } -export function BeforeDelete(target: any, propertyKey: string) { +export function BeforeDelete(target: ModelClass, propertyKey: string) { LifecycleManager.registerHook(target, propertyKey, "beforeDelete"); } -export function AfterDelete(target: any, propertyKey: string) { +export function AfterDelete(target: ModelClass, propertyKey: string) { LifecycleManager.registerHook(target, propertyKey, "afterDelete"); } diff --git a/app/models/decorators/Relation.ts b/app/models/decorators/Relation.ts index 3b55fbecc026..db100aa605ee 100644 --- a/app/models/decorators/Relation.ts +++ b/app/models/decorators/Relation.ts @@ -85,7 +85,7 @@ export default function Relation<T extends typeof Model>( classResolver: () => T, options?: RelationOptions ) { - return function (target: any, propertyKey: string) { + return function (target: Model, propertyKey: string) { const idKey = options?.multiple ? `${String(singular(propertyKey))}Ids` : `${String(propertyKey)}Id`; @@ -96,16 +96,16 @@ export default function Relation<T extends typeof Model>( // TODO: requestAnimationFrame is a temporary solution to a bug in rolldown compiled code that // will place static methods _after_ decorators. Temporary fix is to delay the registration until // the next frame. + const modelName = (target.constructor as typeof Model).modelName; requestAnimationFrame(() => { if (options) { - const configForClass = - relations.get(target.constructor.modelName) || new Map(); + const configForClass = relations.get(modelName) ?? new Map(); configForClass.set(propertyKey, { options, relationClassResolver: classResolver, idKey, }); - relations.set(target.constructor.modelName, configForClass); + relations.set(modelName, configForClass); } }); diff --git a/app/scenes/Collection/components/Header.tsx b/app/scenes/Collection/components/Header.tsx index 31f3d997261e..deac81bd7d0f 100644 --- a/app/scenes/Collection/components/Header.tsx +++ b/app/scenes/Collection/components/Header.tsx @@ -4,6 +4,7 @@ import first from "lodash/first"; import { Suspense, useCallback } from "react"; import styled from "styled-components"; import { CollectionValidation } from "@shared/validations"; +import { isRTL } from "@shared/utils/rtl"; import Heading from "~/components/Heading"; import ContentEditable from "~/components/ContentEditable"; import CollectionIcon from "~/components/Icons/CollectionIcon"; @@ -48,9 +49,11 @@ export const Header = observer(function Header_({ <CollectionIcon collection={collection} size={40} expanded /> ) : null; + const dir = isRTL(collection.name) ? "rtl" : "ltr"; + return ( - <StyledHeading> - <IconTitleWrapper> + <StyledHeading dir={dir}> + <IconTitleWrapper dir={dir}> {canEdit ? ( <Suspense fallback={fallbackIcon}> <IconPicker diff --git a/app/scenes/Collection/components/Overview.tsx b/app/scenes/Collection/components/Overview.tsx index e26032a45f88..cd906d983532 100644 --- a/app/scenes/Collection/components/Overview.tsx +++ b/app/scenes/Collection/components/Overview.tsx @@ -50,7 +50,7 @@ function Overview({ collection, readOnly }: Props) { useEffect( () => () => { - handleSave.flush(); + void handleSave.flush(); }, [handleSave] ); diff --git a/app/scenes/Collection/components/ShareButton.tsx b/app/scenes/Collection/components/ShareButton.tsx index 6f25eabb5a98..f2b75c671a70 100644 --- a/app/scenes/Collection/components/ShareButton.tsx +++ b/app/scenes/Collection/components/ShareButton.tsx @@ -11,6 +11,7 @@ import { } from "~/components/primitives/Popover"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useMobile from "~/hooks/useMobile"; +import useShareDataLoader from "~/hooks/useShareDataLoader"; import useStores from "~/hooks/useStores"; import { preventDefault } from "~/utils/events"; import lazyWithRetry from "~/utils/lazyWithRetry"; @@ -33,14 +34,23 @@ function ShareButton({ collection }: Props) { const share = shares.getByCollectionId(collection.id); const isPubliclyShared = team.sharing !== false && collection?.sharing !== false && share?.published; + const { preload, loading, reset } = useShareDataLoader({ collection }); - const closePopover = useCallback(() => { - setOpen(false); - }, []); + const handleOpenChange = useCallback( + (isOpen: boolean) => { + setOpen(isOpen); + if (isOpen) { + preload(); + } else { + reset(); + } + }, + [preload, reset] + ); - const handleMouseEnter = useCallback(() => { - void collection.share(); - }, [collection]); + const closePopover = useCallback(() => { + handleOpenChange(false); + }, [handleOpenChange]); if (isMobile) { return null; @@ -53,9 +63,9 @@ function ShareButton({ collection }: Props) { ); return ( - <Popover open={open} onOpenChange={setOpen}> + <Popover open={open} onOpenChange={handleOpenChange}> <PopoverTrigger> - <Button icon={icon} neutral onMouseEnter={handleMouseEnter}> + <Button icon={icon} neutral onMouseEnter={preload}> {t("Share")} </Button> </PopoverTrigger> @@ -72,6 +82,7 @@ function ShareButton({ collection }: Props) { collection={collection} onRequestClose={closePopover} visible={open} + loading={loading} /> </Suspense> </PopoverContent> diff --git a/app/scenes/Collection/index.tsx b/app/scenes/Collection/index.tsx index 16e58eb793fc..40646e22b17a 100644 --- a/app/scenes/Collection/index.tsx +++ b/app/scenes/Collection/index.tsx @@ -14,6 +14,7 @@ import styled from "styled-components"; import { s } from "@shared/styles"; import { StatusFilter } from "@shared/types"; import type Collection from "~/models/Collection"; +import type DocumentsStore from "~/stores/DocumentsStore"; import CenteredContent from "~/components/CenteredContent"; import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb"; import Heading from "~/components/Heading"; @@ -362,9 +363,15 @@ const Content = styled.div` `; const RecentDocuments = observer( - ({ collection, documents }: { collection: Collection; documents: any }) => { + ({ + collection, + documents, + }: { + collection: Collection; + documents: DocumentsStore; + }) => { useEffect(() => { - collection.fetchDocuments(); + void collection.fetchDocuments(); }, [collection]); return ( diff --git a/app/scenes/Document/components/Comments/CommentForm.tsx b/app/scenes/Document/components/Comments/CommentForm.tsx index 1688aff14066..07278d1544ba 100644 --- a/app/scenes/Document/components/Comments/CommentForm.tsx +++ b/app/scenes/Document/components/Comments/CommentForm.tsx @@ -51,8 +51,6 @@ type Props = { animatePresence?: boolean; /** Text to highlight at the top of the comment */ highlightedText?: string; - /** The text direction of the editor */ - dir?: "rtl" | "ltr"; /** Callback when the editor is focused */ onFocus?: () => void; /** Callback when the editor is blurred */ @@ -75,7 +73,6 @@ function CommentForm({ placeholder, animatePresence, highlightedText, - dir, ...rest }: Props) { const { editor } = useDocumentContext(); @@ -103,6 +100,11 @@ function CommentForm({ useOnClickOutside(formRef, reset); + React.useEffect(() => { + window.addEventListener("beforeunload", reset); + return () => window.removeEventListener("beforeunload", reset); + }, [reset]); + const handleCreateComment = action(async (event: React.FormEvent) => { event.preventDefault(); @@ -254,11 +256,13 @@ function CommentForm({ const handleMounted = React.useCallback( (ref) => { if (autoFocus && ref && !hasFocusedOnMount.current) { - ref.focusAtStart(); + if (!draft) { + ref.focusAtStart(); + } hasFocusedOnMount.current = true; } }, - [autoFocus] + [autoFocus, draft] ); const presence = animatePresence @@ -299,7 +303,7 @@ function CommentForm({ tabIndex={-1} /> </VisuallyHidden.Root> - <Flex gap={8} align="flex-start" reverse={dir === "rtl"}> + <Flex gap={8} align="flex-start"> <Avatar model={user} size={24} style={{ marginTop: 8 }} /> <Bubble gap={10} @@ -334,7 +338,7 @@ function CommentForm({ /> </React.Suspense> {(inputFocused || draft) && ( - <Flex justify="space-between" reverse={dir === "rtl"} gap={8}> + <Flex justify="space-between" gap={8}> <HStack> <ButtonSmall type="submit" borderOnHover> {thread && !thread.isNew ? t("Reply") : t("Post")} diff --git a/app/scenes/Document/components/Comments/CommentSortMenu.tsx b/app/scenes/Document/components/Comments/CommentSortMenu.tsx index 444b5ab745ca..782e3e93a25a 100644 --- a/app/scenes/Document/components/Comments/CommentSortMenu.tsx +++ b/app/scenes/Document/components/Comments/CommentSortMenu.tsx @@ -75,7 +75,7 @@ const CommentSortMenu = ({ viewingResolved, onChange }: Props) => { value={value} onChange={handleChange} label={t("Sort comments")} - hideLabel + labelHidden borderOnHover /> ); diff --git a/app/scenes/Document/components/Comments/CommentThread.tsx b/app/scenes/Document/components/Comments/CommentThread.tsx index d93dfcfa33fe..5b2ec858f5c9 100644 --- a/app/scenes/Document/components/Comments/CommentThread.tsx +++ b/app/scenes/Document/components/Comments/CommentThread.tsx @@ -219,7 +219,6 @@ function CommentThread({ ref={topRef} $focused={focused} $recessed={recessed} - $dir={document.dir} onClick={handleClickThread} > {commentsInThread.map((comment, index) => { @@ -252,7 +251,6 @@ function CommentThread({ firstOfAuthor={firstOfAuthor} lastOfAuthor={lastOfAuthor} previousCommentCreatedAt={commentsInThread[index - 1]?.createdAt} - dir={document.dir} forceEdit={editingCommentIds.has(comment.id)} onEditStart={() => handleCommentEditStart(comment.id)} onEditEnd={() => handleCommentEditEnd(comment.id)} @@ -270,7 +268,6 @@ function CommentThread({ documentId={document.id} thread={thread} standalone={commentsInThread.length === 0} - dir={document.dir} autoFocus={autoFocus} highlightedText={ commentsInThread.length === 0 ? highlightedText : undefined @@ -298,23 +295,22 @@ const Reply = styled.button` cursor: var(--pointer); transition: opacity 100ms ease-out; position: absolute; - text-align: left; + text-align: start; width: 100%; bottom: -30px; - left: 32px; + inset-inline-start: 32px; ${breakpoint("tablet")` opacity: 0; `} `; -const ShowMore = styled.div<{ $dir?: "rtl" | "ltr" }>` +const ShowMore = styled.div` display: flex; justify-content: space-between; align-items: center; margin-bottom: 1px; - margin-left: ${(props) => (props.$dir === "rtl" ? 0 : 32)}px; - margin-right: ${(props) => (props.$dir !== "rtl" ? 0 : 32)}px; + margin-inline-start: 32px; padding: 8px 12px; color: ${s("textTertiary")}; background: ${(props) => darken(0.015, props.theme.backgroundSecondary)}; @@ -334,11 +330,10 @@ const ShowMore = styled.div<{ $dir?: "rtl" | "ltr" }>` const Thread = styled.div<{ $focused: boolean; $recessed: boolean; - $dir?: "rtl" | "ltr"; }>` margin: 12px 12px 32px; - margin-right: ${(props) => (props.$dir !== "rtl" ? "18px" : "12px")}; - margin-left: ${(props) => (props.$dir === "rtl" ? "18px" : "12px")}; + margin-inline-end: 18px; + margin-inline-start: 12px; position: relative; transition: opacity 100ms ease-out; diff --git a/app/scenes/Document/components/Comments/CommentThreadItem.tsx b/app/scenes/Document/components/Comments/CommentThreadItem.tsx index a98800ad0b3c..b7bd4f7d9022 100644 --- a/app/scenes/Document/components/Comments/CommentThreadItem.tsx +++ b/app/scenes/Document/components/Comments/CommentThreadItem.tsx @@ -70,8 +70,6 @@ function useShowTime( type Props = { /** The comment to render */ comment: Comment; - /** The text direction of the editor */ - dir?: "rtl" | "ltr"; /** Whether this is the first comment in the thread */ firstOfThread?: boolean; /** Whether this is the last comment in the thread */ @@ -103,7 +101,6 @@ function CommentThreadItem({ firstOfAuthor, firstOfThread, lastOfThread, - dir, previousCommentCreatedAt, canReply, onDelete, @@ -161,7 +158,7 @@ function CommentThreadItem({ setFocusedCommentId(null); } }, - [comment.id, onUpdate] + [comment.id, onUpdate, setFocusedCommentId] ); const handleDelete = React.useCallback(() => { @@ -200,7 +197,7 @@ function CommentThreadItem({ }; return ( - <Flex gap={8} align="flex-start" reverse={dir === "rtl"}> + <Flex gap={8} align="flex-start"> {firstOfAuthor && ( <AvatarSpacer> <Avatar model={comment.createdBy} size={24} /> @@ -210,12 +207,11 @@ function CommentThreadItem({ $firstOfThread={firstOfThread} $firstOfAuthor={firstOfAuthor} $lastOfThread={lastOfThread} - $dir={dir} $canReply={canReply} column > {(showAuthor || showTime) && ( - <Meta size="xsmall" type="secondary" dir={dir}> + <Meta size="xsmall" type="secondary"> {showAuthor && <em>{comment.createdBy.name}</em>} {showAuthor && showTime && <> · </>} {showTime && ( @@ -277,7 +273,7 @@ function CommentThreadItem({ </Body> <EventBoundary> {!isEditing && ( - <Actions gap={4} dir={dir}> + <Actions gap={4}> {!comment.isResolved && ( <> {firstOfThread && ( @@ -386,14 +382,13 @@ const Action = styled.span<{ $rounded?: boolean }>` } `; -const Actions = styled(Flex)<{ dir?: "rtl" | "ltr" }>` +const Actions = styled(Flex)` position: absolute; - left: ${(props) => (props.dir !== "rtl" ? "auto" : "4px")}; - right: ${(props) => (props.dir === "rtl" ? "auto" : "4px")}; + inset-inline-end: 4px; top: 4px; transition: opacity 100ms ease-in-out; background: ${s("backgroundSecondary")}; - padding-left: 4px; + padding-inline-start: 4px; ${breakpoint("tablet")` opacity: 0; @@ -423,7 +418,6 @@ export const Bubble = styled(Flex)<{ $lastOfThread?: boolean; $canReply?: boolean; $focused?: boolean; - $dir?: "rtl" | "ltr"; }>` position: relative; flex-grow: 1; @@ -440,16 +434,13 @@ export const Bubble = styled(Flex)<{ ${({ $lastOfThread, $canReply }) => $lastOfThread && !$canReply && - "border-bottom-left-radius: 8px; border-bottom-right-radius: 8px"}; + "border-end-start-radius: 8px; border-end-end-radius: 8px"}; ${({ $firstOfThread }) => $firstOfThread && - "border-top-left-radius: 8px; border-top-right-radius: 8px"}; + "border-start-start-radius: 8px; border-start-end-radius: 8px"}; - margin-left: ${(props) => - props.$firstOfAuthor || props.$dir === "rtl" ? 0 : 32}px; - margin-right: ${(props) => - props.$firstOfAuthor || props.$dir !== "rtl" ? 0 : 32}px; + margin-inline-start: ${(props) => (props.$firstOfAuthor ? 0 : 32)}px; p:last-child { margin-bottom: 0; diff --git a/app/scenes/Document/components/Comments/Comments.tsx b/app/scenes/Document/components/Comments/Comments.tsx index d96f80c2d7e7..9bcd4b0a7b2d 100644 --- a/app/scenes/Document/components/Comments/Comments.tsx +++ b/app/scenes/Document/components/Comments/Comments.tsx @@ -180,7 +180,6 @@ function Comments() { documentId={document.id} placeholder={`${t("Add a comment")}…`} autoFocus={false} - dir={document.dir} animatePresence standalone /> @@ -245,10 +244,10 @@ const JumpToRecent = styled(ButtonSmall)` } `; -const NewCommentForm = styled(CommentForm)<{ dir?: "ltr" | "rtl" }>` +const NewCommentForm = styled(CommentForm)` padding: 12px; - padding-right: ${(props) => (props.dir !== "rtl" ? "18px" : "12px")}; - padding-left: ${(props) => (props.dir === "rtl" ? "18px" : "12px")}; + padding-inline-end: 18px; + padding-inline-start: 12px; `; export default observer(Comments); diff --git a/app/scenes/Document/components/Comments/HighlightText.ts b/app/scenes/Document/components/Comments/HighlightText.ts index 401d2947c898..b2ae52984aff 100644 --- a/app/scenes/Document/components/Comments/HighlightText.ts +++ b/app/scenes/Document/components/Comments/HighlightText.ts @@ -19,7 +19,7 @@ export const HighlightedText = styled(Text)` content: ""; width: 2px; position: absolute; - left: 0; + inset-inline-start: 0; top: 2px; bottom: 2px; background: ${s("commentMarkBackground")}; diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 8d5c1bfecf38..a2a33960e5ae 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import type { RouteComponentProps, StaticContext } from "react-router"; -import { useLocation } from "react-router"; +import { Redirect, useLocation } from "react-router"; import { TeamPreference } from "@shared/types"; import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; import { RevisionHelper } from "@shared/utils/RevisionHelper"; @@ -27,7 +27,11 @@ import { PaymentRequiredError, } from "~/utils/errors"; import history from "~/utils/history"; -import { matchDocumentEdit, settingsPath } from "~/utils/routeHelpers"; +import { + matchDocumentEdit, + settingsPath, + updateDocumentPath, +} from "~/utils/routeHelpers"; import useDocumentSidebar from "../hooks/useDocumentSidebar"; import Loading from "./Loading"; import MarkAsViewed from "./MarkAsViewed"; @@ -107,25 +111,38 @@ function DataLoader({ match, children }: Props) { void fetchDocument(); }, [ui, documents, missingPolicy, documentSlug]); - React.useEffect(() => { - async function fetchRevision() { - if (!revisionId) { - return; - } + const fetchRevisionById = React.useCallback( + async (id: string, onError: (err: Error) => void) => { try { - if (revisionId === "latest") { + if (id === "latest") { if (document?.id) { await revisions.fetchLatest(document.id); } } else { - await revisions.fetch(revisionId); + await revisions.fetch(id); } } catch (err) { - setError(err); + onError(err as Error); } + }, + [revisions, document?.id] + ); + + React.useEffect(() => { + if (revisionId) { + void fetchRevisionById(revisionId, setError); } - void fetchRevision(); - }, [revisions, revisionId, document?.id]); + }, [fetchRevisionById, revisionId]); + + const compareTo = query.get("compareTo"); + + React.useEffect(() => { + if (compareTo) { + void fetchRevisionById(compareTo, (err) => + Logger.error("Failed to fetch compareTo revision", err) + ); + } + }, [fetchRevisionById, compareTo]); React.useEffect(() => { async function fetchViews() { @@ -205,6 +222,7 @@ function DataLoader({ match, children }: Props) { shares, ui, revisionId, + missingPolicy, ]); // Auto-enter presentation mode when ?present=true query param is set @@ -240,6 +258,21 @@ function DataLoader({ match, children }: Props) { ); } + // Redirect to the canonical URL if the document slug has changed, e.g. + // after a rename, so the browser address bar stays in sync. + const canonicalUrl = updateDocumentPath(match.url, document); + if (location.pathname !== canonicalUrl) { + return ( + <Redirect + to={{ + pathname: canonicalUrl, + state: location.state, + hash: location.hash, + }} + /> + ); + } + const canEdit = can.update && !document.isArchived && !revisionId; const readOnly = !isEditing || !canEdit; diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index 73295927ba9d..f2273cbe69e1 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -1,16 +1,9 @@ -import cloneDeep from "lodash/cloneDeep"; -import debounce from "lodash/debounce"; -import isEqual from "lodash/isEqual"; -import { action, observable } from "mobx"; import { observer } from "mobx-react"; -import { Node } from "prosemirror-model"; -import type { Selection } from "prosemirror-state"; -import { AllSelection, TextSelection } from "prosemirror-state"; +import { AllSelection } from "prosemirror-state"; +import { useRef, useCallback } from "react"; import * as React from "react"; -import type { WithTranslation } from "react-i18next"; -import { withTranslation } from "react-i18next"; -import type { RouteComponentProps, StaticContext } from "react-router"; -import { Prompt, withRouter, Redirect } from "react-router"; +import { useTranslation } from "react-i18next"; +import { Prompt, useHistory, useLocation } from "react-router-dom"; import { toast } from "sonner"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; @@ -18,13 +11,9 @@ import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper"; import { s } from "@shared/styles"; import type { NavigationNode } from "@shared/types"; import { IconType, TOCPosition, TeamPreference } from "@shared/types"; -import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; -import { TextHelper } from "@shared/utils/TextHelper"; import { determineIconType } from "@shared/utils/icon"; import { isModKey } from "@shared/utils/keyboard"; -import type RootStore from "~/stores/RootStore"; import type Document from "~/models/Document"; -import Template from "~/models/Template"; import type Revision from "~/models/Revision"; import DocumentMove from "~/components/DocumentExplorer/DocumentMove"; import DocumentPublish from "~/scenes/DocumentPublish"; @@ -33,18 +22,15 @@ import LoadingIndicator from "~/components/LoadingIndicator"; import PageTitle from "~/components/PageTitle"; import PlaceholderDocument from "~/components/PlaceholderDocument"; import RegisterKeyDown from "~/components/RegisterKeyDown"; -import type { SidebarContextType } from "~/components/Sidebar/components/SidebarContext"; -import withStores from "~/components/withStores"; import { MeasuredContainer } from "~/components/MeasuredContainer"; import type { Editor as TEditor } from "~/editor"; import type { Properties } from "~/types"; +import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext"; +import useStores from "~/hooks/useStores"; import { client } from "~/utils/ApiClient"; import { emojiToUrl } from "~/utils/emoji"; -import { - documentHistoryPath, - documentEditPath, - updateDocumentPath, -} from "~/utils/routeHelpers"; +import { documentHistoryPath, documentEditPath } from "~/utils/routeHelpers"; +import { useDocumentSave } from "../hooks/useDocumentSave"; import Container from "./Container"; import Contents from "./Contents"; import Editor from "./Editor"; @@ -52,155 +38,79 @@ import Header from "./Header"; import Notices from "./Notices"; import References from "./References"; import RevisionViewer from "./RevisionViewer"; - -const AUTOSAVE_DELAY = 3000; - -type Params = { - documentSlug: string; - revisionId?: string; - shareId?: string; -}; +import SharedHeader from "./SharedHeader"; type LocationState = { title?: string; restore?: boolean; revisionId?: string; - sidebarContext?: SidebarContextType; }; -type Props = WithTranslation & - RootStore & - RouteComponentProps<Params, StaticContext, LocationState> & { - sharedTree?: NavigationNode; - abilities: Record<string, boolean>; - document: Document; - revision?: Revision; - readOnly: boolean; - shareId?: string; - tocPosition?: TOCPosition | false; - onCreateLink?: ( - params: Properties<Document>, - nested?: boolean - ) => Promise<string>; - }; - -@observer -class DocumentScene extends React.Component<Props> { - @observable - editor = React.createRef<TEditor>(); - - @observable - isUploading = false; - - @observable - isSaving = false; - - @observable - isPublishing = false; - - @observable - isEditorDirty = false; - - @observable - isEmpty = true; - - @observable - title: string = this.props.document.title; - - componentDidMount() { - this.updateIsDirty(); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.readOnly && !this.props.readOnly) { - this.updateIsDirty(); - } - } - - componentWillUnmount() { - if ( - this.isEmpty && - this.props.document.createdBy?.id === this.props.auth.user?.id && - this.props.document.isDraft && - this.props.document.isActive && - this.props.document.hasEmptyTitle && - this.props.document.isPersistedOnce - ) { - void this.props.document.delete(); - } else if (this.props.document.isDirty()) { - void this.props.document.save(undefined, { - autosave: true, - }); - } - } - - /** - * Replaces the given selection with a template, if no selection is provided - * then the template is inserted at the beginning of the document. - * - * @param template The template to use - * @param selection The selection to replace, if any - */ - replaceSelection = (template: Template | Revision, selection?: Selection) => { - const editorRef = this.editor.current; - - if (!editorRef) { - return; - } - - const { view, schema } = editorRef; - const sel = selection ?? TextSelection.near(view.state.doc.resolve(0)); - const doc = Node.fromJSON( - schema, - ProsemirrorHelper.replaceTemplateVariables( - template.data, - this.props.auth.user! - ) - ); - - if (doc) { - view.dispatch(view.state.tr.setSelection(sel).replaceSelectionWith(doc)); - } - - this.isEditorDirty = true; - - if (template instanceof Template) { - this.props.document.templateId = template.id; - this.props.document.fullWidth = template.fullWidth; - } - - if (!this.title) { - const title = TextHelper.replaceTemplateVariables( - template.title, - this.props.auth.user! - ); - this.title = title; - this.props.document.title = title; - } - if (template.icon) { - this.props.document.icon = template.icon; - } - if (template.color) { - this.props.document.color = template.color; - } - - this.props.document.data = cloneDeep(template.data); - this.updateIsDirty(); - - return this.onSave({ - autosave: true, - publish: false, - done: false, - }); - }; +interface Props { + /** Tree of navigation nodes for shared documents. */ + sharedTree?: NavigationNode; + /** Map of ability names to booleans representing current user permissions. */ + abilities: Record<string, boolean>; + /** The document model being viewed or edited. */ + document: Document; + /** An optional revision to display instead of the live document. */ + revision?: Revision; + /** Whether the document is in read-only mode. */ + readOnly: boolean; + /** The share ID when viewing a publicly shared document. */ + shareId?: string; + /** Override for the table of contents position, or false to hide it. */ + tocPosition?: TOCPosition | false; + /** Callback to create a linked document from the editor. */ + onCreateLink?: ( + params: Properties<Document>, + nested?: boolean + ) => Promise<string>; + /** Optional children rendered after the main document content. */ + children?: React.ReactNode; +} - onSynced = async () => { - const { history, location, t } = this.props; +/** Scene component responsible for rendering and interacting with a document. */ +function DocumentScene({ + document, + revision, + readOnly, + abilities, + shareId, + tocPosition, + onCreateLink, + children, +}: Props) { + const { auth, ui, dialogs } = useStores(); + const { t } = useTranslation(); + const history = useHistory(); + const location = useLocation<LocationState>(); + const sidebarContext = useLocationSidebarContext(); + const { team, user } = auth; + + const editorRef = useRef<TEditor>(null); + + const { + isUploading, + isSaving, + isPublishing, + isEditorDirty, + isEmpty, + onSave, + replaceSelection, + handleSelectTemplate, + handleChangeTitle, + handleChangeIcon, + onFileUploadStart, + onFileUploadStop, + } = useDocumentSave({ document, editorRef, readOnly }); + + const onSynced = useCallback(async () => { const restore = location.state?.restore; const revisionId = location.state?.revisionId; - const editorRef = this.editor.current; + const editor = editorRef.current; - if (!editorRef) { + if (!editor) { return; } @@ -208,7 +118,7 @@ class DocumentScene extends React.Component<Props> { const params = new URLSearchParams(location.search); const searchTerm = params.get("q"); if (searchTerm) { - editorRef.commands.find({ text: searchTerm }); + editor.commands.find({ text: searchTerm }); } if (!restore) { @@ -220,424 +130,297 @@ class DocumentScene extends React.Component<Props> { }); if (response) { - await this.replaceSelection( + await replaceSelection( response.data, - new AllSelection(editorRef.view.state.doc) + new AllSelection(editor.view.state.doc) ); toast.success(t("Document restored")); - history.replace(this.props.document.url, history.location.state); + history.replace(document.url, history.location.state); } - }; - - onUndoRedo = (event: KeyboardEvent) => { - if (isModKey(event)) { - event.preventDefault(); - - if (event.shiftKey) { - if (!this.props.readOnly) { - this.editor.current?.commands.redo(); - } - } else { - if (!this.props.readOnly) { - this.editor.current?.commands.undo(); + }, [location, replaceSelection, t, history, document.url]); + + const onUndoRedo = useCallback( + (event: KeyboardEvent) => { + if (isModKey(event)) { + event.preventDefault(); + + if (event.shiftKey) { + if (!readOnly) { + editorRef.current?.commands.redo(); + } + } else { + if (!readOnly) { + editorRef.current?.commands.undo(); + } } } - } - }; - - onMove = (ev: React.MouseEvent | KeyboardEvent) => { - ev.preventDefault(); - const { document, dialogs, t, abilities } = this.props; - if (abilities.move) { - dialogs.openModal({ - title: t("Move document"), - content: <DocumentMove document={document} />, - }); - } - }; + }, + [readOnly] + ); - goToEdit = (ev: KeyboardEvent) => { - if (this.props.readOnly) { + const onMove = useCallback( + (ev: React.MouseEvent | KeyboardEvent) => { ev.preventDefault(); - const { document, abilities } = this.props; - - if (abilities.update) { - this.props.history.push({ - pathname: documentEditPath(document), - state: { sidebarContext: this.props.location.state?.sidebarContext }, + if (abilities.move) { + dialogs.openModal({ + title: t("Move document"), + content: <DocumentMove document={document} />, }); } - } else if (this.editor.current?.isBlurred) { - ev.preventDefault(); - this.editor.current?.focus(); - } - }; - - goToHistory = (ev: KeyboardEvent) => { - if (!this.props.readOnly) { - return; - } - if (ev.ctrlKey) { - return; - } - ev.preventDefault(); - const { document, location } = this.props; - - if (location.pathname.endsWith("history")) { - this.props.history.push({ - pathname: document.url, - state: { sidebarContext: this.props.location.state?.sidebarContext }, - }); - } else { - this.props.history.push({ - pathname: documentHistoryPath(document), - state: { sidebarContext: this.props.location.state?.sidebarContext }, - }); - } - }; - - onPublish = (ev: React.MouseEvent | KeyboardEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - const { document, dialogs, t } = this.props; - if (document.publishedAt) { - return; - } - - if (document?.collectionId) { - void this.onSave({ - publish: true, - done: true, - }); - } else { - dialogs.openModal({ - title: t("Publish document"), - content: <DocumentPublish document={document} />, - }); - } - }; - - onSave = async ( - options: { - done?: boolean; - publish?: boolean; - autosave?: boolean; - } = {} - ) => { - const { document } = this.props; - // prevent saves when we are already saving - if (document.isSaving) { - return; - } - - // get the latest version of the editor text value - const doc = this.editor.current?.view.state.doc; - if (!doc) { - return; - } - - // prevent save before anything has been written (single hash is empty doc) - if (ProsemirrorHelper.isEmpty(doc) && document.title.trim() === "") { - return; - } - - document.data = doc.toJSON(); - document.tasks = ProsemirrorHelper.getTasksSummary(doc); - - // prevent autosave if nothing has changed - if (options.autosave && !this.isEditorDirty && !document.isDirty()) { - return; - } + }, + [document, dialogs, t, abilities.move] + ); - this.isSaving = true; - this.isPublishing = !!options.publish; + const goToEdit = useCallback( + (ev: KeyboardEvent) => { + if (readOnly) { + ev.preventDefault(); + if (abilities.update) { + history.push({ + pathname: documentEditPath(document), + state: { sidebarContext }, + }); + } + } else if (editorRef.current?.isBlurred) { + ev.preventDefault(); + editorRef.current?.focus(); + } + }, + [readOnly, abilities.update, history, document, sidebarContext] + ); - try { - const savedDocument = await document.save(undefined, options); - this.isEditorDirty = false; + const goToHistory = useCallback( + (ev: KeyboardEvent) => { + if (!readOnly) { + return; + } + if (ev.ctrlKey) { + return; + } + ev.preventDefault(); - if (options.done) { - this.props.history.push({ - pathname: savedDocument.url, - state: { sidebarContext: this.props.location.state?.sidebarContext }, + if (location.pathname.endsWith("history")) { + history.push({ + pathname: document.path, + state: { sidebarContext }, }); - this.props.ui.setActiveDocument(savedDocument); - } else if (document.isNew) { - this.props.history.push({ - pathname: documentEditPath(savedDocument), - state: { sidebarContext: this.props.location.state?.sidebarContext }, + } else { + history.push({ + pathname: documentHistoryPath(document), + state: { sidebarContext }, }); - this.props.ui.setActiveDocument(savedDocument); } - } catch (err) { - toast.error(err.message); - } finally { - this.isSaving = false; - this.isPublishing = false; - } - }; - - autosave = debounce( - () => - this.onSave({ - done: false, - autosave: true, - }), - AUTOSAVE_DELAY + }, + [readOnly, location.pathname, history, document, sidebarContext] ); - updateIsDirty = action(() => { - const { document } = this.props; - const doc = this.editor.current?.view.state.doc; - - this.isEditorDirty = !isEqual(doc?.toJSON(), document.data); - this.isEmpty = (!doc || ProsemirrorHelper.isEmpty(doc)) && !this.title; - }); - - updateIsDirtyDebounced = debounce(this.updateIsDirty, 500); - - onFileUploadStart = action(() => { - this.isUploading = true; - }); - - onFileUploadStop = action(() => { - this.isUploading = false; - }); + const onPublish = useCallback( + (ev: React.MouseEvent | KeyboardEvent) => { + ev.preventDefault(); + ev.stopPropagation(); - handleChangeTitle = action((value: string) => { - this.title = value; - this.props.document.title = value; - this.updateIsDirty(); - void this.autosave(); - }); + if (document.publishedAt) { + return; + } - handleChangeIcon = action((icon: string | null, color: string | null) => { - this.props.document.icon = icon; - this.props.document.color = color; - void this.onSave(); - }); + if (document?.collectionId) { + void onSave({ + publish: true, + done: true, + }); + } else { + dialogs.openModal({ + title: t("Publish document"), + content: <DocumentPublish document={document} />, + }); + } + }, + [document, dialogs, t, onSave] + ); - handleSelectTemplate = async (template: Template | Revision) => { - const editorRef = this.editor.current; - if (!editorRef) { - return; - } + const handlePublishShortcut = useCallback( + (event: KeyboardEvent) => { + if (isModKey(event) && event.shiftKey) { + onPublish(event); + } + }, + [onPublish] + ); - const { view } = editorRef; - const doc = view.state.doc; - - return this.replaceSelection( - template, - ProsemirrorHelper.isEmpty(doc) - ? new AllSelection(doc) - : view.state.selection - ); - }; - - goBack = () => { - if (!this.props.readOnly) { - this.props.history.push({ - pathname: this.props.document.url, - state: { sidebarContext: this.props.location.state?.sidebarContext }, + const goBack = useCallback(() => { + if (!readOnly) { + history.push({ + pathname: document.url, + state: { sidebarContext }, }); } - }; - - render() { - const { - children, - document, - revision, - readOnly, - abilities, - auth, - ui, - shareId, - tocPosition, - t, - } = this.props; - const { team, user } = auth; - const isShare = !!shareId; - const embedsDisabled = - (team && team.documentEmbeds === false) || document.embedsDisabled; - - const tocPos = - tocPosition ?? - ((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) || - TOCPosition.Left); - const showContents = - tocPos && (isShare ? ui.tocVisible !== false : ui.tocVisible === true); - const tocOffset = - tocPos === TOCPosition.Left - ? EditorStyleHelper.tocWidth / -2 - : EditorStyleHelper.tocWidth / 2; - - const multiplayerEditor = - !document.isArchived && !document.isDeleted && !revision && !isShare; - - const canonicalUrl = shareId - ? this.props.match.url - : updateDocumentPath(this.props.match.url, document); - - const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji; - const title = hasEmojiInTitle - ? document.titleWithDefault.replace(document.icon!, "") - : document.titleWithDefault; - const favicon = hasEmojiInTitle ? emojiToUrl(document.icon!) : undefined; - - const fullWidthTransformOffsetStyle = { - ["--full-width-transform-offset"]: `${document.fullWidth && showContents ? tocOffset : 0}px`, - } as React.CSSProperties; - - return ( - <ErrorBoundary showTitle> - {this.props.location.pathname !== canonicalUrl && ( - <Redirect - to={{ - pathname: canonicalUrl, - state: this.props.location.state, - hash: this.props.location.hash, - }} - /> - )} - <RegisterKeyDown trigger="m" handler={this.onMove} /> - <RegisterKeyDown trigger="z" handler={this.onUndoRedo} /> - <RegisterKeyDown trigger="e" handler={this.goToEdit} /> - <RegisterKeyDown trigger="Escape" handler={this.goBack} /> - <RegisterKeyDown trigger="h" handler={this.goToHistory} /> - <RegisterKeyDown - trigger="p" - options={{ - allowInInput: true, - }} - handler={(event) => { - if (isModKey(event) && event.shiftKey) { - this.onPublish(event); - } - }} - /> - <MeasuredContainer - as={Background} - name="container" - key={revision ? revision.id : document.id} - column - auto - > - <PageTitle title={title} favicon={favicon} /> - {(this.isUploading || this.isSaving) && <LoadingIndicator />} - <Container column> - {!readOnly && ( - <Prompt - when={this.isUploading && !this.isEditorDirty} - message={t( - `Images are still uploading.\nAre you sure you want to discard them?` - )} - /> - )} + }, [readOnly, history, document, sidebarContext]); + + // Render + const isShare = !!shareId; + const embedsDisabled = + (team && team.documentEmbeds === false) || document.embedsDisabled; + + const tocPos = + tocPosition ?? + ((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) || + TOCPosition.Left); + const showContents = + tocPos && (isShare ? ui.tocVisible !== false : ui.tocVisible === true); + const tocOffset = + tocPos === TOCPosition.Left + ? EditorStyleHelper.tocWidth / -2 + : EditorStyleHelper.tocWidth / 2; + + const multiplayerEditor = + !document.isArchived && !document.isDeleted && !revision && !isShare; + + const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji; + const pageTitle = hasEmojiInTitle + ? document.titleWithDefault.replace(document.icon!, "") + : document.titleWithDefault; + const favicon = hasEmojiInTitle ? emojiToUrl(document.icon!) : undefined; + + const fullWidthTransformOffsetStyle = { + ["--full-width-transform-offset"]: `${document.fullWidth && showContents ? tocOffset : 0}px`, + } as React.CSSProperties; + + return ( + <ErrorBoundary showTitle> + <RegisterKeyDown trigger="m" handler={onMove} /> + <RegisterKeyDown trigger="z" handler={onUndoRedo} /> + <RegisterKeyDown trigger="e" handler={goToEdit} /> + <RegisterKeyDown trigger="Escape" handler={goBack} /> + <RegisterKeyDown trigger="h" handler={goToHistory} /> + <RegisterKeyDown + trigger="p" + options={{ + allowInInput: true, + }} + handler={handlePublishShortcut} + /> + <MeasuredContainer + as={Background} + name="container" + key={revision ? revision.id : document.id} + column + auto + > + <PageTitle title={pageTitle} favicon={favicon} /> + {(isUploading || isSaving) && <LoadingIndicator />} + <Container column> + {!readOnly && ( + <Prompt + when={isUploading && !isEditorDirty} + message={t( + `Images are still uploading.\nAre you sure you want to discard them?` + )} + /> + )} + {isShare ? ( + <SharedHeader document={document} /> + ) : ( <Header - editorRef={this.editor} + editorRef={editorRef} document={document} revision={revision} isDraft={document.isDraft} isEditing={!readOnly && !!user?.separateEditMode} - isSaving={this.isSaving} - isPublishing={this.isPublishing} + isSaving={isSaving} + isPublishing={isPublishing} publishingIsDisabled={ - document.isSaving || this.isPublishing || this.isEmpty + document.isSaving || isPublishing || isEmpty } - savingIsDisabled={document.isSaving || this.isEmpty} - onSelectTemplate={this.handleSelectTemplate} - onSave={this.onSave} + savingIsDisabled={document.isSaving || isEmpty} + onSelectTemplate={handleSelectTemplate} + onSave={onSave} /> - <Main - fullWidth={document.fullWidth} - tocPosition={tocPos} - style={fullWidthTransformOffsetStyle} - > - <React.Suspense - fallback={ - <EditorContainer - docFullWidth={document.fullWidth} - showContents={showContents} - tocPosition={tocPos} - > - <PlaceholderDocument /> - </EditorContainer> - } - > - <MeasuredContainer - name="document" - as={EditorContainer} + )} + <Main + fullWidth={document.fullWidth} + tocPosition={tocPos} + style={fullWidthTransformOffsetStyle} + > + <React.Suspense + fallback={ + <EditorContainer docFullWidth={document.fullWidth} showContents={showContents} tocPosition={tocPos} > - {revision ? ( - <RevisionViewer - ref={this.editor} + <PlaceholderDocument /> + </EditorContainer> + } + > + <MeasuredContainer + name="document" + as={EditorContainer} + docFullWidth={document.fullWidth} + showContents={showContents} + tocPosition={tocPos} + > + {revision ? ( + <RevisionViewer + ref={editorRef} + document={document} + revision={revision} + id={revision.id} + /> + ) : ( + <> + <Notices document={document} readOnly={readOnly} /> + + {showContents && ( + <PrintContentsContainer> + <Contents /> + </PrintContentsContainer> + )} + <Editor + id={document.id} + key={embedsDisabled ? "disabled" : "enabled"} + ref={editorRef} + multiplayer={multiplayerEditor} + isDraft={document.isDraft} document={document} - revision={revision} - id={revision.id} - /> - ) : ( - <> - <Notices document={document} readOnly={readOnly} /> - - {showContents && ( - <PrintContentsContainer> - <Contents /> - </PrintContentsContainer> - )} - <Editor - id={document.id} - key={embedsDisabled ? "disabled" : "enabled"} - ref={this.editor} - multiplayer={multiplayerEditor} - isDraft={document.isDraft} - document={document} - value={readOnly ? document.data : undefined} - defaultValue={document.data} - embedsDisabled={embedsDisabled} - onSynced={this.onSynced} - onFileUploadStart={this.onFileUploadStart} - onFileUploadStop={this.onFileUploadStop} - onCreateLink={this.props.onCreateLink} - onChangeTitle={this.handleChangeTitle} - onChangeIcon={this.handleChangeIcon} - onSave={this.onSave} - onPublish={this.onPublish} - onCancel={this.goBack} - readOnly={readOnly} - canUpdate={abilities.update} - canComment={abilities.comment} - autoFocus={document.createdAt === document.updatedAt} - > - {!revision && ( - <ReferencesWrapper> - <References document={document} /> - </ReferencesWrapper> - )} - </Editor> - </> - )} - </MeasuredContainer> - {showContents && ( - <ContentsContainer - docFullWidth={document.fullWidth} - position={tocPos} - > - <Contents /> - </ContentsContainer> + value={readOnly ? document.data : undefined} + defaultValue={document.data} + embedsDisabled={embedsDisabled} + onSynced={onSynced} + onFileUploadStart={onFileUploadStart} + onFileUploadStop={onFileUploadStop} + onCreateLink={onCreateLink} + onChangeTitle={handleChangeTitle} + onChangeIcon={handleChangeIcon} + onSave={onSave} + onPublish={onPublish} + onCancel={goBack} + readOnly={readOnly} + canUpdate={abilities.update} + canComment={abilities.comment} + autoFocus={document.createdAt === document.updatedAt} + > + <ReferencesWrapper> + <References document={document} /> + </ReferencesWrapper> + </Editor> + </> )} - </React.Suspense> - </Main> - {children} - </Container> - </MeasuredContainer> - </ErrorBoundary> - ); - } + </MeasuredContainer> + {showContents && ( + <ContentsContainer + docFullWidth={document.fullWidth} + position={tocPos} + > + <Contents /> + </ContentsContainer> + )} + </React.Suspense> + </Main> + {children} + </Container> + </MeasuredContainer> + </ErrorBoundary> + ); } type MainProps = { @@ -670,10 +453,9 @@ const Main = styled.div<MainProps>` @media print { display: block; max-width: ${({ fullWidth }: MainProps) => - fullWidth - ? `100%` - : `calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})` - }; + fullWidth + ? `100%` + : `calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})`}; } `; @@ -722,10 +504,10 @@ const EditorContainer = styled.div<EditorContainerProps>` // Decides the editor column position & span grid-column: ${({ - docFullWidth, - showContents, - tocPosition, -}: EditorContainerProps) => + docFullWidth, + showContents, + tocPosition, + }: EditorContainerProps) => docFullWidth ? showContents ? tocPosition === TOCPosition.Left @@ -753,4 +535,4 @@ const ReferencesWrapper = styled.div` } `; -export default withTranslation()(withStores(withRouter(DocumentScene))); +export default observer(DocumentScene); diff --git a/app/scenes/Document/components/DocumentTitle.tsx b/app/scenes/Document/components/DocumentTitle.tsx index f0aefb818d79..bc0bfba087b6 100644 --- a/app/scenes/Document/components/DocumentTitle.tsx +++ b/app/scenes/Document/components/DocumentTitle.tsx @@ -275,7 +275,9 @@ const DocumentTitle = React.forwardRef(function DocumentTitle_( </React.Suspense> </IconTitleWrapper> ) : icon ? ( - <IconTitleWrapper dir={dir}>{fallbackIcon}</IconTitleWrapper> + <IconTitleWrapper dir={dir} aria-hidden> + {fallbackIcon} + </IconTitleWrapper> ) : null} ); @@ -295,7 +297,7 @@ const StyledIconPicker = styled(IconPicker)` const Title = styled(ContentEditable)` position: relative; line-height: ${lineHeight}; - margin-top: 10vh; + margin-top: ${(props: TitleProps) => (props.$containsIcon ? "10vh" : "3vh")}; margin-bottom: 0.5em; font-size: ${fontSize}; font-weight: 600; diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 457bdb90af3f..66d9af3f53b4 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -15,6 +15,7 @@ import type { RefHandle } from "~/components/ContentEditable"; import { useDocumentContext } from "~/components/DocumentContext"; import type { Props as EditorProps } from "~/components/Editor"; import Editor from "~/components/Editor"; +import type { Editor as SharedEditor } from "~/editor"; import Flex from "~/components/Flex"; import Time from "~/components/Time"; import { withUIExtensions } from "~/editor/extensions"; @@ -59,7 +60,8 @@ type Props = Omit & { * The main document editor includes an editable title with metadata below it, * and support for commenting. */ -function DocumentEditor(props: Props, ref: React.RefObject) { +function DocumentEditor(props: Props, ref: React.ForwardedRef) { + const editorRef = React.useRef(null); const titleRef = React.useRef(null); const { t } = useTranslation(); const match = useRouteMatch(); @@ -70,7 +72,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { const team = useCurrentTeam({ rejectOnEmpty: false }); const sidebarContext = useLocationSidebarContext(); const params = useQuery(); - const { shareId } = useShare(); + const { shareId, showLastUpdated } = useShare(); const { document, onChangeTitle, @@ -87,13 +89,13 @@ function DocumentEditor(props: Props, ref: React.RefObject) { const iconColor = document.color ?? (first(colorPalette) as string); const childRef = React.useRef(null); const focusAtStart = React.useCallback(() => { - if (ref.current) { - ref.current.focusAtStart(); + if (editorRef.current) { + editorRef.current.focusAtStart(); } - }, [ref]); + }, []); React.useEffect(() => { - if (focusedComment) { + if (focusedComment && focusedComment.documentId === document.id) { const viewingResolved = params.get("resolved") === ""; if ( (focusedComment.isResolved && !viewingResolved) || @@ -103,7 +105,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { } ui.set({ rightSidebar: "comments" }); } - }, [focusedComment, ui, document.id, params]); + }, [focusedComment, ui, document.id, params, setFocusedCommentId]); // Save document when blurring title, but delay so that if clicking on a // button this is allowed to execute first. @@ -113,21 +115,21 @@ function DocumentEditor(props: Props, ref: React.RefObject) { const handleGoToNextInput = React.useCallback( (insertParagraph: boolean) => { - if (insertParagraph && ref.current) { - const { view } = ref.current; + if (insertParagraph && editorRef.current) { + const { view } = editorRef.current; const { dispatch, state } = view; dispatch(state.tr.insert(0, state.schema.nodes.paragraph.create())); } focusAtStart(); }, - [focusAtStart, ref] + [focusAtStart] ); // Create a Comment model in local store when a comment mark is created, this // acts as a local draft before submission. const handleDraftComment = React.useCallback( - (commentId: string, createdById: string) => { + (commentId: string, createdById: string, options?: { focus: boolean }) => { if (comments.get(commentId) || createdById !== user?.id) { return; } @@ -143,9 +145,12 @@ function DocumentEditor(props: Props, ref: React.RefObject) { ); comment.id = commentId; comments.add(comment); - setFocusedCommentId(commentId); + + if (options?.focus) { + setFocusedCommentId(commentId); + } }, - [comments, user?.id, props.id] + [comments, user?.id, props.id, setFocusedCommentId] ); // Soft delete the Comment model when associated mark is totally removed. @@ -209,7 +214,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { placeholder={t("Untitled")} /> {shareId ? ( - document.updatedAt ? ( + showLastUpdated && document.updatedAt ? ( {t("Last updated")} @@ -217,22 +222,18 @@ function DocumentEditor(props: Props, ref: React.RefObject) { ) : !rest.template ? ( ) : null} ; @@ -92,13 +90,12 @@ function DocumentHeader({ const { hasHeadings, editor } = useDocumentContext(); const sidebarContext = useLocationSidebarContext(); const [measureRef, size] = useMeasure(); - const { isShare, shareId, sharedTree } = useShare(); - const isMobile = isMobileMedia || size.width < 700; + const isMobile = isMobileMedia || (size.width > 0 && size.width < 700); // We cache this value for as long as the component is mounted so that if you // apply a template there is still the option to replace it until the user // navigates away from the doc - const [isNew] = useState(document.isPersistedOnce); + const [wasNew] = useState(document.isPersistedOnce); const handleSave = useCallback(() => { onSave({ @@ -107,19 +104,19 @@ function DocumentHeader({ }, [onSave]); const handleToggle = useCallback(() => { - // Public shares, by default, show ToC on load. - if (isShare && ui.tocVisible === undefined) { - ui.set({ tocVisible: false }); - } else { - ui.set({ tocVisible: !ui.tocVisible }); - } - }, [ui, isShare]); + ui.set({ tocVisible: !ui.tocVisible }); + }, [ui]); const can = usePolicy(document); const { isDeleted } = document; const canToggleEmbeds = team?.documentEmbeds; - const showContents = - ui.tocVisible === true || (isShare && ui.tocVisible !== false); + const showContents = ui.tocVisible === true; + + useEffect(() => { + if (isMobile && showContents) { + ui.set({ tocVisible: false }); + } + }, [isMobile, showContents, ui]); const toc = ( - {document.icon && ( - - )} - {document.title} - - } - hasSidebar={sharedTree && sharedTree.children?.length > 0} - left={ - isMobile ? ( - hasHeadings ? ( - - ) : null - ) : ( - - {hasHeadings ? toc : null} - - ) - } - actions={ - <> - - {can.update && !isEditing ? editAction :
} - - } - /> - ); - } - return ( - <> - - ) : ( - - {toc}{" "} - - - ) - } - title={ - - {document.icon && ( - - )} - {document.title} - {document.isArchived && {t("Archived")}} - - } - actions={({ isCompact }) => ( - <> - - {!isDeleted && !isRevision && can.listViews && ( - + ) : ( + + {toc} + + ) + } + title={ + + {document.icon && ( + + )} + {document.title} + {document.isArchived && {t("Archived")}} + {document.isDraft && {t("Draft")}} + + } + actions={({ isCompact }) => ( + <> + + + {!isDeleted && !isRevision && can.listViews && ( + + )} + {(isEditing || !user?.separateEditMode) && wasNew && can.update && ( + + - )} - {(isEditing || !user?.separateEditMode) && isNew && can.update && ( + + )} + {!isEditing && !isRevision && can.update && ( + + + + )} + {isEditing && ( + + + + + + )} + {can.update && + !isEditing && + user?.separateEditMode && + !isRevision && + editAction} + {can.update && + can.createChildDocument && + !isRevision && + !isCompact && + !isMobile && ( - + )} - {!isEditing && !isRevision && can.update && ( + {revision && ( + <> - + - )} - {isEditing && ( - + - )} - {can.update && - !isEditing && - user?.separateEditMode && - !isRevision && - editAction} - {can.update && - can.createChildDocument && - !isRevision && - !isCompact && - !isMobile && ( - - - - )} - {revision && ( - <> - - - - - - - - - - )} - {can.publish && ( - - - - )} - {!isDeleted && } + + )} + {can.publish && ( - + - - )} - /> - + )} + {!isDeleted && } + + + + + )} + /> ); } diff --git a/app/components/EventListItem.tsx b/app/scenes/Document/components/History/EventListItem.tsx similarity index 98% rename from app/components/EventListItem.tsx rename to app/scenes/Document/components/History/EventListItem.tsx index 178f3fdde77e..d5b38cd35e98 100644 --- a/app/components/EventListItem.tsx +++ b/app/scenes/Document/components/History/EventListItem.tsx @@ -12,11 +12,11 @@ import { import { useTranslation } from "react-i18next"; import styled, { css } from "styled-components"; import { s } from "@shared/styles"; +import Text from "@shared/components/Text"; import type Document from "~/models/Document"; import type Event from "~/models/Event"; import Time from "~/components/Time"; import Logger from "~/utils/Logger"; -import Text from "./Text"; type Props = { document: Document; diff --git a/app/scenes/Document/components/History/HighlightChangesControl.tsx b/app/scenes/Document/components/History/HighlightChangesControl.tsx new file mode 100644 index 000000000000..c6df7bde9e29 --- /dev/null +++ b/app/scenes/Document/components/History/HighlightChangesControl.tsx @@ -0,0 +1,150 @@ +import { format as formatDate } from "date-fns"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import Text from "@shared/components/Text"; +import { dateLocale } from "@shared/utils/date"; +import { RevisionHelper } from "@shared/utils/RevisionHelper"; +import type Document from "~/models/Document"; +import type Event from "~/models/Event"; +import Revision from "~/models/Revision"; +import { InputSelect, type Option } from "~/components/InputSelect"; +import Switch from "~/components/Switch"; +import useUserLocale from "~/hooks/useUserLocale"; +import { revisionCollaboratorText } from "./utils"; +import { ResizingHeightContainer } from "~/components/ResizingHeightContainer"; +import { ConditionalFade } from "~/components/Fade"; + +export const COMPARE_TO_PREVIOUS = "previous"; + +interface Props { + showChanges: boolean; + onShowChangesToggle: (checked: boolean) => void; + items: (Revision | Event)[]; + document?: Document; + selectedRevisionId?: string; + compareTo: string; + onCompareToChange: (value: string) => void; +} + +export function HighlightChangesControl({ + showChanges, + onShowChangesToggle, + items, + document, + selectedRevisionId, + compareTo, + onCompareToChange, +}: Props) { + const { t } = useTranslation(); + const userLocale = useUserLocale(); + const skipFadeRef = React.useRef(showChanges); + + const compareOptions = React.useMemo((): Option[] => { + const revisionItems = items.filter( + (item): item is Revision => item instanceof Revision + ); + + const locale = dateLocale(userLocale); + const resolvedSelectedId = + selectedRevisionId === "latest" && document + ? RevisionHelper.latestId(document.id) + : selectedRevisionId; + + const options: Option[] = [ + { + type: "item", + label: t("Previous version"), + value: COMPARE_TO_PREVIOUS, + }, + ]; + + const latestId = document + ? RevisionHelper.latestId(document.id) + : undefined; + + const currentYear = new Date().getFullYear(); + let lastHeadingYear: number | undefined; + + for (const rev of revisionItems) { + if (rev.id === resolvedSelectedId) { + continue; + } + + const revDate = new Date(rev.createdAt); + const revYear = revDate.getFullYear(); + + if (revYear !== currentYear && revYear !== lastHeadingYear) { + options.push({ + type: "heading", + label: String(revYear), + }); + lastHeadingYear = revYear; + } + + const dateLabel = formatDate(revDate, "MMM do, h:mm a", { + locale, + }); + const collaboratorText = revisionCollaboratorText(rev, t); + + options.push({ + type: "item", + label: rev.name ?? dateLabel, + value: rev.id === latestId ? "latest" : rev.id, + description: collaboratorText, + }); + } + + return options; + }, [items, selectedRevisionId, document, userLocale, t]); + + return ( + + + + + + {showChanges && ( + + ( + <> + {t("Compare to")} {item?.label} + + )} + labelHidden + nude + short + /> + + )} + + + ); +} + +const StyledInputSelect = styled(InputSelect)` + margin: -4px -9px -1px; + width: calc(100% + 18px); + border-top-left-radius: 0; + border-top-right-radius: 0; + position: relative; + inset-block-end: -1px; + height: 40px; +`; + +const Content = styled.div` + margin: 0 16px 8px; + border: 1px solid ${(props) => props.theme.inputBorder}; + border-radius: 6px; + padding: 8px 8px 0; + flex-shrink: 0; +`; diff --git a/app/scenes/Document/components/History.tsx b/app/scenes/Document/components/History/History.tsx similarity index 76% rename from app/scenes/Document/components/History.tsx rename to app/scenes/Document/components/History/History.tsx index 647a9da3f354..6dd9aa31860d 100644 --- a/app/scenes/Document/components/History.tsx +++ b/app/scenes/Document/components/History/History.tsx @@ -4,20 +4,22 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useHistory, useRouteMatch } from "react-router-dom"; -import styled from "styled-components"; import { Pagination } from "@shared/constants"; import { RevisionHelper } from "@shared/utils/RevisionHelper"; import Revision from "~/models/Revision"; import Empty from "~/components/Empty"; -import PaginatedEventList from "~/components/PaginatedEventList"; +import PaginatedEventList from "./PaginatedEventList"; +import { + COMPARE_TO_PREVIOUS, + HighlightChangesControl, +} from "./HighlightChangesControl"; import useKeyDown from "~/hooks/useKeyDown"; import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext"; +import useQuery from "~/hooks/useQuery"; import useStores from "~/hooks/useStores"; -import { documentPath } from "~/utils/routeHelpers"; -import Sidebar from "./SidebarLayout"; +import { documentPath, matchDocumentHistory } from "~/utils/routeHelpers"; +import Sidebar from "../SidebarLayout"; import useMobile from "~/hooks/useMobile"; -import Switch from "~/components/Switch"; -import Text from "@shared/components/Text"; import usePersistedState from "~/hooks/usePersistedState"; import Scrollable from "~/components/Scrollable"; import Flex from "@shared/components/Flex"; @@ -38,12 +40,19 @@ function History() { const { events, documents, revisions } = useStores(); const { t } = useTranslation(); const match = useRouteMatch<{ documentSlug: string }>(); + const historyMatch = useRouteMatch<{ revisionId?: string }>({ + path: matchDocumentHistory, + }); const history = useHistory(); + const query = useQuery(); const sidebarContext = useLocationSidebarContext(); const document = documents.get(match.params.documentSlug); const [revisionsOffset, setRevisionsOffset] = React.useState(0); const [eventsOffset, setEventsOffset] = React.useState(0); const isMobile = useMobile(); + const [compareTo, setCompareTo] = React.useState( + () => query.get("compareTo") ?? COMPARE_TO_PREVIOUS + ); const [defaultShowChanges, setDefaultShowChanges] = usePersistedState("history-show-changes", true); @@ -80,9 +89,37 @@ function History() { (checked: boolean) => { setShowChanges(checked); setDefaultShowChanges(checked); - updateLocation({ changes: checked ? "true" : null }); + if (checked) { + updateLocation({ changes: "true" }); + } else { + setCompareTo(COMPARE_TO_PREVIOUS); + updateLocation({ changes: null, compareTo: null }); + } }, - [history] + [updateLocation, setDefaultShowChanges] + ); + + const selectedRevisionId = historyMatch?.params.revisionId; + + // Reset "Compare to" when the user clicks a different revision in the list, + // but not on initial mount (which would break deep links with ?compareTo=…) + const prevSelectedRef = React.useRef(selectedRevisionId); + React.useEffect(() => { + if (prevSelectedRef.current !== selectedRevisionId) { + prevSelectedRef.current = selectedRevisionId; + setCompareTo(COMPARE_TO_PREVIOUS); + updateLocation({ compareTo: null }); + } + }, [selectedRevisionId, updateLocation]); + + const handleCompareToChange = React.useCallback( + (value: string) => { + setCompareTo(value); + updateLocation({ + compareTo: value === COMPARE_TO_PREVIOUS ? null : value, + }); + }, + [updateLocation] ); // Ensure that the URL parameter is in sync with the persisted state on mount @@ -90,7 +127,7 @@ function History() { if (defaultShowChanges) { updateLocation({ changes: "true" }); } - }, [defaultShowChanges]); + }, [defaultShowChanges, updateLocation]); const fetchHistory = React.useCallback(async () => { if (!document) { @@ -133,6 +170,7 @@ function History() { .getByDocumentId(document.id) .filter((revision: Revision) => revision.id !== latestRevisionId) .slice(0, revisionsOffset); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [document, revisions.orderedData, revisionsOffset]); const nonRevisionEvents = React.useMemo( @@ -140,6 +178,7 @@ function History() { document ? events.getByDocumentId(document.id).slice(0, eventsOffset) : [], + // eslint-disable-next-line react-hooks/exhaustive-deps [document, events.orderedData, eventsOffset] ); @@ -191,21 +230,21 @@ function History() { } else { history.goBack(); } - }, [history, document, sidebarContext]); + }, [history, document, sidebarContext, isMobile]); useKeyDown("Escape", onCloseHistory); return ( - - - - - + {document ? ( props.theme.inputBorder}; - border-radius: 8px; - padding: 8px 8px 0; - flex-shrink: 0; -`; - export default observer(History); diff --git a/app/components/PaginatedEventList.tsx b/app/scenes/Document/components/History/PaginatedEventList.tsx similarity index 94% rename from app/components/PaginatedEventList.tsx rename to app/scenes/Document/components/History/PaginatedEventList.tsx index fb65c2b2f5d3..dcebe8224032 100644 --- a/app/components/PaginatedEventList.tsx +++ b/app/scenes/Document/components/History/PaginatedEventList.tsx @@ -13,8 +13,8 @@ type Item = Revision | Event; type Props = { items: Item[]; document: Document; - fetch: (options: Record | undefined) => Promise; - options?: Record; + fetch: (options: Record | undefined) => Promise; + options?: Record; heading?: React.ReactNode; empty?: JSX.Element; }; diff --git a/app/components/RevisionListItem.tsx b/app/scenes/Document/components/History/RevisionListItem.tsx similarity index 82% rename from app/components/RevisionListItem.tsx rename to app/scenes/Document/components/History/RevisionListItem.tsx index 442eece96344..d14a0c767c33 100644 --- a/app/components/RevisionListItem.tsx +++ b/app/scenes/Document/components/History/RevisionListItem.tsx @@ -4,9 +4,9 @@ import { EditIcon, TrashIcon } from "outline-icons"; import { useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; -import styled from "styled-components"; +import styled, { css } from "styled-components"; import EventBoundary from "@shared/components/EventBoundary"; -import { ellipsis, hover } from "@shared/styles"; +import { ellipsis, hover, s } from "@shared/styles"; import { RevisionHelper } from "@shared/utils/RevisionHelper"; import type Document from "~/models/Document"; import type Revision from "~/models/Revision"; @@ -27,8 +27,9 @@ import { useMenuAction } from "~/hooks/useMenuAction"; import RevisionMenu from "~/menus/RevisionMenu"; import { documentHistoryPath } from "~/utils/routeHelpers"; import { EventItem, lineStyle } from "./EventListItem"; -import Facepile from "./Facepile"; -import Text from "./Text"; +import Facepile from "~/components/Facepile"; +import Text from "~/components/Text"; +import { revisionCollaboratorText } from "./utils"; type Props = { document: Document; @@ -70,16 +71,7 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => { } else { icon = ; - let collaboratorText: string | undefined; - if (item.collaborators && item.collaborators.length === 2) { - collaboratorText = `${item.collaborators[0].name} and ${item.collaborators[1].name}`; - } else if (item.collaborators && item.collaborators.length > 2) { - collaboratorText = t("{{count}} people", { - count: item.collaborators.length, - }); - } else { - collaboratorText = item.createdBy?.name; - } + const collaboratorText = revisionCollaboratorText(item, t); meta = isLatestRevision ? ( <> @@ -101,11 +93,6 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => { }; } - const isActive = - typeof to === "string" - ? location.pathname === to - : location.pathname === to?.pathname; - if (document.isDeleted) { to = undefined; } @@ -157,11 +144,9 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => { } subtitle={{meta}} actions={ - isActive ? ( - - - - ) : undefined + + + } ref={ref} $menuOpen={menuOpen} @@ -192,15 +177,33 @@ const RevisionItem = styled(Item)<{ $menuOpen?: boolean }>` padding: 8px; border-radius: 8px; - ${lineStyle} - ${Actions} { - opacity: ${(props) => (props.$menuOpen ? 1 : 0.5)}; + opacity: 0; + } - &: ${hover} { + &:${hover}, + &:active, + &:focus, + &:focus-within, + &:has([data-state="open"]) { + background: ${s("listItemHoverBackground")}; + + ${Actions} { opacity: 1; } } + + ${(props) => + props.$menuOpen && + css` + background: ${s("listItemHoverBackground")}; + + ${Actions} { + opacity: 1; + } + `} + + ${lineStyle} `; export default observer(RevisionListItem); diff --git a/app/scenes/Document/components/History/utils.ts b/app/scenes/Document/components/History/utils.ts new file mode 100644 index 000000000000..2493c0647d64 --- /dev/null +++ b/app/scenes/Document/components/History/utils.ts @@ -0,0 +1,23 @@ +import type { TFunction } from "i18next"; +import type Revision from "~/models/Revision"; + +/** + * Returns a human-readable summary of who collaborated on a revision. Uses the + * collaborator list when available, falling back to the creator's name. + * + * @param revision the revision to summarize. + * @param t translation function. + * @returns the collaborator text, or undefined if unavailable. + */ +export function revisionCollaboratorText( + revision: Revision, + t: TFunction +): string | undefined { + if (revision.collaborators && revision.collaborators.length === 2) { + return `${revision.collaborators[0].name} and ${revision.collaborators[1].name}`; + } + if (revision.collaborators && revision.collaborators.length > 2) { + return t("{{count}} people", { count: revision.collaborators.length }); + } + return revision.createdBy?.name; +} diff --git a/app/scenes/Document/components/Insights.tsx b/app/scenes/Document/components/Insights.tsx index 5205e36e361a..e45495098395 100644 --- a/app/scenes/Document/components/Insights.tsx +++ b/app/scenes/Document/components/Insights.tsx @@ -15,6 +15,7 @@ import { useTextStats } from "~/hooks/useTextStats"; import type Document from "~/models/Document"; import { useFormatNumber } from "~/hooks/useFormatNumber"; import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper"; +import { useLayoutEffect, useRef } from "react"; type Props = { document: Document; @@ -22,13 +23,19 @@ type Props = { function Insights({ document }: Props) { const { t } = useTranslation(); + const wrapperRef = useRef(null); const selectedText = useTextSelection(); const text = ProsemirrorHelper.toPlainText(document); const stats = useTextStats(text ?? "", selectedText); const formatNumber = useFormatNumber(); + // Move focus into the modal to account for lazy-loading + useLayoutEffect(() => { + wrapperRef.current?.focus(); + }, []); + return ( -
+
{document ? ( +) { const documentId = props.id; const history = useHistory(); const { t } = useTranslation(); @@ -59,8 +64,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { const { presence, auth, ui } = useStores(); const [editorVersionBehind, setEditorVersionBehind] = useState(false); const [showCursorNames, setShowCursorNames] = useState(false); - const [remoteProvider, setRemoteProvider] = - useState(null); + const [remoteProvider, setRemoteProvider] = useState(); + const [hasLocalPersistence, setHasLocalPersistence] = useState(true); const [isLocalSynced, setLocalSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false); const [ydoc] = useState(() => new Y.Doc()); @@ -76,7 +81,15 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { useLayoutEffect(() => { const debug = env.ENVIRONMENT === "development"; const name = `document.${documentId}`; - const localProvider = new IndexeddbPersistence(name, ydoc); + const localProvider = + typeof indexedDB !== "undefined" + ? new IndexeddbPersistence(name, ydoc) + : undefined; + + if (!localProvider) { + setHasLocalPersistence(false); + } + const provider = new HocuspocusProvider({ parameters: { editorVersion: EDITOR_VERSION, @@ -112,7 +125,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { provider.shouldConnect = false; retryCount.current++; - sleep(retryCount.current * 1000 - 1000).then(() => + void sleep(retryCount.current * 1000 - 1000).then(() => auth .fetchAuth() .then(() => { @@ -156,7 +169,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { }; provider.on("awarenessChange", showCursorNames); - localProvider.on("synced", () => + localProvider?.on("synced", () => // only set local storage to "synced" if it's loaded a non-empty doc setLocalSynced(!!ydoc.get("default")._start) ); @@ -195,7 +208,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { message: ev.message, }) ); - localProvider.on("synced", () => + localProvider?.on("synced", () => Logger.debug("collaboration", "local synced") ); } @@ -214,7 +227,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { window.removeEventListener("scroll", syncScrollPosition); provider?.destroy(); void localProvider?.destroy(); - setRemoteProvider(null); + setRemoteProvider(undefined); ui.setMultiplayerStatus(undefined, undefined); }; }, [ @@ -254,10 +267,10 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { }, [remoteProvider, user, ydoc, props.extensions]); useEffect(() => { - if (isLocalSynced && isRemoteSynced) { + if ((!hasLocalPersistence || isLocalSynced) && isRemoteSynced) { void onSynced?.(); } - }, [onSynced, isLocalSynced, isRemoteSynced]); + }, [onSynced, hasLocalPersistence, isLocalSynced, isRemoteSynced]); // Disconnect the realtime connection while idle. `isIdle` also checks for // page visibility and will immediately disconnect when a tab is hidden. @@ -306,7 +319,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { // while the collaborative document is loading, we render a version of the // document from the last text cache in read-only mode if we have it. - const showCache = !isLocalSynced && !isRemoteSynced; + const isLocalReady = !hasLocalPersistence || isLocalSynced; + const showCache = !isLocalReady && !isRemoteSynced; return ( <> @@ -343,4 +357,4 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { ); } -export default forwardRef(MultiplayerEditor); +export default forwardRef(MultiplayerEditor); diff --git a/app/scenes/Document/components/PresentationMode.tsx b/app/scenes/Document/components/PresentationMode.tsx index 71692daff288..6c61e87b1963 100644 --- a/app/scenes/Document/components/PresentationMode.tsx +++ b/app/scenes/Document/components/PresentationMode.tsx @@ -7,7 +7,9 @@ import Icon from "@shared/components/Icon"; import { richExtensions } from "@shared/editor/nodes"; import { canUseElementFullscreen } from "@shared/utils/browser"; import { s, depths, hover } from "@shared/styles"; +import cloneDeep from "lodash/cloneDeep"; import type { ProsemirrorData } from "@shared/types"; +import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; import { colorPalette } from "@shared/utils/collections"; import Editor from "~/components/Editor"; import NudeButton from "~/components/NudeButton"; @@ -130,8 +132,16 @@ function PresentationMode({ title, icon, iconColor, data, onClose }: Props) { const supportsFullscreen = React.useMemo(() => canUseElementFullscreen(), []); const isIdle = useIdle(3000, idleEvents); + const strippedData = React.useMemo( + () => + ProsemirrorHelper.removeMarks(cloneDeep(data), [ + "comment", + ]) as ProsemirrorData, + [data] + ); + const slides = React.useMemo(() => { - const result = splitIntoSlides(data, title, icon, iconColor); + const result = splitIntoSlides(strippedData, title, icon, iconColor); const contentSlides = result.filter((s) => s.type === "content"); const hasContent = contentSlides.length > 0 && @@ -144,7 +154,7 @@ function PresentationMode({ title, icon, iconColor, data, onClose }: Props) { } return result; - }, [data, title, icon, iconColor]); + }, [strippedData, title, icon, iconColor]); const totalSlides = slides.length; @@ -246,7 +256,7 @@ function PresentationMode({ title, icon, iconColor, data, onClose }: Props) { } const availableWidth = container.clientWidth - 160; - const availableHeight = container.clientHeight - 48 - 160; + const availableHeight = container.clientHeight - 160; const scaleX = availableWidth / width; const scaleY = availableHeight / height; const newScale = Math.min(scaleX, scaleY, 1.5); @@ -321,7 +331,13 @@ function PresentationMode({ title, icon, iconColor, data, onClose }: Props) { - + { + if (!(event.target as HTMLElement).closest("a")) { + goNext(); + } + }} + > {slide.type === "title" ? ( @@ -385,6 +401,10 @@ const Container = styled.div<{ $background: string; $idle: boolean }>` * { cursor: inherit; } + + a[href] { + cursor: ${(props) => (props.$idle ? "none" : "pointer")}; + } `; const SlideArea = styled.div` @@ -406,6 +426,12 @@ const SlideContent = styled.div` font-size: 1.4em; } + .image-wrapper, + .image-wrapper img, + .mermaid-diagram-wrapper { + pointer-events: none !important; + } + h1 { font-size: 2.4em; } @@ -444,8 +470,13 @@ const TopBar = styled.div<{ $idle: boolean }>` align-items: center; justify-content: center; padding: 16px; - position: relative; + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1; opacity: ${(props) => (props.$idle ? 0 : 1)}; + pointer-events: ${(props) => (props.$idle ? "none" : "auto")}; transition: opacity 300ms ease; `; diff --git a/app/scenes/Document/components/ReferenceListItem.tsx b/app/scenes/Document/components/ReferenceListItem.tsx index 79e3494db7e3..313ea5258a3a 100644 --- a/app/scenes/Document/components/ReferenceListItem.tsx +++ b/app/scenes/Document/components/ReferenceListItem.tsx @@ -77,30 +77,32 @@ function ReferenceListItem({ const initial = title.charAt(0).toUpperCase(); return ( - - - {icon ? ( - - ) : ( - - )} - {isEmoji ? title.replace(icon!, "") : title} - - +
  • + + + {icon ? ( + + ) : ( + + )} + {isEmoji ? title.replace(icon!, "") : title} + + +
  • ); } diff --git a/app/scenes/Document/components/References.tsx b/app/scenes/Document/components/References.tsx index 58266669a22c..60e05b55f4d9 100644 --- a/app/scenes/Document/components/References.tsx +++ b/app/scenes/Document/components/References.tsx @@ -171,12 +171,15 @@ const Content = styled.div` position: relative; `; -const List = styled.div<{ $active: boolean }>` +const List = styled.ul<{ $active: boolean }>` visibility: ${({ $active }) => ($active ? "visible" : "hidden")}; position: absolute; top: 0; left: 0; right: 0; + list-style: none; + margin: 0; + padding: 0; `; export default observer(References); diff --git a/app/scenes/Document/components/RevisionViewer.tsx b/app/scenes/Document/components/RevisionViewer.tsx index c8450ca9ffdb..31e7b81fc38a 100644 --- a/app/scenes/Document/components/RevisionViewer.tsx +++ b/app/scenes/Document/components/RevisionViewer.tsx @@ -11,7 +11,9 @@ import DocumentTitle from "./DocumentTitle"; import Editor from "~/components/Editor"; import { richExtensions, withComments } from "@shared/editor/nodes"; import Diff from "@shared/editor/extensions/Diff"; +import { RevisionHelper } from "@shared/utils/RevisionHelper"; import useQuery from "~/hooks/useQuery"; +import useStores from "~/hooks/useStores"; import { type Editor as TEditor } from "~/editor"; import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper"; @@ -38,8 +40,27 @@ type Props = Omit & { */ function RevisionViewer(props: Props, ref: React.Ref) { const { document, children, revision } = props; + const { revisions } = useStores(); const query = useQuery(); const showChanges = props.showChanges ?? query.has("changes"); + const compareToParam = query.get("compareTo"); + + const compareToRevisionId = React.useMemo(() => { + if (!compareToParam) { + return undefined; + } + return compareToParam === "latest" + ? RevisionHelper.latestId(revision.documentId) + : compareToParam; + }, [compareToParam, revision.documentId]); + + const compareToRevision = compareToRevisionId + ? revisions.get(compareToRevisionId) + : undefined; + + const comparisonData = compareToRevisionId + ? compareToRevision?.data + : revision.before?.data; /** * Create editor extensions with the Diff extension configured to render @@ -48,7 +69,7 @@ function RevisionViewer(props: Props, ref: React.Ref) { const extensions = React.useMemo(() => { const changeset = ChangesetHelper.getChangeset( revision.data, - revision.before?.data + comparisonData ); return [ ...withComments(richExtensions), @@ -56,7 +77,7 @@ function RevisionViewer(props: Props, ref: React.Ref) { ? [new Diff({ changes: changeset?.changes })] : []), ]; - }, [revision.data, showChanges]); + }, [revision.data, comparisonData, showChanges]); return ( @@ -74,6 +95,7 @@ function RevisionViewer(props: Props, ref: React.Ref) { rtl={revision.rtl} /> { + editor?.commands.clearSearch(); + const params = new URLSearchParams(location.search); + params.delete("q"); + const search = params.toString(); + history.replace({ + pathname: location.pathname, + search: search ? `?${search}` : "", + hash: location.hash, + state: location.state, + }); + }, [editor, history, location]); + + if (!searchHighlight) { + return null; + } + + return ( + + + + + + + ); +} + +const Chip = styled.button` + display: inline-flex; + align-items: center; + gap: 2px; + height: 28px; + padding: 0 6px 0 10px; + margin: 0 4px; + background: rgba(255, 213, 0, 0.25); + color: ${(props) => props.theme.text}; + border: 0; + border-radius: 14px; + font-size: 14px; + font-weight: 500; + line-height: 1; + cursor: var(--pointer); + user-select: none; + max-width: 200px; + + &:hover { + background: rgba(255, 213, 0, 0.5); + } + + & > svg { + flex-shrink: 0; + } +`; + +const Label = styled.span` + ${ellipsis()} + line-height: 1.5; +`; diff --git a/app/scenes/Document/components/ShareButton.tsx b/app/scenes/Document/components/ShareButton.tsx index 756d95f80457..40d664cdd73f 100644 --- a/app/scenes/Document/components/ShareButton.tsx +++ b/app/scenes/Document/components/ShareButton.tsx @@ -10,6 +10,7 @@ import { PopoverContent, } from "~/components/primitives/Popover"; import useMobile from "~/hooks/useMobile"; +import useShareDataLoader from "~/hooks/useShareDataLoader"; import useStores from "~/hooks/useStores"; import { preventDefault } from "~/utils/events"; import lazyWithRetry from "~/utils/lazyWithRetry"; @@ -31,14 +32,23 @@ function ShareButton({ document }: Props) { const share = shares.getByDocumentId(document.id); const sharedParent = shares.getByDocumentParents(document); const domain = share?.domain || sharedParent?.domain; + const { preload, loading, reset } = useShareDataLoader({ document }); - const closePopover = useCallback(() => { - setOpen(false); - }, []); + const handleOpenChange = useCallback( + (isOpen: boolean) => { + setOpen(isOpen); + if (isOpen) { + preload(); + } else { + reset(); + } + }, + [preload, reset] + ); - const handleMouseEnter = useCallback(() => { - void document.share(); - }, [document]); + const closePopover = useCallback(() => { + handleOpenChange(false); + }, [handleOpenChange]); if (isMobile) { return null; @@ -47,9 +57,9 @@ function ShareButton({ document }: Props) { const icon = document.isPubliclyShared ? : undefined; return ( - + - @@ -66,6 +76,7 @@ function ShareButton({ document }: Props) { document={document} onRequestClose={closePopover} visible={open} + loading={loading} /> diff --git a/app/scenes/Document/components/SharedHeader.tsx b/app/scenes/Document/components/SharedHeader.tsx new file mode 100644 index 000000000000..3704466dfc9a --- /dev/null +++ b/app/scenes/Document/components/SharedHeader.tsx @@ -0,0 +1,223 @@ +import { observer } from "mobx-react"; +import { TableOfContentsIcon, EditIcon, SettingsIcon } from "outline-icons"; +import { useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import useMeasure from "react-use-measure"; +import styled from "styled-components"; +import Icon from "@shared/components/Icon"; +import useShare from "@shared/hooks/useShare"; +import type { PublicTeam } from "@shared/types"; +import { TOCPosition } from "@shared/types"; +import { altDisplay } from "@shared/utils/keyboard"; +import { Action } from "~/components/Actions"; +import Button from "~/components/Button"; +import { useDocumentContext } from "~/components/DocumentContext"; +import Flex from "~/components/Flex"; +import Header from "~/components/Header"; +import { + AppearanceAction, + SubscribeAction, +} from "~/components/Sharing/components/Actions"; +import HeaderBranding from "~/components/Sharing/components/HeaderBranding"; +import ShareSettingsPopover from "~/components/Sharing/components/ShareSettingsPopover"; +import { useTeamContext } from "~/components/TeamContext"; +import Tooltip from "~/components/Tooltip"; +import env from "~/env"; +import useCurrentUser from "~/hooks/useCurrentUser"; +import useEditingFocus from "~/hooks/useEditingFocus"; +import useKeyDown from "~/hooks/useKeyDown"; +import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext"; +import useMobile from "~/hooks/useMobile"; +import usePolicy from "~/hooks/usePolicy"; +import useStores from "~/hooks/useStores"; +import TableOfContentsMenu from "~/menus/TableOfContentsMenu"; +import type Document from "~/models/Document"; +import { documentEditPath } from "~/utils/routeHelpers"; +import PublicBreadcrumb from "./PublicBreadcrumb"; + +type Props = { + document: Document; +}; + +function SharedDocumentHeader({ document }: Props) { + const { t } = useTranslation(); + const { ui, shares } = useStores(); + const user = useCurrentUser({ rejectOnEmpty: false }); + const isMobileMedia = useMobile(); + const isEditingFocus = useEditingFocus(); + + // Set CSS variable for header offset (used by sticky table headers) + useEffect(() => { + window.document.documentElement.style.setProperty( + "--header-offset", + isEditingFocus ? "0px" : "64px" + ); + }, [isEditingFocus]); + + const { hasHeadings } = useDocumentContext(); + const sidebarContext = useLocationSidebarContext(); + const [measureRef, size] = useMeasure(); + const { shareId, sharedTree, allowSubscriptions } = useShare(); + const share = shareId ? shares.get(shareId) : undefined; + const team = useTeamContext() as PublicTeam | undefined; + const tocPosition = team?.tocPosition ?? TOCPosition.Left; + const isMobile = isMobileMedia || (size.width > 0 && size.width < 700); + + const handleToggle = useCallback(() => { + // Public shares, by default, show ToC on load. + if (ui.tocVisible === undefined) { + ui.set({ tocVisible: false }); + } else { + ui.set({ tocVisible: !ui.tocVisible }); + } + }, [ui]); + + const can = usePolicy(document); + const showContents = ui.tocVisible !== false; + + useEffect(() => { + if (isMobile && showContents) { + ui.set({ tocVisible: false }); + } + }, [isMobile, showContents, ui]); + + useKeyDown( + (event) => event.ctrlKey && event.altKey && event.code === "KeyH", + handleToggle, + { + allowInInput: true, + } + ); + + if (!shareId) { + return null; + } + + const toc = ( + + } + $flipped={tocPosition === TOCPosition.Right} + borderOnHover + neutral + /> + + ); + + const editAction = can.update ? ( + + + + + + ) : ( +
    + ); + + const hasSidebar = !!(sharedTree && sharedTree.children?.length); + const tocInLeft = !isMobile && hasSidebar && tocPosition === TOCPosition.Left; + + return ( + + {document.icon && ( + + )} + {document.title} + + } + hasSidebar={hasSidebar} + left={ + isMobile ? ( + hasHeadings ? ( + + ) : null + ) : hasSidebar ? ( + + {hasHeadings && tocInLeft ? toc : null} + + ) : share ? ( + + ) : null + } + actions={ + <> + {hasHeadings && !isMobile && !tocInLeft && {toc}} + {allowSubscriptions !== false && !user && env.EMAIL_ENABLED && ( + + )} + + {can.update && share && ( + + + - diff --git a/app/scenes/KeyboardShortcuts.tsx b/app/scenes/KeyboardShortcuts.tsx index d72b8288560b..e0fa76de349a 100644 --- a/app/scenes/KeyboardShortcuts.tsx +++ b/app/scenes/KeyboardShortcuts.tsx @@ -88,6 +88,15 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) { ), label: t("Toggle sidebar"), }, + { + shortcut: ( + <> + {metaDisplay} + +{" "} + l + + ), + label: t("Toggle theme"), + }, { shortcut: ( <> @@ -557,6 +566,7 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) { { event.preventDefault(); const data = Object.fromEntries(new FormData(event.target)); - await navigateToSubdomain(data.subdomain.toString()); + await navigateToSubdomain(data.subdomain as string); }, []); React.useEffect(() => { diff --git a/app/scenes/Login/OAuthAuthorize.tsx b/app/scenes/Login/OAuthAuthorize.tsx index 57c4f43e57c0..d916f9a9234a 100644 --- a/app/scenes/Login/OAuthAuthorize.tsx +++ b/app/scenes/Login/OAuthAuthorize.tsx @@ -87,10 +87,11 @@ function Authorize() { redirect_uri: redirectUri, response_type: responseType, code_challenge: codeChallenge, - code_challenge_method: codeChallengeMethod, + code_challenge_method: rawCodeChallengeMethod, state, scope, } = Object.fromEntries(params); + const codeChallengeMethod = rawCodeChallengeMethod?.trim(); const [scopes] = useState(() => inputScopes(scope)); const { error: clientError, data: response } = useRequest<{ data: OAuthClient; diff --git a/app/scenes/Login/OAuthScopeHelper.ts b/app/scenes/Login/OAuthScopeHelper.ts index ebbdfc850b8f..ac301e4fb6c8 100644 --- a/app/scenes/Login/OAuthScopeHelper.ts +++ b/app/scenes/Login/OAuthScopeHelper.ts @@ -42,7 +42,7 @@ export class OAuthScopeHelper { return t("Write all data"); } - const [namespace, method] = scope.replace("/api/", "").split(/[:\.]/g); + const [namespace, method] = scope.replace("/api/", "").split(/[:.]/g); const readableMethod = methodToReadable[method as keyof typeof methodToReadable] ?? method; if (!readableMethod) { diff --git a/app/scenes/Login/components/AuthenticationProvider.tsx b/app/scenes/Login/components/AuthenticationProvider.tsx index 0dad1a8a98b7..f23ff39262ca 100644 --- a/app/scenes/Login/components/AuthenticationProvider.tsx +++ b/app/scenes/Login/components/AuthenticationProvider.tsx @@ -32,7 +32,8 @@ function AuthenticationProvider(props: Props) { const [isSubmitting, setSubmitting] = React.useState(false); const [email, setEmail] = React.useState(""); const formRef = React.useRef(null); - const { isCreate, id, name, authUrl, onEmailSuccess, ...rest } = props; + const { isCreate, id, name, authUrl, onEmailSuccess, preferOTP, ...rest } = + props; const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web; const handleChangeEmail = (event: React.ChangeEvent) => { @@ -51,7 +52,7 @@ function AuthenticationProvider(props: Props) { const response = await client.post(event.currentTarget.action, { email, client: clientType, - preferOTP: props.preferOTP, + preferOTP, }); if (response.redirect) { @@ -89,18 +90,22 @@ function AuthenticationProvider(props: Props) { // Populate hidden form fields with authentication data if (formRef.current) { - const createInputs = (obj: any, prefix = "") => { + const createInputs = (obj: Record, prefix = "") => { Object.entries(obj).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + const fieldName = prefix ? `${prefix}[${key}]` : key; - if (value && typeof value === "object" && !Array.isArray(value)) { - createInputs(value, fieldName); + if (typeof value === "object" && !Array.isArray(value)) { + createInputs(value as Record, fieldName); } else { // Create hidden input for primitive values const input = document.createElement("input"); input.type = "hidden"; input.name = fieldName; - input.value = String(value); + input.value = String(value as string | number | boolean); formRef.current?.appendChild(input); } }); diff --git a/app/scenes/Logout.tsx b/app/scenes/Logout.tsx index 31a4f73b7b73..6f5949436e1b 100644 --- a/app/scenes/Logout.tsx +++ b/app/scenes/Logout.tsx @@ -1,17 +1,16 @@ -import { Redirect } from "react-router-dom"; -import env from "~/env"; import useStores from "~/hooks/useStores"; -import { logoutPath } from "~/utils/routeHelpers"; const Logout = () => { const { auth } = useStores(); - void auth.logout({ userInitiated: true }); + void auth.logout({ + userInitiated: true, + clearCache: true, + }); - if (env.OIDC_LOGOUT_URI) { - return null; // user will be redirected to logout URI after logout - } - return ; + // AuthStore.logout() always sets logoutRedirectUri to the portal host; the + // unauthenticated branch in Authenticated.tsx performs the actual navigation. + return null; }; export default Logout; diff --git a/app/scenes/Search/Search.tsx b/app/scenes/Search/Search.tsx index fca52e9bc249..bdbe4abd15ea 100644 --- a/app/scenes/Search/Search.tsx +++ b/app/scenes/Search/Search.tsx @@ -52,6 +52,7 @@ function Search() { const location = useLocation(); const history = useHistory(); const routeMatch = useRouteMatch<{ query: string }>(); + const handleGoBack = React.useCallback(() => history.goBack(), [history]); // refs const searchInputRef = React.useRef(null); @@ -249,7 +250,7 @@ function Search() { textTitle={query ? `${query} – ${t("Search")}` : t("Search")} actions={isMobile ? sortInput : null} > - + {loading && } diff --git a/app/scenes/Search/components/SearchInput.tsx b/app/scenes/Search/components/SearchInput.tsx index 438c8ce1cc45..874bbbd991e1 100644 --- a/app/scenes/Search/components/SearchInput.tsx +++ b/app/scenes/Search/components/SearchInput.tsx @@ -53,7 +53,8 @@ const Wrapper = styled(Flex)` const StyledInput = styled.input` width: 100%; - padding: 10px 10px 10px 60px; + padding-block: 10px 10px; + padding-inline: 60px 10px; font-size: 30px; font-weight: 400; outline: none; @@ -81,7 +82,7 @@ const StyledInput = styled.input` const StyledIcon = styled(SearchIcon)` position: absolute; - left: 8px; + inset-inline-start: 8px; opacity: 0.7; `; diff --git a/app/scenes/Settings/APIAndAccess.tsx b/app/scenes/Settings/APIAndAccess.tsx index 808052bd3cbb..66dd25b56604 100644 --- a/app/scenes/Settings/APIAndAccess.tsx +++ b/app/scenes/Settings/APIAndAccess.tsx @@ -45,7 +45,7 @@ function APIAndAccess() { } > {t("API & Access")} -

    {t("API keys")}

    +

    {t("Personal keys")}

    {can.createApiKey ? ( ({ + query: params.get("query") || undefined, + sort: params.get("sort") || "createdAt", + direction: (params.get("direction") || "desc").toUpperCase() as + | "ASC" + | "DESC", + }), + [params] + ); + + const sort: ColumnSort = useMemo( + () => ({ + id: reqParams.sort, + desc: reqParams.direction === "DESC", + }), + [reqParams.sort, reqParams.direction] + ); + + const orderedData = apiKeys.orderedData; + const filteredApiKeys = useMemo( + () => + reqParams.query ? apiKeys.findByQuery(reqParams.query) : orderedData, + [apiKeys, orderedData, reqParams.query] + ); + + const { data, error, loading, next } = useTableRequest({ + data: filteredApiKeys, + sort, + reqFn: apiKeys.fetchPage, + reqParams, + }); + + const updateParams = useCallback( + (name: string, value: string) => { + if (value) { + params.set(name, value); + } else { + params.delete(name); + } + + history.replace({ + pathname: location.pathname, + search: params.toString(), + }); + }, + [params, history, location.pathname] + ); + + const handleSearch = useCallback( + (event: React.ChangeEvent) => { + setQuery(event.target.value); + }, + [] + ); + + useEffect(() => { + if (error) { + toast.error(t("Could not load API keys")); + } + }, [t, error]); + + useEffect(() => { + const timeout = setTimeout(() => updateParams("query", query), 250); + return () => clearTimeout(timeout); + }, [query, updateParams]); return ( } + wide > {t("API Keys")} @@ -54,13 +133,25 @@ function ApiKeys() { }} /> - - fetch={apiKeys.fetchPage} - items={apiKeys.orderedData} - renderItem={(apiKey) => ( - - )} - /> + + + + + + ); } diff --git a/app/scenes/Settings/Application.tsx b/app/scenes/Settings/Application.tsx index a3bf50398aa2..a422d62586b7 100644 --- a/app/scenes/Settings/Application.tsx +++ b/app/scenes/Settings/Application.tsx @@ -187,7 +187,7 @@ const Application = observer(function Application({ oauthClient }: Props) { name="clientType" render={({ field }) => ( @@ -246,6 +248,8 @@ function Details() { onChange={(ev: React.ChangeEvent) => { setDescription(ev.target.value); }} + maxLength={TeamValidation.maxDescriptionLength} + showCharacterCount /> diff --git a/app/scenes/Settings/Features.tsx b/app/scenes/Settings/Features.tsx index d3651f636a55..684f9c7afcc7 100644 --- a/app/scenes/Settings/Features.tsx +++ b/app/scenes/Settings/Features.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import { toast } from "sonner"; import { TeamPreference } from "@shared/types"; +import { TeamValidation } from "@shared/validations"; import Heading from "~/components/Heading"; import Scene from "~/components/Scene"; import Switch from "~/components/Switch"; @@ -30,6 +31,18 @@ function Features() { [team, t] ); + const handleGuidanceMCPChange = React.useCallback( + async (ev: React.ChangeEvent) => { + team.guidanceMCP = ev.target.value || null; + }, + [team] + ); + + const handleGuidanceMCPBlur = React.useCallback(async () => { + await team.save(); + toast.success(t("Settings saved")); + }, [team, t]); + const handleCopied = React.useCallback(() => { toast.success(t("Copied to clipboard")); }, [t]); @@ -46,6 +59,7 @@ function Features() { @@ -97,6 +111,34 @@ function Features() { /> + {team.getPreference(TeamPreference.MCP) && ( + +
    + {t( + "You can use these optional instructions to tell MCP clients how to use your knowledge base." + )} +
    + + + } + /> + )} + - - - - + + - item.group === "Integrations" && + item.group === t("Integrations") && item.enabled && item.path !== settingsPath("integrations") && item.name.toLowerCase().includes(query.toLowerCase()) diff --git a/app/scenes/Settings/Preferences.tsx b/app/scenes/Settings/Preferences.tsx index 49b408d459fc..1fc49228a26f 100644 --- a/app/scenes/Settings/Preferences.tsx +++ b/app/scenes/Settings/Preferences.tsx @@ -188,7 +188,7 @@ function Preferences() { value={user.language} onChange={handleLanguageChange} label={t("Language")} - hideLabel + labelHidden /> diff --git a/app/scenes/Settings/Profile.tsx b/app/scenes/Settings/Profile.tsx index 965c89d060ec..947f6801d3dc 100644 --- a/app/scenes/Settings/Profile.tsx +++ b/app/scenes/Settings/Profile.tsx @@ -12,6 +12,7 @@ import { UserChangeEmailDialog } from "~/components/UserDialogs"; import env from "~/env"; import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; +import { UserValidation } from "@shared/validations"; import ImageInput from "./components/ImageInput"; import SettingRow from "./components/SettingRow"; @@ -91,6 +92,8 @@ const Profile = () => { autoComplete="name" value={name} onChange={handleNameChange} + maxLength={UserValidation.maxNameLength} + showCharacterCount required />
    diff --git a/app/scenes/Settings/Security.tsx b/app/scenes/Settings/Security.tsx index 5d4d147352a7..93445473b46f 100644 --- a/app/scenes/Settings/Security.tsx +++ b/app/scenes/Settings/Security.tsx @@ -258,7 +258,7 @@ function Security() { options={userRoleOptions} onChange={handleDefaultRoleChange} label={t("Default role")} - hideLabel + labelHidden short /> @@ -331,7 +331,7 @@ function Security() { options={emailDisplayOptions} onChange={handleEmailDisplayChange} label={t("Email address visibility")} - hideLabel + labelHidden short /> diff --git a/app/scenes/Settings/Template.tsx b/app/scenes/Settings/Template.tsx index a5fe77741073..beb9d814b7a5 100644 --- a/app/scenes/Settings/Template.tsx +++ b/app/scenes/Settings/Template.tsx @@ -43,7 +43,9 @@ const LoadingState = observer(function LoadingState() { ui.addActiveModel(template); } return () => { - template && ui.removeActiveModel(template); + if (template) { + ui.removeActiveModel(template); + } }; }, [template, ui]); diff --git a/app/scenes/Settings/components/ActionRow.tsx b/app/scenes/Settings/components/ActionRow.tsx index d17889d3266c..00a90dc3bf67 100644 --- a/app/scenes/Settings/components/ActionRow.tsx +++ b/app/scenes/Settings/components/ActionRow.tsx @@ -14,7 +14,7 @@ export const ActionRow = styled(HStack).attrs({ bottom: 0; width: 100vw; padding: 16px 12px; - margin-left: -12px; + margin-inline-start: -12px; background: ${s("background")}; diff --git a/app/scenes/Settings/components/ApiKeysTable.tsx b/app/scenes/Settings/components/ApiKeysTable.tsx new file mode 100644 index 000000000000..8b28f88cbeb6 --- /dev/null +++ b/app/scenes/Settings/components/ApiKeysTable.tsx @@ -0,0 +1,192 @@ +import { observer } from "mobx-react"; +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import styled from "styled-components"; +import type ApiKey from "~/models/ApiKey"; +import { Avatar, AvatarSize } from "~/components/Avatar"; +import Badge from "~/components/Badge"; +import CopyToClipboard from "~/components/CopyToClipboard"; +import { HEADER_HEIGHT } from "~/components/Header"; +import { ContextMenu } from "~/components/Menu/ContextMenu"; +import { + type Props as TableProps, + SortableTable, +} from "~/components/SortableTable"; +import { type Column as TableColumn } from "~/components/Table"; +import Text from "~/components/Text"; +import Time from "~/components/Time"; +import Tooltip from "~/components/Tooltip"; +import { useApiKeyMenuActions } from "~/hooks/useApiKeyMenuActions"; +import useUserLocale from "~/hooks/useUserLocale"; +import ApiKeyMenu from "~/menus/ApiKeyMenu"; +import { HStack } from "~/components/primitives/HStack"; +import { dateToExpiry } from "~/utils/date"; +import { FILTER_HEIGHT } from "./StickyFilters"; +import { CopyIcon } from "outline-icons"; + +const ROW_HEIGHT = 50; +const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT; + +type Props = Omit, "columns" | "rowHeight">; + +const ApiKeyRowContextMenu = observer(function ApiKeyRowContextMenu({ + apiKey, + menuLabel, + children, +}: { + apiKey: ApiKey; + menuLabel: string; + children: React.ReactNode; +}) { + const action = useApiKeyMenuActions(apiKey); + return ( + + {children} + + ); +}); + +export const ApiKeysTable = observer(function ApiKeysTable(props: Props) { + const { t } = useTranslation(); + const userLocale = useUserLocale(); + + const applyContextMenu = useCallback( + (apiKey: ApiKey, rowElement: React.ReactNode) => ( + + {rowElement} + + ), + [t] + ); + + const columns = useMemo[]>( + () => [ + { + type: "data", + id: "name", + header: t("Name"), + accessor: (apiKey) => apiKey.name, + component: (apiKey) => ( + + {apiKey.name} + {apiKey.scope && ( + ( + + {s} +
    +
    + ))} + > + {t("Restricted scope")} +
    + )} +
    + ), + width: "3fr", + }, + { + type: "data", + id: "value", + header: t("Key"), + sortable: false, + accessor: (apiKey) => apiKey.obfuscatedValue, + component: (apiKey) => + apiKey.value ? ( + toast.success(t("API key copied"))} + > + + +  {apiKey.value} + + + ) : ( + + {apiKey.obfuscatedValue} + + ), + width: "2.5fr", + }, + { + type: "data", + id: "userId", + header: t("Created by"), + accessor: (apiKey) => apiKey.user?.name, + component: (apiKey) => + apiKey.user ? ( + + + {apiKey.user.name} + + ) : null, + width: "2fr", + }, + { + type: "data", + id: "lastActiveAt", + header: t("Last used"), + accessor: (apiKey) => apiKey.lastActiveAt, + component: (apiKey) => + apiKey.lastActiveAt ? ( +