Compare commits

..

22 Commits

Author SHA1 Message Date
Tom Moor fe2c0d75ef Switch icon 2024-07-24 21:56:16 -04:00
Tom Moor 2e9c451979 fix: Hover on DD
fix: Guard editing state
2024-07-24 21:49:34 -04:00
Tom Moor 3a97361654 Fixed attribute order 2024-07-24 21:49:34 -04:00
Tom Moor a8bdc14ca2 Remove save button 2024-07-24 21:49:34 -04:00
Tom Moor 563b41e34a fix: Correct updatedAt on document data attributes 2024-07-24 21:49:34 -04:00
Tom Moor 0621706a95 Add realtime events 2024-07-24 21:49:33 -04:00
Tom Moor ec1cb61807 fix initial attribute 2024-07-24 21:49:09 -04:00
Tom Moor 5850181c29 Do not render properties div if none 2024-07-24 21:49:09 -04:00
Tom Moor d29bc22676 Property editing 2024-07-24 21:49:09 -04:00
Tom Moor a9dd771598 tsc 2024-07-24 21:49:09 -04:00
Tom Moor 35d185a971 wip 2024-07-24 21:49:09 -04:00
Tom Moor d6c85f0aac wip 2024-07-24 21:49:09 -04:00
Tom Moor b721287d19 Settings UI 2024-07-24 21:49:09 -04:00
Tom Moor 57296e3139 dataAttributes.delete endpoint 2024-07-24 21:49:09 -04:00
Tom Moor 4210f877b4 stash 2024-07-24 21:49:09 -04:00
Tom Moor 4db8682423 wip 2024-07-24 21:49:09 -04:00
Tom Moor 74229813d2 stash 2024-07-24 21:49:09 -04:00
Tom Moor 75e457feee stash 2024-07-24 21:49:07 -04:00
Tom Moor 36682574c6 stash 2024-07-24 21:48:54 -04:00
Tom Moor c636c4e5df stash 2024-07-24 21:48:52 -04:00
Tom Moor c23b5d1ef4 Model, endpoints 2024-07-24 21:48:28 -04:00
Tom Moor 9f8298d012 Migration 2024-07-24 21:47:27 -04:00
915 changed files with 22343 additions and 36881 deletions
+9 -10
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:
@@ -90,7 +88,7 @@ jobs:
- run:
name: test
command: |
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
TESTFILES=$(circleci tests glob "server/**/*.test.ts" | circleci tests split)
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
<<: *defaults
@@ -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: |
-4
View File
@@ -189,10 +189,6 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
+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
-7
View File
@@ -20,11 +20,6 @@ COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base $APP_PATH/node_modules ./node_modules
COPY --from=base $APP_PATH/package.json ./package.json
# Install wget to healthcheck the server
RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
@@ -41,7 +36,5 @@ VOLUME /var/lib/outline/data
USER nodejs
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
EXPOSE 3000
CMD ["yarn", "start"]
+5
View File
@@ -6,6 +6,10 @@ WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
@@ -19,3 +23,4 @@ RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 &
yarn cache clean
ENV PORT=3000
HEALTHCHECK CMD wget -qO- http://localhost:${PORT}/_health | grep -q "OK" || exit 1
+3 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.81.0
The Licensed Work is (c) 2024 General Outline, Inc.
Licensed Work: Outline 0.71.0
The Licensed Work is (c) 2020 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
Service.
@@ -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: 2027-08-18
Change License: Apache License, Version 2.0
+2 -8
View File
@@ -3,13 +3,7 @@
"description": "Open source wiki and knowledge base for growing teams",
"website": "https://www.getoutline.com/",
"repository": "https://github.com/outline/outline",
"keywords": [
"wiki",
"team",
"node",
"markdown",
"slack"
],
"keywords": ["wiki", "team", "node", "markdown", "slack"],
"success_url": "/",
"formation": {
"web": {
@@ -218,4 +212,4 @@
"required": false
}
}
}
}
+21 -102
View File
@@ -1,10 +1,8 @@
import {
ArchiveIcon,
CollectionIcon,
EditIcon,
PadlockIcon,
PlusIcon,
RestoreIcon,
SearchIcon,
ShapesIcon,
StarredIcon,
@@ -12,18 +10,16 @@ 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";
import { createAction } from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
@@ -74,9 +70,9 @@ export const editCollection = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
analyticsName: "Edit collection",
section: ActiveCollectionSection,
section: CollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId }) =>
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
@@ -100,12 +96,12 @@ export const editCollectionPermissions = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
section: CollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId }) =>
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
perform: ({ t, stores, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -131,22 +127,11 @@ export const editCollectionPermissions = createAction({
export const searchInCollection = createAction({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
section: ActiveCollectionSection,
section: CollectionSection,
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 }));
},
@@ -155,10 +140,10 @@ export const searchInCollection = createAction({
export const starCollection = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star collection",
section: ActiveCollectionSection,
section: CollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -168,7 +153,7 @@ export const starCollection = createAction({
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ activeCollectionId }) => {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -182,10 +167,10 @@ export const starCollection = createAction({
export const unstarCollection = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar collection",
section: ActiveCollectionSection,
section: CollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -195,7 +180,7 @@ export const unstarCollection = createAction({
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ activeCollectionId }) => {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -205,85 +190,19 @@ 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",
section: ActiveCollectionSection,
section: CollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t }) => {
perform: ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
@@ -308,10 +227,10 @@ export const deleteCollection = createAction({
export const createTemplate = createAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: ActiveCollectionSection,
section: CollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId }) =>
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
+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} />,
});
},
});
@@ -0,0 +1,25 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { DataAttributeNew } from "~/components/DataAttribute/DataAttributeNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createDataAttribute = createAction({
name: ({ t }) => t("New attribute"),
analyticsName: "New attribute",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createDataAttribute,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New attribute"),
content: <DataAttributeNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+96 -294
View File
@@ -24,39 +24,25 @@ import {
UnpublishIcon,
PublishIcon,
CommentIcon,
GlobeIcon,
CopyIcon,
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 { ExportContentType, TeamPreference } 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 MarkdownIcon from "~/components/Icons/MarkdownIcon";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import DuplicateDialog from "~/components/DuplicateDialog";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import { createAction } from "~/actions";
import {
ActiveDocumentSection,
DocumentSection,
TrashSection,
} from "~/actions/sections";
import { DocumentSection, TrashSection } from "~/actions/sections";
import env from "~/env";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
@@ -80,24 +66,23 @@ export const openDocument = createAction({
keywords: "go to",
icon: <DocumentIcon />,
children: ({ stores }) => {
const nodes = stores.collections.navigationNodes.reduce(
(acc, node) => [...acc, ...node.children],
[] as NavigationNode[]
);
const paths = stores.collections.pathsToDocuments;
return nodes.map((item) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: item.url,
name: item.title,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon />
),
section: DocumentSection,
perform: () => history.push(item.url),
}));
return paths
.filter((path) => path.type === "document")
.map((path) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: path.url,
name: path.title,
icon: function _Icon() {
return stores.documents.get(path.id)?.isStarred ? (
<StarredIcon />
) : null;
},
section: DocumentSection,
perform: () => history.push(path.url),
}));
},
});
@@ -119,9 +104,9 @@ export const createDocument = createAction({
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
perform: ({ activeCollectionId, sidebarContext }) =>
perform: ({ activeCollectionId, inStarredSection }) =>
history.push(newDocumentPath(activeCollectionId), {
sidebarContext,
starred: inStarredSection,
}),
});
@@ -131,35 +116,16 @@ 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;
},
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
!!stores.documents.get(activeDocumentId)?.template &&
stores.policies.abilities(currentTeamId).createDocument,
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
history.push(
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
{
sidebarContext,
starred: inStarredSection,
}
),
});
@@ -167,7 +133,7 @@ export const createDocumentFromTemplate = createAction({
export const createNestedDocument = createAction({
name: ({ t }) => t("New nested document"),
analyticsName: "New document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
@@ -175,16 +141,16 @@ export const createNestedDocument = createAction({
!!activeDocumentId &&
stores.policies.abilities(currentTeamId).createDocument &&
stores.policies.abilities(activeDocumentId).createChildDocument,
perform: ({ activeDocumentId, sidebarContext }) =>
perform: ({ activeDocumentId, inStarredSection }) =>
history.push(newNestedDocumentPath(activeDocumentId), {
sidebarContext,
starred: inStarredSection,
}),
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeDocumentId, stores }) => {
@@ -210,7 +176,7 @@ export const starDocument = createAction({
export const unstarDocument = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeDocumentId, stores }) => {
@@ -236,7 +202,7 @@ export const unstarDocument = createAction({
export const publishDocument = createAction({
name: ({ t }) => t("Publish"),
analyticsName: "Publish document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <PublishIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -257,7 +223,7 @@ export const publishDocument = createAction({
return;
}
if (document?.collectionId || document?.template) {
if (document?.collectionId) {
await document.save(undefined, {
publish: true,
});
@@ -278,7 +244,7 @@ export const publishDocument = createAction({
export const unpublishDocument = createAction({
name: ({ t }) => t("Unpublish"),
analyticsName: "Unpublish document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <UnpublishIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -309,7 +275,7 @@ export const unpublishDocument = createAction({
export const subscribeDocument = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <SubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -337,7 +303,7 @@ export const subscribeDocument = createAction({
export const unsubscribeDocument = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <UnsubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -365,20 +331,18 @@ export const unsubscribeDocument = createAction({
});
export const shareDocument = createAction({
name: ({ t }) => `${t("Permissions")}`,
name: ({ t }) => t("Share"),
analyticsName: "Share document",
section: ActiveDocumentSection,
icon: <PadlockIcon />,
visible: ({ stores, activeDocumentId }) => {
const can = stores.policies.abilities(activeDocumentId!);
return can.manageUsers || can.share;
},
section: DocumentSection,
icon: <GlobeIcon />,
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
if (!activeDocumentId || !currentUserId) {
return;
}
const document = stores.documents.get(activeDocumentId);
const share = stores.shares.getByDocumentId(activeDocumentId);
const sharedParent = stores.shares.getByDocumentParents(activeDocumentId);
if (!document) {
return;
}
@@ -389,6 +353,8 @@ export const shareDocument = createAction({
content: (
<SharePopover
document={document}
share={share}
sharedParent={sharedParent}
onRequestClose={stores.dialogs.closeAllModals}
visible
/>
@@ -400,7 +366,7 @@ export const shareDocument = createAction({
export const downloadDocumentAsHTML = createAction({
name: ({ t }) => t("HTML"),
analyticsName: "Download document as HTML",
section: ActiveDocumentSection,
section: DocumentSection,
keywords: "html export",
icon: <DownloadIcon />,
iconInContextMenu: false,
@@ -419,7 +385,7 @@ export const downloadDocumentAsHTML = createAction({
export const downloadDocumentAsPDF = createAction({
name: ({ t }) => t("PDF"),
analyticsName: "Download document as PDF",
section: ActiveDocumentSection,
section: DocumentSection,
keywords: "export",
icon: <DownloadIcon />,
iconInContextMenu: false,
@@ -443,7 +409,7 @@ export const downloadDocumentAsPDF = createAction({
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Markdown"),
analyticsName: "Download document as Markdown",
section: ActiveDocumentSection,
section: DocumentSection,
keywords: "md markdown export",
icon: <DownloadIcon />,
iconInContextMenu: false,
@@ -463,11 +429,9 @@ export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
analyticsName: "Download document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <DownloadIcon />,
keywords: "export",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
children: [
downloadDocumentAsHTML,
downloadDocumentAsPDF,
@@ -477,10 +441,8 @@ export const downloadDocument = createAction({
export const copyDocumentAsMarkdown = createAction({
name: ({ t }) => t("Copy as Markdown"),
section: ActiveDocumentSection,
section: DocumentSection,
keywords: "clipboard",
icon: <MarkdownIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
@@ -494,33 +456,10 @@ export const copyDocumentAsMarkdown = createAction({
},
});
export const copyDocumentShareLink = createAction({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
keywords: "clipboard share",
icon: <GlobeIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId &&
!!stores.shares.getByDocumentId(activeDocumentId)?.published,
perform: ({ stores, activeDocumentId, t }) => {
if (!activeDocumentId) {
return;
}
const share = stores.shares.getByDocumentId(activeDocumentId);
if (share) {
copy(share.url);
toast.success(t("Link copied to clipboard"));
}
},
});
export const copyDocumentLink = createAction({
name: ({ t }) => t("Copy link"),
section: ActiveDocumentSection,
section: DocumentSection,
keywords: "clipboard",
icon: <CopyIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
@@ -536,17 +475,17 @@ export const copyDocumentLink = createAction({
export const copyDocument = createAction({
name: ({ t }) => t("Copy"),
analyticsName: "Copy document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
children: [copyDocumentLink, copyDocumentAsMarkdown],
});
export const duplicateDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
analyticsName: "Duplicate document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <DuplicateIcon />,
keywords: "copy",
visible: ({ activeDocumentId, stores }) =>
@@ -562,7 +501,7 @@ export const duplicateDocument = createAction({
stores.dialogs.openModal({
title: t("Copy document"),
content: (
<DocumentCopy
<DuplicateDialog
document={document}
onSubmit={(response) => {
stores.dialogs.closeAllModals();
@@ -590,7 +529,7 @@ export const pinDocumentToCollection = createAction({
});
},
analyticsName: "Pin document to collection",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
@@ -626,7 +565,7 @@ export const pinDocumentToCollection = createAction({
export const pinDocumentToHome = createAction({
name: ({ t }) => t("Pin to home"),
analyticsName: "Pin document to home",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, currentTeamId, stores }) => {
@@ -658,7 +597,7 @@ export const pinDocumentToHome = createAction({
export const pinDocument = createAction({
name: ({ t }) => t("Pin"),
analyticsName: "Pin document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <PinIcon />,
children: [pinDocumentToCollection, pinDocumentToHome],
});
@@ -666,7 +605,7 @@ export const pinDocument = createAction({
export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
@@ -684,7 +623,7 @@ export const printDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
analyticsName: "Print document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
perform: () => {
@@ -719,20 +658,15 @@ export const importDocument = createAction({
const files = getEventFiles(ev);
const file = files[0];
try {
const document = await documents.import(
file,
activeDocumentId,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toast.error(err.message);
}
const document = await documents.import(
file,
activeDocumentId,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
};
input.click();
@@ -742,7 +676,7 @@ export const importDocument = createAction({
export const createTemplateFromDocument = createAction({
name: ({ t }) => t("Templatize"),
analyticsName: "Templatize document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
@@ -754,7 +688,7 @@ export const createTemplateFromDocument = createAction({
}
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).updateDocument
stores.policies.abilities(activeCollectionId).update
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
@@ -777,14 +711,14 @@ export const openRandomDocument = createAction({
section: DocumentSection,
icon: <ShuffleIcon />,
perform: ({ stores, activeDocumentId }) => {
const nodes = stores.collections.navigationNodes
.reduce((acc, node) => [...acc, ...node.children], [] as NavigationNode[])
.filter((node) => node.id !== activeDocumentId);
const documentPaths = stores.collections.pathsToDocuments.filter(
(path) => path.type === "document" && path.id !== activeDocumentId
);
const documentPath =
documentPaths[Math.round(Math.random() * documentPaths.length)];
const random = nodes[Math.round(Math.random() * nodes.length)];
if (random) {
history.push(random.url);
if (documentPath) {
history.push(documentPath.url);
}
},
});
@@ -801,50 +735,11 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: async ({ activeDocumentId, stores }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.move({
collectionId: null,
});
}
},
});
export const moveDocumentToCollection = createAction({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
}
const document = stores.documents.get(activeDocumentId);
return document?.template && document?.collectionId
? t("Move to collection")
: t("Move");
},
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
@@ -868,48 +763,10 @@ export const moveDocumentToCollection = createAction({
},
});
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the button if this is a non-workspace template.
if (!document || (document.template && !document.isWorkspaceTemplate)) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the menu if this is not a template (or) a workspace template.
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
name: ({ t }) => `${t("Archive")}`,
name: ({ t }) => t("Archive"),
analyticsName: "Archive document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <ArchiveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -918,30 +775,14 @@ export const archiveDocument = createAction({
return !!stores.policies.abilities(activeDocumentId).archive;
},
perform: async ({ activeDocumentId, stores, t }) => {
const { dialogs, documents } = stores;
if (activeDocumentId) {
const document = documents.get(activeDocumentId);
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
dialogs.openModal({
title: t("Are you sure you want to archive this document?"),
content: (
<ConfirmationDialog
onSubmit={async () => {
await document.archive();
toast.success(t("Document archived"));
}}
savingText={`${t("Archiving")}`}
>
{t(
"Archiving this document will remove it from the collection and search results."
)}
</ConfirmationDialog>
),
});
await document.archive();
toast.success(t("Document archived"));
}
},
});
@@ -949,7 +790,7 @@ export const archiveDocument = createAction({
export const deleteDocument = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
@@ -983,7 +824,7 @@ export const deleteDocument = createAction({
export const permanentlyDeleteDocument = createAction({
name: ({ t }) => t("Permanently delete"),
analyticsName: "Permanently delete document",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <CrossIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
@@ -1038,7 +879,7 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
export const openDocumentComments = createAction({
name: ({ t }) => t("Comments"),
analyticsName: "Open comments",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <CommentIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1053,14 +894,14 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.toggleComments();
stores.ui.toggleComments(activeDocumentId);
},
});
export const openDocumentHistory = createAction({
name: ({ t }) => t("History"),
analyticsName: "Open document history",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <HistoryIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1081,7 +922,7 @@ export const openDocumentHistory = createAction({
export const openDocumentInsights = createAction({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <GraphIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1118,7 +959,7 @@ export const toggleViewerInsights = createAction({
: t("Enable viewer insights");
},
analyticsName: "Toggle viewer insights",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <EyeIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1139,42 +980,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,
@@ -1184,7 +989,6 @@ export const rootDocumentActions = [
importDocument,
downloadDocument,
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
starDocument,
unstarDocument,
@@ -1193,9 +997,7 @@ export const rootDocumentActions = [
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
moveDocument,
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
+1 -12
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",
@@ -225,9 +216,7 @@ export const logout = createAction({
perform: async () => {
await stores.auth.logout();
if (env.OIDC_LOGOUT_URI) {
setTimeout(() => {
window.location.replace(env.OIDC_LOGOUT_URI);
}, 200);
window.location.replace(env.OIDC_LOGOUT_URI);
}
},
});
+2 -2
View File
@@ -17,7 +17,7 @@ export const restoreRevision = createAction({
analyticsName: "Restore revision",
icon: <RestoreIcon />,
section: RevisionSection,
visible: ({ activeDocumentId }) =>
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ event, location, activeDocumentId }) => {
event?.preventDefault();
@@ -47,7 +47,7 @@ export const copyLinkToRevision = createAction({
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
section: RevisionSection,
perform: async ({ activeDocumentId, t }) => {
perform: async ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
+3 -3
View File
@@ -18,7 +18,7 @@ export const inviteUser = createAction({
icon: <PlusIcon />,
keywords: "team member workspace user",
section: UserSection,
visible: () =>
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
perform: ({ t }) => {
stores.dialogs.openModal({
@@ -40,7 +40,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
})}…`,
analyticsName: "Update user role",
section: UserSection,
visible: () => {
visible: ({ stores }) => {
const can = stores.policies.abilities(user.id);
return UserRoleHelper.isRoleHigher(role, user.role)
@@ -70,7 +70,7 @@ export const deleteUserActionFactory = (userId: string) =>
keywords: "leave",
dangerous: true,
section: UserSection,
visible: () => stores.policies.abilities(userId).delete,
visible: ({ stores }) => stores.policies.abilities(userId).delete,
perform: ({ t }) => {
const user = stores.users.get(userId);
if (!user) {
-6
View File
@@ -98,11 +98,6 @@ export function actionToKBar(
)
: [];
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? (action.section.priority as number) ?? 0
: 0;
return [
{
id: action.id,
@@ -113,7 +108,6 @@ export function actionToKBar(
keywords: action.keywords ?? "",
shortcut: action.shortcut || [],
icon: resolvedIcon,
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
perform: action.perform
? () => performAction(action, context)
: undefined,
-24
View File
@@ -2,30 +2,10 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
const activeCollection = stores.collections.active;
return `${t("Collection")} · ${activeCollection?.name}`;
};
ActiveCollectionSection.priority = 0.8;
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}`;
};
ActiveDocumentSection.priority = 0.9;
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
RecentSection.priority = 1;
export const RevisionSection = ({ t }: ActionContext) => t("Revision");
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
@@ -36,13 +16,9 @@ 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) =>
t("Recent searches");
RecentSearchesSection.priority = -0.1;
export const TrashSection = ({ t }: ActionContext) => t("Trash");
+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);
-18
View File
@@ -106,24 +106,6 @@ const Analytics: React.FC = ({ children }: Props) => {
});
}, []);
// Umami
React.useEffect(() => {
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service !== IntegrationService.Umami) {
return;
}
const script = document.createElement("script");
script.defer = true;
script.src = `${integration.settings?.instanceUrl}${integration.settings?.scriptName}`;
script.setAttribute(
"data-website-id",
integration.settings?.measurementId
);
document.getElementsByTagName("head")[0]?.appendChild(script);
});
}, []);
return <>{children}</>;
};
+1 -1
View File
@@ -19,7 +19,7 @@ function ArrowKeyNavigation(
return;
}
if (ev.key === "Escape" || ev.key === "Backspace") {
if (ev.key === "Escape") {
ev.preventDefault();
onEscape(ev);
}
+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);
+13 -5
View File
@@ -1,14 +1,17 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import { observer, useLocalStore } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import DocumentContext from "~/components/DocumentContext";
import type { DocumentContextValue } from "~/components/DocumentContext";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import type { Editor as TEditor } from "~/editor";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -22,7 +25,6 @@ import {
matchDocumentSlug as slug,
matchDocumentInsights,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import { PortalContext } from "./Portal";
@@ -48,6 +50,12 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const documentContext = useLocalStore<DocumentContextValue>(() => ({
editor: null,
setEditor: (editor: TEditor) => {
documentContext.editor = editor;
},
}));
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
@@ -94,7 +102,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 = (
@@ -117,7 +125,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
);
return (
<DocumentContextProvider>
<DocumentContext.Provider value={documentContext}>
<PortalContext.Provider value={layoutRef.current}>
<Layout
title={team.name}
@@ -134,7 +142,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
</React.Suspense>
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
</DocumentContext.Provider>
);
};
+1 -1
View File
@@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Tooltip from "~/components/Tooltip";
import Avatar from "./Avatar";
type Props = {
user: User;
-35
View File
@@ -1,35 +0,0 @@
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import Group from "~/models/Group";
import { AvatarSize } from "../Avatar/Avatar";
type Props = {
/** The group to show an avatar for */
group: Group;
/** The size of the icon, 24px is default to match standard avatars */
size?: number;
/** The color of the avatar */
color?: string;
/** The background color of the avatar */
backgroundColor?: string;
className?: string;
};
export function GroupAvatar({
color,
backgroundColor,
size = AvatarSize.Medium,
className,
}: Props) {
const theme = useTheme();
return (
<Squircle color={color ?? theme.text} size={size} className={className}>
<GroupIcon
color={backgroundColor ?? theme.background}
size={size * 0.75}
/>
</Squircle>
);
}
+1 -2
View File
@@ -1,5 +1,4 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
const Initials = styled(Flex)<{
@@ -12,7 +11,7 @@ const Initials = styled(Flex)<{
border-radius: 50%;
width: 100%;
height: 100%;
color: ${s("white75")};
color: #fff;
background-color: ${(props) => props.color};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
+3 -4
View File
@@ -1,7 +1,6 @@
import Avatar, { IAvatar, AvatarSize } from "./Avatar";
import Avatar from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
import { GroupAvatar } from "./GroupAvatar";
export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence };
export { AvatarWithPresence };
export type { IAvatar };
export default Avatar;
+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;
+3 -7
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";
@@ -105,7 +105,7 @@ const RealButton = styled(ActionButton)<RealProps>`
background: ${lighten(0.05, props.theme.danger)};
}
&:focus-visible {
&.focus-visible {
outline-color: ${darken(0.2, props.theme.danger)} !important;
}
`};
@@ -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);
+10 -25
View File
@@ -1,13 +1,13 @@
import filter from "lodash/filter";
import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
import sortBy from "lodash/sortBy";
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "~/models/Document";
import { AvatarWithPresence } from "~/components/Avatar";
import AvatarWithPresence from "~/components/Avatar/AvatarWithPresence";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";
@@ -16,18 +16,10 @@ import useCurrentUser from "~/hooks/useCurrentUser";
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;
};
/**
* Displays a list of live collaborators for a document, including their avatars
* and presence status.
*/
function Collaborators(props: Props) {
const { limit = 6 } = props;
const { t } = useTranslation();
const user = useCurrentUser();
const currentUserId = user?.id;
@@ -47,16 +39,15 @@ function Collaborators(props: Props) {
// ensure currently present via websocket are always ordered first
const collaborators = React.useMemo(
() =>
orderBy(
sortBy(
filter(
users.orderedData,
(u) =>
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
!u.isSuspended
(user) =>
(presentIds.includes(user.id) ||
document.collaboratorIds.includes(user.id)) &&
!user.isSuspended
),
[(u) => presentIds.includes(u.id), "id"],
["asc", "asc"]
(user) => presentIds.includes(user.id)
),
[document.collaboratorIds, users.orderedData, presentIds]
);
@@ -81,15 +72,9 @@ function Collaborators(props: Props) {
return (
<>
<PopoverDisclosure {...popover}>
{(popoverProps) => (
<NudeButton
width={Math.min(collaborators.length, limit) * 32}
height={32}
{...popoverProps}
>
{(props) => (
<NudeButton width={collaborators.length * 32} height={32} {...props}>
<Facepile
limit={limit}
overflow={collaborators.length - limit}
users={collaborators}
renderAvatar={(collaborator) => {
const isPresent = presentIds.includes(collaborator.id);
+14 -11
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";
@@ -19,6 +19,7 @@ import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { EmptySelectValue } from "~/types";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
@@ -155,16 +156,18 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{team.sharing && (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...register("sharing")}
/>
)}
{team.sharing &&
(!collection ||
FeatureFlags.isEnabled(Feature.newCollectionSharing)) && (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...register("sharing")}
/>
)}
<Flex justify="flex-end">
<Button
+3 -6
View File
@@ -1,7 +1,7 @@
import { runInAction } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { CollectionForm, FormData } from "./CollectionForm";
@@ -17,11 +17,8 @@ export const CollectionNew = observer(function CollectionNew_({
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const collection = await collections.save(data);
// Avoid flash of loading state for the new collection, we know it's empty.
runInAction(() => {
collection.documents = [];
});
const collection = new Collection(data, collections);
await collection.save();
onSubmit?.();
history.push(collection.path);
} catch (error) {
-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,
@@ -6,27 +6,20 @@ import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import CommandBarResults from "./CommandBarResults";
import useRecentDocumentActions from "./useRecentDocumentActions";
import useSettingsAction from "./useSettingsAction";
import useTemplatesAction from "./useTemplatesAction";
import useSettingsActions from "~/hooks/useSettingsActions";
import useTemplateActions from "~/hooks/useTemplateActions";
function CommandBar() {
const { t } = useTranslation();
const recentDocumentActions = useRecentDocumentActions();
const settingsAction = useSettingsAction();
const templatesAction = useTemplatesAction();
const settingsActions = useSettingsActions();
const templateActions = useTemplateActions();
const commandBarActions = React.useMemo(
() => [
...recentDocumentActions,
...rootActions,
templatesAction,
settingsAction,
],
[recentDocumentActions, settingsAction, templatesAction]
() => [...rootActions, templateActions, settingsActions],
[settingsActions, templateActions]
);
useCommandBarActions(commandBarActions);
@@ -37,9 +30,7 @@ function CommandBar() {
<Positioner>
<Animator>
<SearchActions />
<SearchInput
defaultPlaceholder={`${t("Type a command or search")}`}
/>
<SearchInput defaultPlaceholder={t("Type a command or search")} />
<CommandBarResults />
</Animator>
</Positioner>
@@ -69,19 +60,13 @@ const Positioner = styled(KBarPositioner)`
`;
const SearchInput = styled(KBarSearch)`
position: relative;
padding: 16px 12px;
margin: 0 8px;
width: calc(100% - 16px);
padding: 16px 20px;
width: 100%;
outline: none;
border: none;
background: ${s("menuBackground")};
color: ${s("text")};
&:not(:last-child) {
border-bottom: 1px solid ${s("inputBorder")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
-3
View File
@@ -1,3 +0,0 @@
import CommandBar from "./CommandBar";
export default CommandBar;
@@ -1,35 +0,0 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { documentPath } from "~/utils/routeHelpers";
const useRecentDocumentActions = (count = 6) => {
const { documents, ui } = useStores();
return React.useMemo(
() =>
documents.recentlyViewed
.filter((document) => document.id !== ui.activeDocumentId)
.slice(0, count)
.map((item) =>
createAction({
name: item.titleWithDefault,
analyticsName: "Recently viewed document",
section: RecentSection,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon />
),
perform: () => history.push(documentPath(item)),
})
),
[count, ui.activeDocumentId, documents.recentlyViewed]
);
};
export default useRecentDocumentActions;
@@ -1,89 +0,0 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import {
ActiveCollectionSection,
DocumentSection,
TeamSection,
} from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
const { documents } = useStores();
React.useEffect(() => {
void documents.fetchAllTemplates();
}, [documents]);
const actions = React.useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
createAction({
name: template.titleWithDefault,
analyticsName: "New document",
section: template.isWorkspaceTemplate
? TeamSection
: ActiveCollectionSection,
icon: template.icon ? (
<Icon value={template.icon} color={template.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,
}),
{
sidebarContext,
}
),
})
),
[documents.templatesAlphabetical]
);
const newFromTemplate = React.useMemo(
() =>
createAction({
id: "templates",
name: ({ t }) => t("New from template"),
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
);
},
children: () => actions,
}),
[actions]
);
return newFromTemplate;
};
export default useTemplatesAction;
@@ -5,7 +5,7 @@ import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
import Text from "./Text";
type Props = {
action: ActionImpl;
@@ -69,8 +69,8 @@ function CommandBarItem(
) : (
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{key}</Key>
{sc.split("+").map((s) => (
<Key key={s}>{s}</Key>
))}
</React.Fragment>
))}
@@ -1,16 +1,12 @@
import { useMatches, KBarResults } from "kbar";
import * as React from "react";
import styled from "styled-components";
import Text from "~/components/Text";
import CommandBarItem from "./CommandBarItem";
import { s } from "@shared/styles";
import CommandBarItem from "~/components/CommandBarItem";
export default function CommandBarResults() {
const { results, rootActionId } = useMatches();
if (results.length === 0) {
return null;
}
return (
<Container>
<KBarResults
@@ -18,9 +14,7 @@ export default function CommandBarResults() {
maxHeight={400}
onRender={({ item, active }) =>
typeof item === "string" ? (
<Header type="tertiary" size="xsmall" ellipsis>
{item}
</Header>
<Header>{item}</Header>
) : (
<CommandBarItem
action={item}
@@ -41,10 +35,11 @@ const Container = styled.div`
}
`;
const Header = styled(Text).attrs({ as: "h3" })`
letter-spacing: 0.03em;
const Header = styled.h3`
font-size: 13px;
letter-spacing: 0.04em;
margin: 0;
padding: 16px 0 4px 20px;
color: ${s("textTertiary")};
height: 36px;
cursor: default;
`;
-64
View File
@@ -1,64 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { CollectionPermission, NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
type Props = {
/** The navigation node to move, must represent a document. */
item: NavigationNode;
/** The collection to move the document to. */
collection: Collection;
/** The parent document to move the document under. */
parentDocumentId?: string | null;
/** The index to move the document to. */
index?: number | null;
};
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 handleSubmit = async () => {
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
dialogs.closeAllModals();
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Move document")}
savingText={`${t("Moving")}`}
>
<Trans
defaults="Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>."
values={{
title: item.title,
prevCollectionName: prevCollection?.name,
newCollectionName: collection.name,
prevPermission: accessMapping[prevCollection?.permission || "null"],
newPermission: accessMapping[collection.permission || "null"],
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(ConfirmMoveDialog);
+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);
+5 -4
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
@@ -11,6 +11,7 @@ import useStores from "~/hooks/useStores";
function ConnectionStatus() {
const { ui } = useStores();
const theme = useTheme();
const { t } = useTranslation();
const codeToMessage = {
@@ -35,7 +36,7 @@ function ConnectionStatus() {
};
const message = ui.multiplayerErrorCode
? codeToMessage[ui.multiplayerErrorCode as keyof typeof codeToMessage]
? codeToMessage[ui.multiplayerErrorCode]
: undefined;
return ui.multiplayerStatus === "connecting" ||
@@ -60,7 +61,7 @@ function ConnectionStatus() {
>
<Button>
<Fade>
<DisconnectedIcon />
<DisconnectedIcon color={theme.sidebarText} />
</Fade>
</Button>
</Tooltip>
@@ -71,7 +72,7 @@ const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
margin: 20px;
margin: 24px;
transform: translateX(-32px);
${breakpoint("tablet")`
+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;
+4 -16
View File
@@ -6,7 +6,6 @@ import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
@@ -23,7 +22,7 @@ type Props = {
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactNode;
icon?: React.ReactElement | null;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
@@ -75,9 +74,9 @@ const MenuItem = (
])}
>
{selected !== undefined && (
<SelectedWrapper aria-hidden>
<MenuIconWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : <Spacer />}
</SelectedWrapper>
</MenuIconWrapper>
)}
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
<Title>{children}</Title>
@@ -109,8 +108,6 @@ const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = {
@@ -155,7 +152,7 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
&.focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
box-shadow: none;
@@ -199,13 +196,4 @@ export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
const SelectedWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 4px;
margin-left: -8px;
flex-shrink: 0;
color: ${s("textSecondary")};
`;
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
+3 -38
View File
@@ -6,7 +6,6 @@ import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useEventListener from "~/hooks/useEventListener";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
@@ -51,8 +50,6 @@ type Props = MenuStateReturn & {
onClick?: (ev: React.MouseEvent) => void;
/** The maximum width of the context menu. */
maxWidth?: number;
/** The minimum height of the context menu. */
minHeight?: number;
children?: React.ReactNode;
};
@@ -137,7 +134,6 @@ type InnerContextMenuProps = MenuStateReturn & {
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
minHeight?: number;
};
/**
@@ -175,32 +171,6 @@ const InnerContextMenu = (props: InnerContextMenuProps) => {
};
}, [props.isSubMenu, props.visible]);
useEventListener(
"animationstart",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "none";
}
}
},
backgroundRef.current
);
useEventListener(
"animationend",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "auto";
}
}
},
backgroundRef.current
);
const style =
topAnchor && !isMobile
? {
@@ -223,7 +193,6 @@ const InnerContextMenu = (props: InnerContextMenuProps) => {
<Background
dir="auto"
maxWidth={props.maxWidth}
minHeight={props.minHeight}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
@@ -254,14 +223,10 @@ export const Position = styled.div`
position: absolute;
z-index: ${depths.menu};
// Note: pointer events are re-enabled after the animation ends, see event listeners above
pointer-events: none;
&:focus-visible {
&.focus-visible {
transition-delay: 250ms;
transition-property: outline-width;
transition-duration: 0;
outline: none;
}
/*
@@ -282,7 +247,6 @@ type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
minHeight?: number;
theme: DefaultTheme;
};
@@ -294,8 +258,9 @@ export const Background = styled(Scrollable)<BackgroundProps>`
border-radius: 6px;
padding: 6px;
min-width: 180px;
min-height: ${(props) => props.minHeight || 44}px;
min-height: 44px;
max-height: 75vh;
pointer-events: all;
font-weight: normal;
@media print {
@@ -0,0 +1,34 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import { DataAttributeForm, FormData } from "./DataAttributeForm";
type Props = {
dataAttribute: DataAttribute;
onSubmit: () => void;
};
export const DataAttributeEdit = observer(function DataAttributeEdit_({
dataAttribute,
onSubmit,
}: Props) {
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await dataAttribute.save(data);
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
},
[dataAttribute, onSubmit]
);
return (
<DataAttributeForm
dataAttribute={dataAttribute}
handleSubmit={handleSubmit}
/>
);
});
@@ -0,0 +1,212 @@
import { observer } from "mobx-react";
import { CloseIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import {
DataAttributeDataType,
type DataAttributeOptions,
} from "@shared/models/types";
import { DataAttributeValidation } from "@shared/validations";
import type DataAttribute from "~/models/DataAttribute";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
import InputSelect from "../InputSelect";
import NudeButton from "../NudeButton";
type Props = {
handleSubmit: (data: FormData) => void;
dataAttribute?: DataAttribute;
};
export interface FormData {
name: string;
description?: string;
dataType: DataAttributeDataType;
options?: DataAttributeOptions;
}
export const DataAttributeForm = observer(function DataAttributeForm_({
handleSubmit,
dataAttribute,
}: Props) {
const theme = useTheme();
const { t } = useTranslation();
const {
register,
handleSubmit: formHandleSubmit,
formState,
watch,
control,
setFocus,
setValue,
} = useForm<FormData>({
mode: "all",
defaultValues: {
name: dataAttribute?.name,
description: dataAttribute?.description ?? undefined,
dataType: dataAttribute?.dataType ?? DataAttributeDataType.String,
options: dataAttribute?.options ?? undefined,
},
});
const values = watch();
const isEditing = !!dataAttribute;
React.useEffect(() => {
if (isEditing) {
return;
}
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [isEditing, setFocus]);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<div>
<Controller
control={control}
name="dataType"
render={({ field }) => (
<InputSelect
ref={field.ref}
value={field.value}
disabled={isEditing}
onChange={(value: DataAttributeDataType) => {
field.onChange(value);
if (value === DataAttributeDataType.List) {
setValue("options", {
options: [
{
value: "",
},
{
value: "",
},
],
});
}
}}
ariaLabel={t("Format")}
label={t("Format")}
options={Object.values(DataAttributeDataType).map((dataType) => ({
value: dataType,
label: DataAttributesHelper.getName(dataType, t),
}))}
style={{ width: "auto" }}
/>
)}
/>
</div>
{values.dataType === DataAttributeDataType.List && (
<Options gap={8} column>
{values.options?.options?.map((option, index) => (
<Flex gap={4} align="center" key={index}>
<Input
value={option.value}
onChange={(event) => {
const newOptions = [...(values.options?.options ?? [])];
newOptions[index] = { value: event.target.value };
setValue("options", { options: newOptions });
}}
type="text"
autoComplete="off"
autoFocus={index !== 1}
minLength={DataAttributeValidation.minOptionLength}
maxLength={DataAttributeValidation.maxOptionLength}
margin={0}
required
flex
/>
<NudeButton
disabled={
(values.options?.options?.length ?? 0) <=
DataAttributeValidation.minOptions
}
onClick={() => {
const newOptions = [...(values.options?.options ?? [])];
newOptions.splice(index, 1);
setValue("options", { options: newOptions });
}}
>
<CloseIcon color={theme.textSecondary} />
</NudeButton>
</Flex>
))}
<div>
<Controller
control={control}
name="options"
render={({ field }) => (
<Button
neutral
borderOnHover
icon={<PlusIcon size={20} />}
disabled={
(values.options?.options?.length ?? 0) >=
DataAttributeValidation.maxOptions
}
onClick={() => {
field.onChange({
options: [
...(field.value?.options ?? []),
{
value: "",
},
],
});
}}
>
{t("Add option")}
</Button>
)}
/>
</div>
</Options>
)}
<Input
type="text"
label={t("Name")}
{...register("name", {
required: true,
minLength: DataAttributeValidation.minNameLength,
maxLength: DataAttributeValidation.maxNameLength,
})}
autoComplete="off"
autoFocus
flex
/>
<Input
type="text"
label={t("Description")}
placeholder={t("Optional")}
{...register("description", {
maxLength: DataAttributeValidation.maxDescriptionLength,
})}
autoComplete="off"
flex
/>
<Flex justify="flex-end">
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{dataAttribute
? formState.isSubmitting
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
);
});
const Options = styled(Flex)`
margin-left: 16px;
margin-bottom: 16px;
`;
@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import useStores from "~/hooks/useStores";
import { DataAttributeForm, FormData } from "./DataAttributeForm";
type Props = {
onSubmit: () => void;
};
export const DataAttributeNew = observer(function DataAttributeNew_({
onSubmit,
}: Props) {
const { dataAttributes } = useStores();
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const dataAttribute = new DataAttribute(data, dataAttributes);
await dataAttribute.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
},
[dataAttributes, onSubmit]
);
return <DataAttributeForm handleSubmit={handleSubmit} />;
});
@@ -49,7 +49,7 @@ const DefaultCollectionInputSelect = ({
const options = React.useMemo(
() =>
collections.nonPrivate.reduce(
collections.publicCollections.reduce(
(acc, collection) => [
...acc,
{
@@ -78,7 +78,7 @@ const DefaultCollectionInputSelect = ({
},
]
),
[collections.nonPrivate, t]
[collections.publicCollections, t]
);
if (fetching) {
+20 -24
View File
@@ -3,16 +3,19 @@ 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,34 +56,30 @@ 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;
const can = usePolicy(collection);
React.useEffect(() => {
void document.loadRelations({ withoutPolicies: true });
void document.loadRelations();
}, [document]);
let collectionNode: MenuInternalLink | undefined;
if (collection && can.readDocument) {
if (collection) {
collectionNode = {
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 +113,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 +138,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 +158,4 @@ const SmallSlash = styled(GoToIcon)`
opacity: 0.5;
`;
export default observer(React.forwardRef(DocumentBreadcrumb));
export default observer(DocumentBreadcrumb);
+11 -6
View File
@@ -7,17 +7,18 @@ 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 { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -38,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;
@@ -122,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" />
@@ -143,7 +143,12 @@ function DocumentCard(props: Props) {
</Heading>
<DocumentMeta size="xsmall">
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
<Time
dateTime={document.updatedAt}
tooltipDelay={500}
addSuffix
shorten
/>
</DocumentMeta>
</div>
</Content>
+37
View File
@@ -0,0 +1,37 @@
import * as React from "react";
import { Editor } from "~/editor";
import useIdle from "~/hooks/useIdle";
export type DocumentContextValue = {
/** The current editor instance for this document. */
editor: Editor | null;
/** Set the current editor instance for this document. */
setEditor: (editor: Editor) => void;
};
const DocumentContext = React.createContext<DocumentContextValue>({
editor: null,
// eslint-disable-next-line @typescript-eslint/no-empty-function
setEditor() {},
});
export const useDocumentContext = () => React.useContext(DocumentContext);
const activityEvents = [
"click",
"mousemove",
"DOMMouseScroll",
"mousewheel",
"mousedown",
"touchstart",
"touchmove",
"focus",
];
export const useEditingFocus = () => {
const { editor } = useDocumentContext();
const isIdle = useIdle(3000, activityEvents);
return isIdle && !!editor?.view.hasFocus();
};
export default DocumentContext;
-84
View File
@@ -1,84 +0,0 @@
import { action, computed, observable } from "mobx";
import React, { PropsWithChildren } from "react";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import Document from "~/models/Document";
import { Editor } from "~/editor";
class DocumentContext {
/** The current document */
document?: Document;
/** The editor instance for this document */
editor?: Editor;
@observable
isEditorInitialized: boolean = false;
@observable
headings: Heading[] = [];
@computed
get hasHeadings() {
return this.headings.length > 0;
}
@action
setDocument = (document: Document) => {
this.document = document;
this.updateState();
};
@action
setEditor = (editor: Editor) => {
this.editor = editor;
this.updateState();
};
@action
setEditorInitialized = (initialized: boolean) => {
this.isEditorInitialized = initialized;
};
@action
updateState = () => {
this.updateHeadings();
this.updateTasks();
};
private updateHeadings() {
const currHeadings = this.editor?.getHeadings() ?? [];
const hasChanged =
currHeadings.map((h) => h.level + h.title).join("") !==
this.headings.map((h) => h.level + h.title).join("");
if (hasChanged) {
this.headings = currHeadings;
}
}
private updateTasks() {
const tasks = this.editor?.getTasks() ?? [];
const total = tasks.length ?? 0;
const completed = tasks.filter((t) => t.completed).length ?? 0;
this.document?.updateTasks(total, completed);
}
}
const Context = React.createContext<DocumentContext | null>(null);
export const useDocumentContext = () => {
const ctx = React.useContext(Context);
if (!ctx) {
throw new Error(
"useDocumentContext must be used within DocumentContextProvider"
);
}
return ctx;
};
export const DocumentContextProvider = ({
children,
}: PropsWithChildren<unknown>) => {
const context = React.useMemo(() => new DocumentContext(), []);
return <Context.Provider value={context}>{children}</Context.Provider>;
};
-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);
+8 -30
View File
@@ -11,35 +11,35 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import scrollIntoView from "smooth-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)
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
+14 -18
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> =
@@ -78,13 +76,8 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const sidebarContext = determineSidebarContext({
document,
user,
currentContext: locationSidebarContext,
});
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
return (
<DocumentLink
@@ -97,7 +90,6 @@ function DocumentListItem(
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
@@ -119,16 +111,20 @@ function DocumentListItem(
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isDraft && showDraft && (
<Tooltip
content={t("Only visible to you")}
delay={500}
placement="top"
>
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
+3 -7
View File
@@ -140,7 +140,7 @@ const DocumentMeta: React.FC<Props> = ({
}
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
? collection.getDocumentChildren(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
@@ -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")};
@@ -0,0 +1,49 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
type Props = {
documentId: string;
};
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize();
if (template) {
history.push(documentPath(template));
toast.success(t("Template created, go ahead and customize it"));
}
}, [document, history, t]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);
+1 -1
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar } from "~/components/Avatar";
import Avatar from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import useCurrentUser from "~/hooks/useCurrentUser";
+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("Published")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
</>
)}
</ConfirmationDialog>
);
}
export default observer(DuplicateDialog);
+112 -15
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,11 @@ import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import { AttachmentPreset } from "@shared/types";
import { Heading } from "@shared/utils/ProsemirrorHelper";
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 +23,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"));
@@ -33,21 +43,92 @@ export type Props = Optional<
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
onHeadingsChange?: (headings: Heading[]) => void;
onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => void;
editorStyle?: React.CSSProperties;
};
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
props;
const { comments } = useStores();
const {
id,
shareId,
onChange,
onHeadingsChange,
onCreateCommentMark,
onDeleteCommentMark,
} = props;
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 previousHeadings = React.useRef<Heading[] | null>(null);
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, {
@@ -131,6 +212,21 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[]
);
// Calculate if headings have changed and trigger callback if so
const updateHeadings = React.useCallback(() => {
if (onHeadingsChange) {
const headings = localRef?.current?.getHeadings();
if (
headings &&
headings.map((h) => h.level + h.title).join("") !==
previousHeadings.current?.map((h) => h.level + h.title).join("")
) {
previousHeadings.current = headings;
onHeadingsChange(headings);
}
}
}, [localRef, onHeadingsChange]);
const updateComments = React.useCallback(() => {
if (onCreateCommentMark && onDeleteCommentMark && localRef.current) {
const commentMarks = localRef.current.getComments();
@@ -165,25 +261,26 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const handleChange = React.useCallback(
(event) => {
onChange?.(event);
updateHeadings();
updateComments();
},
[onChange, updateComments]
[onChange, updateComments, updateHeadings]
);
const handleRefChanged = React.useCallback(
(node: SharedEditor | null) => {
if (node) {
updateHeadings();
updateComments();
}
},
[updateComments]
[updateComments, updateHeadings]
);
return (
<ErrorBoundary component="div" reloadOnChunkMissing>
<>
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
@@ -191,19 +288,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;
+7 -17
View File
@@ -12,22 +12,21 @@ import * as React from "react";
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 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 +34,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 +65,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 +139,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",
@@ -162,9 +158,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
}
actions={
isRevision && isActive && event.modelId && !latest ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.modelId} />
</StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
onMouseEnter={prefetchRevision}
@@ -181,10 +175,6 @@ const BaseItem = React.forwardRef(function _BaseItem(
return <ListItem to={to} ref={ref} {...rest} />;
});
const StyledEventBoundary = styled(EventBoundary)`
height: 24px;
`;
const Subtitle = styled.span`
svg {
margin: -3px;
+2 -1
View File
@@ -3,8 +3,9 @@ import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Avatar from "~/components/Avatar";
import Flex from "~/components/Flex";
import { AvatarSize } from "./Avatar/Avatar";
type Props = {
users: User[];
+25 -183
View File
@@ -1,23 +1,18 @@
import deburr from "lodash/deburr";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { FetchPageParams } from "~/stores/base/Store";
import Button, { Inner } from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Text from "~/components/Text";
import Input, { NativeInput, Outline } from "./Input";
import PaginatedList, { PaginatedItem } from "./PaginatedList";
interface TFilterOption extends PaginatedItem {
type TFilterOption = {
key: string;
label: string;
note?: string;
icon?: React.ReactNode;
}
};
type Props = {
options: TFilterOption[];
@@ -26,9 +21,6 @@ type Props = {
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
fetchQueryOptions?: Record<string, string>;
};
const FilterOptions = ({
@@ -38,20 +30,13 @@ const FilterOptions = ({
selectedPrefix = "",
className,
onSelect,
showFilter,
fetchQuery,
fetchQueryOptions,
}: Props) => {
const { t } = useTranslation();
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)
);
const [query, setQuery] = React.useState("");
const selectedLabel = selectedItems.length
? selectedItems
@@ -59,109 +44,6 @@ const FilterOptions = ({
.join(", ")
: "";
const renderItem = React.useCallback(
(option: TFilterOption) => (
<MenuItem
key={option.key}
onClick={() => {
onSelect(option.key);
menu.hide();
}}
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon && <Icon>{option.icon}</Icon>}
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
),
[menu, onSelect, selectedKeys]
);
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
};
const filteredOptions = React.useMemo(() => {
const normalizedQuery = deburr(query.toLowerCase());
return query
? options
.filter((option) =>
deburr(option.label).toLowerCase().includes(normalizedQuery)
)
// sort options starting with query first
.sort((a, b) => {
const aStartsWith = deburr(a.label)
.toLowerCase()
.startsWith(normalizedQuery);
const bStartsWith = deburr(b.label)
.toLowerCase()
.startsWith(normalizedQuery);
if (aStartsWith && !bStartsWith) {
return -1;
}
if (!aStartsWith && bStartsWith) {
return 1;
}
return 0;
})
: options;
}, [options, query]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent) => {
if (ev.nativeEvent.isComposing || ev.shiftKey) {
return;
}
switch (ev.key) {
case "Escape":
menu.hide();
break;
case "Enter":
if (filteredOptions.length === 1) {
ev.preventDefault();
onSelect(filteredOptions[0].key);
menu.hide();
}
break;
case "ArrowDown":
ev.preventDefault();
(listRef.current?.firstElementChild as HTMLElement)?.focus();
break;
default:
break;
}
},
[filteredOptions, menu, onSelect]
);
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
searchInputRef.current?.focus();
if (ev.key === "Backspace") {
setQuery((prev) => prev.slice(0, -1));
}
}, []);
React.useEffect(() => {
if (menu.visible) {
searchInputRef.current?.focus();
} else {
setQuery("");
}
}, [menu.visible]);
const showFilterInput = showFilter || options.length > 10;
return (
<div>
<MenuButton {...menu}>
@@ -171,73 +53,33 @@ const FilterOptions = ({
</StyledButton>
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
<PaginatedList
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
/>
{showFilterInput && (
<SearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
autoFocus
/>
)}
<ContextMenu aria-label={defaultLabel} {...menu}>
{options.map((option) => (
<MenuItem
key={option.key}
onClick={() => {
onSelect(option.key);
menu.hide();
}}
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon && <Icon>{option.icon}</Icon>}
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
))}
</ContextMenu>
</div>
);
};
const Empty = () => {
const { t } = useTranslation();
return (
<>
<Spacer />
<Text size="small" type="tertiary" style={{ marginLeft: 6 }}>
{t("No results")}
</Text>
</>
);
};
const Spacer = styled.div`
height: 30px;
`;
const SearchInput = styled(Input)`
position: absolute;
width: 100%;
border: none;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
overflow: hidden;
margin: 0;
top: 0;
left: 0;
right: 0;
${Outline} {
border: none;
border-radius: 0;
border-bottom: 1px solid ${s("divider")};
background: ${s("menuBackground")};
}
${NativeInput} {
font-size: 14px;
}
`;
const Note = styled(Text)`
display: block;
margin: 2px 0;
+94
View File
@@ -0,0 +1,94 @@
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 useStores from "~/hooks/useStores";
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 { groupUsers } = useStores();
const { t } = useTranslation();
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
useBoolean();
const memberCount = group.memberCount;
const membershipsInGroup = groupUsers.inGroup(group.id);
const users = membershipsInGroup
.slice(0, MAX_AVATAR_DISPLAY)
.map((gm) => gm.user);
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
@@ -1,8 +1,8 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Avatar from "../Avatar";
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
import Text from "../Text";
import Time from "../Time";
@@ -1,13 +1,14 @@
import * as React from "react";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import Flex from "~/components/Flex";
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 +26,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>
@@ -1,8 +1,8 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Avatar from "../Avatar";
import { PullRequestIcon } from "../Icons/PullRequestIcon";
import Text from "../Text";
import Time from "../Time";
@@ -2,13 +2,15 @@ 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 breakpoint from "styled-components-breakpoint";
import { randomElement } from "@shared/random";
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 +42,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 +64,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,7 +82,8 @@ const SVGIcon = observer(
forceColor,
}: Props) => {
const { ui } = useStores();
let color = inputColor ?? colorPalette[0];
let color = inputColor ?? randomElement(colorPalette);
// If the chosen icon color is very dark then we invert it in dark mode
if (!forceColor) {
@@ -116,7 +119,12 @@ export const IconTitleWrapper = styled(Flex)<{ dir?: string }>`
z-index: 1;
${(props: { dir?: string }) =>
props.dir === "rtl" ? "right: -44px" : "left: -44px"};
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
${breakpoint("desktop")`
${(props: { dir?: string }) =>
props.dir === "rtl" ? "right: -44px" : "left: -44px"};
`}
`;
export default Icon;
@@ -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,
@@ -79,8 +80,8 @@ const BuiltinColors = ({
{colorPalette.map((color) => (
<ColorButton
key={color}
$color={color}
$active={color === activeColor}
color={color}
active={color === activeColor}
onClick={() => onClick(color)}
>
<Selected />
@@ -155,22 +156,22 @@ const Selected = styled.span`
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButton = styled(NudeButton)<{ $color: string; $active: boolean }>`
const ColorButton = styled(NudeButton)<{ color: string; active: boolean }>`
display: inline-flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: ${({ $color }) => $color};
background-color: ${({ color }) => color};
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: ${({ $color }) => `0px 0px 3px 3px ${$color}`};
box-shadow: ${({ color }) => `0px 0px 3px 3px ${color}`};
}
& ${Selected} {
display: ${({ $active }) => ($active ? "block" : "none")};
display: ${({ active }) => (active ? "block" : "none")};
}
`;
@@ -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}
/>
@@ -44,7 +44,6 @@ const Row = ({ index, style, data }: ListChildComponentProps<RowProps>) => {
const Container = styled(FixedSizeList<RowProps>)`
padding: 0px 12px;
overflow-x: hidden !important;
// Needed for the absolutely positioned children
// to respect the VirtualList's padding
@@ -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>
+21 -21
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: "";
@@ -309,4 +309,4 @@ const StyledTabPanel = styled(TabPanel)`
overflow-y: auto;
`;
export default React.memo(IconPicker);
export default IconPicker;
+3 -7
View File
@@ -1,10 +1,10 @@
import { observer } from "mobx-react";
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
import { CollectionIcon } 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 = {
@@ -40,11 +40,8 @@ function ResolvedCollectionIcon({
: "currentColor"
: collectionColor);
const Component = collection.isPrivate
? PrivateCollectionIcon
: CollectionIcon;
return (
<Component
<CollectionIcon
color={color}
expanded={expanded}
size={size}
@@ -60,7 +57,6 @@ function ResolvedCollectionIcon({
size={size}
initial={collection.initial}
className={className}
forceColor={inputColor ? true : false}
/>
);
}
@@ -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 */
+12 -4
View File
@@ -143,8 +143,12 @@ export interface Props
onRequestSubmit?: (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
onFocus?: (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onBlur?: (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
}
function Input(
@@ -154,7 +158,9 @@ function Input(
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
const [focused, setFocused] = React.useState(false);
const handleBlur = (ev: React.SyntheticEvent) => {
const handleBlur = (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFocused(false);
if (props.onBlur) {
@@ -162,7 +168,9 @@ function Input(
}
};
const handleFocus = (ev: React.SyntheticEvent) => {
const handleFocus = (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFocused(true);
if (props.onFocus) {
+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";
+12 -7
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;
@@ -55,6 +55,8 @@ export type Props = Omit<ButtonProps<any>, "onChange"> & {
* The Modal will take care of preventing body scroll behaviour.
*/
skipBodyScroll?: boolean;
autoFocus?: boolean;
placeholder?: string;
};
export interface InputSelectRef {
@@ -85,6 +87,8 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
icon,
nude,
skipBodyScroll,
autoFocus,
placeholder,
...rest
} = props;
@@ -214,6 +218,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
neutral
disclosure
className={className}
autoFocus={autoFocus}
icon={icon}
$nude={nude}
{...buttonProps}
@@ -221,7 +226,9 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
{option ? (
labelForOption(option)
) : (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
<Placeholder>
{placeholder ?? `Select a ${ariaLabel.toLowerCase()}`}
</Placeholder>
)}
</StyledButton>
)}
@@ -313,7 +320,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")};
@@ -352,9 +359,7 @@ const Wrapper = styled.label<{ short?: boolean }>`
`;
export const Positioner = styled(Position)`
pointer-events: all;
&:focus-visible {
&.focus-visible {
${StyledSelectOption} {
&[aria-selected="true"] {
color: ${(props) => props.theme.white};
+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>
+3 -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;
@@ -41,6 +41,7 @@ const Layout = React.forwardRef(function Layout_(
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</Helmet>
<SkipNavLink />
@@ -76,6 +77,7 @@ const Layout = React.forwardRef(function Layout_(
const Container = styled(Flex)`
background: ${s("background")};
transition: ${s("backgroundTransition")};
position: relative;
width: 100%;
min-height: 100%;
+8 -11
View File
@@ -4,11 +4,12 @@ import {
} from "@getoutline/react-roving-tabindex";
import { LocationDescriptor } from "history";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import scrollIntoView from "smooth-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 */
@@ -141,14 +142,10 @@ const ListItem = (
$hover={!!rest.onClick}
{...rest}
{...rovingTabIndex}
onClick={
rest.onClick
? (ev) => {
rest.onClick?.(ev);
rovingTabIndex.onClick(ev);
}
: undefined
}
onClick={(ev) => {
rest.onClick?.(ev);
rovingTabIndex.onClick(ev);
}}
onKeyDown={(ev) => {
rest.onKeyDown?.(ev);
rovingTabIndex.onKeyDown(ev);
@@ -191,7 +188,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>
);
+3 -1
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,12 +265,13 @@ 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;
${NudeButton} {
&:hover,
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { LocationDescriptor, LocationDescriptorObject } from "history";
import * as React from "react";
import { type match, NavLink, Route } from "react-router-dom";
import { match, NavLink, Route } from "react-router-dom";
type Props = React.ComponentProps<typeof NavLink> & {
children?: (
@@ -4,11 +4,13 @@ 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 { Avatar, AvatarSize } from "../Avatar";
import { hover, truncateMultiline } from "~/styles";
import Avatar from "../Avatar";
import { AvatarSize } from "../Avatar/Avatar";
import Flex from "../Flex";
import Text from "../Text";
import Time from "../Time";
@@ -51,7 +53,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>
</>
);

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