Compare commits

..

12 Commits

Author SHA1 Message Date
Tom Moor bfaed5d9cb test 2022-09-05 15:38:00 +02:00
Tom Moor 5923281edb Allow arbitrary revisions to be compared 2022-09-05 13:55:07 +02:00
Tom Moor 01bfe2bde7 Add revisions.diff endpoint, first version 2022-09-05 13:35:27 +02:00
Tom Moor 9c6780adab Allow DocumentHelper to be used with Revisions 2022-09-05 12:58:40 +02:00
Tom Moor 93f1d4cfc7 div>article for easier programatic content extraction 2022-09-05 11:52:12 +02:00
Tom Moor 21a43dfc5e Refactor to allow for styling of HTML export 2022-09-04 22:57:54 +02:00
Tom Moor 47fafb5d69 fix nodes that required document to render 2022-09-04 17:29:02 +02:00
Tom Moor 32d76eeb9e docs 2022-09-04 17:07:26 +02:00
Tom Moor 99bef2c02b Add HTML download option to UI 2022-09-04 16:30:35 +02:00
Tom Moor 1125412972 fix: Add compatability for documents without collab state 2022-09-04 16:10:03 +02:00
Tom Moor 2e0d160fcc Add title to HTML export 2022-09-04 15:58:59 +02:00
Tom Moor 21e31be517 tidy 2022-09-04 15:31:42 +02:00
396 changed files with 6310 additions and 10136 deletions
+4 -7
View File
@@ -21,7 +21,7 @@ DATABASE_CONNECTION_POOL_MAX=
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://localhost:6379
# or alternatively, if you would like to provide additional connection options,
# or alternatively, if you would like to provide addtional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
# Example: Use Redis Sentinel for high availability
@@ -38,7 +38,7 @@ PORT=3000
COLLABORATION_URL=
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundancy
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
@@ -131,7 +131,7 @@ ENABLE_UPDATES=true
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# Override the maximum size of document imports, could be required if you have
# Override the maxium size of document imports, could be required if you have
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
@@ -150,11 +150,8 @@ SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# 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)
# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
+1 -1
View File
@@ -11,4 +11,4 @@ fakes3/*
.idea
*.pem
*.key
*.cert
*.cert
-4
View File
@@ -195,10 +195,6 @@
"description": "An API key for Sentry if you wish to collect error reporting (optional)",
"required": false
},
"SENTRY_TUNNEL": {
"description": "A sentry tunnel URL for bypassing ad blockers in the UI (optional)",
"required": false
},
"TEAM_LOGO": {
"description": "A logo that will be displayed on the signed out home page",
"required": false
+1 -24
View File
@@ -1,7 +1,6 @@
import {
CollectionIcon,
EditIcon,
PadlockIcon,
PlusIcon,
StarredIcon,
UnstarredIcon,
@@ -11,7 +10,6 @@ import stores from "~/stores";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionNew from "~/scenes/CollectionNew";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import DynamicCollectionIcon from "~/components/CollectionIcon";
import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
@@ -58,8 +56,7 @@ export const createCollection = createAction({
});
export const editCollection = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
name: ({ t }) => t("Edit collection"),
section: CollectionSection,
icon: <EditIcon />,
visible: ({ stores, activeCollectionId }) =>
@@ -82,26 +79,6 @@ export const editCollection = createAction({
},
});
export const editCollectionPermissions = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
section: CollectionSection,
icon: <PadlockIcon />,
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
stores.dialogs.openModal({
title: t("Collection permissions"),
content: <CollectionPermissions collectionId={activeCollectionId} />,
});
},
});
export const starCollection = createAction({
name: ({ t }) => t("Star"),
section: CollectionSection,
-20
View File
@@ -17,7 +17,6 @@ import {
TrashIcon,
CrossIcon,
ArchiveIcon,
ShuffleIcon,
} from "outline-icons";
import * as React from "react";
import { getEventFiles } from "@shared/utils/files";
@@ -417,24 +416,6 @@ export const createTemplate = createAction({
},
});
export const openRandomDocument = createAction({
id: "random",
section: DocumentSection,
name: ({ t }) => t(`Open random document`),
icon: <ShuffleIcon />,
perform: ({ stores, activeDocumentId }) => {
const documentPaths = stores.collections.pathsToDocuments.filter(
(path) => path.type === "document" && path.id !== activeDocumentId
);
const documentPath =
documentPaths[Math.round(Math.random() * documentPaths.length)];
if (documentPath) {
history.push(documentPath.url);
}
},
});
export const searchDocumentsForQuery = (searchQuery: string) =>
createAction({
id: "search",
@@ -585,7 +566,6 @@ export const rootDocumentActions = [
unsubscribeDocument,
duplicateDocument,
moveDocument,
openRandomDocument,
permanentlyDeleteDocument,
printDocument,
pinDocumentToCollection,
-9
View File
@@ -28,7 +28,6 @@ import history from "~/utils/history";
import {
organizationSettingsPath,
profileSettingsPath,
accountPreferencesPath,
homePath,
searchPath,
draftsPath,
@@ -105,14 +104,6 @@ export const navigateToProfileSettings = createAction({
perform: () => history.push(profileSettingsPath()),
});
export const navigateToAccountPreferences = createAction({
name: ({ t }) => t("Preferences"),
section: NavigationSection,
iconInContextMenu: false,
icon: <SettingsIcon />,
perform: () => history.push(accountPreferencesPath()),
});
export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"),
section: NavigationSection,
-85
View File
@@ -1,85 +0,0 @@
import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon } from "outline-icons";
import * as React from "react";
import { matchPath } from "react-router-dom";
import stores from "~/stores";
import { createAction } from "~/actions";
import { RevisionSection } from "~/actions/sections";
import history from "~/utils/history";
import { documentHistoryUrl, matchDocumentHistory } from "~/utils/routeHelpers";
export const restoreRevision = createAction({
name: ({ t }) => t("Restore revision"),
icon: <RestoreIcon />,
section: RevisionSection,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ t, event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const { team } = stores.auth;
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
if (team?.collaborativeEditing) {
history.push(document.url, {
restore: true,
revisionId,
});
} else {
await document.restore({
revisionId,
});
stores.toasts.showToast(t("Document restored"), {
type: "success",
});
history.push(document.url);
}
},
});
export const copyLinkToRevision = createAction({
name: ({ t }) => t("Copy link"),
icon: <LinkIcon />,
section: RevisionSection,
perform: async ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
const url = `${window.location.origin}${documentHistoryUrl(
document,
revisionId
)}`;
copy(url, {
format: "text/plain",
onCopy: () => {
stores.toasts.showToast(t("Link copied"), {
type: "info",
});
},
});
},
});
export const rootRevisionActions = [];
+25 -49
View File
@@ -1,64 +1,40 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import TeamNew from "~/scenes/TeamNew";
import { createAction } from "~/actions";
import { loadSessionsFromCookie } from "~/hooks/useSessions";
import { TeamSection } from "../sections";
export const switchTeamList = getSessions().map((session) => {
return createAction({
name: session.name,
section: TeamSection,
keywords: "change switch workspace organization team",
icon: () => <Logo alt={session.name} src={session.logoUrl} />,
visible: ({ currentTeamId }) => currentTeamId !== session.teamId,
perform: () => (window.location.href = session.url),
});
});
const switchTeam = createAction({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
keywords: "change switch workspace organization team",
section: TeamSection,
visible: ({ currentTeamId }) =>
getSessions({ exclude: currentTeamId }).length > 0,
children: switchTeamList,
});
export const createTeam = createAction({
name: ({ t }) => `${t("New workspace")}`,
keywords: "create change switch workspace organization team",
section: TeamSection,
icon: <PlusIcon />,
visible: ({ stores, currentTeamId }) => {
return stores.policies.abilities(currentTeamId ?? "").createTeam;
export const changeTeam = createAction({
name: ({ t }) => t("Switch team"),
placeholder: ({ t }) => t("Select a team"),
keywords: "change workspace organization",
section: "Account",
visible: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.length > 0;
},
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
const { user } = stores.auth;
user &&
stores.dialogs.openModal({
title: t("Create a workspace"),
content: <TeamNew user={user} />,
});
children: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.map((session) => ({
id: session.url,
name: session.name,
section: "Account",
icon: <Logo alt={session.name} src={session.logoUrl} />,
perform: () => (window.location.href = session.url),
}));
},
});
function getSessions(params?: { exclude?: string }) {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== params?.exclude
);
return otherSessions;
}
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export const rootTeamActions = [switchTeam, createTeam];
export const rootTeamActions = [changeTeam];
+1 -1
View File
@@ -8,7 +8,7 @@ import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,
icon: <PlusIcon />,
keywords: "team member workspace user",
keywords: "team member user",
section: UserSection,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
-2
View File
@@ -2,7 +2,6 @@ import { rootCollectionActions } from "./definitions/collections";
import { rootDeveloperActions } from "./definitions/developer";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootRevisionActions } from "./definitions/revisions";
import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams";
import { rootUserActions } from "./definitions/users";
@@ -12,7 +11,6 @@ export default [
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
...rootRevisionActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
-4
View File
@@ -6,15 +6,11 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const RevisionSection = ({ t }: ActionContext) => t("Revision");
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { Action, ActionContext } from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
export type Props = {
/** Show the button in a disabled state */
disabled?: boolean;
/** Hide the button entirely if action is not applicable */
@@ -2,8 +2,8 @@
import * as React from "react";
import env from "~/env";
const Analytics: React.FC = ({ children }) => {
React.useEffect(() => {
export default class Analytics extends React.Component {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) {
return;
}
@@ -33,9 +33,9 @@ const Analytics: React.FC = ({ children }) => {
if (document.body) {
document.body.appendChild(script);
}
}, []);
}
return <>{children}</>;
};
export default Analytics;
render() {
return this.props.children || null;
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import {
CompositeStateReturn,
} from "reakit/Composite";
type Props = React.HTMLAttributes<HTMLDivElement> & {
type Props = {
children: (composite: CompositeStateReturn) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
+54 -55
View File
@@ -1,23 +1,23 @@
import { AnimatePresence } from "framer-motion";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { withTranslation, WithTranslation } from "react-i18next";
import { Switch, Route } from "react-router-dom";
import RootStore from "~/stores/RootStore";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SettingsSidebar from "~/components/Sidebar/Settings";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import {
searchPath,
matchDocumentSlug as slug,
newDocumentPath,
settingsPath,
matchDocumentHistory,
} from "~/utils/routeHelpers";
import Fade from "./Fade";
import withStores from "./withStores";
const DocumentHistory = React.lazy(
() =>
@@ -34,13 +34,16 @@ const CommandBar = React.lazy(
)
);
const AuthenticatedLayout: React.FC = ({ children }) => {
const { ui, auth } = useStores();
const location = useLocation();
const can = usePolicy(ui.activeCollectionId);
const { user, team } = auth;
type Props = WithTranslation & RootStore;
const goToSearch = (ev: KeyboardEvent) => {
@observer
class AuthenticatedLayout extends React.Component<Props> {
scrollable: HTMLDivElement | null | undefined;
@observable
keyboardShortcutsOpen = false;
goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
ev.stopPropagation();
@@ -48,64 +51,60 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
}
};
const goToNewDocument = (event: KeyboardEvent) => {
goToNewDocument = (event: KeyboardEvent) => {
if (event.metaKey || event.altKey) {
return;
}
const { activeCollectionId } = ui;
if (!activeCollectionId || !can.update) {
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) {
return;
}
const can = this.props.policies.abilities(activeCollectionId);
if (!can.update) {
return;
}
history.push(newDocumentPath(activeCollectionId));
};
if (auth.isSuspended) {
return <ErrorSuspended />;
}
render() {
const { auth } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const showSidebar = auth.authenticated && user && team;
const sidebar = showSidebar ? (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
) : undefined;
const sidebar = showSidebar ? (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
) : undefined;
const sidebarRight = (
<React.Suspense fallback={null}>
<AnimatePresence key={ui.activeDocumentId}>
<Switch
location={location}
key={
matchPath(location.pathname, {
path: matchDocumentHistory,
})
? "history"
: ""
}
>
const rightRail = (
<React.Suspense fallback={null}>
<Switch>
<Route
key="document-history"
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</AnimatePresence>
</React.Suspense>
);
</React.Suspense>
);
return (
<Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
</Layout>
);
};
return (
<Layout title={team?.name} sidebar={sidebar} rightRail={rightRail}>
<RegisterKeyDown trigger="n" handler={this.goToNewDocument} />
<RegisterKeyDown trigger="t" handler={this.goToSearch} />
<RegisterKeyDown trigger="/" handler={this.goToSearch} />
{this.props.children}
<CommandBar />
</Layout>
);
}
}
export default observer(AuthenticatedLayout);
export default withTranslation()(withStores(AuthenticatedLayout));
+105 -78
View File
@@ -1,114 +1,141 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { WithTranslation, withTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import User from "~/models/User";
import UserProfile from "~/scenes/UserProfile";
import Avatar from "~/components/Avatar";
import Tooltip from "~/components/Tooltip";
type Props = {
type Props = WithTranslation & {
user: User;
isPresent: boolean;
isEditing: boolean;
isObserving: boolean;
isCurrentUser: boolean;
profileOnClick: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
};
function AvatarWithPresence({
onClick,
user,
isPresent,
isEditing,
isObserving,
isCurrentUser,
}: Props) {
const { t } = useTranslation();
const status = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
@observer
class AvatarWithPresence extends React.Component<Props> {
@observable
isOpen = false;
return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && (
<>
<br />
{status}
</>
)}
</Centered>
}
placement="bottom"
>
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
handleOpenProfile = () => {
this.isOpen = true;
};
handleCloseProfile = () => {
this.isOpen = false;
};
render() {
const {
onClick,
user,
isPresent,
isEditing,
isObserving,
isCurrentUser,
t,
} = this.props;
const status = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && (
<>
<br />
{status}
</>
)}
</Centered>
}
placement="bottom"
>
<Avatar src={user.avatarUrl} onClick={onClick} size={32} />
</AvatarWrapper>
</Tooltip>
</>
);
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
>
<Avatar
src={user.avatarUrl}
onClick={
this.props.profileOnClick === false
? onClick
: this.handleOpenProfile
}
size={32}
/>
</AvatarWrapper>
</Tooltip>
{this.props.profileOnClick && (
<UserProfile
user={user}
isOpen={this.isOpen}
onRequestClose={this.handleCloseProfile}
/>
)}
</>
);
}
}
const Centered = styled.div`
text-align: center;
`;
type AvatarWrapperProps = {
const AvatarWrapper = styled.div<{
$isPresent: boolean;
$isObserving: boolean;
$color: string;
};
const AvatarWrapper = styled.div<AvatarWrapperProps>`
}>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
position: relative;
${(props) =>
props.$isPresent &&
css<AvatarWrapperProps>`
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
transition: border-color 100ms ease-in-out;
border: 2px solid transparent;
pointer-events: none;
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
transition: border-color 100ms ease-in-out;
border: 2px solid transparent;
pointer-events: none;
${(props) =>
props.$isObserving &&
css`
border: 2px solid ${props.$color};
box-shadow: inset 0 0 0 2px ${props.theme.background};
${(props) =>
props.$isObserving &&
css`
border: 2px solid ${props.$color};
box-shadow: inset 0 0 0 2px ${props.theme.background};
&:hover {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
`}
}
&:hover {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
`}
}
&:hover:after {
border: 2px solid ${(props) => props.$color};
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
}
`}
&:hover:after {
border: 2px solid ${(props) => props.$color};
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
}
`;
export default observer(AvatarWithPresence);
export default withTranslation()(AvatarWithPresence);
-1
View File
@@ -67,7 +67,6 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
display: flex;
flex-shrink: 1;
min-width: 0;
cursor: var(--pointer);
color: ${(props) => props.theme.text};
font-size: 15px;
height: 24px;
+6 -18
View File
@@ -3,19 +3,14 @@ import { ExpandedIcon } from "outline-icons";
import { darken, lighten } from "polished";
import * as React from "react";
import styled from "styled-components";
import ActionButton, {
Props as ActionButtonProps,
} from "~/components/ActionButton";
type RealProps = {
const RealButton = styled.button<{
fullwidth?: boolean;
borderOnHover?: boolean;
$neutral?: boolean;
danger?: boolean;
iconColor?: string;
};
const RealButton = styled(ActionButton)<RealProps>`
}>`
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
margin: 0;
@@ -30,7 +25,7 @@ const RealButton = styled(ActionButton)<RealProps>`
height: 32px;
text-decoration: none;
flex-shrink: 0;
cursor: var(--pointer);
cursor: pointer;
user-select: none;
appearance: none !important;
@@ -151,7 +146,7 @@ export const Inner = styled.span<{
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
export type Props<T> = ActionButtonProps & {
export type Props<T> = {
icon?: React.ReactNode;
iconColor?: string;
children?: React.ReactNode;
@@ -173,19 +168,12 @@ const Button = <T extends React.ElementType = "button">(
props: Props<T> & React.ComponentPropsWithoutRef<T>,
ref: React.Ref<HTMLButtonElement>
) => {
const { type, children, value, disclosure, neutral, action, ...rest } = props;
const { type, icon, children, value, disclosure, neutral, ...rest } = props;
const hasText = children !== undefined || value !== undefined;
const icon = action?.icon ?? rest.icon;
const hasIcon = icon !== undefined;
return (
<RealButton
type={type || "button"}
ref={ref}
$neutral={neutral}
action={action}
{...rest}
>
<RealButton type={type || "button"} ref={ref} $neutral={neutral} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
+1
View File
@@ -90,6 +90,7 @@ function Collaborators(props: Props) {
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
profileOnClick={false}
onClick={
isObservable
? (ev) => {
+1 -1
View File
@@ -38,10 +38,10 @@ function CommandBar() {
return (
<>
<SearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchActions />
<SearchInput
placeholder={`${
rootAction?.placeholder ||
+1 -1
View File
@@ -98,7 +98,7 @@ const Item = styled.div<{ active?: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
cursor: var(--pointer);
cursor: pointer;
text-overflow: ellipsis;
white-space: nowrap;
+1 -1
View File
@@ -139,7 +139,7 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
color: ${props.theme.white};
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
box-shadow: none;
cursor: var(--pointer);
cursor: pointer;
svg {
fill: ${props.theme.white};
+29 -32
View File
@@ -1,6 +1,7 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -8,7 +9,6 @@ import { depths } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
@@ -59,7 +59,6 @@ const ContextMenu: React.FC<Props> = ({
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
const isMobile = useMobile();
useUnmount(() => {
setIsMenuOpen(false);
@@ -116,7 +115,7 @@ const ContextMenu: React.FC<Props> = ({
// trigger and the bottom of the window
return (
<>
<Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...rest}>
<Menu hideOnClickOutside preventBodyScroll={false} {...rest}>
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
@@ -126,38 +125,32 @@ const ContextMenu: React.FC<Props> = ({
const rightAnchor = props.placement === "bottom-end";
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
rest.hide?.();
}}
/>
)}
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
</>
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
);
}}
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
<Backdrop onClick={rest.hide} />
</Portal>
)}
</>
);
};
@@ -173,6 +166,10 @@ export const Backdrop = styled.div`
bottom: 0;
background: ${(props) => props.theme.backdrop};
z-index: ${depths.menu - 1};
${breakpoint("tablet")`
display: none;
`};
`;
export const Position = styled.div`
+103 -120
View File
@@ -3,10 +3,11 @@ import { CSS } from "@dnd-kit/utilities";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { getLuminance, transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import styled, { css } from "styled-components";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
@@ -14,8 +15,6 @@ import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import CollectionIcon from "./CollectionIcon";
import EmojiIcon from "./EmojiIcon";
import Squircle from "./Squircle";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -33,7 +32,6 @@ type Props = {
function DocumentCard(props: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const theme = useTheme();
const { document, pin, canUpdatePin, isDraggable } = props;
const collection = collections.get(document.collectionId);
const {
@@ -43,24 +41,16 @@ function DocumentCard(props: Props) {
transform,
transition,
isDragging,
} = useSortable({
id: props.document.id,
disabled: !isDraggable || !canUpdatePin,
});
} = useSortable({ id: props.document.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const handleUnpin = React.useCallback(
(ev) => {
ev.preventDefault();
ev.stopPropagation();
pin?.delete();
},
[pin]
);
const handleUnpin = React.useCallback(() => {
pin?.delete();
}, [pin]);
return (
<Reorderable
@@ -68,8 +58,6 @@ function DocumentCard(props: Props) {
style={style}
$isDragging={isDragging}
{...attributes}
{...listeners}
tabIndex={-1}
>
<AnimatePresence
initial={{ opacity: 0, scale: 0.95 }}
@@ -85,6 +73,12 @@ function DocumentCard(props: Props) {
>
<DocumentLink
dir={document.dir}
style={{
background:
collection?.color && getLuminance(collection.color) < 0.6
? collection.color
: undefined,
}}
$isDragging={isDragging}
to={{
pathname: document.url,
@@ -94,117 +88,89 @@ function DocumentCard(props: Props) {
}}
>
<Content justify="space-between" column>
<Fold
width="20"
height="21"
viewBox="0 0 20 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19.5 20.5H6C2.96243 20.5 0.5 18.0376 0.5 15V0.5H0.792893L19.5 19.2071V20.5Z" />
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
</Fold>
{document.emoji ? (
<Squircle color={theme.slateLight}>
<EmojiIcon emoji={document.emoji} size={26} />
</Squircle>
{collection?.icon &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
) : (
<Squircle color={collection?.color}>
{collection?.icon &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
) : (
<DocumentIcon color="white" />
)}
</Squircle>
<DocumentIcon color="white" />
)}
<div>
<Heading dir={document.dir}>
{document.emoji
? document.titleWithDefault.replace(document.emoji, "")
: document.titleWithDefault}
</Heading>
<Heading dir={document.dir}>{document.titleWithDefault}</Heading>
<DocumentMeta size="xsmall">
<Clock color="currentColor" size={18} />
<Time
dateTime={document.updatedAt}
tooltipDelay={500}
addSuffix
shorten
/>
<ClockIcon color="currentColor" size={18} />{" "}
<Time dateTime={document.updatedAt} addSuffix shorten />
</DocumentMeta>
</div>
</Content>
{canUpdatePin && (
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<CloseIcon color="currentColor" />
</PinButton>
</Tooltip>
)}
</Actions>
)}
</DocumentLink>
{canUpdatePin && (
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<CloseIcon color="currentColor" />
</PinButton>
</Tooltip>
)}
{isDraggable && (
<DragHandle $isDragging={isDragging} {...listeners}>
:::
</DragHandle>
)}
</Actions>
)}
</AnimatePresence>
</Reorderable>
);
}
const Clock = styled(ClockIcon)`
flex-shrink: 0;
`;
const AnimatePresence = styled(m.div)`
width: 100%;
height: 100%;
`;
const Fold = styled.svg`
fill: ${(props) => props.theme.background};
stroke: ${(props) => props.theme.inputBorder};
background: ${(props) => props.theme.background};
position: absolute;
top: -1px;
right: -2px;
`;
const PinButton = styled(NudeButton)`
color: ${(props) => props.theme.textTertiary};
color: ${(props) => props.theme.white75};
&:hover,
&:active {
color: ${(props) => props.theme.text};
color: ${(props) => props.theme.white};
}
`;
const Actions = styled(Flex)`
position: absolute;
top: 4px;
right: ${(props) => (props.dir === "rtl" ? "auto" : "4px")};
left: ${(props) => (props.dir === "rtl" ? "4px" : "auto")};
top: 12px;
right: ${(props) => (props.dir === "rtl" ? "auto" : "12px")};
left: ${(props) => (props.dir === "rtl" ? "12px" : "auto")};
opacity: 0;
color: ${(props) => props.theme.textTertiary};
transition: opacity 100ms ease-in-out;
// move actions above content
z-index: 2;
`;
const DragHandle = styled.div<{ $isDragging: boolean }>`
cursor: ${(props) => (props.$isDragging ? "grabbing" : "grab")};
padding: 0 4px;
font-weight: bold;
color: ${(props) => props.theme.white75};
line-height: 1.35;
&:hover,
&:active {
color: ${(props) => props.theme.white};
}
`;
const AnimatePresence = m.div;
const Reorderable = styled.div<{ $isDragging: boolean }>`
position: relative;
user-select: none;
touch-action: none;
width: 170px;
height: 180px;
transition: box-shadow 200ms ease;
border-radius: 8px;
// move above other cards when dragging
z-index: ${(props) => (props.$isDragging ? 1 : "inherit")};
pointer-events: ${(props) => (props.$isDragging ? "none" : "inherit")};
transform: scale(${(props) => (props.$isDragging ? "1.025" : "1")});
box-shadow: ${(props) =>
props.$isDragging ? "0 0 20px rgba(0,0,0,0.3);" : "0 0 0 rgba(0,0,0,0)"};
&:hover ${Actions} {
opacity: 1;
@@ -214,34 +180,45 @@ const Reorderable = styled.div<{ $isDragging: boolean }>`
const Content = styled(Flex)`
min-width: 0;
height: 100%;
// move content above ::after
position: relative;
z-index: 1;
`;
const DocumentMeta = styled(Text)`
display: flex;
align-items: center;
gap: 2px;
color: ${(props) => props.theme.textTertiary};
margin: 0 0 0 -2px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
color: ${(props) => transparentize(0.25, props.theme.white)};
margin: 0;
`;
const DocumentLink = styled(Link)<{
$menuOpen?: boolean;
$isDragging?: boolean;
}>`
position: relative;
display: block;
padding: 12px;
width: 100%;
height: 100%;
border-radius: 8px;
cursor: var(--pointer);
background: ${(props) => props.theme.background};
height: 160px;
background: ${(props) => props.theme.slate};
color: ${(props) => props.theme.white};
transition: transform 50ms ease-in-out;
border: 1px solid ${(props) => props.theme.inputBorder};
border-bottom-width: 2px;
border-right-width: 2px;
&:after {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.1));
border-radius: 8px;
pointer-events: none;
}
${Actions} {
opacity: 0;
@@ -251,22 +228,28 @@ const DocumentLink = styled(Link)<{
&:active,
&:focus,
&:focus-within {
transform: ${(props) => (props.$isDragging ? "scale(1.1)" : "scale(1.08)")}
rotate(-2deg);
box-shadow: ${(props) =>
props.$isDragging
? "0 0 20px rgba(0,0,0,0.2);"
: "0 0 10px rgba(0,0,0,0.1)"};
z-index: 1;
${Fold} {
display: none;
}
${Actions} {
opacity: 1;
}
${(props) =>
!props.$isDragging &&
css`
&:after {
background: rgba(0, 0, 0, 0.1);
}
`}
}
${(props) =>
props.$menuOpen &&
css`
background: ${(props) => props.theme.listItemHoverBackground};
${Actions} {
opacity: 1;
}
`}
`;
const Heading = styled.h3`
@@ -276,7 +259,7 @@ const Heading = styled.h3`
max-height: 66px; // 3*line-height
overflow: hidden;
color: ${(props) => props.theme.text};
color: ${(props) => props.theme.white};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
+6 -31
View File
@@ -1,10 +1,9 @@
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "~/models/Event";
import Button from "~/components/Button";
@@ -12,7 +11,6 @@ import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import PaginatedEventList from "~/components/PaginatedEventList";
import Scrollable from "~/components/Scrollable";
import useKeyDown from "~/hooks/useKeyDown";
import useStores from "~/hooks/useStores";
import { documentUrl } from "~/utils/routeHelpers";
@@ -23,7 +21,6 @@ function DocumentHistory() {
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const theme = useTheme();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
@@ -47,8 +44,7 @@ function DocumentHistory() {
eventsInDocument.unshift(
new Event(
{
id: "live",
name: "documents.live_editing",
name: "documents.latest_version",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
@@ -61,25 +57,8 @@ function DocumentHistory() {
return eventsInDocument;
}, [eventsInDocument, events, document]);
useKeyDown("Escape", onCloseHistory);
return (
<Sidebar
initial={{
width: 0,
}}
animate={{
transition: {
type: "spring",
bounce: 0.2,
duration: 0.6,
},
width: theme.sidebarWidth,
}}
exit={{
width: 0,
}}
>
<Sidebar>
{document ? (
<Position column>
<Header>
@@ -100,7 +79,7 @@ function DocumentHistory() {
documentId: document.id,
}}
document={document}
empty={<EmptyHistory>{t("No history yet")}</EmptyHistory>}
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
/>
</Scrollable>
</Position>
@@ -109,10 +88,6 @@ function DocumentHistory() {
);
}
const EmptyHistory = styled(Empty)`
padding: 0 12px;
`;
const Position = styled(Flex)`
position: fixed;
top: 0;
@@ -120,7 +95,7 @@ const Position = styled(Flex)`
width: ${(props) => props.theme.sidebarWidth}px;
`;
const Sidebar = styled(m.div)`
const Sidebar = styled(Flex)`
display: none;
position: relative;
flex-shrink: 0;
@@ -150,7 +125,7 @@ const Title = styled(Flex)`
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 16px 12px;
padding: 12px;
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
-1
View File
@@ -201,7 +201,6 @@ const DocumentLink = styled(Link)<{
border-radius: 8px;
max-height: 50vh;
width: calc(100vw - 8px);
cursor: var(--pointer);
&:focus-visible {
outline: none;
+19 -27
View File
@@ -12,13 +12,30 @@ import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const Viewed = styled.span`
text-overflow: ellipsis;
overflow: hidden;
`;
const Modified = styled.span<{ highlight?: boolean }>`
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
type Props = {
showCollection?: boolean;
showPublished?: boolean;
showLastViewed?: boolean;
showParentDocuments?: boolean;
document: Document;
replace?: boolean;
to?: LocationDescriptor;
};
@@ -29,7 +46,6 @@ const DocumentMeta: React.FC<Props> = ({
showParentDocuments,
document,
children,
replace,
to,
...rest
}) => {
@@ -147,13 +163,7 @@ const DocumentMeta: React.FC<Props> = ({
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? (
<Link to={to} replace={replace}>
{content}
</Link>
) : (
content
)}
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
@@ -182,22 +192,4 @@ const DocumentMeta: React.FC<Props> = ({
);
};
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const Viewed = styled.span`
text-overflow: ellipsis;
overflow: hidden;
`;
const Modified = styled.span<{ highlight?: boolean }>`
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
export default observer(DocumentMeta);
+9 -1
View File
@@ -24,6 +24,14 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
React.useEffect(() => {
if (!document.isDeleted) {
views.fetchPage({
documentId: document.id,
});
}
}, [views, document.id, document.isDeleted]);
const popover = usePopoverState({
gutter: 8,
placement: "bottom",
@@ -31,7 +39,7 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
});
return (
<Meta document={document} to={to} replace {...rest}>
<Meta document={document} to={to} {...rest}>
{totalViewers && !isDraft ? (
<PopoverDisclosure {...popover}>
{(props) => (
+3 -6
View File
@@ -25,14 +25,13 @@ import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
const LazyLoadedEditor = React.lazy(
() =>
import(
/* webpackChunkName: "preload-shared-editor" */
/* webpackChunkName: "shared-editor" */
"~/editor"
)
);
@@ -160,10 +159,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
}
}
// If we're navigating to an internal document link then prepend the
// share route to the URL so that the document is loaded in context
if (shareId && navigateTo.includes("/doc/")) {
navigateTo = sharedDocumentPath(shareId, navigateTo);
if (shareId) {
navigateTo = `/share/${shareId}${navigateTo}`;
}
history.push(navigateTo);
+23 -38
View File
@@ -1,13 +1,11 @@
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
CheckboxIcon,
UnpublishIcon,
LightningIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -22,7 +20,7 @@ import CompositeItem, {
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import usePolicy from "~/hooks/usePolicy";
import RevisionMenu from "~/menus/RevisionMenu";
import { documentHistoryUrl } from "~/utils/routeHelpers";
@@ -34,45 +32,36 @@ type Props = {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const location = useLocation();
const can = usePolicy(document);
const opts = {
userName: event.actor.name,
};
const isRevision = event.name === "revisions.create";
let meta, icon, to: LocationDescriptor | undefined;
let meta, icon, to;
const ref = React.useRef<HTMLAnchorElement>(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
const handleTimeClick = () => {
const handleTimeClick = React.useCallback(() => {
ref.current?.focus();
};
const prefetchRevision = () => {
if (event.name === "revisions.create" && event.modelId) {
revisions.fetch(event.modelId);
}
};
}, [ref]);
switch (event.name) {
case "revisions.create":
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = {
pathname: documentHistoryUrl(document, event.modelId || ""),
state: { retainScrollPosition: true },
};
break;
case "documents.live_editing":
icon = <LightningIcon color="currentColor" size={16} />;
meta = t("Latest");
to = {
pathname: documentHistoryUrl(document),
state: { retainScrollPosition: true },
};
break;
case "documents.latest_version": {
if (latest) {
icon = <CheckboxIcon color="currentColor" size={16} checked />;
meta = t("Latest version");
to = documentHistoryUrl(document);
break;
} else {
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = documentHistoryUrl(document, event.modelId || "");
break;
}
}
case "documents.archive":
icon = <ArchiveIcon color="currentColor" size={16} />;
@@ -115,10 +104,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
return null;
}
const isActive =
typeof to === "string"
? location.pathname === to
: location.pathname === to?.pathname;
const isActive = location.pathname === to;
if (document.isDeleted) {
to = undefined;
@@ -150,11 +136,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
</Subtitle>
}
actions={
isRevision && isActive && event.modelId ? (
isRevision && isActive && event.modelId && can.update ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
onMouseEnter={prefetchRevision}
ref={ref}
{...rest}
/>
@@ -181,7 +166,7 @@ const Subtitle = styled.span`
const ItemStyle = css`
border: 0;
position: relative;
margin: 8px 0;
margin: 8px;
padding: 8px;
border-radius: 8px;
@@ -232,4 +217,4 @@ const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
export default observer(EventListItem);
export default EventListItem;
+4 -8
View File
@@ -9,7 +9,7 @@ type Props = {
users: User[];
size?: number;
overflow?: number;
limit?: number;
onClick?: React.MouseEventHandler<HTMLDivElement>;
renderAvatar?: (user: User) => React.ReactNode;
};
@@ -17,7 +17,6 @@ function Facepile({
users,
overflow = 0,
size = 32,
limit = 8,
renderAvatar = DefaultAvatar,
...rest
}: Props) {
@@ -25,13 +24,10 @@ function Facepile({
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>
{users.length ? "+" : ""}
{overflow}
</span>
<span>+{overflow}</span>
</More>
)}
{users.slice(0, limit).map((user) => (
{users.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
@@ -69,7 +65,7 @@ const More = styled.div<{ size: number }>`
const Avatars = styled(Flex)`
align-items: center;
flex-direction: row-reverse;
cursor: var(--pointer);
cursor: pointer;
`;
export default observer(Facepile);
+5 -8
View File
@@ -13,7 +13,6 @@ import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import withStores from "~/components/withStores";
import NudeButton from "./NudeButton";
type Props = RootStore & {
group: Group;
@@ -64,13 +63,11 @@ class GroupListItem extends React.Component<Props> {
actions={
<Flex align="center" gap={8}>
{showFacepile && (
<NudeButton
width="auto"
height="auto"
<Facepile
onClick={this.handleMembersModalOpen}
>
<Facepile users={users} overflow={overflow} />
</NudeButton>
users={users}
overflow={overflow}
/>
)}
{renderActions({
openMembersModal: this.handleMembersModalOpen,
@@ -102,7 +99,7 @@ const Image = styled(Flex)`
const Title = styled.span`
&:hover {
text-decoration: underline;
cursor: var(--pointer);
cursor: pointer;
}
`;
+6 -6
View File
@@ -15,19 +15,19 @@ import useStores from "~/hooks/useStores";
import { supportsPassiveListener } from "~/utils/browser";
type Props = {
left?: React.ReactNode;
breadcrumb?: React.ReactNode;
title: React.ReactNode;
actions?: React.ReactNode;
hasSidebar?: boolean;
};
function Header({ left, title, actions, hasSidebar }: Props) {
function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
const { ui } = useStores();
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
const passThrough = !actions && !left && !title;
const passThrough = !actions && !breadcrumb && !title;
const [isScrolled, setScrolled] = React.useState(false);
const handleScroll = React.useMemo(
@@ -51,7 +51,7 @@ function Header({ left, title, actions, hasSidebar }: Props) {
return (
<Wrapper align="center" shrink={false} $passThrough={passThrough}>
{left || hasMobileSidebar ? (
{breadcrumb || hasMobileSidebar ? (
<Breadcrumbs>
{hasMobileSidebar && (
<MobileMenuButton
@@ -61,7 +61,7 @@ function Header({ left, title, actions, hasSidebar }: Props) {
neutral
/>
)}
{left}
{breadcrumb}
</Breadcrumbs>
) : null}
@@ -143,7 +143,7 @@ const Title = styled("div")`
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: var(--pointer);
cursor: pointer;
min-width: 0;
${breakpoint("tablet")`
+1 -1
View File
@@ -50,7 +50,7 @@ function HoverPreviewDocument({ url, children }: Props) {
}
const Content = styled(Link)`
cursor: var(--pointer);
cursor: pointer;
`;
const Heading = styled.h2`
+2 -4
View File
@@ -58,13 +58,11 @@ const Wrapper = styled.div<{
flex?: boolean;
short?: boolean;
minHeight?: number;
minWidth?: number;
maxHeight?: number;
}>`
flex: ${(props) => (props.flex ? "1" : "0")};
width: ${(props) => (props.short ? "49%" : "auto")};
max-width: ${(props) => (props.short ? "350px" : "100%")};
min-width: ${({ minWidth }) => (minWidth ? `${minWidth}px` : "initial")};
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "initial")};
`;
@@ -175,7 +173,7 @@ class Input extends React.Component<Props> {
{type === "textarea" ? (
<RealTextarea
ref={this.props.innerRef}
onBlur={this.handleBlur}
onBlur={this.props.onBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
{...rest}
@@ -183,7 +181,7 @@ class Input extends React.Component<Props> {
) : (
<RealInput
ref={this.props.innerRef}
onBlur={this.handleBlur}
onBlur={this.props.onBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
type={type}
+5 -6
View File
@@ -35,11 +35,14 @@ function InputSearchPage({
const history = useHistory();
const { t } = useTranslation();
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
const focus = React.useCallback(() => {
inputRef.current?.focus();
}, []);
useKeyDown("f", (ev: KeyboardEvent) => {
if (isModKey(ev) && document.activeElement !== inputRef.current) {
if (isModKey(ev)) {
ev.preventDefault();
inputRef.current?.focus();
focus();
}
});
@@ -54,10 +57,6 @@ function InputSearchPage({
})
);
}
if (ev.key === "Escape") {
ev.preventDefault();
inputRef.current?.blur();
}
if (onKeyDown) {
onKeyDown(ev);
+2 -13
View File
@@ -13,13 +13,7 @@ import styled, { css } from "styled-components";
import Button, { Inner } from "~/components/Button";
import Text from "~/components/Text";
import useMenuHeight from "~/hooks/useMenuHeight";
import { fadeAndScaleIn } from "~/styles/animations";
import {
Position,
Background as ContextMenuBackground,
Backdrop,
Placement,
} from "./ContextMenu";
import { Position, Background, Backdrop, Placement } from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import { LabelText } from "./Input";
@@ -176,7 +170,6 @@ const InputSelect = (props: Props) => {
ref={contentRef}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
@@ -223,10 +216,6 @@ const InputSelect = (props: Props) => {
);
};
const Background = styled(ContextMenuBackground)`
animation: ${fadeAndScaleIn} 200ms ease;
`;
const Placeholder = styled.span`
color: ${(props) => props.theme.placeholder};
`;
@@ -288,7 +277,7 @@ const Positioner = styled(Position)`
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
box-shadow: none;
cursor: var(--pointer);
cursor: pointer;
svg {
fill: ${(props) => props.theme.white};
+3 -8
View File
@@ -15,15 +15,10 @@ import { isModKey } from "~/utils/keyboard";
type Props = {
title?: string;
sidebar?: React.ReactNode;
sidebarRight?: React.ReactNode;
rightRail?: React.ReactNode;
};
const Layout: React.FC<Props> = ({
title,
children,
sidebar,
sidebarRight,
}) => {
const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed;
@@ -65,7 +60,7 @@ const Layout: React.FC<Props> = ({
{children}
</Content>
{sidebarRight}
{rightRail}
</Container>
</Container>
);
+4 -5
View File
@@ -1,12 +1,11 @@
import { LocationDescriptor } from "history";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
export type Props = {
image?: React.ReactNode;
to?: LocationDescriptor;
to?: string;
exact?: boolean;
title: React.ReactNode;
subtitle?: React.ReactNode;
@@ -73,7 +72,7 @@ const ListItem = (
const Wrapper = styled.a<{
$small?: boolean;
$border?: boolean;
to?: LocationDescriptor;
to?: string;
}>`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
@@ -87,7 +86,7 @@ const Wrapper = styled.a<{
border-bottom: 0;
}
cursor: ${({ to }) => (to ? "var(--pointer)" : "default")};
cursor: ${({ to }) => (to ? "pointer" : "default")};
`;
const Image = styled(Flex)`
+2 -2
View File
@@ -14,7 +14,7 @@ type Props = {
body?: PlaceholderTextProps;
};
const Placeholder = ({ count, className, header, body }: Props) => {
const ListPlaceHolder = ({ count, className, header, body }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
@@ -31,4 +31,4 @@ const Item = styled(Flex)`
padding: 10px 0;
`;
export default Placeholder;
export default ListPlaceHolder;
+2 -3
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import * as React from "react";
import { match, NavLink, Route } from "react-router-dom";
@@ -13,7 +12,7 @@ type Props = React.ComponentProps<typeof NavLink> & {
) => React.ReactNode;
exact?: boolean;
activeStyle?: React.CSSProperties;
to: LocationDescriptor;
to: string;
};
function NavLinkWithChildrenFunc(
@@ -21,7 +20,7 @@ function NavLinkWithChildrenFunc(
ref?: React.Ref<HTMLAnchorElement>
) {
return (
<Route path={typeof to === "string" ? to : to?.pathname} exact={exact}>
<Route path={to} exact={exact}>
{({ match, location }) => (
<NavLink {...rest} to={to} exact={exact} ref={ref}>
{children
+7 -13
View File
@@ -4,32 +4,26 @@ import ActionButton, {
} from "~/components/ActionButton";
type Props = ActionButtonProps & {
width?: number | string;
height?: number | string;
width?: number;
height?: number;
size?: number;
type?: "button" | "submit" | "reset";
};
const NudeButton = styled(ActionButton).attrs((props: Props) => ({
const StyledNudeButton = styled(ActionButton).attrs((props: Props) => ({
type: "type" in props ? props.type : "button",
}))<Props>`
width: ${(props) =>
typeof props.width === "string"
? props.width
: `${props.width || props.size || 24}px`};
height: ${(props) =>
typeof props.height === "string"
? props.height
: `${props.height || props.size || 24}px`};
width: ${(props) => props.width || props.size || 24}px;
height: ${(props) => props.height || props.size || 24}px;
background: none;
border-radius: 4px;
display: inline-block;
line-height: 0;
border: 0;
padding: 0;
cursor: var(--pointer);
cursor: pointer;
user-select: none;
color: inherit;
`;
export default NudeButton;
export default StyledNudeButton;
+13 -16
View File
@@ -13,7 +13,6 @@ type Props = {
heading?: React.ReactNode;
empty?: React.ReactNode;
};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
empty,
heading,
@@ -24,34 +23,32 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
...rest
}: Props) {
return (
<StyledPaginatedList
<PaginatedList
items={events}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event, index, compositeProps) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...compositeProps}
/>
)}
renderItem={(item: Event, index, compositeProps) => {
return (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...compositeProps}
/>
);
}}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
/>
);
});
const StyledPaginatedList = styled(PaginatedList)`
padding: 0 8px;
`;
const Heading = styled("h3")`
font-size: 14px;
padding: 0 4px;
padding: 0 12px;
`;
export default PaginatedEventList;
+1 -5
View File
@@ -29,7 +29,6 @@ type Props<T> = WithTranslation &
empty?: React.ReactNode;
loading?: React.ReactElement;
items?: T[];
className?: string;
renderItem: (
item: T,
index: number,
@@ -165,9 +164,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
return (
this.props.loading || (
<DelayedMount>
<div className={this.props.className}>
<PlaceholderList count={5} />
</div>
<PlaceholderList count={5} />
</DelayedMount>
)
);
@@ -187,7 +184,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
<ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
className={this.props.className}
>
{(composite: CompositeStateReturn) => {
let previousHeading = "";
+6 -11
View File
@@ -42,12 +42,7 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
}, [pins]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 5,
},
}),
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
@@ -59,8 +54,8 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
if (over && active.id !== over.id) {
setItems((items) => {
const activePos = items.indexOf(active.id as string);
const overPos = items.indexOf(over.id as string);
const activePos = items.indexOf(active.id);
const overPos = items.indexOf(over.id);
const overIndex = pins[overPos]?.index || null;
const nextIndex = pins[overPos + 1]?.index || null;
@@ -126,7 +121,7 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
const List = styled.div`
display: grid;
column-gap: 8px;
row-gap: 12px;
row-gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 0;
list-style: none;
@@ -136,11 +131,11 @@ const List = styled.div`
display: none;
}
${breakpoint("mobileLarge")`
${breakpoint("tablet")`
grid-template-columns: repeat(3, minmax(0, 1fr));
`};
${breakpoint("tablet")`
${breakpoint("desktop")`
grid-template-columns: repeat(4, minmax(0, 1fr));
`};
`;
+1 -1
View File
@@ -46,7 +46,7 @@ const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
border-radius: 6px;
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
max-height: 50vh;
overflow-y: auto;
overflow-y: scroll;
box-shadow: ${(props) => props.theme.menuShadow};
width: ${(props) => props.$width}px;
+3 -3
View File
@@ -8,7 +8,7 @@ type Props = {
icon?: React.ReactNode;
title?: React.ReactNode;
textTitle?: string;
left?: React.ReactNode;
breadcrumb?: React.ReactNode;
actions?: React.ReactNode;
centered?: boolean;
};
@@ -18,7 +18,7 @@ const Scene: React.FC<Props> = ({
icon,
textTitle,
actions,
left,
breadcrumb,
children,
centered,
}) => {
@@ -37,7 +37,7 @@ const Scene: React.FC<Props> = ({
)
}
actions={actions}
left={left}
breadcrumb={breadcrumb}
/>
{centered !== false ? (
<CenteredContent withStickyHeader>{children}</CenteredContent>
+3 -10
View File
@@ -8,14 +8,11 @@ type Props = {
};
export default function ScrollToTop({ children }: Props) {
const location = useLocation<{ retainScrollPosition?: boolean }>();
const location = useLocation();
const previousLocationPathname = usePrevious(location.pathname);
React.useEffect(() => {
if (
location.pathname === previousLocationPathname ||
location.state?.retainScrollPosition
) {
if (location.pathname === previousLocationPathname) {
return;
}
// exception for when entering or exiting document edit, scroll position should not reset
@@ -26,11 +23,7 @@ export default function ScrollToTop({ children }: Props) {
return;
}
window.scrollTo(0, 0);
}, [
location.pathname,
previousLocationPathname,
location.state?.retainScrollPosition,
]);
}, [location.pathname, previousLocationPathname]);
return children;
}
+1 -1
View File
@@ -92,7 +92,7 @@ const Wrapper = styled.div<{
return "none";
}};
transition: box-shadow 100ms ease-in-out;
transition: all 100ms ease-in-out;
${(props) =>
props.$hiddenScrollbars &&
+1 -3
View File
@@ -10,9 +10,7 @@ export default function SearchActions() {
const { searches } = useStores();
React.useEffect(() => {
if (!searches.isLoaded) {
searches.fetchPage({});
}
searches.fetchPage({});
}, [searches]);
const { searchQuery } = useKBar((state) => ({
+1 -5
View File
@@ -7,7 +7,6 @@ import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { hover } from "~/styles";
import { sharedDocumentPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -39,9 +38,7 @@ function DocumentListItem(
ref={ref}
dir={document.dir}
to={{
pathname: shareId
? sharedDocumentPath(shareId, document.url)
: document.url,
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
state: {
title: document.titleWithDefault,
},
@@ -83,7 +80,6 @@ const DocumentLink = styled(Link)<{
align-items: center;
padding: 6px 12px;
max-height: 50vh;
cursor: var(--pointer);
&:not(:last-child) {
margin-bottom: 4px;
@@ -43,10 +43,6 @@ function Collections() {
}),
});
React.useEffect(() => {
collections.fetchPage({ limit: 100 });
}, [collections]);
return (
<Flex column>
<Header id="collections" title={t("Collections")}>
@@ -54,6 +50,8 @@ function Collections() {
<PaginatedList
aria-label={t("Collections")}
items={collections.orderedData}
fetch={collections.fetchPage}
options={{ limit: 100 }}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof Button> & {
type Props = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
expanded: boolean;
root?: boolean;
@@ -291,21 +291,6 @@ function InnerDocumentLink(
const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0;
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent) => {
if (!hasChildren) {
return;
}
if (ev.key === "ArrowRight" && !expanded) {
setExpanded(true);
}
if (ev.key === "ArrowLeft" && expanded) {
setExpanded(false);
}
},
[hasChildren, expanded]
);
return (
<>
<Relative onDragLeave={resetHoverExpanding}>
@@ -314,7 +299,6 @@ function InnerDocumentLink(
ref={drag}
$isDragging={isDragging}
$isMoving={isMoving}
onKeyDown={handleKeyDown}
>
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
+2 -2
View File
@@ -14,7 +14,7 @@ type Props = {
*/
export const Header: React.FC<Props> = ({ id, title, children }) => {
const [firstRender, setFirstRender] = React.useState(true);
const [expanded, setExpanded] = usePersistedState<boolean>(
const [expanded, setExpanded] = usePersistedState(
`sidebar-header-${id}`,
true
);
@@ -80,7 +80,7 @@ const Button = styled.button`
&:not(:disabled):hover,
&:not(:disabled):active {
color: ${(props) => props.theme.textSecondary};
cursor: var(--pointer);
cursor: pointer;
}
`;
@@ -5,7 +5,6 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import Disclosure from "./Disclosure";
import SidebarLink from "./SidebarLink";
@@ -93,7 +92,7 @@ function DocumentLink(
<>
<SidebarLink
to={{
pathname: sharedDocumentPath(shareId, node.url),
pathname: `/share/${shareId}${node.url}`,
state: {
title: node.title,
},
@@ -68,7 +68,7 @@ const Wrapper = styled(Flex)<{ minHeight: number }>`
text-align: left;
overflow: hidden;
user-select: none;
cursor: var(--pointer);
cursor: pointer;
&:active,
&:hover,
@@ -104,7 +104,6 @@ function SidebarLink(
expanded={expanded}
onClick={onDisclosureClick}
root={depth === 0}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
@@ -179,7 +178,7 @@ const Link = styled(NavLink)<{
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
font-size: 16px;
cursor: var(--pointer);
cursor: pointer;
overflow: hidden;
${(props) =>
+1 -1
View File
@@ -47,7 +47,7 @@ export const ToggleButton = styled.button<{ $direction?: "left" | "right" }>`
${breakpoint("tablet")`
pointer-events: all;
cursor: var(--pointer);
cursor: pointer;
`}
@media (hover: none) {
-40
View File
@@ -1,40 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import Flex from "./Flex";
type Props = {
size?: number;
color?: string;
};
const Squircle: React.FC<Props> = ({ color, size = 28, children }) => {
return (
<Wrapper
style={{ width: size, height: size }}
align="center"
justify="center"
>
<svg width={size} height={size} viewBox="0 0 28 28">
<path
fill={color}
d="M0 11.1776C0 1.97285 1.97285 0 11.1776 0H16.8224C26.0272 0 28 1.97285 28 11.1776V16.8224C28 26.0272 26.0272 28 16.8224 28H11.1776C1.97285 28 0 26.0272 0 16.8224V11.1776Z"
/>
</svg>
<Content>{children}</Content>
</Wrapper>
);
};
const Wrapper = styled(Flex)`
position: relative;
`;
const Content = styled.div`
display: flex;
transform: translate(-50%, -50%);
position: absolute;
top: 50%;
left: 50%;
`;
export default Squircle;
+1 -1
View File
@@ -86,7 +86,7 @@ const Input = styled.label<{ width: number; height: number }>`
const Slider = styled.span<{ width: number; height: number }>`
position: absolute;
cursor: var(--pointer);
cursor: pointer;
top: 0;
left: 0;
right: 0;
+2 -10
View File
@@ -4,13 +4,11 @@ import { ThemeProvider } from "styled-components";
import { breakpoints } from "@shared/styles";
import GlobalStyles from "@shared/styles/globals";
import { dark, light, lightMobile, darkMobile } from "@shared/styles/theme";
import { UserPreference } from "@shared/types";
import useMediaQuery from "~/hooks/useMediaQuery";
import useStores from "~/hooks/useStores";
import { TooltipStyles } from "./Tooltip";
const Theme: React.FC = ({ children }) => {
const { auth, ui } = useStores();
const { ui } = useStores();
const resolvedTheme = ui.resolvedTheme === "dark" ? dark : light;
const resolvedMobileTheme =
ui.resolvedTheme === "dark" ? darkMobile : lightMobile;
@@ -29,13 +27,7 @@ const Theme: React.FC = ({ children }) => {
return (
<ThemeProvider theme={theme}>
<>
<TooltipStyles />
<GlobalStyles
useCursorPointer={auth.user?.getPreference(
UserPreference.UseCursorPointer,
true
)}
/>
<GlobalStyles />
{children}
</>
</ThemeProvider>
+12 -138
View File
@@ -1,8 +1,7 @@
import Tippy, { TippyProps } from "@tippyjs/react";
import Tippy, { TippyProps } from "@tippy.js/react";
import { TFunctionResult } from "i18next";
import * as React from "react";
import styled, { createGlobalStyle } from "styled-components";
import { roundArrow } from "tippy.js";
import styled from "styled-components";
export type Props = Omit<TippyProps, "content" | "theme"> & {
tooltip: React.ReactChild | React.ReactChild[] | TFunctionResult;
@@ -13,7 +12,7 @@ function Tooltip({ shortcut, tooltip, delay = 50, ...rest }: Props) {
let content = <>{tooltip}</>;
if (!tooltip) {
return rest.children ?? null;
return rest.children;
}
if (shortcut) {
@@ -25,8 +24,9 @@ function Tooltip({ shortcut, tooltip, delay = 50, ...rest }: Props) {
}
return (
<Tippy
arrow={roundArrow}
<StyledTippy
arrow
arrowType="round"
animation="shift-away"
content={content}
delay={delay}
@@ -52,139 +52,13 @@ const Shortcut = styled.kbd`
border-radius: 3px;
`;
export const TooltipStyles = createGlobalStyle`
.tippy-box[data-animation=fade][data-state=hidden]{
opacity:0
}
[data-tippy-root]{
max-width:calc(100vw - 10px)
}
.tippy-box{
position:relative;
background-color: ${(props) => props.theme.tooltipBackground};
color: ${(props) => props.theme.tooltipText};
border-radius:4px;
font-size:13px;
line-height:1.4;
white-space:normal;
outline:0;
transition-property:transform,visibility,opacity
}
.tippy-box[data-placement^=top]>.tippy-arrow{
bottom:0
}
.tippy-box[data-placement^=top]>.tippy-arrow:before{
bottom:-7px;
left:0;
border-width:8px 8px 0;
border-top-color:initial;
transform-origin:center top
}
.tippy-box[data-placement^=bottom]>.tippy-arrow{
top:0
}
.tippy-box[data-placement^=bottom]>.tippy-arrow:before{
top:-7px;
left:0;
border-width:0 8px 8px;
border-bottom-color:initial;
transform-origin:center bottom
}
.tippy-box[data-placement^=left]>.tippy-arrow{
right:0
}
.tippy-box[data-placement^=left]>.tippy-arrow:before{
border-width:8px 0 8px 8px;
border-left-color:initial;
right:-7px;
transform-origin:center left
}
.tippy-box[data-placement^=right]>.tippy-arrow{
left:0
}
.tippy-box[data-placement^=right]>.tippy-arrow:before{
left:-7px;
border-width:8px 8px 8px 0;
border-right-color:initial;
transform-origin:center right
}
.tippy-box[data-inertia][data-state=visible]{
transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)
}
.tippy-arrow{
width:16px;
height:16px;
color: ${(props) => props.theme.tooltipBackground};
}
.tippy-arrow:before{
content:"";
position:absolute;
border-color:transparent;
border-style:solid
}
.tippy-content{
position:relative;
padding:5px 9px;
z-index:1
}
/* Arrow Styles */
.tippy-box[data-placement^=top]>.tippy-svg-arrow{
bottom:0
}
.tippy-box[data-placement^=top]>.tippy-svg-arrow:after,.tippy-box[data-placement^=top]>.tippy-svg-arrow>svg{
top:16px;
transform:rotate(180deg)
}
.tippy-box[data-placement^=bottom]>.tippy-svg-arrow{
top:0
}
.tippy-box[data-placement^=bottom]>.tippy-svg-arrow>svg{
bottom:16px
}
.tippy-box[data-placement^=left]>.tippy-svg-arrow{
right:0
}
.tippy-box[data-placement^=left]>.tippy-svg-arrow:after,.tippy-box[data-placement^=left]>.tippy-svg-arrow>svg{
transform:rotate(90deg);
top:calc(50% - 3px);
left:11px
}
.tippy-box[data-placement^=right]>.tippy-svg-arrow{
left:0
}
.tippy-box[data-placement^=right]>.tippy-svg-arrow:after,.tippy-box[data-placement^=right]>.tippy-svg-arrow>svg{
transform:rotate(-90deg);
top:calc(50% - 3px);
right:11px
}
.tippy-svg-arrow{
width:16px;
height:16px;
fill: ${(props) => props.theme.tooltipBackground};
text-align:initial
}
.tippy-svg-arrow,.tippy-svg-arrow>svg{
position:absolute
}
const StyledTippy = styled(Tippy)`
font-size: 13px;
background-color: ${(props) => props.theme.tooltipBackground};
color: ${(props) => props.theme.tooltipText};
/* Animation */
.tippy-box[data-animation=shift-away][data-state=hidden]{opacity:0}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=top]{transform:translateY(10px)}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=bottom]{transform:translateY(-10px)}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=left]{transform:translateX(10px)}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=right]{transform:translateX(-10px)}
.tippy-box[data-animation=shift-away][data-state=hidden]{
opacity:0
}
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=top]{
transform:translateY(10px)
}
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=bottom]{
transform:translateY(-10px)
}
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=left]{
transform:translateX(10px)
}
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=right]{
transform:translateX(-10px)
svg {
fill: ${(props) => props.theme.tooltipBackground};
}
`;
+1 -1
View File
@@ -296,7 +296,7 @@ class WebsocketProvider extends React.Component<Props> {
);
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
auth.team?.updateFromJson(event);
auth.updateTeam(event);
});
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
+1 -1
View File
@@ -76,7 +76,7 @@ const MenuItem = styled.button<{
line-height: 1;
width: 100%;
height: 36px;
cursor: var(--pointer);
cursor: pointer;
border: none;
opacity: ${(props) => (props.disabled ? ".5" : "1")};
color: ${(props) =>
+1 -1
View File
@@ -427,7 +427,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const embedItems: EmbedDescriptor[] = [];
for (const embed of embeds) {
if (embed.title && embed.visible !== false) {
if (embed.title) {
embedItems.push(
new EmbedDescriptor({
...embed,
+1 -1
View File
@@ -61,7 +61,7 @@ const ListItem = styled.li<{
text-decoration: none;
overflow: hidden;
white-space: nowrap;
cursor: var(--pointer);
cursor: pointer;
user-select: none;
line-height: ${(props) => (props.compact ? "inherit" : "1.2")};
height: ${(props) => (props.compact ? "28px" : "auto")};
+1 -1
View File
@@ -7,7 +7,7 @@ export default styled.button<Props>`
flex: 0;
width: 24px;
height: 24px;
cursor: var(--pointer);
cursor: pointer;
border: none;
background: none;
transition: opacity 100ms ease-in-out;
+1 -1
View File
@@ -7,7 +7,7 @@ type Props = {
};
const WrappedTooltip: React.FC<Props> = ({ children, tooltip }) => (
<Tooltip offset={[0, 16]} delay={150} tooltip={tooltip} placement="top">
<Tooltip offset="0, 8" delay={150} tooltip={tooltip} placement="top">
<TooltipContent>{children}</TooltipContent>
</Tooltip>
);
+2 -33
View File
@@ -23,7 +23,6 @@ import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import getHeadings from "@shared/editor/lib/getHeadings";
import getTasks from "@shared/editor/lib/getTasks";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import textBetween from "@shared/editor/lib/textBetween";
import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
@@ -102,6 +101,8 @@ export type Props = {
) => void;
/** Callback when user hovers on any link in the document */
onHoverLink?: (event: MouseEvent) => boolean;
/** Callback when user clicks on any hashtag in the document */
onClickHashtag?: (tag: string, event: MouseEvent) => void;
/** Callback when user presses any key with document focused */
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
/** Collection of embed types to render in the document */
@@ -572,9 +573,6 @@ export class Editor extends React.PureComponent<
this.setState({ blockMenuOpen: false });
};
/**
* Focus the editor at the start of the content.
*/
public focusAtStart = () => {
const selection = Selection.atStart(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
@@ -582,9 +580,6 @@ export class Editor extends React.PureComponent<
this.view.focus();
};
/**
* Focus the editor at the end of the content.
*/
public focusAtEnd = () => {
const selection = Selection.atEnd(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
@@ -592,40 +587,14 @@ export class Editor extends React.PureComponent<
this.view.focus();
};
/**
* Return the headings in the current editor.
*
* @returns A list of headings in the document
*/
public getHeadings = () => {
return getHeadings(this.view.state.doc);
};
/**
* Return the tasks/checkmarks in the current editor.
*
* @returns A list of tasks in the document
*/
public getTasks = () => {
return getTasks(this.view.state.doc);
};
/**
* Return the plain text content of the current editor.
*
* @returns A string of text
*/
public getPlainText = () => {
const { doc } = this.view.state;
const textSerializers = Object.fromEntries(
Object.entries(this.schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
return textBetween(doc, 0, doc.content.size, textSerializers);
};
public render() {
const {
dir,
+11 -23
View File
@@ -12,21 +12,19 @@ import {
BuildingBlocksIcon,
DownloadIcon,
WebhooksIcon,
SettingsIcon,
} from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import Details from "~/scenes/Settings/Details";
import Drawio from "~/scenes/Settings/Drawio";
import Export from "~/scenes/Settings/Export";
import Features from "~/scenes/Settings/Features";
import Groups from "~/scenes/Settings/Groups";
import Import from "~/scenes/Settings/Import";
import Members from "~/scenes/Settings/Members";
import Notifications from "~/scenes/Settings/Notifications";
import Preferences from "~/scenes/Settings/Preferences";
import Profile from "~/scenes/Settings/Profile";
import Security from "~/scenes/Settings/Security";
import SelfHosted from "~/scenes/Settings/SelfHosted";
import Shares from "~/scenes/Settings/Shares";
import Slack from "~/scenes/Settings/Slack";
import Tokens from "~/scenes/Settings/Tokens";
@@ -36,7 +34,6 @@ import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon";
import env from "~/env";
import isCloudHosted from "~/utils/isCloudHosted";
import { accountPreferencesPath } from "~/utils/routeHelpers";
import useCurrentTeam from "./useCurrentTeam";
import usePolicy from "./usePolicy";
@@ -85,14 +82,6 @@ const useAuthorizedSettingsConfig = () => {
group: t("Account"),
icon: ProfileIcon,
},
Preferences: {
name: t("Preferences"),
path: accountPreferencesPath(),
component: Preferences,
enabled: true,
group: t("Account"),
icon: SettingsIcon,
},
Notifications: {
name: t("Notifications"),
path: "/settings/notifications",
@@ -130,7 +119,7 @@ const useAuthorizedSettingsConfig = () => {
name: t("Features"),
path: "/settings/features",
component: Features,
enabled: can.update && team.collaborativeEditing,
enabled: can.update,
group: t("Team"),
icon: BeakerIcon,
},
@@ -151,7 +140,7 @@ const useAuthorizedSettingsConfig = () => {
icon: GroupIcon,
},
Shares: {
name: t("Shared Links"),
name: t("Share Links"),
path: "/settings/shares",
component: Shares,
enabled: true,
@@ -183,10 +172,10 @@ const useAuthorizedSettingsConfig = () => {
group: t("Integrations"),
icon: WebhooksIcon,
},
SelfHosted: {
name: t("Self Hosted"),
path: "/settings/integrations/self-hosted",
component: SelfHosted,
Drawio: {
name: t("Draw.io"),
path: "/settings/integrations/drawio",
component: Drawio,
enabled: can.update,
group: t("Integrations"),
icon: BuildingBlocksIcon,
@@ -209,13 +198,12 @@ const useAuthorizedSettingsConfig = () => {
},
}),
[
t,
can.createApiKey,
can.update,
can.createImport,
can.createExport,
can.createWebhookSubscription,
team.collaborativeEditing,
can.createExport,
can.createImport,
can.update,
t,
]
);
+1 -3
View File
@@ -28,9 +28,7 @@ export default function useEmbeds(loadIfMissing = false) {
}
}
if (!integrations.isLoaded && !integrations.isFetching && loadIfMissing) {
fetchEmbedIntegrations();
}
!integrations.isLoaded && loadIfMissing && fetchEmbedIntegrations();
}, [integrations, loadIfMissing]);
return React.useMemo(
-25
View File
@@ -1,25 +0,0 @@
import * as React from "react";
import usePersistedState from "~/hooks/usePersistedState";
/**
* Hook to set locally and return the path that the user last visited. This is
* used to redirect the user back to the last page they were on if preferred.
*
* @returns A tuple of the last visited path and a method to set it.
*/
export default function useLastVisitedPath(): [string, (path: string) => void] {
const [lastVisitedPath, setLastVisitedPath] = usePersistedState<string>(
"lastVisitedPath",
"/",
{ listen: false }
);
const setPathAsLastVisitedPath = React.useCallback(
(path: string) => {
path !== lastVisitedPath && setLastVisitedPath(path);
},
[lastVisitedPath, setLastVisitedPath]
);
return [lastVisitedPath, setPathAsLastVisitedPath];
}
+1 -1
View File
@@ -10,7 +10,7 @@ const useMenuHeight = (
const isMobile = useMobile();
const { height: windowHeight } = useWindowSize();
React.useEffect(() => {
React.useLayoutEffect(() => {
const padding = 8;
if (visible && !isMobile) {
+16 -26
View File
@@ -4,25 +4,18 @@ import Logger from "~/utils/Logger";
import Storage from "~/utils/Storage";
import useEventListener from "./useEventListener";
type Options = {
/* Whether to listen and react to changes in the value from other tabs */
listen?: boolean;
};
/**
* A hook with the same API as `useState` that persists its value locally and
* syncs the value between browser tabs.
*
* @param key Key to store value under
* @param defaultValue An optional default value if no key exists
* @param options Options for the hook
* @returns Tuple of the current value and a function to update it
*/
export default function usePersistedState<T extends Primitive>(
export default function usePersistedState(
key: string,
defaultValue: T,
options?: Options
): [T, (value: T) => void] {
defaultValue: Primitive
) {
const [storedValue, setStoredValue] = React.useState(() => {
if (typeof window === "undefined") {
return defaultValue;
@@ -30,26 +23,23 @@ export default function usePersistedState<T extends Primitive>(
return Storage.get(key) ?? defaultValue;
});
const setValue = React.useCallback(
(value: T | ((value: T) => void)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
const setValue = (value: Primitive | ((value: Primitive) => void)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
Storage.set(key, valueToStore);
} catch (error) {
// A more advanced implementation would handle the error case
Logger.debug("misc", "Failed to persist state", { error });
}
},
[key, storedValue]
);
setStoredValue(valueToStore);
Storage.set(key, valueToStore);
} catch (error) {
// A more advanced implementation would handle the error case
Logger.debug("misc", "Failed to persist state", { error });
}
};
// Listen to the key changing in other tabs so we can keep UI in sync
useEventListener("storage", (event: StorageEvent) => {
if (options?.listen && event.key === key && event.newValue) {
if (event.key === key && event.newValue) {
setStoredValue(JSON.parse(event.newValue));
}
});
-43
View File
@@ -1,43 +0,0 @@
import * as React from "react";
type RequestResponse<T> = {
/** The return value of the request function. */
data: T | undefined;
/** The request error, if any. */
error: unknown;
/** Whether the request is currently in progress. */
loading: boolean;
/** Function to start the request. */
request: () => Promise<T | undefined>;
};
/**
* A hook to make an API request and track its state within a component.
*
* @param requestFn The function to call to make the request, it should return a promise.
* @returns
*/
export default function useRequest<T = unknown>(
requestFn: () => Promise<T>
): RequestResponse<T> {
const [data, setData] = React.useState<T>();
const [loading, setLoading] = React.useState<boolean>(false);
const [error, setError] = React.useState();
const request = React.useCallback(async () => {
setLoading(true);
try {
const response = await requestFn();
setData(response);
return response;
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
return undefined;
}, [requestFn]);
return { data, loading, error, request };
}
-2
View File
@@ -6,7 +6,6 @@ import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import {
navigateToProfileSettings,
navigateToAccountPreferences,
openKeyboardShortcuts,
openChangelog,
openAPIDocumentation,
@@ -45,7 +44,6 @@ const AccountMenu: React.FC = ({ children }) => {
openBugReportUrl,
changeTheme,
navigateToProfileSettings,
navigateToAccountPreferences,
separator(),
logout,
];
+39 -15
View File
@@ -1,9 +1,11 @@
import { observer } from "mobx-react";
import {
NewDocumentIcon,
EditIcon,
TrashIcon,
ImportIcon,
ExportIcon,
PadlockIcon,
AlphabeticalSortIcon,
ManualSortIcon,
UnstarredIcon,
@@ -16,17 +18,13 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionExport from "~/scenes/CollectionExport";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ContextMenu, { Placement } from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { actionToMenuItem } from "~/actions";
import {
editCollection,
editCollectionPermissions,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -62,10 +60,28 @@ function CollectionMenu({
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
const handlePermissions = React.useCallback(() => {
dialogs.openModal({
title: t("Collection permissions"),
content: <CollectionPermissions collection={collection} />,
});
}, [collection, dialogs, t]);
const handleEdit = React.useCallback(() => {
dialogs.openModal({
title: t("Edit collection"),
content: (
<CollectionEdit
collectionId={collection.id}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [collection.id, dialogs, t]);
const handleExport = React.useCallback(() => {
dialogs.openModal({
title: t("Export collection"),
isCentered: true,
content: (
<CollectionExport
collection={collection}
@@ -170,11 +186,6 @@ function CollectionMenu({
[collection]
);
const context = useActionContext({
isContextMenu: true,
activeCollectionId: collection.id,
});
const alphabeticalSort = collection.sort.field === "title";
const can = usePolicy(collection);
const canUserInTeam = usePolicy(team);
@@ -214,8 +225,6 @@ function CollectionMenu({
{
type: "separator",
},
actionToMenuItem(editCollection, context),
actionToMenuItem(editCollectionPermissions, context),
{
type: "submenu",
title: t("Sort in sidebar"),
@@ -240,6 +249,20 @@ function CollectionMenu({
},
],
},
{
type: "button",
title: `${t("Edit")}`,
visible: can.update,
onClick: handleEdit,
icon: <EditIcon />,
},
{
type: "button",
title: `${t("Permissions")}`,
visible: can.update,
onClick: handlePermissions,
icon: <PadlockIcon />,
},
{
type: "button",
title: `${t("Export")}`,
@@ -270,8 +293,9 @@ function CollectionMenu({
handleStar,
handleNewDocument,
handleImportDocument,
context,
alphabeticalSort,
handleEdit,
handlePermissions,
canUserInTeam.createExport,
handleExport,
handleDelete,
+3 -20
View File
@@ -43,7 +43,6 @@ import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { MenuItem } from "~/types";
@@ -80,7 +79,7 @@ function DocumentMenu({
onClose,
}: Props) {
const team = useCurrentTeam();
const { policies, collections, documents, subscriptions } = useStores();
const { policies, collections, documents } = useStores();
const { showToast } = useToasts();
const menu = useMenuState({
modal,
@@ -97,22 +96,6 @@ function DocumentMenu({
const { t } = useTranslation();
const isMobile = useMobile();
const file = React.useRef<HTMLInputElement>(null);
const { data, loading, request } = useRequest(() =>
subscriptions.fetchPage({
documentId: document.id,
event: "documents.update",
})
);
const handleOpen = React.useCallback(async () => {
if (!data && !loading) {
request();
}
if (onOpen) {
onOpen();
}
}, [data, loading, onOpen, request]);
const handleRestore = React.useCallback(
async (
@@ -236,7 +219,7 @@ function DocumentMenu({
<ContextMenu
{...menu}
aria-label={t("Document options")}
onOpen={handleOpen}
onOpen={onOpen}
onClose={onClose}
>
<Template
@@ -280,7 +263,7 @@ function DocumentMenu({
type: "route",
title: t("Edit"),
to: editDocumentUrl(document),
visible: !!can.update && !team.seamlessEditing,
visible: !!can.update && !team.collaborativeEditing,
icon: <EditIcon />,
},
{
+2 -8
View File
@@ -5,7 +5,7 @@ import { MenuButton, useMenuState } from "reakit/Menu";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { navigateToSettings, logout } from "~/actions/definitions/navigation";
import { createTeam, switchTeamList } from "~/actions/definitions/teams";
import { changeTeam } from "~/actions/definitions/teams";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePrevious from "~/hooks/usePrevious";
import useSessions from "~/hooks/useSessions";
@@ -34,13 +34,7 @@ const OrganizationMenu: React.FC = ({ children }) => {
// NOTE: it's useful to memoize on the team id and session because the action
// menu is not cached at all.
const actions = React.useMemo(() => {
return [
...switchTeamList,
createTeam,
separator(),
navigateToSettings,
logout,
];
return [navigateToSettings, separator(), changeTeam, logout];
}, [team.id, sessions]);
return (
+61 -19
View File
@@ -1,18 +1,19 @@
import { observer } from "mobx-react";
import { RestoreIcon, LinkIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState } from "reakit/Menu";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { actionToMenuItem } from "~/actions";
import {
copyLinkToRevision,
restoreRevision,
} from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import separator from "./separator";
import Separator from "~/components/ContextMenu/Separator";
import CopyToClipboard from "~/components/CopyToClipboard";
import MenuIconWrapper from "~/components/MenuIconWrapper";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useToasts from "~/hooks/useToasts";
import { documentHistoryUrl } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -20,14 +21,47 @@ type Props = {
className?: string;
};
function RevisionMenu({ document, className }: Props) {
function RevisionMenu({ document, revisionId, className }: Props) {
const { showToast } = useToasts();
const team = useCurrentTeam();
const menu = useMenuState({
modal: true,
});
const { t } = useTranslation();
const context = useActionContext({
activeDocumentId: document.id,
});
const history = useHistory();
const handleRestore = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
if (team.collaborativeEditing) {
history.push(document.url, {
restore: true,
revisionId,
});
} else {
await document.restore({
revisionId,
});
showToast(t("Document restored"), {
type: "success",
});
history.push(document.url);
}
},
[history, showToast, t, team.collaborativeEditing, document, revisionId]
);
const handleCopy = React.useCallback(() => {
showToast(t("Link copied"), {
type: "info",
});
}, [showToast, t]);
const url = `${window.location.origin}${documentHistoryUrl(
document,
revisionId
)}`;
return (
<>
@@ -38,13 +72,21 @@ function RevisionMenu({ document, className }: Props) {
{...menu}
/>
<ContextMenu {...menu} aria-label={t("Revision options")}>
<Template
items={[
actionToMenuItem(restoreRevision, context),
separator(),
actionToMenuItem(copyLinkToRevision, context),
]}
/>
<MenuItem {...menu} onClick={handleRestore}>
<MenuIconWrapper>
<RestoreIcon />
</MenuIconWrapper>
{t("Restore version")}
</MenuItem>
<Separator />
<CopyToClipboard text={url} onCopy={handleCopy}>
<MenuItem {...menu}>
<MenuIconWrapper>
<LinkIcon />
</MenuIconWrapper>
{t("Copy link")}
</MenuItem>
</CopyToClipboard>
</ContextMenu>
</>
);
+13 -10
View File
@@ -2,7 +2,6 @@ import { addDays, differenceInDays } from "date-fns";
import { floor } from "lodash";
import { action, autorun, computed, observable, set } from "mobx";
import parseTitle from "@shared/utils/parseTitle";
import { isRTL } from "@shared/utils/rtl";
import DocumentsStore from "~/stores/DocumentsStore";
import User from "~/models/User";
import type { NavigationNode } from "~/types";
@@ -107,19 +106,23 @@ export default class Document extends ParanoidModel {
}
/**
* Returns the direction of the document text, either "rtl" or "ltr"
* Best-guess the text direction of the document based on the language the
* title is written in. Note: wrapping as a computed getter means that it will
* only be called directly when the title changes.
*/
@computed
get dir(): "rtl" | "ltr" {
return this.rtl ? "rtl" : "ltr";
}
const element = document.createElement("p");
element.innerText = this.title;
element.style.visibility = "hidden";
element.dir = "auto";
/**
* Returns true if the document text is right-to-left
*/
@computed
get rtl() {
return isRTL(this.title);
// element must appear in body for direction to be computed
document.body?.appendChild(element);
const direction = window.getComputedStyle(element).direction;
document.body?.removeChild(element);
return direction === "rtl" ? "rtl" : "ltr";
}
@computed
-23
View File
@@ -1,5 +1,3 @@
import { computed } from "mobx";
import { isRTL } from "@shared/utils/rtl";
import BaseModel from "./BaseModel";
import User from "./User";
@@ -8,34 +6,13 @@ class Revision extends BaseModel {
documentId: string;
/** The document title when the revision was created */
title: string;
/** Markdown string of the content when revision was created */
text: string;
/** HTML string representing the revision as a diff from the previous version */
html: string;
createdAt: string;
createdBy: User;
/**
* Returns the direction of the revision text, either "rtl" or "ltr"
*/
@computed
get dir(): "rtl" | "ltr" {
return this.rtl ? "rtl" : "ltr";
}
/**
* Returns true if the revision text is right-to-left
*/
@computed
get rtl() {
return isRTL(this.title);
}
}
export default Revision;
-44
View File
@@ -1,5 +1,4 @@
import { computed, observable } from "mobx";
import { TeamPreference, TeamPreferences } from "@shared/types";
import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
@@ -52,10 +51,6 @@ class Team extends BaseModel {
@observable
defaultUserRole: string;
@Field
@observable
preferences: TeamPreferences | null;
domain: string | null | undefined;
url: string;
@@ -68,45 +63,6 @@ class Team extends BaseModel {
get signinMethods(): string {
return "SSO";
}
/**
* Returns whether this team is using a separate editing mode behind an "Edit"
* button rather than seamless always-editing.
*
* @returns True if editing mode is seamless (no button)
*/
@computed
get seamlessEditing(): boolean {
return (
this.collaborativeEditing &&
this.getPreference(TeamPreference.SeamlessEdit, true)
);
}
/**
* Get the value for a specific preference key, or return the fallback if
* none is set.
*
* @param key The TeamPreference key to retrieve
* @param fallback An optional fallback value, defaults to false.
* @returns The value
*/
getPreference(key: TeamPreference, fallback = false): boolean {
return this.preferences?.[key] ?? fallback;
}
/**
* Set the value for a specific preference key.
*
* @param key The TeamPreference key to retrieve
* @param value The value to set
*/
setPreference(key: TeamPreference, value: boolean) {
this.preferences = {
...this.preferences,
[key]: value,
};
}
}
export default Team;
+1 -43
View File
@@ -1,7 +1,5 @@
import { subMinutes } from "date-fns";
import { computed, observable } from "mobx";
import { now } from "mobx-utils";
import type { Role, UserPreference, UserPreferences } from "@shared/types";
import type { Role } from "@shared/types";
import ParanoidModel from "./ParanoidModel";
import Field from "./decorators/Field";
@@ -26,10 +24,6 @@ class User extends ParanoidModel {
@observable
language: string;
@Field
@observable
preferences: UserPreferences | null;
email: string;
isAdmin: boolean;
@@ -45,17 +39,6 @@ class User extends ParanoidModel {
return !this.lastActiveAt;
}
/**
* Whether the user has been recently active. Recently is currently defined
* as within the last 5 minutes.
*
* @returns true if the user has been active recently
*/
@computed
get isRecentlyActive(): boolean {
return new Date(this.lastActiveAt) > subMinutes(now(10000), 5);
}
@computed
get role(): Role {
if (this.isAdmin) {
@@ -66,31 +49,6 @@ class User extends ParanoidModel {
return "member";
}
}
/**
* Get the value for a specific preference key, or return the fallback if
* none is set.
*
* @param key The UserPreference key to retrieve
* @param fallback An optional fallback value, defaults to false.
* @returns The value
*/
getPreference(key: UserPreference, fallback = false): boolean {
return this.preferences?.[key] ?? fallback;
}
/**
* Set the value for a specific preference key.
*
* @param key The UserPreference key to retrieve
* @param value The value to set
*/
setPreference(key: UserPreference, value: boolean) {
this.preferences = {
...this.preferences,
[key]: value,
};
}
}
export default User;
-4
View File
@@ -15,10 +15,6 @@ class WebhookSubscription extends BaseModel {
@observable
url: string;
@Field
@observable
secret: string;
@Field
@observable
enabled: boolean;
+4 -4
View File
@@ -7,7 +7,7 @@ import Drafts from "~/scenes/Drafts";
import Error404 from "~/scenes/Error404";
import Templates from "~/scenes/Templates";
import Trash from "~/scenes/Trash";
import AuthenticatedLayout from "~/components/AuthenticatedLayout";
import Layout from "~/components/AuthenticatedLayout";
import CenteredContent from "~/components/CenteredContent";
import PlaceholderDocument from "~/components/PlaceholderDocument";
import Route from "~/components/ProfiledRoute";
@@ -26,7 +26,7 @@ const SettingsRoutes = React.lazy(
const Document = React.lazy(
() =>
import(
/* webpackChunkName: "preload-document" */
/* webpackChunkName: "document" */
"~/scenes/Document"
)
);
@@ -68,7 +68,7 @@ function AuthenticatedRoutes() {
return (
<WebsocketProvider>
<AuthenticatedLayout>
<Layout>
<React.Suspense
fallback={
<CenteredContent>
@@ -115,7 +115,7 @@ function AuthenticatedRoutes() {
<Route component={Error404} />
</Switch>
</React.Suspense>
</AuthenticatedLayout>
</Layout>
</WebsocketProvider>
);
}
+5 -12
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { Switch, Redirect } from "react-router-dom";
import { Switch } from "react-router-dom";
import DelayedMount from "~/components/DelayedMount";
import FullscreenLoading from "~/components/FullscreenLoading";
import Route from "~/components/ProfiledRoute";
@@ -8,14 +8,14 @@ import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
const Authenticated = React.lazy(
() =>
import(
/* webpackChunkName: "preload-authenticated" */
/* webpackChunkName: "authenticated" */
"~/components/Authenticated"
)
);
const AuthenticatedRoutes = React.lazy(
() =>
import(
/* webpackChunkName: "preload-authenticated-routes" */
/* webpackChunkName: "authenticated-routes" */
"./authenticated"
)
);
@@ -55,17 +55,10 @@ export default function Routes() {
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
<Route exact path="/s/:shareId" component={SharedDocument} />
<Redirect
exact
from={`/share/:shareId/doc/${slug}`}
to={`/s/:shareId/doc/${slug}`}
/>
<Route exact path="/share/:shareId" component={SharedDocument} />
<Route
exact
path={`/s/:shareId/doc/${slug}`}
path={`/share/:shareId/doc/${slug}`}
component={SharedDocument}
/>
+1 -27
View File
@@ -8,7 +8,6 @@ import {
Route,
useHistory,
useRouteMatch,
useLocation,
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -19,7 +18,6 @@ import CenteredContent from "~/components/CenteredContent";
import CollectionDescription from "~/components/CollectionDescription";
import CollectionIcon from "~/components/CollectionIcon";
import Heading from "~/components/Heading";
import InputSearchPage from "~/components/InputSearchPage";
import PlaceholderList from "~/components/List/Placeholder";
import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
@@ -31,36 +29,27 @@ import Tabs from "~/components/Tabs";
import Tooltip from "~/components/Tooltip";
import { editCollection } from "~/actions/definitions/collections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers";
import Actions from "./Collection/Actions";
import DropToImport from "./Collection/DropToImport";
import Empty from "./Collection/Empty";
import MembershipPreview from "./Collection/MembershipPreview";
function CollectionScene() {
const params = useParams<{ id?: string }>();
const history = useHistory();
const match = useRouteMatch();
const location = useLocation();
const { t } = useTranslation();
const { documents, pins, collections, ui } = useStores();
const [isFetching, setFetching] = React.useState(false);
const [error, setError] = React.useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
const id = params.id || "";
const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id);
const can = usePolicy(collection?.id || "");
React.useEffect(() => {
setLastVisitedPath(currentPath);
}, [currentPath, setLastVisitedPath]);
React.useEffect(() => {
if (collection?.name) {
const canonicalUrl = updateCollectionUrl(match.url, collection);
@@ -123,28 +112,13 @@ function CollectionScene() {
key={collection.id}
centered={false}
textTitle={collection.name}
left={
collection.isEmpty ? undefined : (
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
label={t("Search in collection")}
collectionId={collection.id}
/>
)
}
title={
<>
<CollectionIcon collection={collection} expanded />
&nbsp;{collection.name}
</>
}
actions={
<>
<MembershipPreview collection={collection} />
<Actions collection={collection} />
</>
}
actions={<Actions collection={collection} />}
>
<DropToImport
accept={documents.importFileTypes.join(", ")}
+30 -17
View File
@@ -6,6 +6,7 @@ import { Link } from "react-router-dom";
import Collection from "~/models/Collection";
import { Action, Separator } from "~/components/Actions";
import Button from "~/components/Button";
import InputSearchPage from "~/components/InputSearchPage";
import Tooltip from "~/components/Tooltip";
import usePolicy from "~/hooks/usePolicy";
import CollectionMenu from "~/menus/CollectionMenu";
@@ -21,26 +22,38 @@ function Actions({ collection }: Props) {
return (
<>
{can.update && (
{!collection.isEmpty && (
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
label={`${t("Search in collection")}`}
collectionId={collection.id}
/>
</Action>
<Separator />
{can.update && (
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
</>
)}
</>
)}
<Action>
+18 -12
View File
@@ -5,11 +5,12 @@ import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Collection from "~/models/Collection";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import Text from "~/components/Text";
import { editCollectionPermissions } from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -20,10 +21,13 @@ type Props = {
function EmptyCollection({ collection }: Props) {
const { t } = useTranslation();
const can = usePolicy(collection);
const context = useActionContext();
const collectionName = collection ? collection.name : "";
console.log({ context });
const [
permissionsModalOpen,
handlePermissionsModalOpen,
handlePermissionsModalClose,
] = useBoolean();
return (
<Centered column>
@@ -44,20 +48,23 @@ function EmptyCollection({ collection }: Props) {
{can.update && (
<Empty>
<Link to={newDocumentPath(collection.id)}>
<Button icon={<NewDocumentIcon color="currentColor" />} neutral>
<Button icon={<NewDocumentIcon color="currentColor" />}>
{t("Create a document")}
</Button>
</Link>
<Button
action={editCollectionPermissions}
context={context}
hideOnActionDisabled
neutral
>
&nbsp;&nbsp;
<Button onClick={handlePermissionsModalOpen} neutral>
{t("Manage permissions")}
</Button>
</Empty>
)}
<Modal
title={t("Collection permissions")}
onRequestClose={handlePermissionsModalClose}
isOpen={permissionsModalOpen}
>
<CollectionPermissions collection={collection} />
</Modal>
</Centered>
);
}
@@ -72,7 +79,6 @@ const Centered = styled(Flex)`
const Empty = styled(Flex)`
justify-content: center;
margin: 10px 0;
gap: 8px;
`;
export default observer(EmptyCollection);
-121
View File
@@ -1,121 +0,0 @@
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { PAGINATION_SYMBOL } from "~/stores/BaseStore";
import Collection from "~/models/Collection";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import { editCollectionPermissions } from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
type Props = {
collection: Collection;
limit?: number;
};
const MembershipPreview = ({ collection, limit = 8 }: Props) => {
const [isLoading, setIsLoading] = React.useState(false);
const [usersCount, setUsersCount] = React.useState(0);
const [groupsCount, setGroupsCount] = React.useState(0);
const { t } = useTranslation();
const { memberships, collectionGroupMemberships, users } = useStores();
const collectionUsers = users.inCollection(collection.id);
const context = useActionContext();
const isMobile = useMobile();
React.useEffect(() => {
const fetchData = async () => {
if (collection.permission || isMobile) {
return;
}
setIsLoading(true);
try {
const options = {
id: collection.id,
limit,
};
const [users, groups] = await Promise.all([
memberships.fetchPage(options),
collectionGroupMemberships.fetchPage(options),
]);
setUsersCount(users[PAGINATION_SYMBOL].total);
setGroupsCount(groups[PAGINATION_SYMBOL].total);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [
isMobile,
collection.permission,
collection.id,
collectionGroupMemberships,
memberships,
limit,
]);
if (isLoading || collection.permission || isMobile) {
return null;
}
const overflow = usersCount - groupsCount - collectionUsers.length;
return (
<NudeButton
context={context}
action={editCollectionPermissions}
tooltip={{
tooltip:
usersCount > 0
? groupsCount > 0
? groupsCount > 1
? t(
`{{ usersCount }} users and {{ groupsCount }} groups with access`,
{ usersCount, count: usersCount }
)
: t(`{{ usersCount }} users and a group have access`, {
usersCount,
count: usersCount,
})
: t(`{{ usersCount }} users with access`, {
usersCount,
count: usersCount,
})
: t(`{{ groupsCount }} groups with access`, {
groupsCount,
count: groupsCount,
}),
delay: 250,
}}
width="auto"
height="auto"
>
<Fade>
<Facepile
users={sortBy(collectionUsers, "lastActiveAt")}
overflow={overflow}
limit={limit}
renderAvatar={(user) => (
<StyledAvatar user={user} src={user.avatarUrl} size={32} />
)}
/>
</Fade>
</NudeButton>
);
};
const StyledAvatar = styled(Avatar)<{ user: User }>`
transition: opacity 250ms ease-in-out;
opacity: ${(props) => (props.user.isRecentlyActive ? 1 : 0.5)};
`;
export default observer(MembershipPreview);
+2 -8
View File
@@ -39,7 +39,7 @@ function CollectionExport({ collection, onSubmit }: Props) {
<form onSubmit={handleSubmit}>
<Text type="secondary">
<Trans
defaults="Exporting the collection <em>{{collectionName}}</em> may take some time."
defaults="Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be a zip of folders with files in Markdown format. Please visit the Export section on settings to get the zip."
values={{
collectionName: collection.name,
}}
@@ -48,14 +48,8 @@ function CollectionExport({ collection, onSubmit }: Props) {
}}
/>
</Text>
<Text type="secondary">
<Trans>
Your documents will be a zip of folders with files in Markdown
format. Please visit the Export section in Settings to get the zip.
</Trans>
</Text>
<Button type="submit" disabled={isLoading} primary>
{isLoading ? `${t("Exporting")}` : t("Export collection")}
{isLoading ? `${t("Exporting")}` : t("Export Collection")}
</Button>
</form>
</Flex>
@@ -109,9 +109,7 @@ class AddPeopleToCollection extends React.Component<Props> {
<Empty>{t("No people left to add")}</Empty>
)
}
items={users
.notInCollection(collection.id, this.query)
.filter((member) => member.id !== user.id)}
items={users.notInCollection(collection.id, this.query)}
fetch={this.query ? undefined : users.fetchPage}
renderItem={(item: User) => (
<MemberListItem
+10 -11
View File
@@ -1,10 +1,10 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Group from "~/models/Group";
import User from "~/models/User";
import Button from "~/components/Button";
@@ -26,14 +26,13 @@ import CollectionGroupMemberListItem from "./components/CollectionGroupMemberLis
import MemberListItem from "./components/MemberListItem";
type Props = {
collectionId: string;
collection: Collection;
};
function CollectionPermissions({ collectionId }: Props) {
function CollectionPermissions({ collection }: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const {
collections,
memberships,
collectionGroupMemberships,
users,
@@ -41,8 +40,6 @@ function CollectionPermissions({ collectionId }: Props) {
auth,
} = useStores();
const { showToast } = useToasts();
const collection = collections.get(collectionId);
invariant(collection, "Collection not found");
const [
addGroupModalOpen,
@@ -213,7 +210,7 @@ function CollectionPermissions({ collectionId }: Props) {
<PermissionExplainer size="small">
{!collection.permission && (
<Trans
defaults="The <em>{{ collectionName }}</em> collection is private. Workspace members have no access to it by default."
defaults="The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default."
values={{
collectionName,
}}
@@ -224,7 +221,8 @@ function CollectionPermissions({ collectionId }: Props) {
)}
{collection.permission === CollectionPermission.ReadWrite && (
<Trans
defaults="Workspace members can view and edit documents in the <em>{{ collectionName }}</em> collection by default."
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
default."
values={{
collectionName,
}}
@@ -235,8 +233,7 @@ function CollectionPermissions({ collectionId }: Props) {
)}
{collection.permission === CollectionPermission.Read && (
<Trans
defaults="Workspace members can view documents in the <em>{{ collectionName }}</em> collection by
default."
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
values={{
collectionName,
}}
@@ -288,7 +285,9 @@ function CollectionPermissions({ collectionId }: Props) {
<Divider />
{isEmpty && (
<Empty>
<Trans>Add additional access for individual members and groups</Trans>
<Trans>
Add specific access for individual groups and team members
</Trans>
</Empty>
)}
<PaginatedList
+1 -11
View File
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { RouteComponentProps, useLocation, Redirect } from "react-router-dom";
import { RouteComponentProps, useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { setCookie } from "tiny-cookie";
import DocumentModel from "~/models/Document";
@@ -12,7 +12,6 @@ import ErrorOffline from "~/scenes/ErrorOffline";
import Layout from "~/components/Layout";
import Sidebar from "~/components/Sidebar/Shared";
import Text from "~/components/Text";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import { AuthorizationError, OfflineError } from "~/utils/errors";
@@ -79,17 +78,12 @@ function SharedDocumentScene(props: Props) {
const { ui } = useStores();
const theme = useTheme();
const location = useLocation();
const searchParams = React.useMemo(
() => new URLSearchParams(location.search),
[location.search]
);
const { t } = useTranslation();
const [response, setResponse] = React.useState<Response>();
const [error, setError] = React.useState<Error | null | undefined>();
const { documents } = useStores();
const { shareId, documentSlug } = props.match.params;
const documentId = useDocumentId(documentSlug, response);
const can = usePolicy(response?.document.id ?? "");
// ensure the wider page color always matches the theme
React.useEffect(() => {
@@ -146,10 +140,6 @@ function SharedDocumentScene(props: Props) {
return <Loading location={props.location} />;
}
if (response && searchParams.get("edit") === "true" && can.update) {
return <Redirect to={response.document.url} />;
}
const sidebar = response.sharedTree ? (
<Sidebar rootNode={response.sharedTree} shareId={shareId} />
) : undefined;
@@ -3,7 +3,7 @@ import * as React from "react";
const MultiplayerEditor = React.lazy(
() =>
import(
/* webpackChunkName: "preload-multiplayer-editor" */
/* webpackChunkName: "multiplayer-editor" */
"./MultiplayerEditor"
)
);
+7 -27
View File
@@ -12,6 +12,7 @@ import Logger from "~/utils/Logger";
import { NotFoundError, OfflineError } from "~/utils/errors";
import history from "~/utils/history";
import { matchDocumentEdit } from "~/utils/routeHelpers";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
type Params = {
@@ -41,15 +42,7 @@ type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
};
function DataLoader({ match, children }: Props) {
const {
ui,
views,
shares,
documents,
auth,
revisions,
subscriptions,
} = useStores();
const { ui, shares, documents, auth, revisions, subscriptions } = useStores();
const { team } = auth;
const [error, setError] = React.useState<Error | null>(null);
const { revisionId, shareId, documentSlug } = match.params;
@@ -64,7 +57,7 @@ function DataLoader({ match, children }: Props) {
? documents.getSharedTree(document.id)
: undefined;
const isEditRoute = match.path === matchDocumentEdit;
const isEditing = isEditRoute || !!auth.team?.seamlessEditing;
const isEditing = isEditRoute || !!auth.team?.collaborativeEditing;
const can = usePolicy(document?.id);
const location = useLocation<LocationState>();
@@ -96,7 +89,7 @@ function DataLoader({ match, children }: Props) {
React.useEffect(() => {
async function fetchSubscription() {
if (document?.id && !revisionId) {
if (document?.id) {
try {
await subscriptions.fetchPage({
documentId: document.id,
@@ -108,22 +101,7 @@ function DataLoader({ match, children }: Props) {
}
}
fetchSubscription();
}, [document?.id, subscriptions, revisionId]);
React.useEffect(() => {
async function fetchViews() {
if (document?.id && !document?.isDeleted && !revisionId) {
try {
await views.fetchPage({
documentId: document.id,
});
} catch (err) {
Logger.error("Failed to fetch views", err);
}
}
}
fetchViews();
}, [document?.id, document?.isDeleted, revisionId, views]);
}, [document?.id, subscriptions]);
const onCreateLink = React.useCallback(
async (title: string) => {
@@ -175,6 +153,7 @@ function DataLoader({ match, children }: Props) {
return (
<>
<Loading location={location} />
{isEditing && !team?.collaborativeEditing && <HideSidebar ui={ui} />}
</>
);
}
@@ -189,6 +168,7 @@ function DataLoader({ match, children }: Props) {
return (
<React.Fragment key={key}>
{isEditing && !team.collaborativeEditing && <HideSidebar ui={ui} />}
{children({
document,
revision,
+171 -202
View File
@@ -45,8 +45,6 @@ import {
} from "~/utils/routeHelpers";
import Container from "./Container";
import Contents from "./Contents";
import DocumentContext from "./DocumentContext";
import type { DocumentContextValue } from "./DocumentContext";
import Editor from "./Editor";
import Header from "./Header";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
@@ -54,7 +52,6 @@ import MarkAsViewed from "./MarkAsViewed";
import Notices from "./Notices";
import PublicReferences from "./PublicReferences";
import References from "./References";
import RevisionViewer from "./RevisionViewer";
const AUTOSAVE_DELAY = 3000;
@@ -112,14 +109,6 @@ class DocumentScene extends React.Component<Props> {
@observable
headings: Heading[] = [];
@observable
documentContext: DocumentContextValue = {
editor: null,
setEditor: action((editor: TEditor) => {
this.documentContext.editor = editor;
}),
};
getEditorText: () => string = () => this.props.document.text;
componentDidMount() {
@@ -442,6 +431,7 @@ class DocumentScene extends React.Component<Props> {
} = this.props;
const team = auth.team;
const isShare = !!shareId;
const value = revision ? revision.text : document.text;
const embedsDisabled =
(team && team.documentEmbeds === false) || document.embedsDisabled;
@@ -462,203 +452,182 @@ class DocumentScene extends React.Component<Props> {
return (
<ErrorBoundary>
<DocumentContext.Provider value={this.documentContext}>
{this.props.location.pathname !== canonicalUrl && (
<Redirect
to={{
pathname: canonicalUrl,
state: this.props.location.state,
hash: this.props.location.hash,
}}
/>
)}
<RegisterKeyDown trigger="m" handler={this.goToMove} />
<RegisterKeyDown trigger="e" handler={this.goToEdit} />
<RegisterKeyDown trigger="Escape" handler={this.goBack} />
<RegisterKeyDown trigger="h" handler={this.goToHistory} />
<RegisterKeyDown
trigger="p"
handler={(event) => {
if (isModKey(event) && event.shiftKey) {
this.onPublish(event);
}
{this.props.location.pathname !== canonicalUrl && (
<Redirect
to={{
pathname: canonicalUrl,
state: this.props.location.state,
hash: this.props.location.hash,
}}
/>
<RegisterKeyDown
trigger="h"
handler={(event) => {
if (event.ctrlKey && event.altKey) {
this.onToggleTableOfContents(event);
}
}}
/>
<Background key={revision ? revision.id : document.id} column auto>
<Route
path={`${document.url}/move`}
component={() => (
<Modal
title={`Move ${document.noun}`}
onRequestClose={this.goBack}
isOpen
>
<DocumentMove
document={document}
onRequestClose={this.goBack}
/>
</Modal>
)}
/>
<PageTitle
title={document.titleWithDefault.replace(
document.emoji || "",
""
)}
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
/>
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Container justify="center" column auto>
{!readOnly && (
<>
<Prompt
when={
this.isEditorDirty &&
!this.isUploading &&
!team?.collaborativeEditing
}
message={(location, action) => {
if (
// a URL replace matching the current document indicates a title change
// no guard is needed for this transition
action === "REPLACE" &&
location.pathname === editDocumentUrl(document)
) {
return true;
}
return t(
`You have unsaved changes.\nAre you sure you want to discard them?`
) as string;
}}
/>
<Prompt
when={this.isUploading && !this.isEditorDirty}
message={t(
`Images are still uploading.\nAre you sure you want to discard them?`
)}
/>
</>
)}
<Header
document={document}
documentHasHeadings={hasHeadings}
shareId={shareId}
isRevision={!!revision}
isDraft={document.isDraft}
isEditing={!readOnly && !team?.seamlessEditing}
isSaving={this.isSaving}
isPublishing={this.isPublishing}
publishingIsDisabled={
document.isSaving || this.isPublishing || this.isEmpty
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
onSelectTemplate={this.replaceDocument}
onSave={this.onSave}
headings={this.headings}
/>
<MaxWidth
archived={document.isArchived}
showContents={showContents}
isEditing={!readOnly}
isFullWidth={document.fullWidth}
column
auto
)}
<RegisterKeyDown trigger="m" handler={this.goToMove} />
<RegisterKeyDown trigger="e" handler={this.goToEdit} />
<RegisterKeyDown trigger="Escape" handler={this.goBack} />
<RegisterKeyDown trigger="h" handler={this.goToHistory} />
<RegisterKeyDown
trigger="p"
handler={(event) => {
if (isModKey(event) && event.shiftKey) {
this.onPublish(event);
}
}}
/>
<RegisterKeyDown
trigger="h"
handler={(event) => {
if (event.ctrlKey && event.altKey) {
this.onToggleTableOfContents(event);
}
}}
/>
<Background key={revision ? revision.id : document.id} column auto>
<Route
path={`${document.url}/move`}
component={() => (
<Modal
title={`Move ${document.noun}`}
onRequestClose={this.goBack}
isOpen
>
<Notices document={document} readOnly={readOnly} />
<React.Suspense fallback={<PlaceholderDocument />}>
<Flex auto={!readOnly}>
{revision ? (
<RevisionViewer
isDraft={document.isDraft}
document={document}
revision={revision}
id={revision.id}
/>
) : (
<>
{showContents && (
<Contents
headings={this.headings}
isFullWidth={document.fullWidth}
/>
)}
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
ref={this.editor}
multiplayer={collaborativeEditing}
<DocumentMove
document={document}
onRequestClose={this.goBack}
/>
</Modal>
)}
/>
<PageTitle
title={document.titleWithDefault.replace(document.emoji || "", "")}
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
/>
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Container justify="center" column auto>
{!readOnly && (
<>
<Prompt
when={
this.isEditorDirty &&
!this.isUploading &&
!team?.collaborativeEditing
}
message={(location, action) => {
if (
// a URL replace matching the current document indicates a title change
// no guard is needed for this transition
action === "REPLACE" &&
location.pathname === editDocumentUrl(document)
) {
return true;
}
return t(
`You have unsaved changes.\nAre you sure you want to discard them?`
) as string;
}}
/>
<Prompt
when={this.isUploading && !this.isEditorDirty}
message={t(
`Images are still uploading.\nAre you sure you want to discard them?`
)}
/>
</>
)}
<Header
document={document}
documentHasHeadings={hasHeadings}
shareId={shareId}
isRevision={!!revision}
isDraft={document.isDraft}
isEditing={!readOnly && !team?.collaborativeEditing}
isSaving={this.isSaving}
isPublishing={this.isPublishing}
publishingIsDisabled={
document.isSaving || this.isPublishing || this.isEmpty
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
onSelectTemplate={this.replaceDocument}
onSave={this.onSave}
headings={this.headings}
/>
<MaxWidth
archived={document.isArchived}
showContents={showContents}
isEditing={!readOnly}
isFullWidth={document.fullWidth}
column
auto
>
<Notices document={document} readOnly={readOnly} />
<React.Suspense fallback={<PlaceholderDocument />}>
<Flex auto={!readOnly}>
{showContents && (
<Contents
headings={this.headings}
isFullWidth={document.fullWidth}
/>
)}
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
ref={this.editor}
multiplayer={collaborativeEditing}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
title={revision ? revision.title : document.title}
document={document}
value={readOnly ? value : undefined}
defaultValue={value}
embedsDisabled={embedsDisabled}
onSynced={this.onSynced}
onFileUploadStart={this.onFileUploadStart}
onFileUploadStop={this.onFileUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.onChangeTitle}
onChange={this.onChange}
onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnly && abilities.update}
>
{shareId && (
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<PublicReferences
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
document={document}
value={readOnly ? document.text : undefined}
defaultValue={document.text}
embedsDisabled={embedsDisabled}
onSynced={this.onSynced}
onFileUploadStart={this.onFileUploadStart}
onFileUploadStop={this.onFileUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.onChangeTitle}
onChange={this.onChange}
onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnly && abilities.update}
>
{shareId && (
<ReferencesWrapper
isOnlyTitle={document.isOnlyTitle}
>
<PublicReferences
shareId={shareId}
documentId={document.id}
sharedTree={this.props.sharedTree}
/>
</ReferencesWrapper>
)}
{!isShare && !revision && (
<>
<MarkAsViewed document={document} />
<ReferencesWrapper
isOnlyTitle={document.isOnlyTitle}
>
<References document={document} />
</ReferencesWrapper>
</>
)}
</Editor>
documentId={document.id}
sharedTree={this.props.sharedTree}
/>
</ReferencesWrapper>
)}
{!isShare && !revision && (
<>
<MarkAsViewed document={document} />
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<References document={document} />
</ReferencesWrapper>
</>
)}
</Flex>
</React.Suspense>
</MaxWidth>
{isShare &&
!parseDomain(window.location.origin).custom &&
!auth.user && (
<Branding href="//www.getoutline.com?ref=sharelink" />
)}
</Container>
</Background>
{!isShare && (
<>
<KeyboardShortcutsButton />
<ConnectionStatus />
</>
)}
</DocumentContext.Provider>
</Editor>
</Flex>
</React.Suspense>
</MaxWidth>
{isShare && !parseDomain(window.location.origin).custom && (
<Branding href="//www.getoutline.com?ref=sharelink" />
)}
</Container>
</Background>
{!isShare && (
<>
<KeyboardShortcutsButton />
<ConnectionStatus />
</>
)}
</ErrorBoundary>
);
}
@@ -1,19 +0,0 @@
import * as React from "react";
import { Editor } from "~/editor";
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);
export default DocumentContext;

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