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
716 changed files with 14241 additions and 24853 deletions
+8 -9
View File
@@ -4,6 +4,12 @@ defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
resource_class: large
environment:
NODE_ENV: test
@@ -72,14 +78,6 @@ jobs:
test-server:
<<: *defaults
parallelism: 3
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
steps:
- checkout
- restore_cache:
@@ -110,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: |
+37
View File
@@ -0,0 +1,37 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots or videos to help explain your problem.
**Outline (please complete the following information):**
- Install: [getoutline.com or self hosted]
- Version: [commit sha if self hosted]
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Mobile (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
-63
View File
@@ -1,63 +0,0 @@
name: Bug report
description: File a bug to help us improve
labels: ["bug"]
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: checkboxes
attributes:
label: This is not related to configuring Outline
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
options:
- label: The issue is not related to self-hosting config
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: false
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
1. With this config...
1. Run '...'
1. See error...
validations:
required: false
- type: textarea
attributes:
label: Environment
description: |
examples:
- **Outline**: Outline 0.80.0
- **Browser**: Safari
value: |
- Outline:
- Browser:
render: markdown
validations:
required: false
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
-13
View File
@@ -13,16 +13,3 @@ updates:
update-types: ["version-update:semver-major"]
schedule:
interval: "weekly"
groups:
babel:
patterns:
- "@babel/*"
sentry:
patterns:
- "@sentry/*"
fortawesome:
patterns:
- "@fortawesome/*"
aws:
patterns:
- "@aws-sdk/*"
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.81.0
Licensed Work: Outline 0.80.2
The Licensed Work is (c) 2024 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2028-11-11
Change Date: 2028-09-26
Change License: Apache License, Version 2.0
+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",
+3 -36
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";
@@ -53,13 +52,9 @@ export const resolveCommentFactory = ({
perform: async ({ t }) => {
await comment.resolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
state: null,
});
onResolve();
@@ -85,39 +80,11 @@ export const unresolveCommentFactory = ({
perform: async () => {
await comment.unresolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
state: null,
});
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} />,
});
},
});
+14 -67
View File
@@ -28,25 +28,23 @@ import {
EyeIcon,
PadlockIcon,
GlobeIcon,
LogoutIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import {
ExportContentType,
TeamPreference,
NavigationNode,
} from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import UserMembership from "~/models/UserMembership";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import DuplicateDialog from "~/components/DuplicateDialog";
import Icon from "~/components/Icon";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -131,30 +129,11 @@ export const createDocumentFromTemplate = createAction({
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({
currentTeamId,
activeCollectionId,
activeDocumentId,
stores,
}) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (
!currentTeamId ||
!document?.isTemplate ||
!!document?.isDraft ||
!!document?.isDeleted
) {
return false;
}
if (activeCollectionId) {
return stores.policies.abilities(activeCollectionId).createDocument;
}
return stores.policies.abilities(currentTeamId).createDocument;
},
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
!!stores.documents.get(activeDocumentId)?.template &&
stores.policies.abilities(currentTeamId).createDocument,
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
history.push(
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
@@ -379,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;
}
@@ -389,6 +370,8 @@ export const shareDocument = createAction({
content: (
<SharePopover
document={document}
share={share}
sharedParent={sharedParent}
onRequestClose={stores.dialogs.closeAllModals}
visible
/>
@@ -562,7 +545,7 @@ export const duplicateDocument = createAction({
stores.dialogs.openModal({
title: t("Copy document"),
content: (
<DocumentCopy
<DuplicateDialog
document={document}
onSubmit={(response) => {
stores.dialogs.closeAllModals();
@@ -732,6 +715,7 @@ export const importDocument = createAction({
history.push(document.url);
} catch (err) {
toast.error(err.message);
throw err;
}
};
@@ -1053,7 +1037,7 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.toggleComments();
stores.ui.toggleComments(activeDocumentId);
},
});
@@ -1139,42 +1123,6 @@ export const toggleViewerInsights = createAction({
},
});
export const leaveDocument = createAction({
name: ({ t }) => t("Leave document"),
analyticsName: "Leave document",
section: ActiveDocumentSection,
icon: <LogoutIcon />,
visible: ({ currentUserId, activeDocumentId, stores }) => {
const membership = stores.userMemberships.orderedData.find(
(m) => m.documentId === activeDocumentId && m.userId === currentUserId
);
return !!membership;
},
perform: async ({ t, location, currentUserId, activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
try {
if (document && location.pathname.startsWith(document.path)) {
history.push(homePath());
}
await stores.userMemberships.delete({
documentId: activeDocumentId,
userId: currentUserId,
} as UserMembership);
toast.success(t("You have left the shared document"));
} catch (err) {
toast.error(t("Could not leave document"));
}
},
});
export const rootDocumentActions = [
openDocument,
archiveDocument,
@@ -1193,7 +1141,6 @@ export const rootDocumentActions = [
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
-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",
-4
View File
@@ -13,8 +13,6 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
const activeDocument = stores.documents.active;
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
@@ -36,8 +34,6 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const UserSection = ({ t }: ActionContext) => t("People");
UserSection.priority = 0.5;
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
+1
View File
@@ -31,6 +31,7 @@ const Actions = styled(Flex)`
left: 0;
border-radius: 3px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
padding: 12px;
backdrop-filter: blur(20px);
+1 -2
View File
@@ -5,7 +5,6 @@ import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
@@ -33,7 +32,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to={logoutPath()} />;
return <Redirect to="/" />;
};
export default observer(Authenticated);
+1 -1
View File
@@ -94,7 +94,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
!showHistory &&
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded &&
ui.commentsExpanded.includes(ui.activeDocumentId) &&
team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
+12 -15
View File
@@ -8,16 +8,18 @@ import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import { MenuInternalLink } from "~/types";
type Props = React.PropsWithChildren<{
type Props = {
items: MenuInternalLink[];
max?: number;
highlightFirstItem?: boolean;
}>;
};
function Breadcrumb(
{ items, highlightFirstItem, children, max = 2 }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
function Breadcrumb({
items,
highlightFirstItem,
children,
max = 2,
}: React.PropsWithChildren<Props>) {
const totalItems = items.length;
const topLevelItems: MenuInternalLink[] = [...items];
let overflowItems;
@@ -35,13 +37,9 @@ function Breadcrumb(
}
return (
<Flex justify="flex-start" align="center" ref={ref}>
<Flex justify="flex-start" align="center">
{topLevelItems.map((item, index) => (
<React.Fragment
key={
(typeof item.to === "string" ? item.to : item.to.pathname) || index
}
>
<React.Fragment key={String(item.to) || index}>
{item.icon}
{item.to ? (
<Item
@@ -69,8 +67,6 @@ const Slash = styled(GoToIcon)`
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
${ellipsis()}
${undraggableOnDesktop()}
display: flex;
flex-shrink: 1;
min-width: 0;
@@ -80,6 +76,7 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
height: 24px;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
${undraggableOnDesktop()}
svg {
flex-shrink: 0;
@@ -90,4 +87,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
}
`;
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
export default Breadcrumb;
+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);
+2 -4
View File
@@ -18,8 +18,6 @@ import useStores from "~/hooks/useStores";
type Props = {
/** The document to display live collaborators for */
document: Document;
/** The maximum number of collaborators to display, defaults to 6 */
limit?: number;
};
/**
@@ -27,7 +25,6 @@ type Props = {
* and presence status.
*/
function Collaborators(props: Props) {
const { limit = 6 } = props;
const { t } = useTranslation();
const user = useCurrentUser();
const currentUserId = user?.id;
@@ -78,6 +75,8 @@ function Collaborators(props: Props) {
placement: "bottom-end",
});
const limit = 8;
return (
<>
<PopoverDisclosure {...popover}>
@@ -89,7 +88,6 @@ function Collaborators(props: Props) {
>
<Facepile
limit={limit}
overflow={collaborators.length - limit}
users={collaborators}
renderAvatar={(collaborator) => {
const isPresent = presentIds.includes(collaborator.id);
+1 -1
View File
@@ -3,7 +3,6 @@ import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
@@ -12,6 +11,7 @@ import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch";
-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 />;
};
+2 -1
View File
@@ -201,6 +201,7 @@ const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
transition: ${s("backgroundTransition")};
&:after {
content: "";
@@ -225,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
@@ -1,6 +1,6 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import Icon from "~/components/Icon";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
@@ -1,12 +1,8 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import Icon from "~/components/Icon";
import { createAction } from "~/actions";
import {
ActiveCollectionSection,
DocumentSection,
TeamSection,
} from "~/actions/sections";
import { DocumentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -15,42 +11,26 @@ const useTemplatesAction = () => {
const { documents } = useStores();
React.useEffect(() => {
void documents.fetchAllTemplates();
void documents.fetchTemplates();
}, [documents]);
const actions = React.useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
documents.templatesAlphabetical.map((item) =>
createAction({
name: template.titleWithDefault,
name: item.titleWithDefault,
analyticsName: "New document",
section: template.isWorkspaceTemplate
? TeamSection
: ActiveCollectionSection,
icon: template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
section: DocumentSection,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<NewDocumentIcon />
),
keywords: "create",
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (activeCollectionId) {
return (
stores.policies.abilities(activeCollectionId).createDocument &&
(template.collectionId === activeCollectionId ||
template.isWorkspaceTemplate)
);
}
return (
!!currentTeamId &&
stores.policies.abilities(currentTeamId).createDocument &&
template.isWorkspaceTemplate
);
},
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(
newDocumentPath(template.collectionId ?? activeCollectionId, {
templateId: template.id,
newDocumentPath(item.collectionId ?? activeCollectionId, {
templateId: item.id,
}),
{
sidebarContext,
@@ -69,15 +49,9 @@ const useTemplatesAction = () => {
placeholder: ({ t }) => t("Choose a template"),
section: DocumentSection,
icon: <ShapesIcon />,
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (activeCollectionId) {
return stores.policies.abilities(activeCollectionId).createDocument;
}
return (
!!currentTeamId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
visible: ({ currentTeamId, stores }) =>
!!currentTeamId &&
stores.policies.abilities(currentTeamId).createDocument,
children: () => actions,
}),
[actions]
+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({
+3 -6
View File
@@ -8,8 +8,8 @@ import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
type Props = {
/** Callback when the dialog is submitted. Return false to prevent closing. */
onSubmit: () => Promise<void | boolean> | void;
/** Callback when the dialog is submitted */
onSubmit: () => Promise<void> | void;
/** Text to display on the submit button */
submitText?: string;
/** Text to display while the form is saving */
@@ -38,10 +38,7 @@ const ConfirmationDialog: React.FC<Props> = ({
ev.preventDefault();
setIsSaving(true);
try {
const res = await onSubmit();
if (res === false) {
return;
}
await onSubmit();
dialogs.closeAllModals();
} catch (err) {
toast.error(err.message);
+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" ||
+1
View File
@@ -182,6 +182,7 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
const Content = styled.span`
background: ${s("background")};
transition: ${s("backgroundTransition")};
color: ${s("text")};
-webkit-text-fill-color: ${s("text")};
outline: none;
+1 -3
View File
@@ -23,7 +23,7 @@ type Props = {
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactNode;
icon?: React.ReactElement;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
@@ -109,8 +109,6 @@ const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = {
+16
View File
@@ -262,6 +262,22 @@ export const Position = styled.div`
transition-property: outline-width;
transition-duration: 0;
outline: none;
&:after {
content: "";
position: absolute;
top: 1px;
left: 1px;
right: 1px;
bottom: 1px;
pointer-events: none;
border-radius: 4px;
outline-color: ${s("accent")};
outline-width: initial;
outline-offset: -1px;
outline-style: solid;
}
}
/*
+18 -20
View File
@@ -3,16 +3,20 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import {
archivePath,
collectionPath,
settingsPath,
trashPath,
} from "~/utils/routeHelpers";
type Props = {
children?: React.ReactNode;
@@ -53,14 +57,14 @@ function useCategory(document: Document): MenuInternalLink | null {
return null;
}
function DocumentBreadcrumb(
{ document, children, onlyText }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const DocumentBreadcrumb: React.FC<Props> = ({
document,
children,
onlyText,
}: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
const sidebarContext = useLocationSidebarContext();
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -77,10 +81,7 @@ function DocumentBreadcrumb(
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: {
pathname: collection.path,
state: { sidebarContext },
},
to: collectionPath(collection.path),
};
} else if (document.isCollectionDeleted) {
collectionNode = {
@@ -114,14 +115,11 @@ function DocumentBreadcrumb(
) : (
node.title
),
to: {
pathname: node.url,
state: { sidebarContext },
},
to: node.url,
});
});
return output;
}, [path, category, sidebarContext, collectionNode]);
}, [path, category, collectionNode]);
if (!collections.isLoaded) {
return null;
@@ -142,11 +140,11 @@ function DocumentBreadcrumb(
}
return (
<Breadcrumb items={items} ref={ref} highlightFirstItem>
<Breadcrumb items={items} highlightFirstItem>
{children}
</Breadcrumb>
);
}
};
const StyledIcon = styled(Icon)`
margin-right: 2px;
@@ -162,4 +160,4 @@ const SmallSlash = styled(GoToIcon)`
opacity: 0.5;
`;
export default observer(React.forwardRef(DocumentBreadcrumb));
export default observer(DocumentBreadcrumb);
+13 -35
View File
@@ -1,25 +1,24 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { subDays } from "date-fns";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import Squircle from "@shared/components/Squircle";
import { s, hover, ellipsis } from "@shared/styles";
import { s, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { useTextStats } from "~/hooks/useTextStats";
import { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -40,7 +39,6 @@ function DocumentCard(props: Props) {
const { collections } = useStores();
const theme = useTheme();
const { document, pin, canUpdatePin, isDraggable } = props;
const pinnedToHome = React.useRef(!pin?.collectionId).current;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -72,10 +70,6 @@ function DocumentCard(props: Props) {
[pin]
);
// If the document was updated within the last 7 days, show a timestamp instead of reading time
const isRecentlyUpdated =
new Date(document.updatedAt) > subDays(new Date(), 7);
return (
<Reorderable
ref={setNodeRef}
@@ -128,13 +122,13 @@ function DocumentCard(props: Props) {
<Squircle
color={
collection?.color ??
(pinnedToHome ? theme.slateLight : theme.slateDark)
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
}
>
{collection?.icon &&
collection?.icon !== "letter" &&
collection?.icon !== "collection" &&
pinnedToHome ? (
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
) : (
<DocumentIcon color="white" />
@@ -148,14 +142,13 @@ function DocumentCard(props: Props) {
: document.titleWithDefault}
</Heading>
<DocumentMeta size="xsmall">
{isRecentlyUpdated ? (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
) : (
<ReadingTime document={document} />
)}
<Clock size={18} />
<Time
dateTime={document.updatedAt}
tooltipDelay={500}
addSuffix
shorten
/>
</DocumentMeta>
</div>
</Content>
@@ -176,21 +169,6 @@ function DocumentCard(props: Props) {
);
}
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
const DocumentSquircle = ({
icon,
color,
-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();
-149
View File
@@ -1,149 +0,0 @@
import flatten from "lodash/flatten";
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { flattenTree } from "~/utils/tree";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
document: Document;
onSubmit: (documents: Document[]) => void;
};
function DocumentCopy({ document, onSubmit }: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const collectionTrees = useCollectionTrees();
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
const [recursive, setRecursive] = React.useState<boolean>(true);
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
null
);
const items = React.useMemo(() => {
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const copy = async () => {
if (!selectedPath) {
toast.message(t("Select a location to copy"));
return;
}
try {
const result = await document.duplicate({
publish,
recursive,
title: document.title,
collectionId: selectedPath.collectionId,
...(selectedPath.type === "document"
? { parentDocumentId: selectedPath.id }
: {}),
});
toast.success(t("Document copied"));
onSubmit(result);
} catch (err) {
toast.error(t("Couldnt copy the document, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={copy}
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
<OptionsContainer>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
</>
)}
</OptionsContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
values={{ location: selectedPath.title }}
components={{ em: <strong /> }}
/>
) : (
t("Select a location to copy")
)}
</StyledText>
<Button disabled={!selectedPath} onClick={copy}>
{t("Copy")}
</Button>
</Footer>
</FlexContainer>
);
}
const OptionsContainer = styled.div`
margin: 16px 0 8px 0;
padding-left: 24px;
padding-right: 24px;
`;
export default observer(DocumentCopy);
+7 -29
View File
@@ -14,32 +14,32 @@ import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
import { ancestors, descendants } from "~/utils/tree";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
onSubmit: () => void;
/** A side-effect of item selection */
onSelect: (item: NavigationNode | null) => void;
/** Items to be shown in explorer */
items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
};
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -47,25 +47,12 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
() => {
const node =
defaultValue && items.find((item) => item.id === defaultValue);
return node || null;
}
null
);
const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
const node = items.find((item) => item.id === defaultValue);
if (node) {
return ancestors(node).map((node) => node.id);
}
}
return [];
});
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
const [itemRefs, setItemRefs] = React.useState<
React.RefObject<HTMLSpanElement>[]
>([]);
@@ -107,15 +94,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
React.useEffect(() => {
if (defaultValue && selectedNode && listRef) {
const index = nodes.findIndex((node) => node.id === selectedNode.id);
if (index > 0) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, []);
function getNodes() {
function includeDescendants(item: NavigationNode): NavigationNode[] {
return expandedNodes.includes(item.id)
+8 -13
View File
@@ -9,22 +9,21 @@ import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import Icon from "@shared/components/Icon";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
type Props = {
document: Document;
@@ -51,7 +50,6 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
let itemRef: React.Ref<HTMLAnchorElement> =
@@ -80,12 +78,6 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const sidebarContext = determineSidebarContext({
document,
user,
currentContext: locationSidebarContext,
});
return (
<DocumentLink
ref={itemRef}
@@ -97,7 +89,6 @@ function DocumentListItem(
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
@@ -120,7 +111,11 @@ function DocumentListItem(
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Tooltip
content={t("Only visible to you")}
delay={500}
placement="top"
>
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
+2 -6
View File
@@ -185,9 +185,9 @@ const DocumentMeta: React.FC<Props> = ({
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
<Strong>
<strong>
<DocumentBreadcrumb document={document} onlyText />
</Strong>
</strong>
</span>
)}
{showParentDocuments && nestedDocumentsCount > 0 && (
@@ -210,10 +210,6 @@ const DocumentMeta: React.FC<Props> = ({
);
};
const Strong = styled.strong`
font-weight: 550;
`;
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")};
+97
View File
@@ -0,0 +1,97 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { DocumentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input from "./Input";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
document: Document;
onSubmit: (documents: Document[]) => void;
};
function DuplicateDialog({ document, onSubmit }: Props) {
const { t } = useTranslation();
const defaultTitle = t(`Copy of {{ documentName }}`, {
documentName: document.title,
});
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
const [recursive, setRecursive] = React.useState<boolean>(true);
const [title, setTitle] = React.useState<string>(defaultTitle);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const handleTitleChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setTitle(ev.target.value);
},
[]
);
const handleSubmit = async () => {
const result = await document.duplicate({
publish,
recursive,
title,
});
onSubmit(result);
};
return (
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Duplicate")}>
<Input
autoFocus
autoSelect
name="title"
label={t("Title")}
onChange={handleTitleChange}
maxLength={DocumentValidation.maxTitleLength}
defaultValue={defaultTitle}
/>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
</>
)}
</ConfirmationDialog>
);
}
export default observer(DuplicateDialog);
+82 -11
View File
@@ -1,4 +1,6 @@
import deburr from "lodash/deburr";
import difference from "lodash/difference";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
@@ -7,7 +9,10 @@ import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import { AttachmentPreset } from "@shared/types";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
@@ -17,8 +22,12 @@ import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
import Icon from "./Icon";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -41,13 +50,76 @@ export type Props = Optional<
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
props;
const { comments } = useStores();
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const { comments, documents } = useStores();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
const previousCommentIds = React.useRef<string[]>();
const handleSearchLink = React.useCallback(
async (term: string) => {
if (isInternalUrl(term)) {
// search for exact internal document
const slug = parseDocumentSlug(term);
if (!slug) {
return [];
}
try {
const document = await documents.fetch(slug);
const time = dateToRelative(Date.parse(document.updatedAt), {
addSuffix: true,
shorten: true,
locale,
});
return [
{
title: document.title,
subtitle: `Updated ${time}`,
url: document.url,
icon: document.icon ? (
<Icon
value={document.icon}
color={document.color ?? undefined}
/>
) : undefined,
},
];
} catch (error) {
// NotFoundError could not find document for slug
if (!(error instanceof NotFoundError)) {
throw error;
}
}
}
// default search for anything that doesn't look like a URL
const results = await documents.searchTitles(term);
return sortBy(
results.map(({ document }) => ({
title: document.title,
subtitle: <DocumentBreadcrumb document={document} onlyText />,
url: document.url,
icon: document.icon ? (
<Icon value={document.icon} color={document.color ?? undefined} />
) : undefined,
})),
(document) =>
deburr(document.title)
.toLowerCase()
.startsWith(deburr(term).toLowerCase())
? -1
: 1
);
},
[locale, documents]
);
const handleUploadFile = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
@@ -183,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}
@@ -191,19 +262,19 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.editorStyle?.paddingBottom &&
(!props.readOnly || props.shareId) && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
{props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding
onClick={focusAtEnd}
onDrop={handleDrop}
onDragOver={handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
</>
</ErrorBoundary>
);
-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;
+5 -8
View File
@@ -13,21 +13,21 @@ import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Event from "~/models/Event";
import { Avatar } from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Time from "~/components/Time";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import { hover } from "~/styles";
import Logger from "~/utils/Logger";
import { documentHistoryPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
event: Event<Document>;
event: Event;
latest?: boolean;
};
@@ -35,7 +35,6 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const opts = {
userName: event.actor.name,
};
@@ -67,10 +66,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
);
to = {
pathname: documentHistoryPath(document, event.modelId || "latest"),
state: {
sidebarContext,
retainScrollPosition: true,
},
state: { retainScrollPosition: true },
};
break;
@@ -144,6 +140,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
title={
<Time
dateTime={event.createdAt}
tooltipDelay={500}
format={{
en_US: "MMM do, h:mm a",
fr_FR: "'Le 'd MMMM 'à' H:mm",
+2 -2
View File
@@ -46,7 +46,7 @@ const FilterOptions = ({
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const menu = useMenuState({
modal: false,
modal: true,
});
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
@@ -229,7 +229,7 @@ const SearchInput = styled(Input)`
${Outline} {
border: none;
border-radius: 0;
border-bottom: 1px solid ${s("divider")};
border-bottom: 1px solid ${s("inputBorder")};
background: ${s("menuBackground")};
}
+89
View File
@@ -0,0 +1,89 @@
import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import { s } from "@shared/styles";
import Group from "~/models/Group";
import GroupMembership from "~/models/GroupMembership";
import GroupMembers from "~/scenes/GroupMembers";
import Facepile from "~/components/Facepile";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import useBoolean from "~/hooks/useBoolean";
import { hover } from "~/styles";
import NudeButton from "./NudeButton";
type Props = {
group: Group;
membership?: GroupMembership;
showFacepile?: boolean;
showAvatar?: boolean;
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
};
function GroupListItem({ group, showFacepile, renderActions }: Props) {
const { t } = useTranslation();
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
useBoolean();
const memberCount = group.memberCount;
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
const overflow = memberCount - users.length;
return (
<>
<ListItem
image={
<Image>
<GroupIcon size={24} />
</Image>
}
title={<Title onClick={setMembersModalOpen}>{group.name}</Title>}
subtitle={t("{{ count }} member", { count: memberCount })}
actions={
<Flex align="center" gap={8}>
{showFacepile && (
<NudeButton
width="auto"
height="auto"
onClick={setMembersModalOpen}
>
<Facepile users={users} overflow={overflow} />
</NudeButton>
)}
{renderActions({
openMembersModal: setMembersModalOpen,
})}
</Flex>
}
/>
<Modal
title={t("Group members")}
onRequestClose={setMembersModalClosed}
isOpen={membersModalOpen}
>
<GroupMembers group={group} />
</Modal>
</>
);
}
const Image = styled(Flex)`
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: ${s("secondaryBackground")};
border-radius: 32px;
`;
const Title = styled.span`
&: ${hover} {
text-decoration: underline;
cursor: var(--pointer);
}
`;
export default observer(GroupListItem);
+1
View File
@@ -94,6 +94,7 @@ const Scene = styled.div`
align-items: flex-start;
width: 350px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
border-radius: 8px;
outline: none;
opacity: 0;
+37 -58
View File
@@ -3,7 +3,6 @@ import { observer } from "mobx-react";
import { MenuIcon } from "outline-icons";
import { transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
@@ -11,35 +10,25 @@ import { supportsPassiveListener } from "@shared/utils/browser";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import Desktop from "~/utils/Desktop";
import { TooltipProvider } from "./TooltipContext";
export const HEADER_HEIGHT = 64;
type Props = {
left?: React.ReactNode;
title: React.ReactNode;
actions?:
| ((props: { isCompact: boolean }) => React.ReactNode)
| React.ReactNode;
actions?: React.ReactNode;
hasSidebar?: boolean;
className?: string;
};
function Header(
{ left, title, actions, hasSidebar, className }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
function Header({ left, title, actions, hasSidebar, className }: Props) {
const { ui } = useStores();
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
const internalRef = React.useRef<HTMLDivElement | null>(null);
const breadcrumbsRef = React.useRef<HTMLDivElement | null>(null);
const passThrough = !actions && !left && !title;
const [isScrolled, setScrolled] = React.useState(false);
@@ -62,50 +51,38 @@ function Header(
});
}, []);
const setBreadcrumbRef = React.useCallback((node: HTMLDivElement | null) => {
breadcrumbsRef.current = node?.firstElementChild as HTMLDivElement;
}, []);
const size = useComponentSize(internalRef);
const breadcrumbsSize = useComponentSize(breadcrumbsRef);
const breadcrumbMakesCompact = breadcrumbsSize.width > size.width / 3;
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
return (
<TooltipProvider>
<Wrapper
ref={mergeRefs([ref, internalRef])}
align="center"
shrink={false}
className={className}
$passThrough={passThrough}
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
>
{left || hasMobileSidebar ? (
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
/>
)}
{left}
</Breadcrumbs>
) : null}
<Wrapper
align="center"
shrink={false}
className={className}
$passThrough={passThrough}
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
>
{left || hasMobileSidebar ? (
<Breadcrumbs>
{hasMobileSidebar && (
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
/>
)}
{left}
</Breadcrumbs>
) : null}
{isScrolled && !isCompact ? (
<Title onClick={handleClickTitle}>
<Fade>{title}</Fade>
</Title>
) : (
<div />
)}
<Actions align="center" justify="flex-end">
{typeof actions === "function" ? actions({ isCompact }) : actions}
</Actions>
</Wrapper>
</TooltipProvider>
{isScrolled ? (
<Title onClick={handleClickTitle}>
<Fade>{title}</Fade>
</Title>
) : (
<div />
)}
<Actions align="center" justify="flex-end">
{actions}
</Actions>
</Wrapper>
);
}
@@ -151,8 +128,9 @@ 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()}
@@ -172,6 +150,7 @@ const Wrapper = styled(Flex)<WrapperProps>`
${breakpoint("tablet")`
padding: 16px;
justify-content: center;
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
`};
`;
@@ -210,4 +189,4 @@ const MobileMenuButton = styled(Button)`
}
`;
export default observer(React.forwardRef(Header));
export default observer(Header);
+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>
@@ -2,13 +2,13 @@ import { observer } from "mobx-react";
import { getLuminance } from "polished";
import * as React from "react";
import styled from "styled-components";
import useStores from "../hooks/useStores";
import { IconType } from "../types";
import { IconLibrary } from "../utils/IconLibrary";
import { colorPalette } from "../utils/collections";
import { determineIconType } from "../utils/icon";
import EmojiIcon from "./EmojiIcon";
// import Logger from "~/utils/Logger";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { determineIconType } from "@shared/utils/icon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
import Flex from "./Flex";
export type Props = {
@@ -40,9 +40,9 @@ const Icon = ({
const iconType = determineIconType(icon);
if (!iconType) {
// Logger.warn("Failed to determine icon type", {
// icon,
// });
Logger.warn("Failed to determine icon type", {
icon,
});
return null;
}
@@ -62,9 +62,9 @@ const Icon = ({
return <EmojiIcon emoji={icon} size={size} className={className} />;
} catch (err) {
// Logger.warn("Failed to render icon", {
// icon,
// });
Logger.warn("Failed to render icon", {
icon,
});
}
return null;
@@ -80,6 +80,7 @@ const SVGIcon = observer(
forceColor,
}: Props) => {
const { ui } = useStores();
let color = inputColor ?? colorPalette[0];
// If the chosen icon color is very dark then we invert it in dark mode
@@ -1,12 +1,13 @@
import { BackIcon } from "outline-icons";
import React from "react";
import styled from "styled-components";
import { breakpoints, s, hover } from "@shared/styles";
import { breakpoints, s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
import { validateColorHex } from "@shared/utils/color";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import { hover } from "~/styles";
enum Panel {
Builtin,
@@ -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>
);
});
@@ -1,11 +1,13 @@
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const IconButton = styled(NudeButton)<{ delay?: number }>`
width: 32px;
height: 32px;
padding: 4px;
--delay: ${({ delay }) => delay && `${delay}ms`};
&: ${hover} {
background: ${s("listItemHoverBackground")};
@@ -1,6 +1,7 @@
import styled, { css } from "styled-components";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
&: ${hover},
@@ -2,12 +2,13 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
import styled from "styled-components";
import { depths, s, hover } from "@shared/styles";
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 = ({
@@ -25,7 +26,7 @@ const SkinTonePicker = ({
);
const menu = useMenuState({
placement: "bottom-end",
placement: "bottom",
});
const handleSkinClick = React.useCallback(
@@ -42,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>
+20 -20
View File
@@ -10,18 +10,19 @@ import {
useTabState,
} from "reakit";
import styled, { css } from "styled-components";
import Icon from "@shared/components/Icon";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import theme from "@shared/styles/theme";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePrevious from "~/hooks/usePrevious";
import useWindowSize from "~/hooks/useWindowSize";
import { hover } from "~/styles";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
import { PopoverButton } from "./components/PopoverButton";
@@ -81,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);
@@ -96,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(
@@ -118,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.
@@ -198,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>
@@ -206,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>
@@ -273,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;
@@ -282,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: "";
+1 -1
View File
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
type Props = {
@@ -1,6 +1,6 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "../styles";
import { s } from "@shared/styles";
type Props = {
/** The emoji to render */
+1 -1
View File
@@ -4,9 +4,9 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { isModKey } from "@shared/utils/keyboard";
import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown";
import { isModKey } from "~/utils/keyboard";
import { searchPath } from "~/utils/routeHelpers";
import Input, { Outline } from "./Input";
+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);
+2 -4
View File
@@ -47,16 +47,14 @@ export default function LanguagePrompt() {
<br />
<Link
onClick={async () => {
ui.set({ languagePromptDismissed: true });
ui.setLanguagePromptDismissed();
await user.save({ language });
}}
>
{t("Change Language")}
</Link>{" "}
&middot;{" "}
<Link onClick={() => ui.set({ languagePromptDismissed: true })}>
{t("Dismiss")}
</Link>
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
</span>
</Flex>
</Wrapper>
+2 -1
View File
@@ -4,7 +4,6 @@ import { Helmet } from "react-helmet-async";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { isModKey } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
import SkipNavContent from "~/components/SkipNavContent";
@@ -14,6 +13,7 @@ import useAutoRefresh from "~/hooks/useAutoRefresh";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
type Props = {
children?: React.ReactNode;
@@ -76,6 +76,7 @@ const Layout = React.forwardRef(function Layout_(
const Container = styled(Flex)`
background: ${s("background")};
transition: ${s("backgroundTransition")};
position: relative;
width: 100%;
min-height: 100%;
+3 -2
View File
@@ -6,9 +6,10 @@ import { LocationDescriptor } from "history";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import { s, hover, ellipsis } from "@shared/styles";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
import { hover } from "~/styles";
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
/** An icon or image to display to the left of the list item */
@@ -191,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) =>
+6 -7
View File
@@ -23,6 +23,7 @@ function eachMinute(fn: () => void) {
export type Props = {
children?: React.ReactNode;
dateTime: string;
tooltipDelay?: number;
addSuffix?: boolean;
shorten?: boolean;
relative?: boolean;
@@ -36,16 +37,14 @@ const LocaleTime: React.FC<Props> = ({
shorten,
format,
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>();
@@ -80,7 +79,7 @@ const LocaleTime: React.FC<Props> = ({
});
return (
<Tooltip content={tooltipContent} placement="bottom">
<Tooltip content={tooltipContent} delay={tooltipDelay} placement="bottom">
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
+2
View File
@@ -174,6 +174,7 @@ const Fullscreen = styled.div<FullscreenProps>`
justify-content: center;
align-items: flex-start;
background: ${s("background")};
transition: ${s("backgroundTransition")};
outline: none;
${breakpoint("tablet")`
@@ -264,6 +265,7 @@ const Small = styled.div`
justify-content: center;
align-items: flex-start;
background: ${s("modalBackground")};
transition: ${s("backgroundTransition")};
box-shadow: ${s("modalShadow")};
border-radius: 8px;
outline: none;
@@ -4,10 +4,11 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, hover, truncateMultiline } from "@shared/styles";
import { s } from "@shared/styles";
import Notification from "~/models/Notification";
import CommentEditor from "~/scenes/Document/components/CommentEditor";
import useStores from "~/hooks/useStores";
import { hover, truncateMultiline } from "~/styles";
import { Avatar, AvatarSize } from "../Avatar";
import Flex from "../Flex";
import Text from "../Text";
@@ -51,7 +52,11 @@ function NotificationListItem({ notification, onNavigate }: Props) {
<Text weight="bold">{notification.subject}</Text>
</Text>
<Text type="tertiary" size="xsmall">
<Time dateTime={notification.createdAt} addSuffix />{" "}
<Time
dateTime={notification.createdAt}
tooltipDelay={1000}
addSuffix
/>{" "}
{collection && <>&middot; {collection.name}</>}
</Text>
{notification.comment && (
@@ -3,12 +3,13 @@ import { MarkAsReadIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import Notification from "~/models/Notification";
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import NotificationMenu from "~/menus/NotificationMenu";
import { hover } from "~/styles";
import Desktop from "~/utils/Desktop";
import Empty from "../Empty";
import ErrorBoundary from "../ErrorBoundary";
@@ -23,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();
@@ -59,7 +58,7 @@ function Notifications(
</Text>
<Flex gap={8}>
{notifications.approximateUnreadCount > 0 && (
<Tooltip content={t("Mark all as read")}>
<Tooltip delay={500} content={t("Mark all as read")}>
<Button action={markNotificationsAsRead} context={context}>
<MarkAsReadIcon />
</Button>
@@ -73,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>
</>
);
+3 -5
View File
@@ -6,11 +6,9 @@ import PaginatedList from "~/components/PaginatedList";
import EventListItem from "./EventListItem";
type Props = {
events: Event<Document>[];
events: Event[];
document: Document;
fetch: (
options: Record<string, any> | undefined
) => Promise<Event<Document>[]>;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -32,7 +30,7 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event<Document>, index) => (
renderItem={(item: Event, index) => (
<EventListItem
key={item.id}
event={item}
+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;
+1 -15
View File
@@ -10,23 +10,13 @@ import { fadeAndScaleIn } from "~/styles/animations";
type Props = PopoverProps & {
children: React.ReactNode;
/** The width of the popover, defaults to 380px. */
width?: number;
/** The minimum width of the popover, use instead of width if contents adjusts size. */
minWidth?: number;
/** Shrink the padding of the popover */
shrink?: boolean;
/** Make the popover flex */
flex?: boolean;
/** The tab index of the popover */
tabIndex?: number;
/** Whether the popover should be scrollable, defaults to true. */
scrollable?: boolean;
/** The position of the popover on mobile, defaults to "top". */
mobilePosition?: "top" | "bottom";
/** Function to show the popover */
show: () => void;
/** Function to hide the popover */
hide: () => void;
};
@@ -35,7 +25,6 @@ const Popover = (
children,
shrink,
width = 380,
minWidth,
scrollable = true,
flex,
mobilePosition,
@@ -82,7 +71,6 @@ const Popover = (
ref={ref}
$shrink={shrink}
$width={width}
$minWidth={minWidth}
$scrollable={scrollable}
$flex={flex}
>
@@ -95,7 +83,6 @@ const Popover = (
type ContentsProps = {
$shrink?: boolean;
$width?: number;
$minWidth?: number;
$flex?: boolean;
$scrollable: boolean;
$mobilePosition?: "top" | "bottom";
@@ -114,8 +101,7 @@ const Contents = styled.div<ContentsProps>`
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
max-height: 75vh;
box-shadow: ${s("menuShadow")};
${(props) => props.$width && `width: ${props.$width}px`};
${(props) => props.$minWidth && `min-width: ${props.$minWidth}px`};
width: ${(props) => props.$width}px;
${(props) =>
props.$scrollable
-171
View File
@@ -1,171 +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, hover } 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";
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} placement="bottom">
{DisplayedEmoji}
</Tooltip>
) : (
<>{DisplayedEmoji}</>
);
};
const EmojiButton = styled(NudeButton)<{
$active: boolean;
disabled: boolean;
}>`
width: auto;
height: 28px;
padding: 6px;
border-radius: 12px;
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);
-156
View File
@@ -1,156 +0,0 @@
import { ReactionIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
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";
import Tooltip from "../Tooltip";
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) => (
<Tooltip content={t("Add reaction")} placement="top" hideOnClick>
<NudeButton
{...props}
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
size={size}
>
<ReactionIcon size={22} />
</NudeButton>
</Tooltip>
)}
</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";
export default ReactionPicker;
@@ -1,145 +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, hover } 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";
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 -1
View File
@@ -7,9 +7,10 @@ import * as React from "react";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s, hover, ellipsis } from "@shared/styles";
import { s, ellipsis } from "@shared/styles";
import Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { hover } from "~/styles";
import { sharedDocumentPath } from "~/utils/routeHelpers";
type Props = {
+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,13 +1,13 @@
import invariant from "invariant";
import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { CopyIcon, GlobeIcon, InfoIcon, QuestionMarkIcon } from "outline-icons";
import { CopyIcon, GlobeIcon, InfoIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import Flex from "@shared/components/Flex";
import Squircle from "@shared/components/Squircle";
import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -17,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";
@@ -38,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("");
@@ -51,30 +53,20 @@ function PublicAccess({ document, share, sharedParent }: Props) {
setUrlId(share?.urlId);
}, [share?.urlId]);
const handleIndexingChanged = React.useCallback(
async (event) => {
try {
await share?.save({
allowIndexing: event.currentTarget.checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
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(
@@ -119,7 +111,7 @@ function PublicAccess({ document, share, sharedParent }: Props) {
: share?.url ?? "";
const copyButton = (
<Tooltip content={t("Copy public link")} placement="top">
<Tooltip content={t("Copy public link")} delay={500} placement="top">
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
<NudeButton type="button" disabled={!share} style={{ marginRight: 3 }}>
<CopyIcon color={theme.placeholder} size={18} />
@@ -167,32 +159,6 @@ function PublicAccess({ document, share, sharedParent }: Props) {
/>
<ResizingHeightContainer>
{share?.published && (
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Search engine indexing")}&nbsp;
<Tooltip
content={t(
"Disable this setting to discourage search engines from indexing the page"
)}
>
<QuestionMarkIcon size={18} />
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Search engine indexing")}
checked={share?.allowIndexing ?? false}
onChange={handleIndexingChanged}
width={26}
height={14}
/>
}
/>
)}
{sharedParent?.published ? (
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
{copyButton}
@@ -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("");
@@ -31,7 +31,7 @@ export function CopyLinkButton({
}, [onCopy, t]);
return (
<Tooltip content={t("Copy link")} placement="top">
<Tooltip content={t("Copy link")} delay={500} placement="top">
<CopyToClipboard text={url} onCopy={handleCopied}>
<NudeButton type="button">
<LinkIcon size={20} />
@@ -1,7 +1,7 @@
import { PlusIcon } from "outline-icons";
import styled from "styled-components";
import { hover } from "@shared/styles";
import BaseListItem from "~/components/List/Item";
import { hover } from "~/styles";
export const InviteIcon = styled(PlusIcon)`
opacity: 0;
@@ -5,7 +5,7 @@ import { CheckmarkIcon, CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import { stringToColor } from "@shared/utils/color";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
@@ -20,6 +20,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useMaxHeight from "~/hooks/useMaxHeight";
import useStores from "~/hooks/useStores";
import useThrottledCallback from "~/hooks/useThrottledCallback";
import { hover } from "~/styles";
import { InviteIcon, ListItem } from "./ListItem";
type Suggestion = IAvatar & {
+2 -1
View File
@@ -1,8 +1,9 @@
import { darken } from "polished";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import { s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
// TODO: Temp until Button/NudeButton styles are normalized
export const Wrapper = styled.div`
+39 -45
View File
@@ -5,7 +5,6 @@ import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
@@ -15,6 +14,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import { metaDisplay } from "~/utils/keyboard";
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Tooltip from "../Tooltip";
@@ -80,6 +80,7 @@ function AppSidebar() {
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
delay={500}
>
<ToggleButton
position="bottom"
@@ -93,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 />
@@ -134,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>
@@ -153,11 +152,6 @@ function AppSidebar() {
);
}
const Overflow = styled.div`
overflow: hidden;
flex-shrink: 0;
`;
const Drafts = styled(Text)`
margin: 0 4px;
`;
+3 -3
View File
@@ -32,13 +32,13 @@ function Right({ children, border, className }: Props) {
Math.min(window.innerWidth - event.pageX, maxWidth),
minWidth
);
ui.set({ sidebarRightWidth: width });
ui.setRightSidebarWidth(width);
},
[minWidth, maxWidth, ui]
);
const handleReset = React.useCallback(() => {
ui.set({ sidebarRightWidth: theme.sidebarRightWidth });
ui.setRightSidebarWidth(theme.sidebarRightWidth);
}, [ui, theme.sidebarRightWidth]);
const handleStopDrag = React.useCallback(() => {
@@ -128,7 +128,7 @@ const Sidebar = styled(m.div)<{
max-width: 80%;
border-left: 1px solid ${s("divider")};
transition: border-left 100ms ease-in-out;
z-index: ${depths.sidebar};
z-index: 1;
${breakpoint("mobile", "tablet")`
display: flex;
+6 -2
View File
@@ -5,12 +5,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import useStores from "~/hooks/useStores";
import isCloudHosted from "~/utils/isCloudHosted";
import { metaDisplay } from "~/utils/keyboard";
import { settingsPath } from "~/utils/routeHelpers";
import Tooltip from "../Tooltip";
import Sidebar from "./Sidebar";
@@ -42,7 +42,11 @@ function SettingsSidebar() {
image={<StyledBackIcon />}
onClick={returnToApp}
>
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
<Tooltip
content={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
delay={500}
>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
+13 -78
View File
@@ -1,15 +1,10 @@
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 { hover } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import { metaDisplay } from "@shared/utils/keyboard";
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 history from "~/utils/history";
@@ -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
@@ -67,76 +50,28 @@ function SharedSidebar({ rootNode, shareId }: Props) {
depth={0}
shareId={shareId}
node={rootNode}
prefetchDocument={documents.prefetchDocument}
activeDocumentId={ui.activeDocumentId}
activeDocument={documents.active}
/>
</Section>
</ScrollContainer>
</StyledSidebar>
</Sidebar>
);
}
const ToggleSidebar = () => {
const { t } = useTranslation();
const { ui } = useStores();
return (
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
<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);
+32 -47
View File
@@ -17,7 +17,6 @@ import { fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import NotificationIcon from "../Notifications/NotificationIcon";
import NotificationsPopover from "../Notifications/NotificationsPopover";
import { TooltipProvider } from "../TooltipContext";
import ResizeBorder from "./components/ResizeBorder";
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
@@ -47,6 +46,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const setWidth = ui.setSidebarWidth;
const [offset, setOffset] = React.useState(0);
const [isHovering, setHovering] = React.useState(false);
const [isAnimating, setAnimating] = React.useState(false);
@@ -62,13 +62,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const width = Math.min(event.pageX - offset, maxWidth);
const isSmallerThanCollapsePoint = width < minWidth / 2;
ui.set({
sidebarWidth: isSmallerThanCollapsePoint
? theme.sidebarCollapsedWidth
: width,
});
if (isSmallerThanCollapsePoint) {
setWidth(theme.sidebarCollapsedWidth);
} else {
setWidth(width);
}
},
[ui, theme, offset, minWidth, maxWidth]
[theme, offset, minWidth, maxWidth, setWidth]
);
const handleStopDrag = React.useCallback(() => {
@@ -86,25 +86,17 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
setCollapsing(true);
ui.collapseSidebar();
} else {
ui.set({ sidebarWidth: minWidth });
setWidth(minWidth);
setAnimating(true);
}
} else {
ui.set({ sidebarWidth: width });
setWidth(width);
}
}, [ui, isSmallerThanMinimum, minWidth, width]);
const handleBlur = React.useCallback(() => {
setHovering(false);
}, []);
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
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
);
}
},
@@ -149,11 +138,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
React.useEffect(() => {
if (isCollapsing) {
setTimeout(() => {
ui.set({ sidebarWidth: minWidth });
setWidth(minWidth);
setCollapsing(false);
}, ANIMATION_MS);
}
}, [ui, minWidth, isCollapsing]);
}, [setWidth, minWidth, isCollapsing]);
React.useEffect(() => {
if (isResizing) {
@@ -164,17 +153,14 @@ 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.set({ sidebarWidth: theme.sidebarWidth });
ui.setSidebarWidth(theme.sidebarWidth);
}, [ui, theme.sidebarWidth]);
React.useEffect(() => {
@@ -195,9 +181,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
);
return (
<TooltipProvider>
<>
<Container
id="sidebar"
ref={ref}
style={style}
$hidden={hidden}
@@ -208,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
>
@@ -233,7 +217,15 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
/>
}
>
<Notifications />
<NotificationsPopover>
{(rest: SidebarButtonProps) => (
<SidebarButton
{...rest}
position="bottom"
image={<NotificationIcon />}
/>
)}
</NotificationsPopover>
</SidebarButton>
)}
</AccountMenu>
@@ -244,18 +236,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
/>
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
</TooltipProvider>
</>
);
});
const Notifications = () => (
<NotificationsPopover>
{(rest: SidebarButtonProps) => (
<SidebarButton {...rest} position="bottom" image={<NotificationIcon />} />
)}
</NotificationsPopover>
);
const Backdrop = styled.a`
animation: ${fadeIn} 250ms ease-in-out;
position: fixed;
@@ -300,8 +284,9 @@ const Container = styled(Flex)<ContainerProps>`
width: 100%;
background: ${s("sidebarBackground")};
transition: box-shadow 150ms ease-in-out, transform 150ms ease-out,
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
${s("backgroundTransition")}
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
@@ -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>
);
@@ -16,7 +16,6 @@ import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarAction from "./SidebarAction";
import SidebarContext from "./SidebarContext";
import { DragObject } from "./SidebarLink";
function Collections() {
@@ -50,44 +49,42 @@ function Collections() {
});
return (
<SidebarContext.Provider value="collections">
<Flex column>
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList
options={params}
aria-label={t("Collections")}
items={collections.allActive}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
/>
) : undefined
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection, index) => (
<DraggableCollectionLink
key={item.id}
collection={item}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
belowCollection={orderedCollections[index + 1]}
<Flex column>
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList
options={params}
aria-label={t("Collections")}
items={collections.orderedData}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
/>
)}
/>
<SidebarAction action={createCollection} depth={0} />
</Relative>
</Header>
</Flex>
</SidebarContext.Provider>
) : undefined
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection, index) => (
<DraggableCollectionLink
key={item.id}
collection={item}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
belowCollection={orderedCollections[index + 1]}
/>
)}
/>
<SidebarAction action={createCollection} depth={0} />
</Relative>
</Header>
</Flex>
);
}
export const StyledError = styled(Error)`
const StyledError = styled(Error)`
font-size: 15px;
padding: 0 8px;
`;
@@ -5,13 +5,13 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
@@ -278,7 +278,7 @@ function InnerDocumentLink(
!isDraggingAnyDocument ? (
<Fade>
{can.createChildDocument && (
<Tooltip content={t("New doc")}>
<Tooltip content={t("New doc")} delay={500}>
<NudeButton
type={undefined}
aria-label={t("New nested document")}
@@ -7,8 +7,8 @@ import styled from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { useLocationState } from "../hooks/useLocationState";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DropCursor from "./DropCursor";
@@ -29,7 +29,7 @@ function DraggableCollectionLink({
prefetchDocument,
belowCollection,
}: Props) {
const locationSidebarContext = useLocationSidebarContext();
const locationSidebarContext = useLocationState();
const sidebarContext = useSidebarContext();
const { ui, policies, collections } = useStores();
const [expanded, setExpanded] = React.useState(
@@ -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]);
@@ -2,11 +2,10 @@ import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import Group from "~/models/Group";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import Folder from "./Folder";
import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
type Props = {
@@ -15,23 +14,13 @@ type Props = {
};
const GroupLink: React.FC<Props> = ({ group }) => {
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = groupSidebarContext(group.id);
const [expanded, setExpanded] = React.useState(
locationSidebarContext === sidebarContext
);
const [expanded, setExpanded] = React.useState(false);
const handleDisclosureClick = React.useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
React.useEffect(() => {
if (locationSidebarContext === sidebarContext) {
setExpanded(true);
}
}, [sidebarContext, locationSidebarContext, setExpanded]);
return (
<Relative>
<SidebarLink
@@ -41,7 +30,7 @@ const GroupLink: React.FC<Props> = ({ group }) => {
onClick={handleDisclosureClick}
depth={0}
/>
<SidebarContext.Provider value={sidebarContext}>
<SidebarContext.Provider value={group.id}>
<Folder expanded={expanded}>
{group.documentMemberships.map((membership) => (
<SharedWithMeLink
@@ -43,12 +43,12 @@ function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
return (
<Navigation gap={4} {...props}>
<Tooltip content={t("Go back")}>
<Tooltip content={t("Go back")} delay={500}>
<NudeButton onClick={() => Desktop.bridge?.goBack()}>
<Back $active={back} />
</NudeButton>
</Tooltip>
<Tooltip content={t("Go forward")}>
<Tooltip content={t("Go forward")} delay={500}>
<NudeButton onClick={() => Desktop.bridge?.goForward()}>
<Forward $active={forward} />
</NudeButton>

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