Compare commits

..

9 Commits

Author SHA1 Message Date
Tom Moor 6e07aa877f clear secret 2024-10-01 22:17:45 -04:00
Tom Moor 19d5ef5694 Fix code scanning alert no. 67: Shell command built from environment values
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2024-10-01 19:05:20 -07:00
Tom Moor b37074304a fix 2024-10-01 22:04:50 -04:00
Tom Moor 35c7cc2086 suppress on cloud 2024-10-01 21:40:09 -04:00
Tom Moor 82f9600d9e fix: last4 not written 2024-10-01 21:38:31 -04:00
Tom Moor 686f9aeb5c Merge main 2024-10-01 21:02:36 -04:00
Tom Moor a2d5598b96 wip 2024-09-30 17:46:30 -04:00
Tom Moor 53272c8c3d test 2024-09-30 07:16:03 -04:00
Tom Moor 65ff9bde3e Add hashed column for API keys 2024-09-30 07:07:38 -04:00
383 changed files with 4084 additions and 10632 deletions
+2 -1
View File
@@ -108,7 +108,8 @@ jobs:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker
- setup_remote_docker:
version: 20.10.6
- run:
name: Install Docker buildx
command: |
+3 -84
View File
@@ -1,10 +1,8 @@
import {
ArchiveIcon,
CollectionIcon,
EditIcon,
PadlockIcon,
PlusIcon,
RestoreIcon,
SearchIcon,
ShapesIcon,
StarredIcon,
@@ -12,13 +10,11 @@ import {
UnstarredIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -133,20 +129,9 @@ export const searchInCollection = createAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection?.isActive) {
return false;
}
return stores.policies.abilities(activeCollectionId).readDocument;
},
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).readDocument,
perform: ({ activeCollectionId }) => {
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
},
@@ -205,72 +190,6 @@ export const unstarCollection = createAction({
},
});
export const archiveCollection = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
section: CollectionSection,
icon: <ArchiveIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).archive;
},
perform: async ({ activeCollectionId, stores, t }) => {
const { dialogs, collections } = stores;
if (!activeCollectionId) {
return;
}
const collection = collections.get(activeCollectionId);
if (!collection) {
return;
}
dialogs.openModal({
title: t("Archive collection"),
content: (
<ConfirmationDialog
onSubmit={async () => {
await collection.archive();
toast.success(t("Collection archived"));
}}
submitText={t("Archive")}
savingText={`${t("Archiving")}`}
>
{t(
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results."
)}
</ConfirmationDialog>
),
});
},
});
export const restoreCollection = createAction({
name: ({ t }) => t("Restore"),
analyticsName: "Restore collection",
section: CollectionSection,
icon: <RestoreIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).restore;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
await collection.restore();
toast.success(t("Collection restored"));
},
});
export const deleteCollection = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
+1 -26
View File
@@ -1,10 +1,9 @@
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
import { DoneIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import history from "~/utils/history";
import { createAction } from "..";
import { DocumentSection } from "../sections";
@@ -89,27 +88,3 @@ export const unresolveCommentFactory = ({
onUnresolve();
},
});
export const viewCommentReactionsFactory = ({
comment,
}: {
comment: Comment;
}) =>
createAction({
name: ({ t }) => `${t("View reactions")}`,
analyticsName: "View comment reactions",
section: DocumentSection,
icon: <SmileyIcon />,
visible: () =>
stores.policies.abilities(comment.id).read &&
comment.reactions.length > 0,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Reactions"),
content: <ViewReactionsDialog model={comment} />,
});
},
});
+4
View File
@@ -358,6 +358,8 @@ export const shareDocument = createAction({
}
const document = stores.documents.get(activeDocumentId);
const share = stores.shares.getByDocumentId(activeDocumentId);
const sharedParent = stores.shares.getByDocumentParents(activeDocumentId);
if (!document) {
return;
}
@@ -368,6 +370,8 @@ export const shareDocument = createAction({
content: (
<SharePopover
document={document}
share={share}
sharedParent={sharedParent}
onRequestClose={stores.dialogs.closeAllModals}
visible
/>
-9
View File
@@ -91,15 +91,6 @@ export const navigateToSettings = createAction({
perform: () => history.push(settingsPath()),
});
export const navigateToWorkspaceSettings = createAction({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to workspace settings",
section: NavigationSection,
icon: <SettingsIcon />,
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(settingsPath("details")),
});
export const navigateToProfileSettings = createAction({
name: ({ t }) => t("Profile"),
analyticsName: "Navigate to profile settings",
+2 -6
View File
@@ -1,5 +1,5 @@
import { LocationDescriptor } from "history";
import { DisclosureIcon } from "outline-icons";
import { ExpandedIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
@@ -189,14 +189,10 @@ const Button = <T extends React.ElementType = "button">(
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && ic}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
{disclosure && <StyledDisclosureIcon />}
{disclosure && <ExpandedIcon />}
</Inner>
</RealButton>
);
};
const StyledDisclosureIcon = styled(DisclosureIcon)`
opacity: 0.8;
`;
export default React.forwardRef(Button);
-45
View File
@@ -1,45 +0,0 @@
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { MenuInternalLink } from "~/types";
import { archivePath, collectionPath } from "~/utils/routeHelpers";
import Breadcrumb from "./Breadcrumb";
type Props = {
collection: Collection;
};
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
const { t } = useTranslation();
const items = React.useMemo(() => {
const collectionNode: MenuInternalLink = {
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
};
const category: MenuInternalLink | undefined = collection.isArchived
? {
type: "route",
icon: <ArchiveIcon />,
title: t("Archive"),
to: archivePath(),
}
: undefined;
const output = [];
if (category) {
output.push(category);
}
output.push(collectionNode);
return output;
}, [collection, t]);
return <Breadcrumb items={items} highlightFirstItem />;
};
+1 -1
View File
@@ -226,7 +226,7 @@ const Input = styled.div`
}
&[data-editing="true"] {
background: ${s("backgroundSecondary")};
background: ${s("secondaryBackground")};
}
.block-menu-trigger,
+1 -4
View File
@@ -77,10 +77,7 @@ const SearchInput = styled(KBarSearch)`
border: none;
background: ${s("menuBackground")};
color: ${s("text")};
&:not(:last-child) {
border-bottom: 1px solid ${s("inputBorder")};
}
border-bottom: 1px solid ${s("inputBorder")};
&:disabled,
&::placeholder {
@@ -7,10 +7,6 @@ import CommandBarItem from "./CommandBarItem";
export default function CommandBarResults() {
const { results, rootActionId } = useMatches();
if (results.length === 0) {
return null;
}
return (
<Container>
<KBarResults
+5 -7
View File
@@ -21,13 +21,11 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
const { documents, dialogs, collections } = useStores();
const { t } = useTranslation();
const prevCollection = collections.get(item.collectionId!);
const accessMapping: Record<Partial<CollectionPermission> | "null", string> =
{
[CollectionPermission.Admin]: t("manage access"),
[CollectionPermission.ReadWrite]: t("view and edit access"),
[CollectionPermission.Read]: t("view only access"),
null: t("no access"),
};
const accessMapping = {
[CollectionPermission.ReadWrite]: t("view and edit access"),
[CollectionPermission.Read]: t("view only access"),
null: t("no access"),
};
const handleSubmit = async () => {
await documents.move({
+1 -1
View File
@@ -35,7 +35,7 @@ function ConnectionStatus() {
};
const message = ui.multiplayerErrorCode
? codeToMessage[ui.multiplayerErrorCode as keyof typeof codeToMessage]
? codeToMessage[ui.multiplayerErrorCode]
: undefined;
return ui.multiplayerStatus === "connecting" ||
-8
View File
@@ -11,9 +11,6 @@ class DocumentContext {
/** The editor instance for this document */
editor?: Editor;
@observable
isEditorInitialized: boolean = false;
@observable
headings: Heading[] = [];
@@ -34,11 +31,6 @@ class DocumentContext {
this.updateState();
};
@action
setEditorInitialized = (initialized: boolean) => {
this.isEditorInitialized = initialized;
};
@action
updateState = () => {
this.updateHeadings();
+5 -6
View File
@@ -98,7 +98,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
}
// default search for anything that doesn't look like a URL
const results = await documents.searchTitles({ query: term });
const results = await documents.searchTitles(term);
return sortBy(
results.map(({ document }) => ({
@@ -255,7 +255,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
<ErrorBoundary component="div" reloadOnChunkMissing>
<>
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
@@ -268,11 +267,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.editorStyle?.paddingBottom && (
{props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
onClick={focusAtEnd}
onDrop={handleDrop}
onDragOver={handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
-20
View File
@@ -1,20 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
/** Width of the containing element. */
width?: number | string;
/** Height of the containing element. */
height?: number | string;
/** Controls the rendered emoji size. */
size?: number;
};
export const Emoji = styled.span<Props>`
font-family: ${s("fontFamilyEmoji")};
width: ${({ width }) =>
typeof width === "string" ? width : width ? `${width}px` : "auto"};
height: ${({ height }) =>
typeof height === "string" ? height : height ? `${height}px` : "auto"};
font-size: ${({ size }) => size && `${size}px`};
`;
+1 -1
View File
@@ -138,7 +138,7 @@ class ErrorBoundary extends React.Component<Props> {
}
const Pre = styled.pre`
background: ${s("backgroundSecondary")};
background: ${s("secondaryBackground")};
padding: 16px;
border-radius: 4px;
font-size: 12px;
+1 -1
View File
@@ -75,7 +75,7 @@ const Image = styled(Flex)`
justify-content: center;
width: 32px;
height: 32px;
background: ${s("backgroundSecondary")};
background: ${s("secondaryBackground")};
border-radius: 32px;
`;
+1 -3
View File
@@ -16,8 +16,6 @@ import useStores from "~/hooks/useStores";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import Desktop from "~/utils/Desktop";
export const HEADER_HEIGHT = 64;
type Props = {
left?: React.ReactNode;
title: React.ReactNode;
@@ -132,7 +130,7 @@ const Wrapper = styled(Flex)<WrapperProps>`
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
min-height: ${HEADER_HEIGHT}px;
min-height: 64px;
justify-content: flex-start;
${draggableOnDesktop()}
+1 -1
View File
@@ -61,7 +61,7 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
color?: string;
}>`
background-color: ${(props) =>
props.color ?? props.theme.backgroundSecondary};
props.color ?? props.theme.secondaryBackground};
color: ${(props) =>
props.color ? getTextColor(props.color) : props.theme.text};
width: fit-content;
@@ -125,7 +125,6 @@ function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
@@ -7,7 +7,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
{ avatarUrl, name, lastActive, color, email }: Props,
{ avatarUrl, name, lastActive, color }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
@@ -25,7 +25,6 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
/>
<Flex column gap={2} justify="center">
<Title>{name}</Title>
{email && <Info>{email}</Info>}
<Info>{lastActive}</Info>
</Flex>
</Flex>
@@ -0,0 +1,8 @@
import styled from "styled-components";
import { s } from "@shared/styles";
export const Emoji = styled.span`
font-family: ${s("fontFamilyEmoji")};
width: 24px;
height: 24px;
`;
@@ -18,7 +18,11 @@ import {
import GridTemplate, { DataNode } from "./GridTemplate";
import SkinTonePicker from "./SkinTonePicker";
const GRID_HEIGHT = 410;
/**
* This is needed as a constant for react-window.
* Calculated from the heights of TabPanel and InputSearch.
*/
const GRID_HEIGHT = 362;
const useEmojiState = () => {
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
@@ -76,7 +80,6 @@ type Props = {
panelWidth: number;
query: string;
panelActive: boolean;
height?: number;
onEmojiChange: (emoji: string) => void;
onQueryChange: (query: string) => void;
};
@@ -87,7 +90,6 @@ const EmojiPanel = ({
panelActive,
onEmojiChange,
onQueryChange,
height = GRID_HEIGHT,
}: Props) => {
const { t } = useTranslation();
@@ -157,7 +159,7 @@ const EmojiPanel = ({
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={height - 48}
height={GRID_HEIGHT}
data={templateData}
onIconSelect={handleEmojiSelection}
/>
@@ -4,9 +4,9 @@ import React from "react";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { Emoji } from "~/components/Emoji";
import Text from "~/components/Text";
import { TRANSLATED_CATEGORIES } from "../utils";
import { Emoji } from "./Emoji";
import Grid from "./Grid";
import { IconButton } from "./IconButton";
@@ -71,7 +71,7 @@ const GridTemplate = (
<IconButton
key={item.name}
onClick={() => onIconSelect({ id: item.name, value: item.name })}
style={{ "--delay": `${item.delay}ms` } as React.CSSProperties}
delay={item.delay}
>
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
{item.initial}
@@ -85,9 +85,7 @@ const GridTemplate = (
key={item.id}
onClick={() => onIconSelect({ id: item.id, value: item.value })}
>
<Emoji width={24} height={24}>
{item.value}
</Emoji>
<Emoji>{item.value}</Emoji>
</IconButton>
);
});
@@ -7,6 +7,7 @@ export const IconButton = styled(NudeButton)<{ delay?: number }>`
width: 32px;
height: 32px;
padding: 4px;
--delay: ${({ delay }) => delay && `${delay}ms`};
&: ${hover} {
background: ${s("listItemHoverBackground")};
@@ -5,10 +5,10 @@ import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
import { Emoji } from "./Emoji";
import { IconButton } from "./IconButton";
const SkinTonePicker = ({
@@ -26,7 +26,7 @@ const SkinTonePicker = ({
);
const menu = useMenuState({
placement: "bottom-end",
placement: "bottom",
});
const handleSkinClick = React.useCallback(
@@ -43,9 +43,7 @@ const SkinTonePicker = ({
<MenuItem {...menu} key={emoji.value}>
{(menuprops) => (
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
<Emoji width={24} height={24}>
{emoji.value}
</Emoji>
<Emoji>{emoji.value}</Emoji>
</IconButton>
)}
</MenuItem>
+17 -18
View File
@@ -82,7 +82,6 @@ const IconPicker = ({
modal: true,
unstable_offset: [0, 0],
});
const { hide, show, visible } = popover;
const tab = useTabState({ selectedId: defaultTab });
const previouslyVisible = usePrevious(popover.visible);
@@ -97,12 +96,12 @@ const IconPicker = ({
const handleIconChange = React.useCallback(
(ic: string) => {
hide();
popover.hide();
const icType = determineIconType(ic);
const finalColor = icType === IconType.SVG ? chosenColor : null;
onChange(ic, finalColor);
},
[hide, onChange, chosenColor]
[popover, onChange, chosenColor]
);
const handleIconColorChange = React.useCallback(
@@ -119,32 +118,32 @@ const IconPicker = ({
);
const handleIconRemove = React.useCallback(() => {
hide();
popover.hide();
onChange(null, null);
}, [hide, onChange]);
}, [popover, onChange]);
const handlePopoverButtonClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
if (visible) {
hide();
if (popover.visible) {
popover.hide();
} else {
show();
popover.show();
}
},
[hide, show, visible]
[popover]
);
// Popover open effect
React.useEffect(() => {
if (visible && !previouslyVisible) {
if (popover.visible && !previouslyVisible) {
onOpen?.();
} else if (!visible && previouslyVisible) {
} else if (!popover.visible && previouslyVisible) {
onClose?.();
setQuery("");
resetDefaultTab();
}
}, [visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
}, [popover.visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
@@ -199,7 +198,7 @@ const IconPicker = ({
{...tab}
id={TAB_NAMES["Icon"]}
aria-label={t("Icons")}
$active={tab.selectedId === TAB_NAMES["Icon"]}
active={tab.selectedId === TAB_NAMES["Icon"]}
>
{t("Icons")}
</StyledTab>
@@ -207,7 +206,7 @@ const IconPicker = ({
{...tab}
id={TAB_NAMES["Emoji"]}
aria-label={t("Emojis")}
$active={tab.selectedId === TAB_NAMES["Emoji"]}
active={tab.selectedId === TAB_NAMES["Emoji"]}
>
{t("Emojis")}
</StyledTab>
@@ -274,7 +273,7 @@ const TabActionsWrapper = styled(Flex)`
border-bottom: 1px solid ${s("inputBorder")};
`;
const StyledTab = styled(Tab)<{ $active: boolean }>`
const StyledTab = styled(Tab)<{ active: boolean }>`
position: relative;
font-weight: 500;
font-size: 14px;
@@ -283,15 +282,15 @@ const StyledTab = styled(Tab)<{ $active: boolean }>`
border: 0;
padding: 8px 12px;
user-select: none;
color: ${({ $active }) => ($active ? s("textSecondary") : s("textTertiary"))};
color: ${({ active }) => (active ? s("textSecondary") : s("textTertiary"))};
transition: color 100ms ease-in-out;
&: ${hover} {
color: ${s("textSecondary")};
}
${({ $active }) =>
$active &&
${({ active }) =>
active &&
css`
&:after {
content: "";
+3 -3
View File
@@ -10,7 +10,7 @@ import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Button, { Props as ButtonProps, Inner } from "~/components/Button";
import Button, { Inner } from "~/components/Button";
import Text from "~/components/Text";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
@@ -33,7 +33,7 @@ export type Option = {
divider?: boolean;
};
export type Props = Omit<ButtonProps<any>, "onChange"> & {
export type Props = {
id?: string;
name?: string;
value?: string | null;
@@ -313,7 +313,7 @@ const StyledButton = styled(Button)<{ $nude?: boolean }>`
margin-bottom: 16px;
display: block;
width: 100%;
cursor: var(--pointer);
cursor: default;
&:hover:not(:disabled) {
background: ${s("buttonNeutralBackground")};
+1 -7
View File
@@ -1,8 +1,6 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { $Diff } from "utility-types";
import { s } from "@shared/styles";
import { CollectionPermission } from "@shared/types";
import { EmptySelectValue } from "~/types";
import InputSelect, { Props, Option, InputSelectRef } from "./InputSelect";
@@ -21,7 +19,7 @@ function InputSelectPermission(
const { t } = useTranslation();
return (
<Select
<InputSelect
ref={ref}
label={t("Permission")}
options={[
@@ -47,8 +45,4 @@ function InputSelectPermission(
);
}
const Select = styled(InputSelect)`
color: ${s("textSecondary")};
`;
export default React.forwardRef(InputSelectPermission);
+1 -1
View File
@@ -192,7 +192,7 @@ const Wrapper = styled.a<{
&:focus,
&:focus-within {
background: ${(props) =>
props.$hover ? props.theme.backgroundSecondary : "inherit"};
props.$hover ? props.theme.secondaryBackground : "inherit"};
}
cursor: ${(props) =>
+3 -6
View File
@@ -39,15 +39,12 @@ const LocaleTime: React.FC<Props> = ({
relative,
tooltipDelay,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
const userLocale: string = useUserLocale() || "";
const dateFormatLong = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocaleLong = dateFormatLong[userLocale] ?? "MMMM do, yyyy h:mm a";
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
@@ -24,15 +24,13 @@ import NotificationListItem from "./NotificationListItem";
type Props = {
/** Callback when the notification panel wants to close. */
onRequestClose: () => void;
/** Whether the panel is open or not. */
isOpen: boolean;
};
/**
* A panel containing a list of notifications and controls to manage them.
*/
function Notifications(
{ onRequestClose, isOpen }: Props,
{ onRequestClose }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const context = useActionContext();
@@ -74,7 +72,7 @@ function Notifications(
<PaginatedList
fetch={notifications.fetchPage}
options={{ archived: false }}
items={isOpen ? notifications.orderedData : undefined}
items={notifications.orderedData}
renderItem={(item: Notification) => (
<NotificationListItem
key={item.id}
@@ -40,11 +40,7 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
shrink
flex
>
<Notifications
onRequestClose={popover.hide}
isOpen={popover.visible}
ref={scrollableRef}
/>
<Notifications onRequestClose={popover.hide} ref={scrollableRef} />
</StyledPopover>
</>
);
+1 -2
View File
@@ -19,8 +19,7 @@ export interface PaginatedItem {
}
type Props<T> = WithTranslation &
RootStore &
React.HTMLAttributes<HTMLDivElement> & {
RootStore & {
fetch?: (
options: Record<string, any> | undefined
) => Promise<T[] | undefined> | undefined;
-173
View File
@@ -1,173 +0,0 @@
import { observer } from "mobx-react";
import { transparentize } from "polished";
import React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import type { ReactionSummary } from "@shared/types";
import { getEmojiId } from "@shared/utils/emoji";
import User from "~/models/User";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import { hover } from "~/styles";
type Props = {
/** Thin reaction data - contains the emoji & active user ids for this reaction. */
reaction: ReactionSummary;
/** Users who reacted using this emoji. */
reactedUsers: User[];
/** Whether the emoji button should be disabled (prevents add/remove events). */
disabled: boolean;
/** Callback when the user intends to add the reaction. */
onAddReaction: (emoji: string) => Promise<void>;
/** Callback when the user intends to remove the reaction. */
onRemoveReaction: (emoji: string) => Promise<void>;
};
const useTooltipContent = ({
reactedUsers,
currUser,
emoji,
active,
}: {
reactedUsers: User[];
currUser: User;
emoji: string;
active: boolean;
}) => {
const { t } = useTranslation();
if (!reactedUsers.length) {
return;
}
const transformedEmoji = `:${getEmojiId(emoji)}:`;
switch (reactedUsers.length) {
case 1: {
return t("{{ username }} reacted with {{ emoji }}", {
username: active ? t("You") : reactedUsers[0].name,
emoji: transformedEmoji,
});
}
case 2: {
const firstUsername = active ? t("You") : reactedUsers[0].name;
const secondUsername = active
? reactedUsers.find((user) => user.id !== currUser.id)?.name
: reactedUsers[1].name;
return t(
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
{
firstUsername,
secondUsername,
emoji: transformedEmoji,
}
);
}
default: {
const firstUsername = active ? t("You") : reactedUsers[0].name;
const count = reactedUsers.length - 1;
return t(
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
{
firstUsername,
count,
emoji: transformedEmoji,
}
);
}
}
};
const Reaction: React.FC<Props> = ({
reaction,
reactedUsers,
disabled,
onAddReaction,
onRemoveReaction,
}) => {
const user = useCurrentUser();
const active = reaction.userIds.includes(user.id);
const tooltipContent = useTooltipContent({
reactedUsers,
currUser: user,
emoji: reaction.emoji,
active,
});
const handleClick = React.useCallback(
(event: React.SyntheticEvent<HTMLButtonElement>) => {
event.stopPropagation();
active
? void onRemoveReaction(reaction.emoji)
: void onAddReaction(reaction.emoji);
},
[reaction, active, onAddReaction, onRemoveReaction]
);
const DisplayedEmoji = React.useMemo(
() => (
<EmojiButton disabled={disabled} $active={active} onClick={handleClick}>
<Flex gap={6} justify="center" align="center">
<Emoji size={15}>{reaction.emoji}</Emoji>
<Count weight="xbold">{reaction.userIds.length}</Count>
</Flex>
</EmojiButton>
),
[reaction.emoji, reaction.userIds, disabled, active, handleClick]
);
return tooltipContent ? (
<Tooltip content={tooltipContent} delay={250} placement="bottom">
{DisplayedEmoji}
</Tooltip>
) : (
<>{DisplayedEmoji}</>
);
};
const EmojiButton = styled(NudeButton)<{
$active: boolean;
disabled: boolean;
}>`
width: auto;
height: 28px;
padding: 6px;
border-radius: 12px;
transition: ${s("backgroundTransition")};
background: ${s("backgroundTertiary")};
pointer-events: ${({ disabled }) => disabled && "none"};
&: ${hover} {
background: ${s("backgroundQuaternary")};
}
${(props) =>
props.$active &&
css`
background: ${transparentize(0.7, props.theme.accent)};
&: ${hover} {
background: ${transparentize(0.5, props.theme.accent)};
}
`}
`;
const Count = styled(Text)`
font-size: 11px;
color: ${s("buttonNeutralText")};
padding-right: 1px;
font-variant-numeric: tabular-nums;
`;
export default observer(Reaction);
-87
View File
@@ -1,87 +0,0 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import React from "react";
import Comment from "~/models/Comment";
import useHover from "~/hooks/useHover";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
import Flex from "../Flex";
import { ResizingHeightContainer } from "../ResizingHeightContainer";
import Reaction from "./Reaction";
type Props = {
/** Model for which to show the reactions. */
model: Comment;
/** Callback when the user intends to add a reaction. */
onAddReaction: (emoji: string) => Promise<void>;
/** Callback when the user intends to remove a reaction. */
onRemoveReaction: (emoji: string) => Promise<void>;
/** classname generated by styled-components. */
className?: string;
/** Picker to render as the last element */
picker?: React.ReactElement;
};
const ReactionList: React.FC<Props> = ({
model,
onAddReaction,
onRemoveReaction,
className,
picker,
}) => {
const { users } = useStores();
const listRef = React.useRef<HTMLDivElement>(null);
const hovered = useHover({
ref: listRef,
duration: 250,
});
React.useEffect(() => {
const loadReactedUsersData = async () => {
try {
await model.loadReactedUsersData();
} catch (err) {
Logger.warn("Could not prefetch reaction data");
}
};
if (hovered) {
void loadReactedUsersData();
}
}, [hovered, model]);
const hasReactions = !!model.reactions.length;
const style = React.useMemo(() => {
if (hasReactions) {
return { minHeight: 28 };
}
return undefined;
}, [hasReactions]);
return (
<ResizingHeightContainer style={style}>
<Flex ref={listRef} className={className} align="center" gap={6} wrap>
{model.reactions.map((reaction) => {
const reactedUsers = compact(
reaction.userIds.map((id) => users.get(id))
);
return (
<Reaction
key={reaction.emoji}
reaction={reaction}
reactedUsers={reactedUsers}
disabled={model.isResolved}
onAddReaction={onAddReaction}
onRemoveReaction={onRemoveReaction}
/>
);
})}
{picker}
</Flex>
</ResizingHeightContainer>
);
};
export default observer(ReactionList);
-158
View File
@@ -1,158 +0,0 @@
import { ReactionIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import styled from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
const EmojiPanel = React.lazy(
() => import("~/components/IconPicker/components/EmojiPanel")
);
type Props = {
/** Callback when an emoji is selected by the user. */
onSelect: (emoji: string) => Promise<void>;
/** Callback when the picker is opened. */
onOpen?: () => void;
/** Callback when the picker is closed. */
onClose?: () => void;
/** Optional classname. */
className?: string;
size?: number;
};
const ReactionPicker: React.FC<Props> = ({
onSelect,
onOpen,
onClose,
className,
size,
}) => {
const { t } = useTranslation();
const popover = usePopoverState({
modal: true,
unstable_offset: [0, 0],
placement: "bottom-end",
});
const { width: windowWidth } = useWindowSize();
const isMobile = useMobile();
const [query, setQuery] = React.useState("");
const contentRef = React.useRef<HTMLDivElement | null>(null);
const popoverWidth = isMobile ? windowWidth : 300;
// In mobile, popover is absolutely positioned to leave 8px on both sides.
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
const { toggle, hide } = popover;
const handlePopoverButtonClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
toggle();
},
[toggle]
);
const handleEmojiSelect = React.useCallback(
(emoji: string) => {
hide();
void onSelect(emoji);
},
[hide, onSelect]
);
// Popover open effect
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
}
}, [popover.visible, onOpen, onClose]);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
contentRef,
(event) => {
if (
popover.visible &&
!popover.unstable_disclosureRef.current?.contains(event.target as Node)
) {
event.stopPropagation();
event.preventDefault();
popover.hide();
}
},
{ capture: true }
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<PopoverButton
{...props}
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
size={size}
>
<ReactionIcon size={22} />
</PopoverButton>
)}
</PopoverDisclosure>
<Popover
{...popover}
ref={contentRef}
width={popoverWidth}
shrink
aria-label={t("Reaction picker")}
onClick={(e) => e.stopPropagation()}
hideOnClickOutside={false}
>
{popover.visible && (
<React.Suspense fallback={<Placeholder />}>
<EventBoundary>
<EmojiPanel
height={300}
panelWidth={panelWidth}
query={query}
onEmojiChange={handleEmojiSelect}
onQueryChange={setQuery}
panelActive
/>
</EventBoundary>
</React.Suspense>
)}
</Popover>
</>
);
};
const Placeholder = React.memo(
() => (
<Flex column gap={6} style={{ height: "300px", padding: "6px 12px" }}>
<Flex gap={8}>
<PlaceholderText height={32} minWidth={90} />
<PlaceholderText height={32} width={32} />
</Flex>
<PlaceholderText height={24} width={120} />
</Flex>
),
() => true
);
Placeholder.displayName = "ReactionPickerPlaceholder";
const PopoverButton = styled(NudeButton)`
border-radius: 50%;
`;
export default ReactionPicker;
@@ -1,146 +0,0 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Tab, TabPanel, useTabState } from "reakit";
import { toast } from "sonner";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Comment from "~/models/Comment";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import PlaceholderText from "~/components/PlaceholderText";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
type Props = {
/** Model for which to show the reactions. */
model: Comment;
};
const ViewReactionsDialog: React.FC<Props> = ({ model }) => {
const { t } = useTranslation();
const { users } = useStores();
const tab = useTabState();
const { reactedUsersLoaded } = model;
React.useEffect(() => {
const loadReactedUsersData = async () => {
try {
await model.loadReactedUsersData();
} catch (err) {
toast.error(t("Could not load reactions"));
}
};
void loadReactedUsersData();
}, [t, model]);
if (!reactedUsersLoaded) {
return <PlaceHolder />;
}
return (
<>
<TabActionsWrapper>
{model.reactions.map((reaction) => (
<StyledTab
{...tab}
key={reaction.emoji}
id={reaction.emoji}
aria-label={t("Reaction")}
$active={tab.selectedId === reaction.emoji}
>
<Emoji size={16}>{reaction.emoji}</Emoji>
</StyledTab>
))}
</TabActionsWrapper>
{model.reactions.map((reaction) => {
const reactedUsers = compact(
reaction.userIds.map((id) => users.get(id))
);
return (
<StyledTabPanel {...tab} key={reaction.emoji}>
{reactedUsers.map((user) => (
<UserInfo key={user.name} align="center" gap={8}>
<Avatar model={user} size={AvatarSize.Medium} />
<Text size="medium">{user.name}</Text>
</UserInfo>
))}
</StyledTabPanel>
);
})}
</>
);
};
const PlaceHolder = React.memo(
() => (
<>
<TabActionsWrapper gap={8} style={{ paddingBottom: "10px" }}>
<PlaceholderText width={40} height={32} />
<PlaceholderText width={40} height={32} />
</TabActionsWrapper>
<UserInfo align="center" gap={12}>
<PlaceholderText width={AvatarSize.Medium} height={AvatarSize.Medium} />
<PlaceholderText height={34} />
</UserInfo>
<UserInfo align="center" gap={12}>
<PlaceholderText width={AvatarSize.Medium} height={AvatarSize.Medium} />
<PlaceholderText height={34} />
</UserInfo>
</>
),
() => true
);
PlaceHolder.displayName = "ViewReactionsPlaceholder";
const TabActionsWrapper = styled(Flex)`
border-bottom: 1px solid ${s("inputBorder")};
`;
const StyledTab = styled(Tab)<{ $active: boolean }>`
position: relative;
font-weight: 500;
font-size: 14px;
cursor: var(--pointer);
background: none;
border: 0;
border-radius: 4px 4px 0 0;
padding: 8px 12px 10px;
user-select: none;
transition: background-color 100ms ease;
&: ${hover} {
background-color: ${s("listItemHoverBackground")};
}
${({ $active }) =>
$active &&
css`
&:after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: ${s("textSecondary")};
}
`}
`;
const StyledTabPanel = styled(TabPanel)`
height: 300px;
padding: 5px 0;
overflow-y: auto;
`;
const UserInfo = styled(Flex)`
padding: 10px 8px;
`;
export default observer(ViewReactionsDialog);
+2 -5
View File
@@ -19,10 +19,9 @@ import SearchListItem from "./SearchListItem";
interface Props extends React.HTMLAttributes<HTMLInputElement> {
shareId: string;
className?: string;
}
function SearchPopover({ shareId, className }: Props) {
function SearchPopover({ shareId }: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const focusRef = React.useRef<HTMLElement | null>(null);
@@ -55,8 +54,7 @@ function SearchPopover({ shareId, className }: Props) {
const performSearch = React.useCallback(
async ({ query, ...options }) => {
if (query?.length > 0) {
const response = await documents.search({
query,
const response = await documents.search(query, {
shareId,
...options,
});
@@ -188,7 +186,6 @@ function SearchPopover({ shareId, className }: Props) {
onChange={handleSearchInputChange}
onFocus={handleSearchInputFocus}
onKeyDown={handleKeyDown}
className={className}
/>
)}
</PopoverDisclosure>
@@ -1,3 +1,4 @@
import invariant from "invariant";
import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
@@ -16,6 +17,7 @@ import Input, { NativeInput } from "~/components/Input";
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";
@@ -37,6 +39,7 @@ type Props = {
};
function PublicAccess({ document, share, sharedParent }: Props) {
const { shares } = useStores();
const { t } = useTranslation();
const theme = useTheme();
const [validationError, setValidationError] = React.useState("");
@@ -52,15 +55,18 @@ function PublicAccess({ document, share, sharedParent }: Props) {
const handlePublishedChange = React.useCallback(
async (event) => {
const share = shares.getByDocumentId(document.id);
invariant(share, "Share must exist");
try {
await share?.save({
await share.save({
published: event.currentTarget.checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
[document.id, shares]
);
const handleUrlChange = React.useMemo(
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { DocumentPermission } from "@shared/types";
import Document from "~/models/Document";
import Group from "~/models/Group";
import Share from "~/models/Share";
import User from "~/models/User";
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
import NudeButton from "~/components/NudeButton";
@@ -31,19 +32,26 @@ import { AccessControlList } from "./AccessControlList";
type Props = {
/** The document to share. */
document: Document;
/** The existing share model, if any. */
share: Share | null | undefined;
/** The existing share parent model, if any. */
sharedParent: Share | null | undefined;
/** Callback fired when the popover requests to be closed. */
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
};
function SharePopover({ document, onRequestClose, visible }: Props) {
function SharePopover({
document,
share,
sharedParent,
onRequestClose,
visible,
}: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const can = usePolicy(document);
const { shares } = useStores();
const share = shares.getByDocumentId(document.id);
const sharedParent = shares.getByDocumentParents(document.id);
const [hasRendered, setHasRendered] = React.useState(visible);
const { users, userMemberships, groups, groupMemberships } = useStores();
const [query, setQuery] = React.useState("");
+37 -44
View File
@@ -94,40 +94,38 @@ function AppSidebar() {
</SidebarButton>
)}
</OrganizationMenu>
<Overflow>
<Section>
<Section>
<SidebarLink
to={homePath()}
icon={<HomeIcon />}
exact={false}
label={t("Home")}
/>
<SidebarLink
to={searchPath()}
icon={<SearchIcon />}
label={t("Search")}
exact={false}
/>
{can.createDocument && (
<SidebarLink
to={homePath()}
icon={<HomeIcon />}
exact={false}
label={t("Home")}
to={draftsPath()}
icon={<DraftsIcon />}
label={
<Flex align="center" justify="space-between">
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts > 25
? "25+"
: documents.totalDrafts}
</Drafts>
) : null}
</Flex>
}
/>
<SidebarLink
to={searchPath()}
icon={<SearchIcon />}
label={t("Search")}
exact={false}
/>
{can.createDocument && (
<SidebarLink
to={draftsPath()}
icon={<DraftsIcon />}
label={
<Flex align="center" justify="space-between">
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts > 25
? "25+"
: documents.totalDrafts}
</Drafts>
) : null}
</Flex>
}
/>
)}
</Section>
</Overflow>
)}
</Section>
<Scrollable flex shadow>
<Section>
<Starred />
@@ -135,16 +133,16 @@ function AppSidebar() {
<Section>
<SharedWithMe />
</Section>
<Section>
<Section auto>
<Collections />
</Section>
{can.createDocument && (
<Section auto>
<ArchiveLink />
</Section>
)}
<Section>
{can.createDocument && <TrashLink />}
{can.createDocument && (
<>
<ArchiveLink />
<TrashLink />
</>
)}
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
@@ -154,11 +152,6 @@ function AppSidebar() {
);
}
const Overflow = styled.div`
overflow: hidden;
flex-shrink: 0;
`;
const Drafts = styled(Text)`
margin: 0 4px;
`;
+13 -81
View File
@@ -1,18 +1,13 @@
import { observer } from "mobx-react";
import { SidebarIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { NavigationNode } from "@shared/types";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import SearchPopover from "~/components/SearchPopover";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import history from "~/utils/history";
import { metaDisplay } from "~/utils/keyboard";
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
import { useTeamContext } from "../TeamContext";
import TeamLogo from "../TeamLogo";
@@ -20,7 +15,6 @@ import Sidebar from "./Sidebar";
import Section from "./components/Section";
import DocumentLink from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
type Props = {
rootNode: NavigationNode;
@@ -33,11 +27,9 @@ function SharedSidebar({ rootNode, shareId }: Props) {
const { ui, documents } = useStores();
const { t } = useTranslation();
const teamAvailable = !!team?.name;
return (
<StyledSidebar $hoverTransition={!teamAvailable}>
{teamAvailable && (
<Sidebar>
{team?.name && (
<SidebarButton
title={team.name}
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
@@ -46,20 +38,11 @@ function SharedSidebar({ rootNode, shareId }: Props) {
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
)
}
>
<ToggleSidebar />
</SidebarButton>
/>
)}
<ScrollContainer topShadow flex>
<TopSection>
<SearchWrapper>
<StyledSearchPopover shareId={shareId} />
</SearchWrapper>
{!teamAvailable && (
<ToggleWrapper>
<ToggleSidebar />
</ToggleWrapper>
)}
<SearchPopover shareId={shareId} />
</TopSection>
<Section>
<DocumentLink
@@ -72,74 +55,23 @@ function SharedSidebar({ rootNode, shareId }: Props) {
/>
</Section>
</ScrollContainer>
</StyledSidebar>
</Sidebar>
);
}
const ToggleSidebar = () => {
const { t } = useTranslation();
const { ui } = useStores();
return (
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
delay={500}
>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
</Tooltip>
);
};
const ScrollContainer = styled(Scrollable)`
padding-bottom: 16px;
`;
const TopSection = styled(Flex)`
padding: 8px;
flex-shrink: 0;
`;
const TopSection = styled(Section)`
// this weird looking && increases the specificity of the style rule
&&:first-child {
margin-top: 16px;
}
const SearchWrapper = styled.div`
width: 100%;
`;
const StyledSearchPopover = styled(SearchPopover)`
width: 100%;
transition: width 100ms ease-out;
margin: 8px 0;
`;
const ToggleWrapper = styled.div`
position: absolute;
right: 0;
opacity: 0;
transform: translateX(10px);
transition: opacity 100ms ease-out, transform 100ms ease-out;
`;
const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
${({ $hoverTransition }) =>
$hoverTransition &&
`
&: ${hover} {
${StyledSearchPopover} {
width: 85%;
}
${ToggleWrapper} {
opacity: 1;
transform: translateX(0);
}
}
`}
&& {
margin-bottom: 16px;
}
`;
export default observer(SharedSidebar);
+14 -29
View File
@@ -94,17 +94,9 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
}
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
const handleBlur = React.useCallback(() => {
setHovering(false);
}, []);
const handleMouseDown = React.useCallback(
(event) => {
event.preventDefault();
if (!document.hasFocus()) {
return;
}
setOffset(event.pageX - width);
setResizing(true);
setAnimating(false);
@@ -112,9 +104,9 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
[width]
);
const handlePointerActivity = React.useCallback(() => {
const handlePointerMove = React.useCallback(() => {
if (ui.sidebarIsClosed) {
setHovering(document.hasFocus());
setHovering(true);
setPointerMoved(true);
}
}, [ui.sidebarIsClosed]);
@@ -123,10 +115,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
(ev) => {
if (hasPointerMoved) {
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
ev.pageX < width && ev.pageY < window.innerHeight && ev.pageY > 0
);
}
},
@@ -164,14 +153,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
document.body.style.cursor = "initial";
}
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("blur", handleBlur);
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", handleStopDrag);
};
}, [isResizing, handleDrag, handleBlur, handleStopDrag]);
}, [isResizing, handleDrag, handleStopDrag]);
const handleReset = React.useCallback(() => {
ui.setSidebarWidth(theme.sidebarWidth);
@@ -207,8 +193,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
$collapsed={collapsed}
$isMobile={isMobile}
className={className}
onPointerDown={handlePointerActivity}
onPointerMove={handlePointerActivity}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
column
>
@@ -232,7 +217,15 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
/>
}
>
<Notifications />
<NotificationsPopover>
{(rest: SidebarButtonProps) => (
<SidebarButton
{...rest}
position="bottom"
image={<NotificationIcon />}
/>
)}
</NotificationsPopover>
</SidebarButton>
)}
</AccountMenu>
@@ -247,14 +240,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
);
});
const Notifications = () => (
<NotificationsPopover>
{(rest: SidebarButtonProps) => (
<SidebarButton {...rest} position="bottom" image={<NotificationIcon />} />
)}
</NotificationsPopover>
);
const Backdrop = styled.a`
animation: ${fadeIn} 250ms ease-in-out;
position: fixed;
@@ -1,101 +1,41 @@
import isUndefined from "lodash/isUndefined";
import { observer } from "mobx-react";
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import Flex from "@shared/components/Flex";
import Collection from "~/models/Collection";
import PaginatedList from "~/components/PaginatedList";
import useRequest from "~/hooks/useRequest";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import { archivePath } from "~/utils/routeHelpers";
import { useDropToArchive } from "../hooks/useDragAndDrop";
import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
import { StyledError } from "./Collections";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarLink from "./SidebarLink";
import SidebarLink, { DragObject } from "./SidebarLink";
function ArchiveLink() {
const { collections } = useStores();
const { policies, documents } = useStores();
const { t } = useTranslation();
const [disclosure, setDisclosure] = React.useState<boolean>(false);
const [expanded, setExpanded] = React.useState<boolean | undefined>();
const { request, data, loading, error } = useRequest(
collections.fetchArchived,
true
);
React.useEffect(() => {
if (!isUndefined(data) && !loading && isUndefined(error)) {
setDisclosure(data.length > 0);
}
}, [data, loading, error]);
React.useEffect(() => {
setDisclosure(collections.archived.length > 0);
}, [collections.archived]);
React.useEffect(() => {
if (disclosure && isUndefined(expanded)) {
setExpanded(false);
}
}, [disclosure]);
React.useEffect(() => {
if (expanded) {
void request();
}
}, [expanded, request]);
const handleDisclosureClick = React.useCallback((ev) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((e) => !e);
}, []);
const handleClick = React.useCallback(() => {
setExpanded(true);
}, []);
const [{ isOverArchiveSection, isDragging }, dropToArchiveRef] =
useDropToArchive();
const [{ isDocumentDropping }, dropToArchiveDocument] = useDrop({
accept: "document",
drop: async (item: DragObject) => {
const document = documents.get(item.id);
await document?.archive();
toast.success(t("Document archived"));
},
canDrop: (item) => policies.abilities(item.id).archive,
collect: (monitor) => ({
isDocumentDropping: monitor.isOver(),
}),
});
return (
<Flex column>
<div ref={dropToArchiveRef}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
exact={false}
label={t("Archive")}
isActiveDrop={isOverArchiveSection && isDragging}
depth={0}
expanded={disclosure ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
</div>
{expanded === true ? (
<Relative>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
collection={item}
/>
)}
/>
</Relative>
) : null}
</Flex>
<div ref={dropToArchiveDocument}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isDocumentDropping} />}
exact={false}
label={t("Archive")}
active={documents.active?.isArchived && !documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
);
}
@@ -1,47 +0,0 @@
import * as React from "react";
import Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import Relative from "./Relative";
type Props = {
collection: Collection;
depth?: number;
};
export function ArchivedCollectionLink({ collection, depth }: Props) {
const { documents } = useStores();
const [expanded, setExpanded] = React.useState(false);
const handleDisclosureClick = React.useCallback((ev) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((e) => !e);
}, []);
const handleClick = React.useCallback(() => {
setExpanded(true);
}, []);
return (
<>
<CollectionLink
depth={depth ? depth : 0}
collection={collection}
expanded={expanded}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={expanded}
prefetchDocument={documents.prefetchDocument}
/>
</Relative>
</>
);
}
@@ -4,6 +4,7 @@ import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
@@ -29,8 +30,6 @@ type Props = {
onDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
activeDocument: Document | undefined;
isDraggingAnyCollection?: boolean;
depth?: number;
onClick?: () => void;
};
const CollectionLink: React.FC<Props> = ({
@@ -38,14 +37,13 @@ const CollectionLink: React.FC<Props> = ({
expanded,
onDisclosureClick,
isDraggingAnyCollection,
depth,
onClick,
}: Props) => {
const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false);
const can = usePolicy(collection);
const { t } = useTranslation();
const history = useHistory();
const sidebarContext = useSidebarContext();
const editableTitleRef = React.useRef<RefHandle>(null);
@@ -54,8 +52,9 @@ const CollectionLink: React.FC<Props> = ({
await collection.save({
name,
});
history.replace(collection.path, history.location.state);
},
[collection]
[collection, history]
);
// Drop to re-parent document
@@ -112,15 +111,10 @@ const CollectionLink: React.FC<Props> = ({
sidebarContext,
});
const handleRename = React.useCallback(() => {
editableTitleRef.current?.setIsEditing(true);
}, [editableTitleRef]);
return (
<Relative ref={drop}>
<DropToImport collectionId={collection.id}>
<SidebarLink
onClick={onClick}
to={{
pathname: collection.path,
state: { sidebarContext },
@@ -146,7 +140,7 @@ const CollectionLink: React.FC<Props> = ({
/>
}
exact={false}
depth={depth ? depth : 0}
depth={0}
menu={
!isEditing &&
!isDraggingAnyCollection && (
@@ -161,7 +155,7 @@ const CollectionLink: React.FC<Props> = ({
</NudeButton>
<CollectionMenu
collection={collection}
onRename={handleRename}
onRename={() => editableTitleRef.current?.setIsEditing(true)}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { toast } from "sonner";
import styled from "styled-components";
import Collection from "~/models/Collection";
@@ -35,13 +34,11 @@ function CollectionLinkChildren({
expanded,
prefetchDocument,
}: Props) {
const pageSize = 250;
const can = usePolicy(collection);
const manualSort = collection.sort.field === "index";
const { documents, dialogs, collections } = useStores();
const { t } = useTranslation();
const childDocuments = useCollectionDocuments(collection, documents.active);
const [showing, setShowing] = React.useState(pageSize);
// Drop to reorder document
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
@@ -86,18 +83,6 @@ function CollectionLinkChildren({
}),
});
React.useEffect(() => {
if (!expanded) {
setShowing(pageSize);
}
}, [expanded]);
const showMore = React.useCallback(() => {
if (childDocuments && childDocuments.length > showing) {
setShowing((value) => value + pageSize);
}
}, [childDocuments, showing]);
return (
<Folder expanded={expanded}>
{isDraggingAnyDocument && can.createDocument && manualSort && (
@@ -113,7 +98,7 @@ function CollectionLinkChildren({
<Loading />
</ResizingHeightContainer>
)}
{childDocuments?.slice(0, showing).map((node, index) => (
{childDocuments?.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
@@ -136,7 +121,6 @@ function CollectionLinkChildren({
depth={2}
/>
)}
<Waypoint key={showing} onEnter={showMore} fireOnRapidScroll />
</DocumentsLoader>
</Folder>
);
@@ -55,7 +55,7 @@ function Collections() {
<PaginatedList
options={params}
aria-label={t("Collections")}
items={collections.allActive}
items={collections.orderedData}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
@@ -84,7 +84,7 @@ function Collections() {
);
}
export const StyledError = styled(Error)`
const StyledError = styled(Error)`
font-size: 15px;
padding: 0 8px;
`;
@@ -41,6 +41,16 @@ function EditableTitle(
setIsEditing(true);
}, []);
const handleKeyDown = React.useCallback(
(event) => {
if (event.key === "Escape") {
setIsEditing(false);
setValue(originalValue);
}
},
[originalValue]
);
const stopPropagation = React.useCallback((event) => {
event.preventDefault();
event.stopPropagation();
@@ -53,7 +63,6 @@ function EditableTitle(
const handleSave = React.useCallback(
async (ev) => {
ev.preventDefault();
ev.stopPropagation();
setIsEditing(false);
const trimmedValue = value.trim();
@@ -76,22 +85,6 @@ function EditableTitle(
[originalValue, value, onSubmit]
);
const handleKeyDown = React.useCallback(
async (ev) => {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "Escape") {
setIsEditing(false);
setValue(originalValue);
}
if (ev.key === "Enter") {
await handleSave(ev);
}
},
[handleSave, originalValue]
);
React.useEffect(() => {
onEditing?.(isEditing);
}, [onEditing, isEditing]);
@@ -59,7 +59,6 @@ const StyledMoreIcon = styled(MoreIcon)`
`;
const Container = styled(Flex)<{ $position: "top" | "bottom" }>`
overflow: hidden;
padding-top: ${(props) =>
props.$position === "top" && Desktop.hasInsetTitlebar() ? 36 : 0}px;
${draggableOnDesktop()}
+30 -18
View File
@@ -2,29 +2,41 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Badge from "~/components/Badge";
import { client } from "~/utils/ApiClient";
import Logger from "~/utils/Logger";
import { version as currentVersion } from "../../../../package.json";
import { version } from "../../../../package.json";
import SidebarLink from "./SidebarLink";
export default function Version() {
const [versionsBehind, setVersionsBehind] = React.useState(-1);
const [releasesBehind, setReleasesBehind] = React.useState(-1);
const { t } = useTranslation();
React.useEffect(() => {
async function loadVersionInfo() {
try {
// Fetch version info from the server-side proxy
const res = await client.post("/installation.info");
if (res.data && res.data.versionsBehind >= 0) {
setVersionsBehind(res.data.versionsBehind);
async function loadReleases() {
const res = await fetch(
"https://api.github.com/repos/outline/outline/releases"
);
const releases = await res.json();
if (Array.isArray(releases)) {
const everyNewRelease = releases
.map((release) => release.tag_name)
.findIndex((tagName) => tagName === `v${version}`);
const onlyFullNewRelease = releases
.filter((release) => !release.prerelease)
.map((release) => release.tag_name)
.findIndex((tagName) => tagName === `v${version}`);
const computedReleasesBehind = version.includes("pre")
? everyNewRelease
: onlyFullNewRelease;
if (computedReleasesBehind >= 0) {
setReleasesBehind(computedReleasesBehind);
}
} catch (error) {
Logger.error("Failed to load version info", error);
}
}
void loadVersionInfo();
void loadReleases();
}, []);
return (
@@ -33,16 +45,16 @@ export default function Version() {
href="https://github.com/outline/outline/releases"
label={
<>
v{currentVersion}
{versionsBehind >= 0 && (
v{version}
{releasesBehind >= 0 && (
<>
<br />
<LilBadge>
{versionsBehind === 0
{releasesBehind === 0
? t("Up to date")
: t(`{{ releasesBehind }} versions behind`, {
releasesBehind: versionsBehind,
count: versionsBehind,
releasesBehind,
count: releasesBehind,
})}
</LilBadge>
</>
@@ -149,7 +149,6 @@ export function useDragDocument(
icon: icon ? <Icon value={icon} color={color} /> : undefined,
collectionId: document?.collectionId || "",
} as DragObject),
canDrag: () => !!document?.isActive,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
@@ -246,7 +245,6 @@ export function useDropToReparentDocument(
!!pathToNode &&
!pathToNode.includes(monitor.getItem().id) &&
item.id !== node.id &&
!!document?.isActive &&
policies.abilities(node.id).update &&
policies.abilities(item.id).move,
hover: (_item, monitor) => {
@@ -299,8 +297,6 @@ export function useDropToReorderDocument(
const { t } = useTranslation();
const { documents, collections, dialogs, policies } = useStores();
const document = documents.get(node.id);
return useDrop<
DragObject,
Promise<void>,
@@ -308,11 +304,7 @@ export function useDropToReorderDocument(
>({
accept: "document",
canDrop: (item: DragObject) => {
if (
item.id === node.id ||
!policies.abilities(item.id)?.move ||
!document?.isActive
) {
if (item.id === node.id || !policies.abilities(item.id)?.move) {
return false;
}
@@ -435,44 +427,3 @@ export function useDropToReorderUserMembership(getIndex?: () => string) {
}),
});
}
/**
* Hook for shared logic that allows dropping documents and collections onto archive section
*/
export function useDropToArchive() {
const accept = ["document", "collection"];
const { documents, collections, policies } = useStores();
const { t } = useTranslation();
return useDrop<
DragObject,
Promise<void>,
{ isOverArchiveSection: boolean; isDragging: boolean }
>({
accept,
drop: async (item, monitor) => {
const type = monitor.getItemType();
let model;
if (type === "collection") {
model = collections.get(item.id);
} else {
model = documents.get(item.id);
}
if (model) {
await model.archive();
toast.success(
type === "collection"
? t("Collection archived")
: t("Document archived")
);
}
},
canDrop: (item) => policies.abilities(item.id).archive,
collect: (monitor) => ({
isOverArchiveSection: !!monitor.isOver(),
isDragging: monitor.canDrop(),
}),
});
}
+24 -27
View File
@@ -120,33 +120,30 @@ function Table({
<Anchor ref={topRef} />
<InnerTable {...getTableProps()}>
<thead>
{headerGroups.map((headerGroup) => {
const groupProps = headerGroup.getHeaderGroupProps();
return (
<tr {...groupProps} key={groupProps.key}>
{headerGroup.headers.map((column) => (
<Head
{...column.getHeaderProps(column.getSortByToggleProps())}
key={column.id}
{headerGroups.map((headerGroup) => (
<tr {...headerGroup.getHeaderGroupProps()} key={headerGroup.id}>
{headerGroup.headers.map((column) => (
<Head
{...column.getHeaderProps(column.getSortByToggleProps())}
key={column.id}
>
<SortWrapper
align="center"
$sortable={!column.disableSortBy}
gap={4}
>
<SortWrapper
align="center"
$sortable={!column.disableSortBy}
gap={4}
>
{column.render("Header")}
{column.isSorted &&
(column.isSortedDesc ? (
<DescSortIcon />
) : (
<AscSortIcon />
))}
</SortWrapper>
</Head>
))}
</tr>
);
})}
{column.render("Header")}
{column.isSorted &&
(column.isSortedDesc ? (
<DescSortIcon />
) : (
<AscSortIcon />
))}
</SortWrapper>
</Head>
))}
</tr>
))}
</thead>
<tbody {...getTableBodyProps()}>
{rows.map((row) => {
@@ -256,7 +253,7 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
&:hover {
background: ${(props) =>
props.$sortable ? props.theme.backgroundSecondary : "none"};
props.$sortable ? props.theme.secondaryBackground : "none"};
}
`;
+49 -132
View File
@@ -25,9 +25,8 @@ import User from "~/models/User";
import UserMembership from "~/models/UserMembership";
import withStores from "~/components/withStores";
import {
PartialExcept,
PartialWithId,
WebsocketCollectionUpdateIndexEvent,
WebsocketCommentReactionEvent,
WebsocketEntitiesEvent,
WebsocketEntityDeletedEvent,
} from "~/types";
@@ -215,20 +214,23 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"documents.update",
action((event: PartialExcept<Document, "id" | "title" | "url">) => {
documents.add(event);
action(
(event: PartialWithId<Document> & { title: string; url: string }) => {
documents.add(event);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.updateDocument(event);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.updateDocument(event);
}
}
})
)
);
this.socket.on(
"documents.archive",
action((event: PartialExcept<Document, "id">) => {
documents.addToArchive(event as Document);
action((event: PartialWithId<Document>) => {
documents.add(event);
policies.remove(event.id);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
@@ -239,7 +241,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"documents.delete",
action((event: PartialExcept<Document, "id">) => {
action((event: PartialWithId<Document>) => {
documents.add(event);
policies.remove(event.id);
@@ -263,7 +265,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"documents.add_user",
async (event: PartialExcept<UserMembership, "id">) => {
async (event: PartialWithId<UserMembership>) => {
userMemberships.add(event);
// Any existing child policies are now invalid
@@ -284,7 +286,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"documents.remove_user",
(event: PartialExcept<UserMembership, "id">) => {
(event: PartialWithId<UserMembership>) => {
userMemberships.remove(event.id);
// Any existing child policies are now invalid
@@ -306,7 +308,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"documents.add_group",
(event: PartialExcept<GroupMembership, "id">) => {
(event: PartialWithId<GroupMembership>) => {
groupMemberships.add(event);
const group = groups.get(event.groupId!);
@@ -328,23 +330,16 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"documents.remove_group",
(event: PartialExcept<GroupMembership, "id">) => {
(event: PartialWithId<GroupMembership>) => {
groupMemberships.remove(event.id);
}
);
this.socket.on("comments.create", (event: PartialExcept<Comment, "id">) => {
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
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);
}
this.socket.on("comments.update", (event: PartialWithId<Comment>) => {
comments.add(event);
});
@@ -352,35 +347,11 @@ class WebsocketProvider extends React.Component<Props> {
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">) => {
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
groups.add(event);
});
this.socket.on("groups.update", (event: PartialExcept<Group, "id">) => {
this.socket.on("groups.update", (event: PartialWithId<Group>) => {
groups.add(event);
});
@@ -388,36 +359,24 @@ class WebsocketProvider extends React.Component<Props> {
groups.remove(event.modelId);
});
this.socket.on(
"groups.add_user",
(event: PartialExcept<GroupUser, "id">) => {
groupUsers.add(event);
}
);
this.socket.on("groups.add_user", (event: PartialWithId<GroupUser>) => {
groupUsers.add(event);
});
this.socket.on(
"groups.remove_user",
(event: PartialExcept<GroupUser, "id">) => {
groupUsers.removeAll({
groupId: event.groupId,
userId: event.userId,
});
}
);
this.socket.on("groups.remove_user", (event: PartialWithId<GroupUser>) => {
groupUsers.removeAll({
groupId: event.groupId,
userId: event.userId,
});
});
this.socket.on(
"collections.create",
(event: PartialExcept<Collection, "id">) => {
collections.add(event);
}
);
this.socket.on("collections.create", (event: PartialWithId<Collection>) => {
collections.add(event);
});
this.socket.on(
"collections.update",
(event: PartialExcept<Collection, "id">) => {
collections.add(event);
}
);
this.socket.on("collections.update", (event: PartialWithId<Collection>) => {
collections.add(event);
});
this.socket.on(
"collections.delete",
@@ -439,49 +398,7 @@ class WebsocketProvider extends React.Component<Props> {
})
);
this.socket.on(
"collections.archive",
async (event: PartialExcept<Collection, "id">) => {
const collectionId = event.id;
// Fetch collection to update policies
await collections.fetch(collectionId, { force: true });
documents.unarchivedInCollection(collectionId).forEach(
action((doc) => {
if (!doc.publishedAt) {
// draft is to be detached from collection, not archived
doc.collectionId = null;
} else {
doc.archivedAt = event.archivedAt as string;
}
policies.remove(doc.id);
})
);
}
);
this.socket.on(
"collections.restore",
async (event: PartialExcept<Collection, "id">) => {
const collectionId = event.id;
documents
.archivedInCollection(collectionId, {
archivedAt: event.archivedAt as string,
})
.forEach(
action((doc) => {
doc.archivedAt = null;
policies.remove(doc.id);
})
);
// Fetch collection to update policies
await collections.fetch(collectionId, { force: true });
}
);
this.socket.on("teams.update", (event: PartialExcept<Team, "id">) => {
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
if ("sharing" in event && event.sharing !== auth.team?.sharing) {
documents.all.forEach((document) => {
policies.remove(document.id);
@@ -493,23 +410,23 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"notifications.create",
(event: PartialExcept<Notification, "id">) => {
(event: PartialWithId<Notification>) => {
notifications.add(event);
}
);
this.socket.on(
"notifications.update",
(event: PartialExcept<Notification, "id">) => {
(event: PartialWithId<Notification>) => {
notifications.add(event);
}
);
this.socket.on("pins.create", (event: PartialExcept<Pin, "id">) => {
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
pins.add(event);
});
this.socket.on("pins.update", (event: PartialExcept<Pin, "id">) => {
this.socket.on("pins.update", (event: PartialWithId<Pin>) => {
pins.add(event);
});
@@ -517,11 +434,11 @@ class WebsocketProvider extends React.Component<Props> {
pins.remove(event.modelId);
});
this.socket.on("stars.create", (event: PartialExcept<Star, "id">) => {
this.socket.on("stars.create", (event: PartialWithId<Star>) => {
stars.add(event);
});
this.socket.on("stars.update", (event: PartialExcept<Star, "id">) => {
this.socket.on("stars.update", (event: PartialWithId<Star>) => {
stars.add(event);
});
@@ -579,14 +496,14 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"fileOperations.create",
(event: PartialExcept<FileOperation, "id">) => {
(event: PartialWithId<FileOperation>) => {
fileOperations.add(event);
}
);
this.socket.on(
"fileOperations.update",
(event: PartialExcept<FileOperation, "id">) => {
(event: PartialWithId<FileOperation>) => {
fileOperations.add(event);
if (
@@ -603,7 +520,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"subscriptions.create",
(event: PartialExcept<Subscription, "id">) => {
(event: PartialWithId<Subscription>) => {
subscriptions.add(event);
}
);
@@ -615,11 +532,11 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on("users.update", (event: PartialExcept<User, "id">) => {
this.socket.on("users.update", (event: PartialWithId<User>) => {
users.add(event);
});
this.socket.on("users.demote", async (event: PartialExcept<User, "id">) => {
this.socket.on("users.demote", async (event: PartialWithId<User>) => {
if (event.id === auth.user?.id) {
documents.all.forEach((document) => policies.remove(document.id));
await collections.fetchAll();
@@ -628,7 +545,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on(
"userMemberships.update",
async (event: PartialExcept<UserMembership, "id">) => {
async (event: PartialWithId<UserMembership>) => {
userMemberships.add(event);
}
);
+45 -47
View File
@@ -1,51 +1,29 @@
import { Node as ProsemirrorNode } from "prosemirror-model";
import { EditorView, Decoration } from "prosemirror-view";
import { FunctionComponent } from "react";
import * as React from "react";
import ReactDOM from "react-dom";
import { ThemeProvider } from "styled-components";
import Extension from "@shared/editor/lib/Extension";
import { ComponentProps } from "@shared/editor/types";
import { Editor } from "~/editor";
import { NodeViewRenderer } from "./NodeViewRenderer";
type ComponentViewConstructor = {
/** The editor instance. */
editor: Editor;
/** The extension the view belongs to. */
extension: Extension;
/** The node that the view is responsible for. */
node: ProsemirrorNode;
/** The editor view instance. */
view: EditorView;
/** A function that returns the current position of the node. */
getPos: () => number;
/** The decorations applied to the node. */
decorations: Decoration[];
};
type Component = (props: ComponentProps) => React.ReactElement;
export default class ComponentView {
/** The React component to render. */
component: FunctionComponent<ComponentProps>;
/** The editor instance. */
component: Component;
editor: Editor;
/** The extension the view belongs to. */
extension: Extension;
/** The node that the view is responsible for. */
node: ProsemirrorNode;
/** The editor view instance. */
view: EditorView;
/** A function that returns the current position of the node. */
getPos: () => number;
/** The decorations applied to the node. */
decorations: Decoration[];
/** The renderer instance. */
renderer: NodeViewRenderer<ComponentProps>;
/** Whether the node is selected. */
isSelected = false;
/** The DOM element that the node is rendered into. */
dom: HTMLElement | null;
// See https://prosemirror.net/docs/ref/#view.NodeView
constructor(
component: FunctionComponent<ComponentProps>,
component: Component,
{
editor,
extension,
@@ -53,7 +31,14 @@ export default class ComponentView {
view,
getPos,
decorations,
}: ComponentViewConstructor
}: {
editor: Editor;
extension: Extension;
node: ProsemirrorNode;
view: EditorView;
getPos: () => number;
decorations: Decoration[];
}
) {
this.component = component;
this.editor = editor;
@@ -67,33 +52,51 @@ export default class ComponentView {
: document.createElement("div");
this.dom.classList.add(`component-${node.type.name}`);
this.renderer = new NodeViewRenderer(this.dom, this.component, this.props);
// Add the renderer to the editor's set of renderers so that it is included in the React tree.
this.editor.renderers.add(this.renderer);
this.renderElement();
window.addEventListener("theme-changed", this.renderElement);
window.addEventListener("location-changed", this.renderElement);
}
renderElement = () => {
const { theme } = this.editor.props;
const children = this.component({
theme,
node: this.node,
view: this.view,
isSelected: this.isSelected,
isEditable: this.view.editable,
getPos: this.getPos,
});
ReactDOM.render(
<ThemeProvider theme={theme}>{children}</ThemeProvider>,
this.dom
);
};
update(node: ProsemirrorNode) {
if (node.type !== this.node.type) {
return false;
}
this.node = node;
this.renderer.updateProps(this.props);
this.renderElement();
return true;
}
selectNode() {
if (this.view.editable) {
this.isSelected = true;
this.renderer.updateProps(this.props);
this.renderElement();
}
}
deselectNode() {
if (this.view.editable) {
this.isSelected = false;
this.renderer.updateProps(this.props);
this.renderElement();
}
}
@@ -102,21 +105,16 @@ export default class ComponentView {
}
destroy() {
this.editor.renderers.delete(this.renderer);
window.removeEventListener("theme-changed", this.renderElement);
window.removeEventListener("location-changed", this.renderElement);
if (this.dom) {
ReactDOM.unmountComponentAtNode(this.dom);
}
this.dom = null;
}
ignoreMutation() {
return true;
}
get props() {
return {
node: this.node,
view: this.view,
isSelected: this.isSelected,
isEditable: this.view.editable,
getPos: this.getPos,
} as ComponentProps;
}
}
-1
View File
@@ -29,7 +29,6 @@ const EmojiMenu = (props: Props) => {
.map((item) => {
// We snake_case the shortcode for backwards compatability with gemoji to
// avoid multiple formats being written into documents.
// @ts-expect-error emojiMartToGemoji key
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
const emoji = item.value;
+43 -49
View File
@@ -7,7 +7,6 @@ import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import { HEADER_HEIGHT } from "~/components/Header";
import { Portal } from "~/components/Portal";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
@@ -131,64 +130,59 @@ function usePosition({
// Images need their own positioning to get the toolbar in the center
if (isImageSelection) {
const element = view.nodeDOM(selection.from) as HTMLElement;
const element = view.nodeDOM(selection.from);
// Images are wrapped which impacts positioning - need to get the element
// specifically tagged as the handle
const imageElement = element.getElementsByClassName(
const imageElement = (element as HTMLElement).getElementsByClassName(
EditorStyleHelper.imageHandle
)[0];
if (imageElement) {
const { left, top, width } = imageElement.getBoundingClientRect();
const { left, top, width } = imageElement.getBoundingClientRect();
return {
left: Math.round(left + width / 2 - menuWidth / 2 - offsetParent.left),
top: Math.round(top - menuHeight - offsetParent.top),
offset: 0,
visible: true,
};
}
}
return {
left: Math.round(left + width / 2 - menuWidth / 2 - offsetParent.left),
top: Math.round(top - menuHeight - offsetParent.top),
offset: 0,
visible: true,
};
} else {
// calculate the horizontal center of the selection
const halfSelection =
Math.abs(selectionBounds.right - selectionBounds.left) / 2;
const centerOfSelection = selectionBounds.left + halfSelection;
// calculate the horizontal center of the selection
const halfSelection =
Math.abs(selectionBounds.right - selectionBounds.left) / 2;
const centerOfSelection = selectionBounds.left + halfSelection;
// position the menu so that it is centered over the selection except in
// the cases where it would extend off the edge of the screen. In these
// instances leave a margin
const margin = 12;
const left = Math.min(
Math.min(
offsetParent.x + offsetParent.width - menuWidth - margin,
window.innerWidth - margin
),
Math.max(
Math.max(offsetParent.x, margin),
centerOfSelection - menuWidth / 2
)
);
const top = Math.max(
HEADER_HEIGHT,
Math.min(
// position the menu so that it is centered over the selection except in
// the cases where it would extend off the edge of the screen. In these
// instances leave a margin
const margin = 12;
const left = Math.min(
Math.min(
offsetParent.x + offsetParent.width - menuWidth - margin,
window.innerWidth - margin
),
Math.max(
Math.max(offsetParent.x, margin),
centerOfSelection - menuWidth / 2
)
);
const top = Math.min(
window.innerHeight - menuHeight - margin,
Math.max(margin, selectionBounds.top - menuHeight)
)
);
);
// if the menu has been offset to not extend offscreen then we should adjust
// the position of the triangle underneath to correctly point to the center
// of the selection still
const offset = left - (centerOfSelection - menuWidth / 2);
return {
left: Math.round(left - offsetParent.left),
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
blockSelection: codeBlock || isColSelection || isRowSelection,
visible: true,
};
// if the menu has been offset to not extend offscreen then we should adjust
// the position of the triangle underneath to correctly point to the center
// of the selection still
const offset = left - (centerOfSelection - menuWidth / 2);
return {
left: Math.round(left - offsetParent.left),
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
blockSelection: codeBlock || isColSelection || isRowSelection,
visible: true,
};
}
}
const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
+1 -1
View File
@@ -92,7 +92,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const user = users.get(item.attrs.modelId);
toast.message(
t(
"{{ userName }} won't be notified, as they do not have access to this document",
"{{ userName }} won't by notified as they do not have access to this document",
{
userName: item.attrs.label,
}
@@ -1,28 +0,0 @@
import isEqual from "lodash/isEqual";
import { action, computed, observable } from "mobx";
import React, { FunctionComponent } from "react";
import { createPortal } from "react-dom";
export class NodeViewRenderer<T extends object> {
@observable public props: T;
public constructor(
public element: HTMLElement,
private Component: FunctionComponent,
props: T
) {
this.props = props;
}
@computed
public get content() {
return createPortal(<this.Component {...this.props} />, this.element);
}
@action
public updateProps(props: T) {
if (!isEqual(props, this.props)) {
this.props = props;
}
}
}
+4 -5
View File
@@ -1,4 +1,3 @@
import deburr from "lodash/deburr";
import escapeRegExp from "lodash/escapeRegExp";
import { observable } from "mobx";
import { Node } from "prosemirror-model";
@@ -244,11 +243,11 @@ export default class FindAndReplaceExtension extends Extension {
});
mergedTextNodes.forEach(({ text = "", pos }) => {
try {
let m;
const search = this.findRegExp;
const search = this.findRegExp;
let m;
while ((m = search.exec(deburr(text)))) {
try {
while ((m = search.exec(text))) {
if (m[0] === "") {
break;
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default class MentionMenuExtension extends Suggestion {
get defaultOptions() {
return {
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d\s{1}@\.]+)?$/u,
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d]+)?$/u,
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s{2})|(\s+[\p{L}\p{M}\d]+))$/u,
};
}
+3 -65
View File
@@ -1,23 +1,13 @@
import isEqual from "lodash/isEqual";
import { keymap } from "prosemirror-keymap";
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "y-prosemirror";
} from "@getoutline/y-prosemirror";
import { keymap } from "prosemirror-keymap";
import * as Y from "yjs";
import Extension from "@shared/editor/lib/Extension";
import { Second } from "@shared/utils/time";
type UserAwareness = {
user?: {
id: string;
};
anchor: object;
head: object;
};
export default class Multiplayer extends Extension {
get name() {
@@ -28,7 +18,6 @@ export default class Multiplayer extends Extension {
const { user, provider, document: doc } = this.options;
const type = doc.get("default", Y.XmlFragment);
// Assign a user to a client ID once they've made a change and then remove the listener
const assignUser = (tr: Y.Transaction) => {
const clientIds = Array.from(doc.store.clients.keys());
@@ -43,54 +32,6 @@ export default class Multiplayer extends Extension {
}
};
const userAwarenessCache = new Map<
string,
{ aw: UserAwareness; changedAt: Date }
>();
// The opacity of a remote user's selection.
const selectionOpacity = 70;
// The time in milliseconds after which a remote user's selection will be hidden.
const selectionTimeout = 10 * Second.ms;
// We're hijacking this method to store the last time a user's awareness changed as a side
// effect, and otherwise behaving as the default.
const awarenessStateFilter = (
currentClientId: number,
userClientId: number,
aw: UserAwareness
) => {
if (currentClientId === userClientId) {
return false;
}
const userId = aw.user?.id;
const cached = userId ? userAwarenessCache.get(userId) : undefined;
if (!cached || !isEqual(cached?.aw, aw)) {
if (userId) {
userAwarenessCache.set(userId, { aw, changedAt: new Date() });
}
}
return true;
};
// Override the default selection builder to add a background color to the selection
// only if the user's awareness has changed recently this stops selections from lingering.
const selectionBuilder = (u: { id: string; color: string }) => {
const cached = userAwarenessCache.get(u.id);
const opacity =
!cached || cached?.changedAt > new Date(Date.now() - selectionTimeout)
? selectionOpacity
: 0;
return {
style: `background-color: ${u.color}${opacity}`,
class: "ProseMirror-yjs-selection",
};
};
provider.setAwarenessField("user", user);
// only once an actual change has been made do we add the userId <> clientId
@@ -99,10 +40,7 @@ export default class Multiplayer extends Extension {
return [
ySyncPlugin(type),
yCursorPlugin(provider.awareness, {
awarenessStateFilter,
selectionBuilder,
}),
yCursorPlugin(provider.awareness),
yUndoPlugin(),
keymap({
"Mod-z": undo,
+112 -113
View File
@@ -90,8 +90,14 @@ export default class PasteHandler extends Extension {
},
},
handlePaste: (view, event: ClipboardEvent) => {
// Do nothing if the document isn't currently editable or there is no clipboard data
if (!view.editable || !event.clipboardData) {
// Do nothing if the document isn't currently editable
if (!view.editable) {
return false;
}
// Default behavior if there is nothing on the clipboard or were
// special pasting with no formatting (Shift held)
if (!event.clipboardData || this.shiftKey) {
return false;
}
@@ -131,6 +137,76 @@ export default class PasteHandler extends Extension {
return true;
}
// Check if the clipboard contents can be parsed as a single url
if (isUrl(text)) {
// If there is selected text then we want to wrap it in a link to the url
if (!state.selection.empty) {
toggleMark(this.editor.schema.marks.link, { href: text })(
state,
dispatch
);
return true;
}
// Is this link embeddable? Create an embed!
const { embeds } = this.editor.props;
if (
embeds &&
this.editor.commands.embed &&
!isInCode(state) &&
!isInList(state)
) {
for (const embed of embeds) {
if (!embed.matchOnInput) {
continue;
}
const matches = embed.matcher(text);
if (matches) {
this.editor.commands.embed({
href: text,
});
return true;
}
}
}
// Is the link a link to a document? If so, we can grab the title and insert it.
if (isDocumentUrl(text)) {
const slug = parseDocumentSlug(text);
if (slug) {
void stores.documents
.fetch(slug)
.then((document) => {
if (view.isDestroyed) {
return;
}
if (document) {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(document.icon) === IconType.Emoji;
const title = `${hasEmoji ? document.icon + " " : ""}${
document.titleWithDefault
}`;
insertLink(`${document.path}${hash}`, title);
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
insertLink(text);
});
}
} else {
insertLink(text);
}
return true;
}
// Because VSCode is an especially popular editor that places metadata
// on the clipboard, we can parse it to find out what kind of content
// was pasted.
@@ -139,129 +215,52 @@ export default class PasteHandler extends Extension {
const supportsCodeBlock = !!state.schema.nodes.code_block;
const supportsCodeMark = !!state.schema.marks.code_inline;
if (!this.shiftKey) {
// Check if the clipboard contents can be parsed as a single url
if (isUrl(text)) {
// If there is selected text then we want to wrap it in a link to the url
if (!state.selection.empty) {
toggleMark(this.editor.schema.marks.link, { href: text })(
state,
dispatch
);
return true;
}
// Is this link embeddable? Create an embed!
const { embeds } = this.editor.props;
if (
embeds &&
this.editor.commands.embed &&
!isInCode(state) &&
!isInList(state)
) {
for (const embed of embeds) {
if (!embed.matchOnInput) {
continue;
}
const matches = embed.matcher(text);
if (matches) {
this.editor.commands.embed({
href: text,
});
return true;
}
}
}
// Is the link a link to a document? If so, we can grab the title and insert it.
if (isDocumentUrl(text)) {
const slug = parseDocumentSlug(text);
if (slug) {
void stores.documents
.fetch(slug)
.then((document) => {
if (view.isDestroyed) {
return;
}
if (document) {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(document.icon) === IconType.Emoji;
const title = `${
hasEmoji ? document.icon + " " : ""
}${document.titleWithDefault}`;
insertLink(`${document.path}${hash}`, title);
}
if (pasteCodeLanguage && pasteCodeLanguage !== "markdown") {
if (text.includes("\n") && supportsCodeBlock) {
event.preventDefault();
view.dispatch(
state.tr
.replaceSelectionWith(
state.schema.nodes.code_block.create({
language: Object.keys(LANGUAGES).includes(
vscodeMeta.mode
)
? vscodeMeta.mode
: null,
})
.catch(() => {
if (view.isDestroyed) {
return;
}
insertLink(text);
});
}
} else {
insertLink(text);
}
)
.insertText(text)
);
return true;
}
if (pasteCodeLanguage && pasteCodeLanguage !== "markdown") {
if (text.includes("\n") && supportsCodeBlock) {
event.preventDefault();
view.dispatch(
state.tr
.replaceSelectionWith(
state.schema.nodes.code_block.create({
language: Object.keys(LANGUAGES).includes(
vscodeMeta.mode
)
? vscodeMeta.mode
: null,
})
)
.insertText(text)
);
return true;
}
if (supportsCodeMark) {
event.preventDefault();
view.dispatch(
state.tr
.insertText(
text,
state.selection.from,
state.selection.to
)
.addMark(
state.selection.from,
state.selection.to + text.length,
state.schema.marks.code_inline.create()
)
);
return true;
}
if (supportsCodeMark) {
event.preventDefault();
view.dispatch(
state.tr
.insertText(text, state.selection.from, state.selection.to)
.addMark(
state.selection.from,
state.selection.to + text.length,
state.schema.marks.code_inline.create()
)
);
return true;
}
}
// If the HTML on the clipboard is from Prosemirror then the best
// compatability is to just use the HTML parser, regardless of
// whether it "looks" like Markdown, see: outline/outline#2416
if (html?.includes("data-pm-slice")) {
return false;
}
// If the HTML on the clipboard is from Prosemirror then the best
// compatability is to just use the HTML parser, regardless of
// whether it "looks" like Markdown, see: outline/outline#2416
if (html?.includes("data-pm-slice")) {
return false;
}
// If the text on the clipboard looks like Markdown OR there is no
// html on the clipboard then try to parse content as Markdown
if (
(isMarkdown(text) && !isDropboxPaper(html)) ||
pasteCodeLanguage === "markdown" ||
this.shiftKey
pasteCodeLanguage === "markdown"
) {
event.preventDefault();
+14 -18
View File
@@ -30,23 +30,19 @@ export default class SmartText extends Extension {
}
inputRules() {
if (this.options.userPreferences?.enableSmartText ?? true) {
return [
rightArrow,
emdash,
oneHalf,
threeQuarters,
copyright,
registered,
trademarked,
ellipsis,
openDoubleQuote,
closeDoubleQuote,
openSingleQuote,
closeSingleQuote,
];
}
return [];
return [
rightArrow,
emdash,
oneHalf,
threeQuarters,
copyright,
registered,
trademarked,
ellipsis,
openDoubleQuote,
closeDoubleQuote,
openSingleQuote,
closeSingleQuote,
];
}
}
+1 -28
View File
@@ -38,7 +38,7 @@ import Mark from "@shared/editor/marks/Mark";
import { basicExtensions as extensions } from "@shared/editor/nodes";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
import { ComponentProps, EventType } from "@shared/editor/types";
import { EventType } from "@shared/editor/types";
import { ProsemirrorData, UserPreferences } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import EventEmitter from "@shared/utils/events";
@@ -50,7 +50,6 @@ import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
import { NodeViewRenderer } from "./components/NodeViewRenderer";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
@@ -91,10 +90,6 @@ export type Props = {
scrollTo?: string;
/** Callback for handling uploaded images, should return the url of uploaded file */
uploadFile?: (file: File) => Promise<string>;
/** Callback when prosemirror nodes are initialized on document mount. */
onInit?: () => void;
/** Callback when prosemirror nodes are destroyed on document unmount. */
onDestroy?: () => void;
/** Callback when editor is blurred, as native input */
onBlur?: () => void;
/** Callback when editor is focused, as native input */
@@ -180,7 +175,6 @@ export class Editor extends React.PureComponent<
linkToolbarOpen: false,
};
isInitialized = false;
isBlurred = true;
extensions: ExtensionManager;
elementRef = React.createRef<HTMLDivElement>();
@@ -198,7 +192,6 @@ export class Editor extends React.PureComponent<
};
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
renderers: Set<NodeViewRenderer<ComponentProps>> = new Set();
nodes: { [name: string]: NodeSpec };
marks: { [name: string]: MarkSpec };
commands: Record<string, CommandFactory>;
@@ -288,7 +281,6 @@ export class Editor extends React.PureComponent<
window.removeEventListener("theme-changed", this.dispatchThemeChanged);
this.view?.destroy();
this.mutationObserver?.disconnect();
this.handleEditorDestroy();
}
private init() {
@@ -488,8 +480,6 @@ export class Editor extends React.PureComponent<
self.handleChange();
}
self.handleEditorInit();
self.calculateDir();
// Because Prosemirror and React are not linked we must tell React that
@@ -748,22 +738,6 @@ export class Editor extends React.PureComponent<
);
};
private handleEditorInit = () => {
if (!this.props.onInit || this.isInitialized) {
return;
}
this.props.onInit();
this.isInitialized = true;
};
private handleEditorDestroy = () => {
if (!this.props.onDestroy) {
return;
}
this.props.onDestroy();
};
private handleEditorBlur = () => {
this.setState({ isEditorFocused: false });
return false;
@@ -864,7 +838,6 @@ export class Editor extends React.PureComponent<
Object.values(this.widgets).map((Widget, index) => (
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
))}
{Array.from(this.renderers).map((view) => view.content)}
</Flex>
</EditorContext.Provider>
</PortalContext.Provider>
+1 -5
View File
@@ -14,10 +14,7 @@ export default function codeMenuItems(
): MenuItem[] {
const node = state.selection.$from.node();
const allLanguages = Object.entries(LANGUAGES) as [
keyof typeof LANGUAGES,
string
][];
const allLanguages = Object.entries(LANGUAGES);
const frequentLanguages = getFrequentCodeLanguages();
const frequentLangMenuItems = frequentLanguages.map((value) => {
@@ -52,7 +49,6 @@ export default function codeMenuItems(
visible: !readOnly,
name: "code_block",
icon: <ExpandedIcon />,
// @ts-expect-error We have a fallback for incorrect mapping
label: LANGUAGES[node.attrs.language ?? "none"],
children: languageMenuItems,
},
+1 -2
View File
@@ -11,11 +11,10 @@ export default function readOnlyMenuItems(
dictionary: Dictionary
): MenuItem[] {
const { schema } = state;
const isEmpty = state.selection.empty;
return [
{
visible: canUpdate && !isEmpty,
visible: canUpdate,
name: "comment",
tooltip: dictionary.comment,
label: dictionary.comment,
+1 -11
View File
@@ -1,4 +1,4 @@
import { AlignFullWidthIcon, DownloadIcon, TrashIcon } from "outline-icons";
import { AlignFullWidthIcon, TrashIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
@@ -32,15 +32,5 @@ export default function tableMenuItems(
tooltip: dictionary.deleteTable,
icon: <TrashIcon />,
},
{
name: "separator",
},
{
name: "exportTable",
tooltip: dictionary.exportAsCSV,
label: "CSV",
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
icon: <DownloadIcon />,
},
];
}
-1
View File
@@ -77,7 +77,6 @@ export default function useDictionary() {
sortAsc: t("Sort ascending"),
sortDesc: t("Sort descending"),
table: t("Table"),
exportAsCSV: t("Export as CSV"),
toggleHeader: t("Toggle header"),
mathInline: t("Math inline (LaTeX)"),
mathBlock: t("Math block (LaTeX)"),
-50
View File
@@ -1,50 +0,0 @@
import React from "react";
import useUnmount from "./useUnmount";
type Props = {
/** Ref to the element that needs to be observed. */
ref: React.RefObject<HTMLElement>;
/** Duration to wait until it's considered as a hover event. */
duration: number;
};
/**
* Hook that will trigger the first time an element is hovered.
*
* @returns {boolean} hovered - Signals when an element is hovered by the user.
*/
const useHover = ({ ref, duration }: Props): boolean => {
const [hovered, setHovered] = React.useState(false);
const timer = React.useRef<number>();
const onMouseEnter = React.useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
}
timer.current = window.setTimeout(() => setHovered(true), duration);
}, [duration]);
const onMouseLeave = React.useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
}
}, []);
useUnmount(() => {
if (timer.current) {
clearTimeout(timer.current);
}
});
React.useEffect(() => {
if (ref.current) {
ref.current.onmouseenter = onMouseEnter;
ref.current.onmouseleave = onMouseLeave;
}
}, [ref, onMouseEnter, onMouseLeave]);
return hovered;
};
export default useHover;
+1 -1
View File
@@ -23,6 +23,6 @@ export default function useOnClickOutside(
[ref, callback]
);
useEventListener("pointerdown", listener, window, options);
useEventListener("mousedown", listener, window, options);
useEventListener("touchstart", listener, window, options);
}
+9 -2
View File
@@ -3,9 +3,16 @@ import useCurrentUser from "./useCurrentUser";
/**
* Returns the user's locale, or undefined if the user is not logged in.
*
* @param languageCode Whether to only return the language code
* @returns The user's locale, or undefined if the user is not logged in
*/
export default function useUserLocale() {
export default function useUserLocale(languageCode?: boolean) {
const user = useCurrentUser({ rejectOnEmpty: false });
return user?.language;
if (!user?.language) {
return undefined;
}
const { language } = user;
return languageCode ? language.split("_")[0] : language;
}
+6
View File
@@ -32,6 +32,12 @@ void PluginManager.loadPlugins();
initI18n(env.DEFAULT_LANGUAGE);
const element = window.document.getElementById("root");
history.listen(() => {
requestAnimationFrame(() =>
window.dispatchEvent(new Event("location-changed"))
);
});
if (env.SENTRY_DSN) {
initSentry(history);
}
-6
View File
@@ -29,8 +29,6 @@ import {
unstarCollection,
searchInCollection,
createTemplate,
archiveCollection,
restoreCollection,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
@@ -124,8 +122,6 @@ function CollectionMenu({
} catch (err) {
toast.error(err.message);
throw err;
} finally {
ev.target.value = "";
}
},
[history, collection.id, documents]
@@ -155,7 +151,6 @@ function CollectionMenu({
const canUserInTeam = usePolicy(team);
const items: MenuItem[] = React.useMemo(
() => [
actionToMenuItem(restoreCollection, context),
actionToMenuItem(starCollection, context),
actionToMenuItem(unstarCollection, context),
{
@@ -229,7 +224,6 @@ function CollectionMenu({
onClick: handleExport,
icon: <ExportIcon />,
},
actionToMenuItem(archiveCollection, context),
actionToMenuItem(searchInCollection, context),
{
type: "separator",
+41 -50
View File
@@ -15,7 +15,6 @@ import {
deleteCommentFactory,
resolveCommentFactory,
unresolveCommentFactory,
viewCommentReactionsFactory,
} from "~/actions/definitions/comments";
import useActionContext from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
@@ -67,55 +66,47 @@ function CommentMenu({
{...menu}
/>
</EventBoundary>
{menu.visible && (
<ContextMenu {...menu} aria-label={t("Comment options")}>
<Template
{...menu}
items={[
{
type: "button",
title: `${t("Edit")}`,
icon: <EditIcon />,
onClick: onEdit,
visible: can.update && !comment.isResolved,
},
actionToMenuItem(
resolveCommentFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
}),
context
),
actionToMenuItem(
unresolveCommentFactory({
comment,
onUnresolve: () => onUpdate({ resolved: false }),
}),
context
),
actionToMenuItem(
viewCommentReactionsFactory({
comment,
}),
context
),
{
type: "button",
icon: <CopyIcon />,
title: t("Copy link"),
onClick: handleCopyLink,
},
{
type: "separator",
},
actionToMenuItem(
deleteCommentFactory({ comment, onDelete }),
context
),
]}
/>
</ContextMenu>
)}
<ContextMenu {...menu} aria-label={t("Comment options")}>
<Template
{...menu}
items={[
{
type: "button",
title: `${t("Edit")}`,
icon: <EditIcon />,
onClick: onEdit,
visible: can.update,
},
actionToMenuItem(
resolveCommentFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
}),
context
),
actionToMenuItem(
unresolveCommentFactory({
comment,
onUnresolve: () => onUpdate({ resolved: false }),
}),
context
),
{
type: "button",
icon: <CopyIcon />,
title: t("Copy link"),
onClick: handleCopyLink,
},
{
type: "separator",
},
actionToMenuItem(
deleteCommentFactory({ comment, onDelete }),
context
),
]}
/>
</ContextMenu>
</>
);
}
+5 -6
View File
@@ -215,8 +215,8 @@ const MenuContent: React.FC<MenuContentProps> = ({
type: "button",
title: t("Restore"),
visible:
!!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive),
((document.isWorkspaceTemplate || !!collection) && can.restore) ||
!!can.unarchive,
onClick: (ev) => handleRestore(ev),
icon: <RestoreIcon />,
},
@@ -224,8 +224,9 @@ const MenuContent: React.FC<MenuContentProps> = ({
type: "submenu",
title: t("Restore"),
visible:
!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive) &&
!document.isWorkspaceTemplate &&
!collection &&
!!can.restore &&
restoreItems.length !== 0,
style: {
left: -170,
@@ -407,8 +408,6 @@ function DocumentMenu({
} catch (err) {
toast.error(err.message);
throw err;
} finally {
ev.target.value = "";
}
},
[history, collection, documents, document.id]
+2 -5
View File
@@ -4,10 +4,7 @@ import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import {
navigateToWorkspaceSettings,
logout,
} from "~/actions/definitions/navigation";
import { navigateToSettings, logout } from "~/actions/definitions/navigation";
import {
createTeam,
createTeamsList,
@@ -48,7 +45,7 @@ const OrganizationMenu: React.FC = ({ children }: Props) => {
createTeam,
desktopLoginTeam,
separator(),
navigateToWorkspaceSettings,
navigateToSettings,
logout,
],
[context]
+1 -4
View File
@@ -16,13 +16,10 @@ class ApiKey extends ParanoidModel {
@observable
expiresAt?: string;
/** Timestamp that the API key was last used. */
/** An optional datetime that the API key was last used at. */
@observable
lastActiveAt?: string;
/** The user ID that the API key belongs to. */
userId: string;
/** The plain text value of the API key, only available on creation. */
value: string;
-31
View File
@@ -80,18 +80,6 @@ export default class Collection extends ParanoidModel {
@observable
urlId: string;
/**
* The date and time the collection was archived.
*/
@observable
archivedAt: string;
/**
* User who archived the collection.
*/
@observable
archivedBy?: User;
/** Returns whether the collection is empty, or undefined if not loaded. */
@computed
get isEmpty(): boolean | undefined {
@@ -166,21 +154,6 @@ export default class Collection extends ParanoidModel {
.filter(Boolean);
}
@computed
get isArchived() {
return !!this.archivedAt;
}
@computed
get isDeleted() {
return !!this.deletedAt;
}
@computed
get isActive() {
return !this.isArchived && !this.isDeleted;
}
fetchDocuments = async (options?: { force: boolean }) => {
if (this.isFetching) {
return;
@@ -341,10 +314,6 @@ export default class Collection extends ParanoidModel {
@action
unstar = async () => this.store.unstar(this);
archive = () => this.store.archive(this);
restore = () => this.store.restore(this);
export = (format: FileOperationFormat, includeAttachments: boolean) =>
client.post("/collections.export", {
id: this.id,
+5 -178
View File
@@ -1,12 +1,8 @@
import { subSeconds } from "date-fns";
import invariant from "invariant";
import uniq from "lodash/uniq";
import { action, computed, observable } from "mobx";
import { computed, observable } from "mobx";
import { now } from "mobx-utils";
import { Pagination } from "@shared/constants";
import type { ProsemirrorData, ReactionSummary } from "@shared/types";
import type { ProsemirrorData } from "@shared/types";
import User from "~/models/User";
import { client } from "~/utils/ApiClient";
import Document from "./Document";
import Model from "./base/Model";
import Field from "./decorators/Field";
@@ -30,7 +26,7 @@ class Comment extends Model {
* The Prosemirror data representing the comment content
*/
@Field
@observable.shallow
@observable
data: ProsemirrorData;
/**
@@ -88,25 +84,6 @@ class Comment extends Model {
*/
resolvedById: string | null;
/**
* Active reactions for this comment.
*
* Note: This contains just the emoji with the associated user-ids.
*/
@observable
reactions: ReactionSummary[];
/**
* Denotes whether the user data for the active reactions are loaded.
*/
@observable
reactedUsersLoaded: boolean = false;
/**
* Denotes whether there is an in-flight request for loading reacted users.
*/
private reactedUsersLoading = false;
/**
* An array of users that are currently typing a reply in this comments thread.
*/
@@ -122,8 +99,8 @@ class Comment extends Model {
* Whether the comment is resolved
*/
@computed
public get isResolved(): boolean {
return !!this.resolvedAt || !!this.parentComment?.isResolved;
public get isResolved() {
return !!this.resolvedAt;
}
/**
@@ -147,156 +124,6 @@ class Comment extends Model {
public unresolve() {
return this.store.rootStore.comments.unresolve(this.id);
}
/**
* Add an emoji as a reaction to this comment.
*
* Optimistically updates the `reactions` cache and invokes the backend API.
*
* @param {Object} reaction - The reaction data.
* @param {string} reaction.emoji - The emoji to add as a reaction.
* @param {string} reaction.user - The user who added this reaction.
*/
@action
public addReaction = async ({
emoji,
user,
}: {
emoji: string;
user: User;
}) => {
this.updateReaction({ type: "add", emoji, user });
try {
await client.post("/comments.add_reaction", {
id: this.id,
emoji,
});
} catch {
this.updateReaction({ type: "remove", emoji, user });
}
};
/**
* Remove an emoji as a reaction from this comment.
*
* Optimistically updates the `reactions` cache and invokes the backend API.
*
* @param {Object} reaction - The reaction data.
* @param {string} reaction.emoji - The emoji to remove as a reaction.
* @param {string} reaction.user - The user who removed this reaction.
*/
@action
public removeReaction = async ({
emoji,
user,
}: {
emoji: string;
user: User;
}) => {
this.updateReaction({ type: "remove", emoji, user });
try {
await client.post("/comments.remove_reaction", {
id: this.id,
emoji,
});
} catch {
this.updateReaction({ type: "add", emoji, user });
}
};
/**
* Update the `reactions` cache.
*
* @param {Object} reaction - The reaction data.
* @param {string} reaction.type - The type of the action.
* @param {string} reaction.emoji - The emoji to update as a reaction.
* @param {string} reaction.user - The user who performed this action.
*/
@action
public updateReaction = ({
type,
emoji,
user,
}: {
type: "add" | "remove";
emoji: string;
user: User;
}) => {
const reaction = this.reactions.find((r) => r.emoji === emoji);
// Step 1: Update the reactions cache.
if (type === "add") {
if (!reaction) {
this.reactions.push({ emoji, userIds: [user.id] });
} else {
reaction.userIds = uniq([...reaction.userIds, user.id]);
}
} else {
if (reaction) {
reaction.userIds = reaction.userIds.filter((id) => id !== user.id);
}
if (reaction?.userIds.length === 0) {
this.reactions = this.reactions.filter(
(r) => r.emoji !== reaction.emoji
);
}
}
// Step 2: Add the user to the store.
this.store.rootStore.users.add(user);
};
/**
* Load the users for the active reactions.
*
*
* @param {Object} options - Options for loading the data.
* @param {string} options.limit - Per request limit for pagination.
*/
@action
loadReactedUsersData = async (
{ limit }: { limit: number } = { limit: Pagination.defaultLimit }
) => {
if (this.reactedUsersLoading || this.reactedUsersLoaded) {
return;
}
this.reactedUsersLoading = true;
try {
const fetchPage = async (offset: number = 0) => {
const res = await client.post("/reactions.list", {
commentId: this.id,
offset,
limit,
});
invariant(res?.data, "Data not available");
// @ts-expect-error reaction from server response
res.data.map((reaction) =>
this.store.rootStore.users.add(reaction.user)
);
return res.pagination;
};
const { total } = await fetchPage();
const pages = Math.ceil(total / limit);
const fetchPages = [];
for (let page = 1; page < pages; page++) {
fetchPages.push(fetchPage(page * limit));
}
await Promise.all(fetchPages);
this.reactedUsersLoaded = true;
} finally {
this.reactedUsersLoading = false;
}
};
}
export default Comment;
+7 -9
View File
@@ -28,7 +28,7 @@ import { settingsPath } from "~/utils/routeHelpers";
import Collection from "./Collection";
import Notification from "./Notification";
import View from "./View";
import ArchivableModel from "./base/ArchivableModel";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
@@ -38,7 +38,7 @@ type SaveOptions = JSONObject & {
autosave?: boolean;
};
export default class Document extends ArchivableModel {
export default class Document extends ParanoidModel {
static modelName = "Document";
constructor(fields: Record<string, any>, store: DocumentsStore) {
@@ -176,10 +176,7 @@ export default class Document extends ArchivableModel {
@observable
parentDocumentId: string | undefined;
/**
* Parent document that this is a child of, if any.
*/
@Relation(() => Document, { onArchive: "cascade" })
@Relation(() => Document)
parentDocument?: Document;
@observable
@@ -194,6 +191,9 @@ export default class Document extends ArchivableModel {
@observable
publishedAt: string | undefined;
@observable
archivedAt: string;
/**
* @deprecated Use path instead
*/
@@ -643,9 +643,7 @@ export default class Document extends ArchivableModel {
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const markdown = serializer.serialize(Node.fromJSON(schema, this.data), {
softBreak: true,
});
const markdown = serializer.serialize(Node.fromJSON(schema, this.data));
return markdown;
};
+2 -6
View File
@@ -1,9 +1,5 @@
import { computed, observable } from "mobx";
import {
FileOperationFormat,
FileOperationState,
FileOperationType,
} from "@shared/types";
import { FileOperationFormat, FileOperationType } from "@shared/types";
import { bytesToHumanReadable } from "@shared/utils/files";
import User from "./User";
import Model from "./base/Model";
@@ -14,7 +10,7 @@ class FileOperation extends Model {
id: string;
@observable
state: FileOperationState;
state: string;
name: string;
+1 -6
View File
@@ -11,7 +11,6 @@ import {
UserRole,
} from "@shared/types";
import type { NotificationSettings } from "@shared/types";
import { locales } from "@shared/utils/date";
import { client } from "~/utils/ApiClient";
import Document from "./Document";
import Group from "./Group";
@@ -40,7 +39,7 @@ class User extends ParanoidModel {
@Field
@observable
language: keyof typeof locales;
language: string;
@Field
@observable
@@ -50,10 +49,6 @@ class User extends ParanoidModel {
@observable
notificationSettings: NotificationSettings;
@Field
@observable
timezone?: string;
@observable
email: string;
-7
View File
@@ -1,7 +0,0 @@
import { observable } from "mobx";
import ParanoidModel from "./ParanoidModel";
export default abstract class ArchivableModel extends ParanoidModel {
@observable
archivedAt: string | null;
}
+1 -3
View File
@@ -40,7 +40,6 @@ export default abstract class Model {
* @returns A promise that resolves when loading is complete.
*/
async loadRelations(
this: Model,
options: { withoutPolicies?: boolean } = {}
): Promise<any> {
const relations = getRelationsForModelClass(
@@ -63,7 +62,7 @@ export default abstract class Model {
if ("fetch" in store) {
const id = this[properties.idKey];
if (id) {
promises.push(store.fetch(id as string));
promises.push(store.fetch(id));
}
}
}
@@ -146,7 +145,6 @@ export default abstract class Model {
if (key === "initialized") {
continue;
}
// @ts-expect-error TODO
this[key] = data[key];
} catch (error) {
Logger.warn(`Error setting ${key} on model`, error);
+2 -6
View File
@@ -3,21 +3,17 @@ import type Model from "../base/Model";
/** The behavior of a relationship on deletion */
type DeleteBehavior = "cascade" | "null" | "ignore";
/** The behavior of a relationship on archival */
type ArchiveBehavior = "cascade" | "null" | "ignore";
type RelationOptions<T = Model> = {
/** Whether this relation is required. */
required?: boolean;
/** Behavior of this model when relationship is deleted. */
onDelete?: DeleteBehavior | ((item: T) => DeleteBehavior);
/** Behavior of this model when relationship is archived. */
onArchive?: ArchiveBehavior | ((item: T) => ArchiveBehavior);
onDelete: DeleteBehavior | ((item: T) => DeleteBehavior);
};
type RelationProperties<T = Model> = {
/** The name of the property on the model that stores the ID of the relation. */
idKey: keyof T;
idKey: string;
/** A function that returns the class of the relation. */
relationClassResolver: () => typeof Model;
/** Options for the relation. */
+1 -1
View File
@@ -112,7 +112,7 @@ function ApiKeyNew({ onSubmit }: Props) {
value={expiryType}
options={expiryOptions}
onChange={handleExpiryTypeChange}
skipBodyScroll
skipBodyScroll={true}
/>
{expiryType === ExpiryType.Custom ? (
<ExpiryDatePicker
@@ -43,12 +43,8 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
memberships.fetchPage(options),
groupMemberships.fetchPage(options),
]);
if (users[PAGINATION_SYMBOL]) {
setUsersCount(users[PAGINATION_SYMBOL].total);
}
if (groups[PAGINATION_SYMBOL]) {
setGroupsCount(groups[PAGINATION_SYMBOL].total);
}
setUsersCount(users[PAGINATION_SYMBOL].total);
setGroupsCount(groups[PAGINATION_SYMBOL].total);
} finally {
setIsLoading(false);
}
@@ -1,29 +0,0 @@
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import ErrorBoundary from "~/components/ErrorBoundary";
import Notice from "~/components/Notice";
import Time from "~/components/Time";
type Props = {
collection: Collection;
};
export default function Notices({ collection }: Props) {
const { t } = useTranslation();
return (
<ErrorBoundary>
{collection.isArchived && !collection.isDeleted && (
<Notice icon={<ArchiveIcon />}>
{t("Archived by {{userName}}", {
userName: collection.archivedBy?.name ?? t("Unknown"),
})}
&nbsp;
<Time dateTime={collection.archivedAt} addSuffix />
</Notice>
)}
</ErrorBoundary>
);
}
+19 -46
View File
@@ -13,13 +13,11 @@ import {
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Search from "~/scenes/Search";
import { Action } from "~/components/Actions";
import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
import CollectionDescription from "~/components/CollectionDescription";
import Heading from "~/components/Heading";
import Icon, { IconTitleWrapper } from "~/components/Icon";
@@ -30,7 +28,6 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import { editCollection } from "~/actions/definitions/collections";
@@ -44,7 +41,6 @@ import Actions from "./components/Actions";
import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import Notices from "./components/Notices";
import ShareButton from "./components/ShareButton";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
@@ -136,9 +132,7 @@ function CollectionScene() {
centered={false}
textTitle={collection.name}
left={
collection.isArchived ? (
<CollectionBreadcrumb collection={collection} />
) : collection.isEmpty ? undefined : (
collection.isEmpty ? undefined : (
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
@@ -169,7 +163,6 @@ function CollectionScene() {
collectionId={collection.id}
>
<CenteredContent withStickyHeader>
<Notices collection={collection} />
<CollectionHeading>
<IconTitleWrapper>
{can.update ? (
@@ -199,28 +192,26 @@ function CollectionScene() {
<CollectionDescription collection={collection} />
<Documents>
{!collection.isArchived && (
<Tabs>
<Tab to={collectionPath(collection.path)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionPath(collection.path, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionPath(collection.path, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
)}
<Tabs>
<Tab to={collectionPath(collection.path)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionPath(collection.path, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionPath(collection.path, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
{collection.isEmpty ? (
<Empty collection={collection} />
) : !collection.isArchived ? (
) : (
<Switch>
<Route path={collectionPath(collection.path, "alphabetical")}>
<PaginatedDocumentList
@@ -288,24 +279,6 @@ function CollectionScene() {
/>
</Route>
</Switch>
) : (
<Switch>
<Route path={collectionPath(collection.path)} exact>
<PaginatedDocumentList
documents={documents.archivedInCollection(collection.id)}
fetch={documents.fetchPage}
heading={<Subheading sticky>{t("Documents")}</Subheading>}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: collection.sort.direction,
statusFilter: [StatusFilter.Archived],
}}
showParentDocuments
/>
</Route>
</Switch>
)}
</Documents>
</CenteredContent>
+1 -2
View File
@@ -172,8 +172,7 @@ function SharedDocumentScene(props: Props) {
}
}
// Note: `sharedTree` will be null when `includeChildDocuments` = false
if (response?.sharedTree === undefined) {
if (!response) {
return <Loading location={props.location} />;
}
@@ -1,4 +1,3 @@
import { observer } from "mobx-react";
import * as React from "react";
import { basicExtensions, withComments } from "@shared/editor/nodes";
import HardBreak from "@shared/editor/nodes/HardBreak";
@@ -37,4 +36,4 @@ const CommentEditor = (
);
};
export default observer(React.forwardRef(CommentEditor));
export default React.forwardRef(CommentEditor);
@@ -109,7 +109,6 @@ function CommentForm({
createdAt: new Date().toISOString(),
documentId,
data: draft,
reactions: [],
},
comments
);
@@ -145,7 +144,6 @@ function CommentForm({
parentCommentId: thread?.id,
documentId,
data: draft,
reactions: [],
},
comments
);
@@ -1,86 +0,0 @@
import queryString from "query-string";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import InputSelect from "~/components/InputSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import useQuery from "~/hooks/useQuery";
import { CommentSortType } from "~/types";
const CommentSortMenu = () => {
const { t } = useTranslation();
const location = useLocation();
const history = useHistory();
const user = useCurrentUser();
const params = useQuery();
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved
? "resolved"
: user.getPreference(UserPreference.SortCommentsByOrderInDocument)
? CommentSortType.OrderInDocument
: CommentSortType.MostRecent;
const handleSortTypeChange = (type: CommentSortType) => {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
type === CommentSortType.OrderInDocument
);
void user.save();
};
const showResolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
});
};
const showUnresolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
});
};
return (
<Select
style={{ margin: 0 }}
ariaLabel={t("Sort comments")}
value={value}
onChange={(ev) => {
if (ev === "resolved") {
showResolved();
} else {
handleSortTypeChange(ev as CommentSortType);
showUnresolved();
}
}}
borderOnHover
options={[
{ value: CommentSortType.MostRecent, label: t("Most recent") },
{ value: CommentSortType.OrderInDocument, label: t("Order in doc") },
{
divider: true,
value: "resolved",
label: t("Resolved"),
},
]}
/>
);
};
const Select = styled(InputSelect)`
color: ${s("textSecondary")};
`;
export default CommentSortMenu;
@@ -36,16 +36,12 @@ type Props = {
focused: boolean;
/** Whether the thread is displayed in a recessed/backgrounded state */
recessed: boolean;
/** Enable scroll for the comments container */
enableScroll: () => void;
/** Disable scroll for the comments container */
disableScroll: () => void;
};
function useTypingIndicator({
document,
comment,
}: Pick<Props, "document" | "comment">): [undefined, () => void] {
}: Omit<Props, "focused" | "recessed">): [undefined, () => void] {
const socket = React.useContext(WebsocketContext);
const setIsTyping = React.useMemo(
@@ -67,8 +63,6 @@ function CommentThread({
document,
recessed,
focused,
enableScroll,
disableScroll,
}: Props) {
const [focusedOnMount] = React.useState(focused);
const { editor } = useDocumentContext();
@@ -86,8 +80,6 @@ function CommentThread({
});
const can = usePolicy(document);
const canReply = can.comment && !thread.isResolved;
const highlightedCommentMarks = editor
?.getComments()
.filter((comment) => comment.id === thread.id);
@@ -100,8 +92,7 @@ function CommentThread({
useOnClickOutside(topRef, (event) => {
if (
focused &&
!(event.target as HTMLElement).classList.contains("comment") &&
event.defaultPrevented === false
!(event.target as HTMLElement).classList.contains("comment")
) {
history.replace({
search: location.search,
@@ -114,7 +105,7 @@ function CommentThread({
const handleClickThread = () => {
history.replace({
// Clear any commentId from the URL when explicitly focusing a thread
search: thread.isResolved ? "resolved=" : "",
search: "",
pathname: location.pathname.replace(/\/history$/, ""),
state: { commentId: thread.id },
});
@@ -199,8 +190,8 @@ function CommentThread({
<CommentThreadItem
highlightedText={index === 0 ? highlightedText : undefined}
comment={comment}
onDelete={editor?.removeComment}
onUpdate={editor?.updateComment}
onDelete={() => editor?.removeComment(comment.id)}
onUpdate={(attrs) => editor?.updateComment(comment.id, attrs)}
key={comment.id}
firstOfThread={index === 0}
lastOfThread={index === commentsInThread.length - 1 && !draft}
@@ -209,8 +200,6 @@ function CommentThread({
lastOfAuthor={lastOfAuthor}
previousCommentCreatedAt={commentsInThread[index - 1]?.createdAt}
dir={document.dir}
enableScroll={enableScroll}
disableScroll={disableScroll}
/>
);
})}
@@ -225,7 +214,7 @@ function CommentThread({
))}
<ResizingHeightContainer hideOverflow={false} ref={replyRef}>
{(focused || draft || commentsInThread.length === 0) && canReply && (
{(focused || draft || commentsInThread.length === 0) && can.comment && (
<Fade timing={100}>
<CommentForm
onSaveDraft={onSaveDraft}
@@ -243,7 +232,7 @@ function CommentThread({
</Fade>
)}
</ResizingHeightContainer>
{!focused && !recessed && !draft && canReply && (
{!focused && !recessed && !draft && can.comment && (
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}</Reply>
)}
</Thread>
@@ -1,5 +1,5 @@
import { differenceInMilliseconds } from "date-fns";
import { action } from "mobx";
import { toJS } from "mobx";
import { observer } from "mobx-react";
import { darken } from "polished";
import * as React from "react";
@@ -16,12 +16,9 @@ import Comment from "~/models/Comment";
import { Avatar } from "~/components/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import Flex from "~/components/Flex";
import ReactionList from "~/components/Reactions/ReactionList";
import ReactionPicker from "~/components/Reactions/ReactionPicker";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
import { hover } from "~/styles";
import CommentEditor from "./CommentEditor";
@@ -79,15 +76,11 @@ type Props = {
/** Whether the user can reply in the thread */
canReply: boolean;
/** Callback when the comment has been deleted */
onDelete?: (id: string) => void;
onDelete: () => void;
/** Callback when the comment has been updated */
onUpdate?: (id: string, attrs: { resolved: boolean }) => void;
onUpdate: (attrs: { resolved: boolean }) => void;
/** Text to highlight at the top of the comment */
highlightedText?: string;
/** Enable scroll for the comments container */
enableScroll: () => void;
/** Disable scroll for the comments container */
disableScroll: () => void;
};
function CommentThreadItem({
@@ -101,12 +94,10 @@ function CommentThreadItem({
onDelete,
onUpdate,
highlightedText,
enableScroll,
disableScroll,
}: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const [data, setData] = React.useState(comment.data);
const [forceRender, setForceRender] = React.useState(0);
const [data, setData] = React.useState(toJS(comment.data));
const showAuthor = firstOfAuthor;
const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt);
const showEdited =
@@ -116,62 +107,41 @@ function CommentThreadItem({
const [isEditing, setEditing, setReadOnly] = useBoolean();
const formRef = React.useRef<HTMLFormElement>(null);
const handleAddReaction = React.useCallback(
async (emoji: string) => {
await comment.addReaction({ emoji, user });
},
[comment, user]
);
const handleChange = (value: (asString: boolean) => ProsemirrorData) => {
setData(value(false));
};
const handleRemoveReaction = React.useCallback(
async (emoji: string) => {
await comment.removeReaction({ emoji, user });
},
[comment, user]
);
const handleUpdate = React.useCallback(
(attrs: { resolved: boolean }) => {
onUpdate?.(comment.id, attrs);
},
[comment.id, onUpdate]
);
const handleDelete = React.useCallback(() => {
onDelete?.(comment.id);
}, [comment.id, onDelete]);
const handleChange = React.useCallback(
(value: (asString: boolean) => ProsemirrorData) => {
setData(value(false));
},
[]
);
const handleSave = React.useCallback(() => {
const handleSave = () => {
formRef.current?.dispatchEvent(
new Event("submit", { cancelable: true, bubbles: true })
);
}, []);
};
const handleSubmit = action(async (event: React.FormEvent) => {
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
try {
setReadOnly();
comment.data = data;
await comment.save();
await comment.save({
data,
});
} catch (error) {
setEditing();
toast.error(t("Error updating comment"));
}
});
};
const handleCancel = () => {
setData(comment.data);
setData(toJS(comment.data));
setReadOnly();
setForceRender((i) => ++i);
};
React.useEffect(() => {
setData(toJS(comment.data));
setForceRender((i) => ++i);
}, [comment.data]);
return (
<Flex gap={8} align="flex-start" reverse={dir === "rtl"}>
{firstOfAuthor && (
@@ -216,9 +186,8 @@ function CommentThreadItem({
)}
<Body ref={formRef} onSubmit={handleSubmit}>
<StyledCommentEditor
key={String(isEditing)}
key={`${forceRender}`}
readOnly={!isEditing}
value={comment.data}
defaultValue={data}
onChange={handleChange}
onSave={handleSave}
@@ -234,43 +203,16 @@ function CommentThreadItem({
</ButtonSmall>
</Flex>
)}
{!!comment.reactions.length && (
<ReactionListContainer gap={6} align="center">
<ReactionList
model={comment}
onAddReaction={handleAddReaction}
onRemoveReaction={handleRemoveReaction}
picker={
!comment.isResolved ? (
<StyledReactionPicker
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
size={28}
/>
) : undefined
}
/>
</ReactionListContainer>
)}
</Body>
<EventBoundary>
{!isEditing && (
<Actions gap={4} dir={dir}>
{!comment.isResolved && (
<StyledReactionPicker
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
/>
)}
<StyledMenu
comment={comment}
onEdit={setEditing}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>
</Actions>
<Menu
comment={comment}
onEdit={setEditing}
onDelete={onDelete}
onUpdate={onUpdate}
dir={dir}
/>
)}
</EventBoundary>
</Bubble>
@@ -308,59 +250,21 @@ const Body = styled.form`
border-radius: 2px;
`;
const StyledMenu = styled(CommentMenu)`
color: ${s("textSecondary")};
svg {
fill: currentColor;
opacity: 0.5;
}
&: ${hover}, &[aria-expanded= "true"] {
background: ${s("backgroundQuaternary")};
svg {
opacity: 0.75;
}
}
`;
const StyledReactionPicker = styled(ReactionPicker)`
color: ${s("textSecondary")};
svg {
fill: currentColor;
opacity: 0.5;
}
&: ${hover}, &[aria-expanded= "true"] {
background: ${s("backgroundQuaternary")};
svg {
opacity: 0.75;
}
}
`;
const Actions = styled(Flex)<{ dir?: "rtl" | "ltr" }>`
const Menu = styled(CommentMenu)<{ dir?: "rtl" | "ltr" }>`
position: absolute;
left: ${(props) => (props.dir !== "rtl" ? "auto" : "4px")};
right: ${(props) => (props.dir === "rtl" ? "auto" : "4px")};
top: 4px;
opacity: 0;
transition: opacity 100ms ease-in-out;
background: ${s("backgroundSecondary")};
padding-left: 4px;
color: ${s("textSecondary")};
&:has(${StyledReactionPicker}[aria-expanded="true"], ${StyledMenu}[aria-expanded="true"]) {
&: ${hover}, &[aria-expanded= "true"] {
opacity: 1;
background: ${s("sidebarActiveBackground")};
}
`;
const ReactionListContainer = styled(Flex)`
margin-top: 6px;
`;
const Meta = styled(Text)`
margin-bottom: 2px;
@@ -382,7 +286,7 @@ export const Bubble = styled(Flex)<{
flex-grow: 1;
font-size: 16px;
color: ${s("text")};
background: ${s("backgroundSecondary")};
background: ${s("commentBackground")};
min-width: 2em;
margin-bottom: 1px;
padding: 8px 12px;
@@ -406,7 +310,7 @@ export const Bubble = styled(Flex)<{
margin-bottom: 0;
}
&: ${hover} ${Actions} {
&: ${hover} ${Menu} {
opacity: 1;
}
+73 -115
View File
@@ -1,49 +1,40 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import { ArrowIcon } from "outline-icons";
import { DoneIcon } from "outline-icons";
import queryString from "query-string";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { ProsemirrorData, UserPreference } from "@shared/types";
import ButtonSmall from "~/components/ButtonSmall";
import { useDocumentContext } from "~/components/DocumentContext";
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import styled, { css } from "styled-components";
import { ProsemirrorData } from "@shared/types";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import Tooltip from "~/components/Tooltip";
import useFocusedComment from "~/hooks/useFocusedComment";
import useKeyDown from "~/hooks/useKeyDown";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { CommentSortOption, CommentSortType } from "~/types";
import { bigPulse } from "~/styles/animations";
import CommentForm from "./CommentForm";
import CommentSortMenu from "./CommentSortMenu";
import CommentThread from "./CommentThread";
import Sidebar from "./SidebarLayout";
function Comments() {
const { ui, comments, documents } = useStores();
const user = useCurrentUser();
const { editor, isEditorInitialized } = useDocumentContext();
const { t } = useTranslation();
const location = useLocation();
const history = useHistory();
const match = useRouteMatch<{ documentSlug: string }>();
const params = useQuery();
// We need to control scroll behaviour when reaction picker is opened / closed.
const [scrollable, enableScroll, disableScroll] = useBoolean(true);
const [pulse, setPulse] = React.useState(false);
const document = documents.getByUrl(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const prevThreadCount = React.useRef(0);
const isAtBottom = React.useRef(true);
const [showJumpToRecentBtn, setShowJumpToRecentBtn] = React.useState(false);
useKeyDown("Escape", () => document && ui.collapseComments(document?.id));
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
@@ -51,79 +42,71 @@ function Comments() {
undefined
);
const sortOption: CommentSortOption = user.getPreference(
UserPreference.SortCommentsByOrderInDocument
)
? {
type: CommentSortType.OrderInDocument,
referencedCommentIds: editor?.getComments().map((c) => c.id) ?? [],
}
: { type: CommentSortType.MostRecent };
const viewingResolved = params.get("resolved") === "";
const threads = !document
? []
: viewingResolved
? comments.resolvedThreadsInDocument(document.id, sortOption)
: comments.unresolvedThreadsInDocument(document.id, sortOption);
const hasComments = threads.length > 0;
const scrollToBottom = () => {
if (scrollableRef.current) {
scrollableRef.current.scrollTo({
top: scrollableRef.current.scrollHeight,
});
}
};
const handleScroll = () => {
const BUFFER_PX = 50;
if (scrollableRef.current) {
const sh = scrollableRef.current.scrollHeight;
const st = scrollableRef.current.scrollTop;
const ch = scrollableRef.current.clientHeight;
isAtBottom.current = Math.abs(sh - (st + ch)) <= BUFFER_PX;
if (isAtBottom.current) {
setShowJumpToRecentBtn(false);
}
}
};
const resolvedThreads = document
? comments.resolvedThreadsInDocument(document.id)
: [];
const resolvedThreadsCount = resolvedThreads.length;
React.useEffect(() => {
// Handles: 1. on refresh 2. when switching sort setting
const readyToDisplay = Boolean(document && isEditorInitialized);
if (readyToDisplay && sortOption.type === CommentSortType.MostRecent) {
scrollToBottom();
}
}, [sortOption.type, document, isEditorInitialized]);
setPulse(true);
const timeout = setTimeout(() => setPulse(false), 250);
React.useEffect(() => {
setShowJumpToRecentBtn(false);
if (sortOption.type === CommentSortType.MostRecent) {
const commentsAdded = threads.length > prevThreadCount.current;
if (commentsAdded) {
if (isAtBottom.current) {
scrollToBottom(); // Remain pinned to bottom on new comments
} else {
setShowJumpToRecentBtn(true);
}
}
}
prevThreadCount.current = threads.length;
}, [sortOption.type, threads.length]);
return () => {
clearTimeout(timeout);
setPulse(false);
};
}, [resolvedThreadsCount]);
if (!document || !isEditorInitialized) {
if (!document) {
return null;
}
const threads = viewingResolved
? resolvedThreads
: comments.unresolvedThreadsInDocument(document.id);
const hasComments = threads.length > 0;
const toggleViewingResolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: viewingResolved ? undefined : "",
}),
pathname: location.pathname,
});
};
return (
<Sidebar
title={
<Flex align="center" justify="space-between" auto>
<span>{t("Comments")}</span>
<CommentSortMenu />
{viewingResolved ? (
<React.Fragment key="resolved">
<span>{t("Resolved comments")}</span>
<Tooltip delay={500} content={t("View comments")}>
<ResolvedButton
neutral
borderOnHover
icon={<DoneIcon />}
onClick={toggleViewingResolved}
/>
</Tooltip>
</React.Fragment>
) : (
<React.Fragment>
<span>{t("Comments")}</span>
<Tooltip delay={250} content={t("View resolved comments")}>
<ResolvedButton
neutral
borderOnHover
icon={<DoneIcon outline />}
onClick={toggleViewingResolved}
$pulse={pulse}
/>
</Tooltip>
</React.Fragment>
)}
</Flex>
}
onClose={() => ui.collapseComments(document?.id)}
@@ -134,10 +117,6 @@ function Comments() {
bottomShadow={!focusedComment}
hiddenScrollbars
topShadow
overflow={scrollable ? "auto" : "hidden"}
style={{ overflowX: "hidden" }}
ref={scrollableRef}
onScroll={handleScroll}
>
<Wrapper $hasComments={hasComments}>
{hasComments ? (
@@ -148,8 +127,6 @@ function Comments() {
document={document}
recessed={!!focusedComment && focusedComment.id !== thread.id}
focused={focusedComment?.id === thread.id}
enableScroll={enableScroll}
disableScroll={disableScroll}
/>
))
) : (
@@ -161,16 +138,6 @@ function Comments() {
</PositionedEmpty>
</NoComments>
)}
{showJumpToRecentBtn && (
<Fade>
<JumpToRecent onClick={scrollToBottom}>
<Flex align="center">
{t("New comments")}&nbsp;
<ArrowDownIcon size={20} />
</Flex>
</JumpToRecent>
</Fade>
)}
</Wrapper>
</Scrollable>
<AnimatePresence initial={false}>
@@ -191,6 +158,14 @@ function Comments() {
);
}
const ResolvedButton = styled(Button)<{ $pulse: boolean }>`
${(props) =>
props.$pulse &&
css`
animation: ${bigPulse} 250ms 1;
`}
`;
const PositionedEmpty = styled(Empty)`
position: absolute;
top: calc(50vh - 30px);
@@ -203,27 +178,10 @@ const NoComments = styled(Flex)`
`;
const Wrapper = styled.div<{ $hasComments: boolean }>`
padding-bottom: ${(props) => (props.$hasComments ? "50vh" : "0")};
height: ${(props) => (props.$hasComments ? "auto" : "100%")};
`;
const JumpToRecent = styled(ButtonSmall)`
position: sticky;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
opacity: 0.8;
border-radius: 12px;
padding: 0 4px;
&:hover {
opacity: 1;
}
`;
const ArrowDownIcon = styled(ArrowIcon)`
transform: rotate(90deg);
`;
const NewCommentForm = styled(CommentForm)<{ dir?: "ltr" | "rtl" }>`
padding: 12px;
padding-right: ${(props) => (props.dir !== "rtl" ? "18px" : "12px")};
+2 -18
View File
@@ -93,6 +93,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
...rest
} = props;
const can = usePolicy(document);
const iconColor = document.color ?? (last(colorPalette) as string);
const childRef = React.useRef<HTMLDivElement>(null);
const focusAtStart = React.useCallback(() => {
@@ -149,7 +150,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
documentId: props.id,
createdAt: new Date(),
createdById,
reactions: [],
},
comments
);
@@ -175,11 +175,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
[comments]
);
const {
setEditor,
setEditorInitialized,
updateState: updateDocState,
} = useDocumentContext();
const { setEditor, updateState: updateDocState } = useDocumentContext();
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
@@ -193,16 +189,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
[childOffsetHeight]
);
const handleInit = React.useCallback(
() => setEditorInitialized(true),
[setEditorInitialized]
);
const handleDestroy = React.useCallback(
() => setEditorInitialized(false),
[setEditorInitialized]
);
return (
<Flex auto column>
<DocumentTitle
@@ -255,8 +241,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
? handleRemoveComment
: undefined
}
onInit={handleInit}
onDestroy={handleDestroy}
onChange={updateDocState}
extensions={extensions}
editorStyle={editorStyle}
+2 -2
View File
@@ -41,7 +41,7 @@ import DocumentMenu from "~/menus/DocumentMenu";
import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu";
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
import TemplatesMenu from "~/menus/TemplatesMenu";
import { altDisplay, metaDisplay } from "~/utils/keyboard";
import { metaDisplay } from "~/utils/keyboard";
import { documentEditPath } from "~/utils/routeHelpers";
import ObservingBanner from "./ObservingBanner";
import PublicBreadcrumb from "./PublicBreadcrumb";
@@ -124,7 +124,7 @@ function DocumentHeader({
? t("Show contents")
: `${t("Show contents")} (${t("available when headings are added")})`
}
shortcut={`ctrl+${altDisplay}+h`}
shortcut="ctrl+alt+h"
delay={250}
placement="bottom"
>

Some files were not shown because too many files have changed in this diff Show More