mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1bad8dbf4 | |||
| 58a70ab62e | |||
| aff26aa809 | |||
| e1cc6395f5 | |||
| 38186d64e5 | |||
| 28d5d0da5d | |||
| 5eb95f7bd9 | |||
| c3ba07cee4 |
@@ -107,7 +107,6 @@ jobs:
|
||||
name: Send bundle stats to RelativeCI
|
||||
command: npx relative-ci-agent
|
||||
build-image:
|
||||
resource_class: xlarge
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
- checkout
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
URL=https://local.outline.dev:3000
|
||||
|
||||
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
SMTP_FROM_EMAIL=hello@example.com
|
||||
|
||||
# Enable unsafe-inline in script-src CSP directive
|
||||
|
||||
+2
-2
@@ -12,14 +12,14 @@ UTILS_SECRET=generate_a_new_key
|
||||
|
||||
# For production point these at your databases, in development the default
|
||||
# should work out of the box.
|
||||
DATABASE_URL=postgres://user:pass@postgres:5432/outline
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/outline
|
||||
DATABASE_CONNECTION_POOL_MIN=
|
||||
DATABASE_CONNECTION_POOL_MAX=
|
||||
# Uncomment this to disable SSL for connecting to Postgres
|
||||
# PGSSLMODE=disable
|
||||
|
||||
# For redis you can either specify an ioredis compatible url like this
|
||||
REDIS_URL=redis://redis:6379
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# or alternatively, if you would like to provide additional connection options,
|
||||
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
|
||||
# for a list of available options.
|
||||
|
||||
@@ -13,16 +13,3 @@ updates:
|
||||
update-types: ["version-update:semver-major"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
fortawesome:
|
||||
patterns:
|
||||
- "@fortawesome/*"
|
||||
aws:
|
||||
patterns:
|
||||
- "@aws-sdk/*"
|
||||
|
||||
@@ -24,6 +24,6 @@ jobs:
|
||||
operations-per-run: 60
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: "security,pinned,A1"
|
||||
exempt-issue-labels: "security,pinned"
|
||||
- name: Print outputs
|
||||
run: echo ${{ join(steps.stale.outputs.*, ',') }}
|
||||
|
||||
@@ -2,7 +2,6 @@ import copy from "copy-to-clipboard";
|
||||
import {
|
||||
BeakerIcon,
|
||||
CopyIcon,
|
||||
EditIcon,
|
||||
ToolsIcon,
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
@@ -84,38 +83,6 @@ export const copyId = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
function generateRandomText() {
|
||||
const characters =
|
||||
"abcdefghijklmno pqrstuvwxyzABCDEFGHIJKL MNOPQRSTUVWXYZ 0123456789\n";
|
||||
let text = "";
|
||||
for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i++) {
|
||||
text += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export const startTyping = createAction({
|
||||
name: "Start automatic typing",
|
||||
icon: <EditIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId && env.ENVIRONMENT === "development",
|
||||
perform: () => {
|
||||
const intervalId = setInterval(() => {
|
||||
const text = generateRandomText();
|
||||
document.execCommand("insertText", false, text);
|
||||
}, 250);
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
intervalId && clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
|
||||
toast.info("Automatic typing started, press Escape to stop");
|
||||
},
|
||||
});
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
name: ({ t }) => t("Clear IndexedDB cache"),
|
||||
icon: <TrashIcon />,
|
||||
@@ -202,7 +169,6 @@ export const developer = createAction({
|
||||
createToast,
|
||||
createTestUsers,
|
||||
clearIndexedDB,
|
||||
startTyping,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@ export const unsubscribeDocument = createAction({
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
await document?.unsubscribe();
|
||||
await document?.unsubscribe(currentUserId);
|
||||
|
||||
toast.success(t("Unsubscribed from document notifications"));
|
||||
},
|
||||
@@ -1179,7 +1179,6 @@ export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createNestedDocument,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
importDocument,
|
||||
|
||||
@@ -7,10 +7,9 @@ export enum AvatarSize {
|
||||
Small = 16,
|
||||
Toast = 18,
|
||||
Medium = 24,
|
||||
Large = 28,
|
||||
XLarge = 32,
|
||||
XXLarge = 48,
|
||||
Upload = 64,
|
||||
Large = 32,
|
||||
XLarge = 48,
|
||||
XXLarge = 64,
|
||||
}
|
||||
|
||||
export interface IAvatar {
|
||||
@@ -21,37 +20,36 @@ export interface IAvatar {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** The size of the avatar */
|
||||
size: AvatarSize;
|
||||
/** The source of the avatar image, if not passing a model. */
|
||||
src?: string;
|
||||
/** The avatar model, if not passing a source. */
|
||||
model?: IAvatar;
|
||||
/** The alt text for the image */
|
||||
alt?: string;
|
||||
/** Optional click handler */
|
||||
showBorder?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
/** Optional class name */
|
||||
className?: string;
|
||||
/** Optional style */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { model, style, ...rest } = props;
|
||||
const { showBorder, model, style, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<Relative style={style}>
|
||||
{src && !error ? (
|
||||
<CircleImg onError={handleError} src={src} {...rest} />
|
||||
<CircleImg
|
||||
onError={handleError}
|
||||
src={src}
|
||||
$showBorder={showBorder}
|
||||
{...rest}
|
||||
/>
|
||||
) : model ? (
|
||||
<Initials color={model.color} {...rest}>
|
||||
<Initials color={model.color} $showBorder={showBorder} {...rest}>
|
||||
{model.initial}
|
||||
</Initials>
|
||||
) : (
|
||||
<Initials {...rest} />
|
||||
<Initials $showBorder={showBorder} {...rest} />
|
||||
)}
|
||||
</Relative>
|
||||
);
|
||||
@@ -67,11 +65,15 @@ const Relative = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img<{ size: number }>`
|
||||
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: ${(props) =>
|
||||
props.$showBorder === false
|
||||
? "none"
|
||||
: `2px solid ${props.theme.background}`};
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
@@ -5,7 +5,7 @@ import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar, { AvatarSize } from "./Avatar";
|
||||
import Avatar from "./Avatar";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
@@ -14,8 +14,6 @@ type Props = {
|
||||
isObserving: boolean;
|
||||
isCurrentUser: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
size?: AvatarSize;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function AvatarWithPresence({
|
||||
@@ -25,8 +23,6 @@ function AvatarWithPresence({
|
||||
isEditing,
|
||||
isObserving,
|
||||
isCurrentUser,
|
||||
size = AvatarSize.Large,
|
||||
style,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const status = isPresent
|
||||
@@ -51,14 +47,13 @@ function AvatarWithPresence({
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<AvatarPresence
|
||||
<AvatarWrapper
|
||||
$isPresent={isPresent}
|
||||
$isObserving={isObserving}
|
||||
$color={user.color}
|
||||
style={style}
|
||||
>
|
||||
<Avatar model={user} onClick={onClick} size={size} />
|
||||
</AvatarPresence>
|
||||
<Avatar model={user} onClick={onClick} size={32} />
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
@@ -74,7 +69,7 @@ type AvatarWrapperProps = {
|
||||
$color: string;
|
||||
};
|
||||
|
||||
const AvatarPresence = styled.div<AvatarWrapperProps>`
|
||||
const AvatarWrapper = styled.div<AvatarWrapperProps>`
|
||||
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -3,12 +3,9 @@ import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
const Initials = styled(Flex)<{
|
||||
/** The color of the background, defaults to textTertiary. */
|
||||
color?: string;
|
||||
/** Content is only used to calculate font size, use children to render. */
|
||||
content?: string;
|
||||
/** The size of the avatar */
|
||||
size: number;
|
||||
$showBorder?: boolean;
|
||||
}>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -16,14 +13,15 @@ const Initials = styled(Flex)<{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: ${s("white75")};
|
||||
background-color: ${(props) => props.color ?? props.theme.textTertiary};
|
||||
background-color: ${(props) => props.color};
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid
|
||||
${(props) =>
|
||||
props.$showBorder === false ? "transparent" : props.theme.background};
|
||||
flex-shrink: 0;
|
||||
|
||||
// adjust font size down for each additional character
|
||||
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
|
||||
font-size: ${(props) => props.size / 2}px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "~/models/Document";
|
||||
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
|
||||
import { AvatarWithPresence } from "~/components/Avatar";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -78,56 +78,49 @@ function Collaborators(props: Props) {
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const renderAvatar = React.useCallback(
|
||||
({ model: collaborator, ...rest }) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
const isEditing = editingIds.includes(collaborator.id);
|
||||
const isObserving = ui.observingUserId === collaborator.id;
|
||||
const isObservable = collaborator.id !== currentUserId;
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
onClick={
|
||||
isObservable
|
||||
? (ev) => {
|
||||
if (isPresent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ui.setObservingUser(
|
||||
isObserving ? undefined : collaborator.id
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[presentIds, ui, currentUserId, editingIds]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(popoverProps) => (
|
||||
<NudeButton
|
||||
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
|
||||
height={AvatarSize.Large}
|
||||
width={Math.min(collaborators.length, limit) * 32}
|
||||
height={32}
|
||||
{...popoverProps}
|
||||
>
|
||||
<Facepile
|
||||
size={AvatarSize.Large}
|
||||
limit={limit}
|
||||
overflow={Math.max(0, collaborators.length - limit)}
|
||||
overflow={collaborators.length - limit}
|
||||
users={collaborators}
|
||||
renderAvatar={renderAvatar}
|
||||
renderAvatar={(collaborator) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
const isEditing = editingIds.includes(collaborator.id);
|
||||
const isObserving = ui.observingUserId === collaborator.id;
|
||||
const isObservable = collaborator.id !== user.id;
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
onClick={
|
||||
isObservable
|
||||
? (ev) => {
|
||||
if (isPresent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ui.setObservingUser(
|
||||
isObserving ? undefined : collaborator.id
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</NudeButton>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -7,14 +8,23 @@ import styled from "styled-components";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import { s } from "@shared/styles";
|
||||
import Collection from "~/models/Collection";
|
||||
import Arrow from "~/components/Arrow";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Text from "./Text";
|
||||
|
||||
const extensions = withUIExtensions(richExtensions);
|
||||
const extensions = [
|
||||
...richExtensions,
|
||||
BlockMenuExtension,
|
||||
EmojiMenuExtension,
|
||||
HoverPreviewsExtension,
|
||||
];
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -23,8 +33,33 @@ type Props = {
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setExpanded] = React.useState(false);
|
||||
const [isEditing, setEditing] = React.useState(false);
|
||||
const [isDirty, setDirty] = React.useState(false);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const handleStartEditing = React.useCallback(() => {
|
||||
setEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleStopEditing = React.useCallback(() => {
|
||||
setEditing(false);
|
||||
}, []);
|
||||
|
||||
const handleClickDisclosure = React.useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isExpanded && document.activeElement) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
setExpanded(!isExpanded);
|
||||
},
|
||||
[isExpanded]
|
||||
);
|
||||
|
||||
const handleSave = React.useMemo(
|
||||
() =>
|
||||
debounce(async (getValue) => {
|
||||
@@ -32,6 +67,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
await collection.save({
|
||||
data: getValue(false),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
toast.error(t("Sorry, an error occurred saving the collection"));
|
||||
throw err;
|
||||
@@ -40,44 +76,162 @@ function CollectionDescription({ collection }: Props) {
|
||||
[collection, t]
|
||||
);
|
||||
|
||||
const childRef = React.useRef<HTMLDivElement>(null);
|
||||
const childOffsetHeight = childRef.current?.offsetHeight || 0;
|
||||
const editorStyle = React.useMemo(
|
||||
() => ({
|
||||
padding: "0 32px",
|
||||
margin: "0 -32px",
|
||||
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
|
||||
}),
|
||||
[childOffsetHeight]
|
||||
const handleChange = React.useCallback(
|
||||
async (getValue) => {
|
||||
setDirty(true);
|
||||
await handleSave(getValue);
|
||||
},
|
||||
[handleSave]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditing(false);
|
||||
}, [collection.id]);
|
||||
const placeholder = `${t("Add a description")}…`;
|
||||
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
|
||||
|
||||
return (
|
||||
<>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{(collection.hasDescription || can.update) && (
|
||||
<React.Suspense fallback={<Placeholder>Loading…</Placeholder>}>
|
||||
<Editor
|
||||
defaultValue={collection.data}
|
||||
onChange={handleSave}
|
||||
placeholder={`${t("Add a description")}…`}
|
||||
extensions={extensions}
|
||||
maxLength={1000}
|
||||
canUpdate={can.update}
|
||||
readOnly={!can.update}
|
||||
editorStyle={editorStyle}
|
||||
embedsDisabled
|
||||
/>
|
||||
<div ref={childRef} />
|
||||
</React.Suspense>
|
||||
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
|
||||
<Input data-editing={isEditing} data-expanded={isExpanded}>
|
||||
<span onClick={can.update ? handleStartEditing : undefined}>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{collection.hasDescription || isEditing || isDirty ? (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<Placeholder
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
Loading…
|
||||
</Placeholder>
|
||||
}
|
||||
>
|
||||
<Editor
|
||||
key={key}
|
||||
defaultValue={collection.data}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
readOnly={!isEditing}
|
||||
autoFocus={isEditing}
|
||||
onBlur={handleStopEditing}
|
||||
extensions={extensions}
|
||||
maxLength={1000}
|
||||
embedsDisabled
|
||||
canUpdate
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
can.update && (
|
||||
<Placeholder
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
{placeholder}
|
||||
</Placeholder>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</Input>
|
||||
{!isEditing && (
|
||||
<Disclosure
|
||||
onClick={handleClickDisclosure}
|
||||
aria-label={isExpanded ? t("Collapse") : t("Expand")}
|
||||
size={30}
|
||||
>
|
||||
<Arrow />
|
||||
</Disclosure>
|
||||
)}
|
||||
</>
|
||||
</MaxHeight>
|
||||
);
|
||||
}
|
||||
|
||||
const Placeholder = styled(Text)`
|
||||
const Disclosure = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${s("divider")};
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
transform: rotate(-90deg) translateX(-50%);
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: ${s("sidebarText")};
|
||||
}
|
||||
`;
|
||||
|
||||
const Placeholder = styled(ButtonLink)`
|
||||
color: ${s("placeholder")};
|
||||
cursor: text;
|
||||
min-height: 27px;
|
||||
`;
|
||||
|
||||
const MaxHeight = styled.div`
|
||||
position: relative;
|
||||
max-height: 25vh;
|
||||
overflow: hidden;
|
||||
margin: 8px -8px -8px;
|
||||
padding: 8px;
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
max-height: initial;
|
||||
overflow: initial;
|
||||
|
||||
${Disclosure} {
|
||||
top: initial;
|
||||
bottom: 0;
|
||||
transform: rotate(90deg) translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover ${Disclosure} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Input = styled.div`
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${s("background")} 100%
|
||||
);
|
||||
}
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
&:after {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${s("backgroundSecondary")};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
.heading-anchor {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(CollectionDescription);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { CollectionPermission, NavigationNode } from "@shared/types";
|
||||
import type Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AuthorizationError } from "~/utils/errors";
|
||||
|
||||
type Props = {
|
||||
/** The navigation node to move, must represent a document. */
|
||||
@@ -32,29 +30,12 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
...rest,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
|
||||
{
|
||||
documentName: item.title,
|
||||
collectionName: collection.name,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
} finally {
|
||||
dialogs.closeAllModals();
|
||||
}
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
...rest,
|
||||
});
|
||||
dialogs.closeAllModals();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -105,15 +105,14 @@ function DocumentBreadcrumb(
|
||||
}
|
||||
|
||||
path.slice(0, -1).forEach((node: NavigationNode) => {
|
||||
const title = node.title || t("Untitled");
|
||||
output.push({
|
||||
type: "route",
|
||||
title: node.icon ? (
|
||||
<>
|
||||
<StyledIcon value={node.icon} color={node.color} /> {title}
|
||||
<StyledIcon value={node.icon} color={node.color} /> {node.title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
node.title
|
||||
),
|
||||
to: {
|
||||
pathname: node.url,
|
||||
@@ -122,7 +121,7 @@ function DocumentBreadcrumb(
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [t, path, category, sidebarContext, collectionNode]);
|
||||
}, [path, category, sidebarContext, collectionNode]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
@@ -135,7 +134,7 @@ function DocumentBreadcrumb(
|
||||
{path.slice(0, -1).map((node: NavigationNode) => (
|
||||
<React.Fragment key={node.id}>
|
||||
<SmallSlash />
|
||||
{node.title || t("Untitled")}
|
||||
{node.title}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { subDays } from "date-fns";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -19,7 +18,6 @@ import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
@@ -72,10 +70,6 @@ function DocumentCard(props: Props) {
|
||||
[pin]
|
||||
);
|
||||
|
||||
// If the document was updated within the last 7 days, show a timestamp instead of reading time
|
||||
const isRecentlyUpdated =
|
||||
new Date(document.updatedAt) > subDays(new Date(), 7);
|
||||
|
||||
return (
|
||||
<Reorderable
|
||||
ref={setNodeRef}
|
||||
@@ -148,14 +142,8 @@ function DocumentCard(props: Props) {
|
||||
: document.titleWithDefault}
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
{isRecentlyUpdated ? (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
) : (
|
||||
<ReadingTime document={document} />
|
||||
)}
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
</Content>
|
||||
@@ -176,21 +164,6 @@ function DocumentCard(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSquircle = ({
|
||||
icon,
|
||||
color,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -71,13 +71,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
subtitle={subtitle}
|
||||
image={
|
||||
<Avatar
|
||||
key={model.id}
|
||||
model={model}
|
||||
size={AvatarSize.Large}
|
||||
/>
|
||||
}
|
||||
image={<Avatar key={model.id} model={model} size={32} />}
|
||||
border={false}
|
||||
small
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@ import useDictionary from "~/hooks/useDictionary";
|
||||
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
@@ -49,15 +49,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleUploadFile = React.useCallback(
|
||||
async (file: File | string) => {
|
||||
const options = {
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
documentId: id,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
};
|
||||
const result =
|
||||
file instanceof File
|
||||
? await uploadFile(file, options)
|
||||
: await uploadFileFromUrl(file, options);
|
||||
});
|
||||
return result.url;
|
||||
},
|
||||
[id]
|
||||
|
||||
@@ -16,7 +16,7 @@ import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
@@ -153,7 +153,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
onClick={handleTimeClick}
|
||||
/>
|
||||
}
|
||||
image={<Avatar model={event.actor} size={AvatarSize.Large} />}
|
||||
image={<Avatar model={event.actor} size={32} />}
|
||||
subtitle={
|
||||
<Subtitle>
|
||||
{icon}
|
||||
|
||||
+38
-62
@@ -1,26 +1,17 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Initials from "./Avatar/Initials";
|
||||
|
||||
type Props = {
|
||||
/** The users to display */
|
||||
users: User[];
|
||||
/** The size of the avatars, defaults to AvatarSize.Large */
|
||||
size?: number;
|
||||
/** A number to show as the number of additional users */
|
||||
overflow?: number;
|
||||
/** The maximum number of users to display, defaults to 8 */
|
||||
limit?: number;
|
||||
/** A component to render the avatar, defaults to Avatar. */
|
||||
renderAvatar?: React.ComponentType<
|
||||
React.ComponentProps<typeof Avatar> & {
|
||||
model: User;
|
||||
}
|
||||
>;
|
||||
renderAvatar?: (user: User) => React.ReactNode;
|
||||
};
|
||||
|
||||
function Facepile({
|
||||
@@ -28,70 +19,55 @@ function Facepile({
|
||||
overflow = 0,
|
||||
size = AvatarSize.Large,
|
||||
limit = 8,
|
||||
renderAvatar = Avatar,
|
||||
renderAvatar = DefaultAvatar,
|
||||
...rest
|
||||
}: Props) {
|
||||
const filtered = users.filter(Boolean).slice(-limit);
|
||||
const Component = renderAvatar;
|
||||
|
||||
return (
|
||||
<Avatars {...rest}>
|
||||
{overflow > 0 && (
|
||||
<Initials size={size} content={String(overflow)}>
|
||||
{users.length ? "+" : ""}
|
||||
{overflow}
|
||||
</Initials>
|
||||
<More size={size}>
|
||||
<span>
|
||||
{users.length ? "+" : ""}
|
||||
{overflow}
|
||||
</span>
|
||||
</More>
|
||||
)}
|
||||
{filtered.map((model, index) => {
|
||||
const lastChild = index === 0 && overflow <= 0;
|
||||
return (
|
||||
<Component
|
||||
key={model.id}
|
||||
{...{
|
||||
model,
|
||||
size,
|
||||
style: {
|
||||
marginRight: lastChild ? 0 : -4,
|
||||
...(lastChild || filtered.length === 1
|
||||
? {}
|
||||
: { clipPath: `url(#${clipPathId(size)})` }),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<FacepileClip size={size} />
|
||||
{users
|
||||
.filter(Boolean)
|
||||
.slice(0, limit)
|
||||
.map((user) => (
|
||||
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||
))}
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
|
||||
function FacepileClip({ size }: { size: number }) {
|
||||
return (
|
||||
<SVG
|
||||
width="25"
|
||||
height="28"
|
||||
viewBox="0 0 25 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<clipPath id={clipPathId(size)}>
|
||||
<path
|
||||
transform={size !== 28 ? `scale(${size / 28})` : ""}
|
||||
d="M14.0633 0.5C18.1978 0.5 21.8994 2.34071 24.3876 5.24462C22.8709 7.81315 22.0012 10.8061 22.0012 14C22.0012 17.1939 22.8709 20.1868 24.3876 22.7554C21.8994 25.6593 18.1978 27.5 14.0633 27.5C6.57035 27.5 0.5 21.4537 0.5 14C0.5 6.54628 6.57035 0.5 14.0633 0.5Z"
|
||||
/>
|
||||
</clipPath>
|
||||
</SVG>
|
||||
);
|
||||
function DefaultAvatar(user: User) {
|
||||
return <Avatar model={user} size={AvatarSize.Large} />;
|
||||
}
|
||||
|
||||
function clipPathId(size: number) {
|
||||
return `facepile-${size}`;
|
||||
}
|
||||
const AvatarWrapper = styled.div`
|
||||
margin-right: -8px;
|
||||
|
||||
const SVG = styled.svg`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
&:first-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const More = styled.div<{ size: number }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 100%;
|
||||
background: ${(props) => props.theme.textTertiary};
|
||||
color: ${s("white")};
|
||||
border: 2px solid ${s("background")};
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const Avatars = styled(Flex)`
|
||||
|
||||
@@ -6,12 +6,12 @@ import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { m, TargetAndTransition } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
|
||||
type Props = {
|
||||
/** The children to render */
|
||||
|
||||
@@ -201,7 +201,11 @@ export const AccessControlList = observer(
|
||||
<ListItem
|
||||
key={membership.id}
|
||||
image={
|
||||
<Avatar model={membership.user} size={AvatarSize.Medium} />
|
||||
<Avatar
|
||||
model={membership.user}
|
||||
size={AvatarSize.Medium}
|
||||
showBorder={false}
|
||||
/>
|
||||
}
|
||||
title={membership.user.name}
|
||||
subtitle={membership.user.email}
|
||||
|
||||
@@ -146,7 +146,7 @@ export const AccessControlList = observer(
|
||||
/>
|
||||
) : (
|
||||
<ListItem
|
||||
image={<Avatar model={user} />}
|
||||
image={<Avatar model={user} showBorder={false} />}
|
||||
title={user.name}
|
||||
subtitle={t("You have full access")}
|
||||
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
|
||||
@@ -160,7 +160,9 @@ export const AccessControlList = observer(
|
||||
) : document.isDraft ? (
|
||||
<>
|
||||
<ListItem
|
||||
image={<Avatar model={document.createdBy} />}
|
||||
image={
|
||||
<Avatar model={document.createdBy} showBorder={false} />
|
||||
}
|
||||
title={document.createdBy?.name}
|
||||
actions={
|
||||
<AccessTooltip content={t("Created the document")}>
|
||||
|
||||
@@ -73,7 +73,9 @@ const DocumentMemberListItem = ({
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
image={<Avatar model={user} size={AvatarSize.Medium} />}
|
||||
image={
|
||||
<Avatar model={user} size={AvatarSize.Medium} showBorder={false} />
|
||||
}
|
||||
subtitle={
|
||||
membership?.sourceId ? (
|
||||
<Trans>
|
||||
|
||||
@@ -158,7 +158,13 @@ export const Suggestions = observer(
|
||||
: suggestion.isViewer
|
||||
? t("Viewer")
|
||||
: t("Editor"),
|
||||
image: <Avatar model={suggestion} size={AvatarSize.Medium} />,
|
||||
image: (
|
||||
<Avatar
|
||||
model={suggestion}
|
||||
size={AvatarSize.Medium}
|
||||
showBorder={false}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { AvatarSize } from "../Avatar";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
@@ -41,9 +40,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={
|
||||
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
|
||||
}
|
||||
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
|
||||
onClick={() =>
|
||||
history.push(
|
||||
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
|
||||
|
||||
@@ -228,6 +228,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -2,29 +2,26 @@ import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { CollectionValidation, DocumentValidation } from "@shared/validations";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { createDocument } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -44,14 +41,12 @@ const CollectionLink: React.FC<Props> = ({
|
||||
depth,
|
||||
onClick,
|
||||
}: Props) => {
|
||||
const { dialogs, documents, collections } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const { documents } = useStores();
|
||||
const history = useHistory();
|
||||
const can = usePolicy(collection);
|
||||
const { t } = useTranslation();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const user = useCurrentUser();
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
@@ -63,130 +58,119 @@ const CollectionLink: React.FC<Props> = ({
|
||||
[collection]
|
||||
);
|
||||
|
||||
const handleExpand = React.useCallback(() => {
|
||||
if (!expanded) {
|
||||
onDisclosureClick();
|
||||
}
|
||||
}, [expanded, onDisclosureClick]);
|
||||
// Drop to re-parent document
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject, monitor) => {
|
||||
const { id, collectionId } = item;
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
|
||||
collection,
|
||||
handleExpand,
|
||||
parentRef
|
||||
);
|
||||
const document = documents.get(id);
|
||||
if (collection.id === collectionId && !document?.parentDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevCollection = collections.get(collectionId);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: <ConfirmMoveDialog item={item} collection={collection} />,
|
||||
});
|
||||
} else {
|
||||
await documents.move({ documentId: id, collectionId: collection.id });
|
||||
|
||||
if (!expanded) {
|
||||
onDisclosureClick();
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: () => can.createDocument,
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver({
|
||||
shallow: true,
|
||||
}),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void collection.fetchDocuments();
|
||||
}, [collection]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeCollectionId: collection.id,
|
||||
sidebarContext,
|
||||
});
|
||||
|
||||
const handleRename = React.useCallback(() => {
|
||||
editableTitleRef.current?.setIsEditing(true);
|
||||
}, [editableTitleRef]);
|
||||
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection.id,
|
||||
title: input,
|
||||
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
},
|
||||
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Relative ref={mergeRefs([parentRef, dropRef])}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={can.update}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
{isAddingNewChild && (
|
||||
<Relative ref={drop}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
depth={2}
|
||||
isActive={() => true}
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={<CollectionIcon collection={collection} expanded={expanded} />}
|
||||
showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
canUpdate
|
||||
isEditing
|
||||
placeholder={`${t("New doc")}…`}
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={can.update}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
action={createDocument}
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</DropToImport>
|
||||
</Relative>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import noop from "lodash/noop";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import DocumentsLoader from "~/components/DocumentsLoader";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Text from "~/components/Text";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import useCollectionDocuments from "../hooks/useCollectionDocuments";
|
||||
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
/** The collection to render the children of. */
|
||||
@@ -34,17 +36,55 @@ function CollectionLinkChildren({
|
||||
prefetchDocument,
|
||||
}: Props) {
|
||||
const pageSize = 250;
|
||||
const { documents } = useStores();
|
||||
const can = usePolicy(collection);
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const { documents, dialogs, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const childDocuments = useCollectionDocuments(collection, documents.active);
|
||||
const [showing, setShowing] = React.useState(pageSize);
|
||||
const dummyRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
|
||||
collection,
|
||||
noop,
|
||||
dummyRef
|
||||
);
|
||||
// Drop to reorder document
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject) => {
|
||||
if (!manualSort && item.collectionId === collection?.id) {
|
||||
toast.message(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog item={item} collection={collection} index={0} />
|
||||
),
|
||||
});
|
||||
} else {
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
isDraggingAnyDocument: !!monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!expanded) {
|
||||
@@ -60,8 +100,12 @@ function CollectionLinkChildren({
|
||||
|
||||
return (
|
||||
<Folder expanded={expanded}>
|
||||
{canDrop && collection.isManualSort && (
|
||||
<DropCursor isActiveDrop={isOver} innerRef={dropRef} position="top" />
|
||||
{isDraggingAnyDocument && can.createDocument && manualSort && (
|
||||
<DropCursor
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
<DocumentsLoader collection={collection} enabled={expanded}>
|
||||
{!childDocuments && (
|
||||
|
||||
@@ -10,7 +10,6 @@ import Error from "~/components/List/Error";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import { createCollection } from "~/actions/definitions/collections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../hooks/useDragAndDrop";
|
||||
import DraggableCollectionLink from "./DraggableCollectionLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
@@ -18,6 +17,7 @@ import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarAction from "./SidebarAction";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
function Collections() {
|
||||
const { documents, collections } = useStores();
|
||||
|
||||
@@ -3,11 +3,10 @@ import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode, UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
@@ -16,11 +15,10 @@ import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { newNestedDocumentPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
useDragDocument,
|
||||
useDropToReorderDocument,
|
||||
@@ -60,7 +58,6 @@ function InnerDocumentLink(
|
||||
) {
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const canUpdate = usePolicy(node.id).update;
|
||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||
const hasChildDocuments =
|
||||
@@ -70,7 +67,6 @@ function InnerDocumentLink(
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
const sidebarContext = useSidebarContext();
|
||||
const user = useCurrentUser();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -220,43 +216,6 @@ function InnerDocumentLink(
|
||||
[setExpanded, setCollapsed, hasChildren, expanded]
|
||||
);
|
||||
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection?.id,
|
||||
parentDocumentId: node.id,
|
||||
fullWidth:
|
||||
doc?.fullWidth ??
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
title: input,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument, node.id);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
},
|
||||
[
|
||||
documents,
|
||||
collection,
|
||||
sidebarContext,
|
||||
user,
|
||||
node,
|
||||
doc,
|
||||
history,
|
||||
closeAddingNewChild,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Relative ref={parentRef}>
|
||||
@@ -323,11 +282,8 @@ function InnerDocumentLink(
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
setExpanded();
|
||||
}}
|
||||
as={Link}
|
||||
to={newNestedDocumentPath(document.id)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
@@ -352,25 +308,8 @@ function InnerDocumentLink(
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</Relative>
|
||||
{isAddingNewChild && (
|
||||
<SidebarLink
|
||||
isActive={() => true}
|
||||
depth={depth + 1}
|
||||
label={
|
||||
<EditableTitle
|
||||
title=""
|
||||
canUpdate
|
||||
isEditing
|
||||
placeholder={`${t("New doc")}…`}
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
{nodeChildren.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
@@ -379,7 +318,7 @@ function InnerDocumentLink(
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={childIndex}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -9,12 +9,12 @@ import Document from "~/models/Document";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../hooks/useDragAndDrop";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Relative from "./Relative";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
|
||||
@@ -3,33 +3,23 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
onSubmit: (title: string) => Promise<void> | void;
|
||||
/** A callback when the editing status changes. */
|
||||
type Props = {
|
||||
onSubmit: (title: string) => Promise<void>;
|
||||
onEditing?: (isEditing: boolean) => void;
|
||||
/** A callback when editing is canceled. */
|
||||
onCancel?: () => void;
|
||||
/** The default title. */
|
||||
title: string;
|
||||
/** Whether the user can update the title. */
|
||||
canUpdate: boolean;
|
||||
/** The maximum length of the title. */
|
||||
maxLength?: number;
|
||||
/** The default editing state. */
|
||||
isEditing?: boolean;
|
||||
};
|
||||
|
||||
export type RefHandle = {
|
||||
/** A function to set the editing state. */
|
||||
setIsEditing: (isEditing: boolean) => void;
|
||||
};
|
||||
|
||||
function EditableTitle(
|
||||
{ title, onSubmit, canUpdate, onEditing, onCancel, ...rest }: Props,
|
||||
{ title, onSubmit, canUpdate, onEditing, ...rest }: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) {
|
||||
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [originalValue, setOriginalValue] = React.useState(title);
|
||||
const [value, setValue] = React.useState(title);
|
||||
|
||||
@@ -69,7 +59,6 @@ function EditableTitle(
|
||||
|
||||
if (trimmedValue === originalValue || trimmedValue.length === 0) {
|
||||
setValue(originalValue);
|
||||
onCancel?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,7 +73,7 @@ function EditableTitle(
|
||||
}
|
||||
}
|
||||
},
|
||||
[originalValue, value, onCancel, onSubmit]
|
||||
[originalValue, value, onSubmit]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
@@ -94,14 +83,13 @@ function EditableTitle(
|
||||
}
|
||||
if (ev.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
onCancel?.();
|
||||
setValue(originalValue);
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
await handleSave(ev);
|
||||
}
|
||||
},
|
||||
[handleSave, onCancel, originalValue]
|
||||
[handleSave, originalValue]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import styled, { useTheme, css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { UnreadBadge } from "~/components/UnreadBadge";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
@@ -11,6 +12,11 @@ import { undraggableOnDesktop } from "~/styles";
|
||||
import Disclosure from "./Disclosure";
|
||||
import NavLink, { Props as NavLinkProps } from "./NavLink";
|
||||
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
collectionId: string;
|
||||
};
|
||||
|
||||
type Props = Omit<NavLinkProps, "to"> & {
|
||||
to?: LocationDescriptor;
|
||||
innerRef?: (ref: HTMLElement | null | undefined) => void;
|
||||
|
||||
@@ -86,11 +86,6 @@ function StarredLink({ star }: Props) {
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(
|
||||
() => documentId && documents.prefetchDocument(documentId),
|
||||
[documents, documentId]
|
||||
);
|
||||
|
||||
const getIndex = () => {
|
||||
const next = star?.next();
|
||||
return fractionalIndex(star?.index || null, next?.index || null);
|
||||
@@ -147,7 +142,6 @@ function StarredLink({ star }: Props) {
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={icon}
|
||||
isActive={(
|
||||
match,
|
||||
@@ -178,7 +172,6 @@ function StarredLink({ star }: Props) {
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
|
||||
@@ -6,8 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { trashPath } from "~/utils/routeHelpers";
|
||||
import { DragObject } from "../hooks/useDragAndDrop";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
|
||||
function TrashLink() {
|
||||
const { policies, dialogs, documents } = useStores();
|
||||
|
||||
@@ -15,49 +15,10 @@ import Star from "~/models/Star";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AuthorizationError } from "~/utils/errors";
|
||||
import { DragObject } from "../components/SidebarLink";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
collectionId: string;
|
||||
};
|
||||
|
||||
function useHover(
|
||||
elementRef: React.RefObject<HTMLDivElement>,
|
||||
callback: () => void
|
||||
) {
|
||||
const hoverTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const startHover = React.useCallback(() => {
|
||||
if (!hoverTimeoutRef.current) {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
hoverTimeoutRef.current = undefined;
|
||||
callback();
|
||||
}, 500);
|
||||
}
|
||||
}, [callback]);
|
||||
|
||||
const unsetHover = React.useCallback(() => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
React.useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
element?.addEventListener("dragleave", unsetHover);
|
||||
return () => element?.removeEventListener("dragleave", unsetHover);
|
||||
}, [elementRef, unsetHover]);
|
||||
|
||||
return startHover;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dragging a Starred item
|
||||
*
|
||||
@@ -201,84 +162,6 @@ export function useDragDocument(
|
||||
return [{ isDragging }, draggableRef] as const;
|
||||
}
|
||||
|
||||
export function useDropToChangeCollection(
|
||||
collection: Collection,
|
||||
expandNode: () => void,
|
||||
parentRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs } = useStores();
|
||||
const can = usePolicy(collection);
|
||||
const startHover = useHover(parentRef, expandNode);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOver: boolean; canDrop: boolean }
|
||||
>({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, collectionId } = item;
|
||||
const prevCollection = collections.get(collectionId);
|
||||
const document = documents.get(id);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog item={item} collection={collection} index={0} />
|
||||
),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
expandNode();
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
|
||||
{
|
||||
documentName: item.title,
|
||||
collectionName: collection.name,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: () => can.createDocument,
|
||||
hover: (_, monitor) => {
|
||||
if (
|
||||
collection.hasDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({ shallow: true })
|
||||
) {
|
||||
startHover();
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver({ shallow: true }),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents to reparent
|
||||
*
|
||||
@@ -292,7 +175,7 @@ export function useDropToReparentDocument(
|
||||
parentRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs } = useStores();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
const hasChildDocuments = !!node?.children.length;
|
||||
const document = node ? documents.get(node.id) : undefined;
|
||||
const pathToNode = React.useMemo(
|
||||
@@ -300,7 +183,25 @@ export function useDropToReparentDocument(
|
||||
[document]
|
||||
);
|
||||
|
||||
const startHover = useHover(parentRef, setExpanded);
|
||||
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
React.useEffect(() => {
|
||||
const resetHoverExpanding = () => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const element = parentRef.current;
|
||||
element?.addEventListener("dragleave", resetHoverExpanding);
|
||||
|
||||
return () => {
|
||||
element?.removeEventListener("dragleave", resetHoverExpanding);
|
||||
};
|
||||
}, [parentRef]);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
@@ -313,9 +214,7 @@ export function useDropToReparentDocument(
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = node.collectionId
|
||||
? collections.get(node.collectionId)
|
||||
: undefined;
|
||||
const collection = documents.get(node.id)?.collection;
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
@@ -334,40 +233,22 @@ export function useDropToReparentDocument(
|
||||
),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
setExpanded();
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t(
|
||||
"{{ documentName }} cannot be moved within {{ parentDocumentName }}",
|
||||
{
|
||||
documentName: item.title,
|
||||
parentDocumentName: node.title,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: (item) => {
|
||||
if (!node || item.id === node.id) {
|
||||
return false;
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (!document) {
|
||||
return true; // optimistic, in case the document is not loaded yet; server will check for permissions before performing the move.
|
||||
}
|
||||
|
||||
return document.isActive && !!pathToNode && !pathToNode.includes(item.id);
|
||||
setExpanded();
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
!!node &&
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem().id) &&
|
||||
item.id !== node.id &&
|
||||
!!document?.isActive &&
|
||||
policies.abilities(node.id).update &&
|
||||
policies.abilities(item.id).move,
|
||||
hover: (_item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
@@ -378,7 +259,15 @@ export function useDropToReparentDocument(
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
startHover();
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = undefined;
|
||||
|
||||
if (monitor.isOver({ shallow: true })) {
|
||||
setExpanded();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
@@ -408,7 +297,7 @@ export function useDropToReorderDocument(
|
||||
}
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs } = useStores();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
|
||||
const document = documents.get(node.id);
|
||||
|
||||
@@ -419,9 +308,22 @@ export function useDropToReorderDocument(
|
||||
>({
|
||||
accept: "document",
|
||||
canDrop: (item: DragObject) => {
|
||||
if (item.id === node.id || (document && !document.isActive)) {
|
||||
if (
|
||||
item.id === node.id ||
|
||||
!policies.abilities(item.id)?.move ||
|
||||
!document?.isActive
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = getMoveParams(item);
|
||||
if (params?.collectionId) {
|
||||
return policies.abilities(params.collectionId)?.updateDocument;
|
||||
}
|
||||
if (params?.parentDocumentId) {
|
||||
return policies.abilities(params.parentDocumentId)?.update;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
drop: async (item) => {
|
||||
@@ -455,19 +357,7 @@ export function useDropToReorderDocument(
|
||||
),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await documents.move(params);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
toast.error(
|
||||
t("The {{ documentName }} cannot be moved here", {
|
||||
documentName: item.title,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
void documents.move(params);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -190,7 +190,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
if (!collection?.documents && !event.fetchIfMissing) {
|
||||
if (!collection?.documents?.length && !event.fetchIfMissing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React from "react";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import getMenuItems from "../menus/block";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps,
|
||||
"renderMenuItem" | "items" | "trigger"
|
||||
> &
|
||||
Required<Pick<SuggestionsMenuProps, "embeds">>;
|
||||
|
||||
function BlockMenu(props: Props) {
|
||||
const dictionary = useDictionary();
|
||||
const { elementRef } = useEditor();
|
||||
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
@@ -28,7 +29,7 @@ function BlockMenu(props: Props) {
|
||||
shortcut={item.shortcut}
|
||||
/>
|
||||
)}
|
||||
items={getMenuItems(dictionary, elementRef)}
|
||||
items={getMenuItems(dictionary)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ type Emoji = {
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps<Emoji>,
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
>;
|
||||
|
||||
const EmojiMenu = (props: Props) => {
|
||||
@@ -48,6 +48,7 @@ const EmojiMenu = (props: Props) => {
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
trigger=":"
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<EmojiMenuItem
|
||||
|
||||
@@ -6,10 +6,10 @@ import styled, { css } from "styled-components";
|
||||
import { isCode } from "@shared/editor/lib/isCode";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
@@ -184,10 +184,10 @@ function usePosition({
|
||||
// of the selection still
|
||||
const offset = left - (centerOfSelection - menuWidth / 2);
|
||||
return {
|
||||
left: Math.max(margin, Math.round(left - offsetParent.left)),
|
||||
left: Math.round(left - offsetParent.left),
|
||||
top: Math.round(top - offsetParent.top),
|
||||
offset: Math.round(offset),
|
||||
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
|
||||
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
|
||||
blockSelection: codeBlock || isColSelection || isRowSelection,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
@@ -187,7 +187,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { view, dictionary } = this.props;
|
||||
const { dictionary } = this.props;
|
||||
const { value } = this.state;
|
||||
const isInternal = isInternalUrl(value);
|
||||
|
||||
@@ -202,7 +202,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
onChange={this.handleSearch}
|
||||
onFocus={this.handleSearch}
|
||||
autoFocus={this.href === ""}
|
||||
readOnly={!view.editable}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
@@ -212,13 +211,11 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
{isInternal ? <ArrowIcon /> : <OpenIcon />}
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
{view.editable && (
|
||||
<Tooltip content={dictionary.removeLink}>
|
||||
<ToolbarButton onClick={this.handleRemoveLink}>
|
||||
<CloseIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={dictionary.removeLink}>
|
||||
<ToolbarButton onClick={this.handleRemoveLink}>
|
||||
<CloseIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ interface MentionItem extends MenuItem {
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps<MentionItem>,
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
>;
|
||||
|
||||
function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
@@ -84,6 +84,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
>
|
||||
<Avatar
|
||||
model={user}
|
||||
showBorder={false}
|
||||
alt={t("Profile picture")}
|
||||
size={AvatarSize.Small}
|
||||
/>
|
||||
@@ -157,9 +158,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
if (item.attrs.type === MentionType.Document) {
|
||||
return;
|
||||
}
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
// Check if the mentioned user has access to the document
|
||||
const res = await client.post("/documents.users", {
|
||||
id: documentId,
|
||||
@@ -196,6 +194,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
{...rest}
|
||||
isActive={isActive}
|
||||
filterable={false}
|
||||
trigger="@"
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
|
||||
@@ -9,7 +9,7 @@ import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps,
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
> & {
|
||||
pastedText: string;
|
||||
embeds: EmbedDescriptor[];
|
||||
@@ -31,7 +31,7 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
const items = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "noop",
|
||||
name: "link",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
@@ -48,7 +48,6 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
trigger=""
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
|
||||
@@ -233,9 +233,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const attrs =
|
||||
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
|
||||
|
||||
if (item.name === "noop") {
|
||||
// Do nothing
|
||||
} else if (command) {
|
||||
if (command) {
|
||||
command(attrs);
|
||||
} else {
|
||||
commands[`create${capitalize(item.name)}`](attrs);
|
||||
@@ -437,8 +435,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
if (
|
||||
item.name &&
|
||||
!commands[item.name] &&
|
||||
!commands[`create${capitalize(item.name)}`] &&
|
||||
item.name !== "noop"
|
||||
!commands[`create${capitalize(item.name)}`]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -99,28 +99,22 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
];
|
||||
}
|
||||
|
||||
private handleClose = action((insertNewLine: boolean) => {
|
||||
const { view } = this.editor;
|
||||
|
||||
if (insertNewLine) {
|
||||
const transaction = view.state.tr.split(view.state.selection.to);
|
||||
view.dispatch(transaction);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
this.state.open = false;
|
||||
});
|
||||
|
||||
widget = ({ rtl }: WidgetProps) => {
|
||||
const { props } = this.editor;
|
||||
|
||||
const { props, view } = this.editor;
|
||||
return (
|
||||
<BlockMenu
|
||||
rtl={rtl}
|
||||
trigger={this.options.trigger}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={this.handleClose}
|
||||
onClose={action((insertNewLine) => {
|
||||
if (insertNewLine) {
|
||||
const transaction = view.state.tr.split(view.state.selection.to);
|
||||
view.dispatch(transaction);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
this.state.open = false;
|
||||
})}
|
||||
uploadFile={props.uploadFile}
|
||||
onFileUploadStart={props.onFileUploadStart}
|
||||
onFileUploadStop={props.onFileUploadStop}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
|
||||
|
||||
/**
|
||||
* A plugin that allows overriding the default behavior of the editor to allow
|
||||
* copying text including the markdown formatting.
|
||||
* copying text for nodes that do not inherently have text children by defining
|
||||
* a `toPlainText` method in the node spec.
|
||||
*/
|
||||
export default class ClipboardTextSerializer extends Extension {
|
||||
get name() {
|
||||
@@ -12,33 +14,19 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const mdSerializer = this.editor.extensions.serializer();
|
||||
const textSerializers = getTextSerializers(this.editor.schema);
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("clipboardTextSerializer"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
const isMultiline = slice.content.childCount > 1;
|
||||
clipboardTextSerializer: () => {
|
||||
const { doc, selection } = this.editor.view.state;
|
||||
const { ranges } = selection;
|
||||
const from = Math.min(...ranges.map((range) => range.$from.pos));
|
||||
const to = Math.max(...ranges.map((range) => range.$to.pos));
|
||||
|
||||
// This is a cheap way to determine if the content is "complex",
|
||||
// aka it has multiple marks or formatting. In which case we'll use
|
||||
// markdown formatting
|
||||
const copyAsMarkdown =
|
||||
isMultiline ||
|
||||
slice.content.content.some(
|
||||
(node) => node.content.content.length > 1
|
||||
);
|
||||
|
||||
return copyAsMarkdown
|
||||
? mdSerializer.serialize(slice.content, {
|
||||
softBreak: true,
|
||||
})
|
||||
: slice.content.content
|
||||
.map((node) =>
|
||||
ProsemirrorHelper.toPlainText(node, this.editor.schema)
|
||||
)
|
||||
.join("");
|
||||
return textBetween(doc, from, to, textSerializers);
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -33,7 +33,6 @@ export default class EmojiMenuExtension extends Suggestion {
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<EmojiMenu
|
||||
rtl={rtl}
|
||||
trigger={this.options.trigger}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={action(() => {
|
||||
|
||||
@@ -21,7 +21,6 @@ export default class MentionMenuExtension extends Suggestion {
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<MentionMenu
|
||||
rtl={rtl}
|
||||
trigger={this.options.trigger}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={action(() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import {
|
||||
ySyncPlugin,
|
||||
yCursorPlugin,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
} from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
import { Second } from "@shared/utils/time";
|
||||
|
||||
type UserAwareness = {
|
||||
@@ -105,11 +103,6 @@ export default class Multiplayer extends Extension {
|
||||
selectionBuilder,
|
||||
}),
|
||||
yUndoPlugin(),
|
||||
new Plugin({
|
||||
props: {
|
||||
handleScrollToSelection: (view) => isRemoteTransaction(view.state.tr),
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -148,13 +148,6 @@ export default class PasteHandler extends Extension {
|
||||
const supportsCodeMark = !!state.schema.marks.code_inline;
|
||||
|
||||
if (!this.shiftKey) {
|
||||
// If the HTML on the clipboard is from Prosemirror then the best
|
||||
// compatability is to just use the HTML parser, regardless of
|
||||
// whether it "looks" like Markdown, see: outline/outline#2416
|
||||
if (html?.includes("data-pm-slice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the clipboard contents can be parsed as a single url
|
||||
if (isUrl(text)) {
|
||||
// If there is selected text then we want to wrap it in a link to the url
|
||||
@@ -256,6 +249,13 @@ export default class PasteHandler extends Extension {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If the HTML on the clipboard is from Prosemirror then the best
|
||||
// compatability is to just use the HTML parser, regardless of
|
||||
// whether it "looks" like Markdown, see: outline/outline#2416
|
||||
if (html?.includes("data-pm-slice")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If the text on the clipboard looks like Markdown OR there is no
|
||||
@@ -435,7 +435,7 @@ export default class PasteHandler extends Extension {
|
||||
|
||||
private handleSelect = (item: MenuItem) => {
|
||||
switch (item.name) {
|
||||
case "noop": {
|
||||
case "link": {
|
||||
this.hidePasteMenu();
|
||||
this.removePlaceholder();
|
||||
break;
|
||||
@@ -466,6 +466,7 @@ export default class PasteHandler extends Extension {
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<PasteMenu
|
||||
rtl={rtl}
|
||||
trigger=""
|
||||
embeds={this.editor.props.embeds}
|
||||
pastedText={this.state.pastedText}
|
||||
isActive={this.state.open}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
|
||||
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
|
||||
import Keys from "~/editor/extensions/Keys";
|
||||
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
|
||||
import PasteHandler from "~/editor/extensions/PasteHandler";
|
||||
import PreventTab from "~/editor/extensions/PreventTab";
|
||||
import SmartText from "~/editor/extensions/SmartText";
|
||||
|
||||
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
|
||||
|
||||
export const withUIExtensions = (nodes: Nodes) => [
|
||||
...nodes,
|
||||
SmartText,
|
||||
PasteHandler,
|
||||
ClipboardTextSerializer,
|
||||
BlockMenuExtension,
|
||||
EmojiMenuExtension,
|
||||
MentionMenuExtension,
|
||||
FindAndReplaceExtension,
|
||||
HoverPreviewsExtension,
|
||||
// Order these default key handlers last
|
||||
PreventTab,
|
||||
Keys,
|
||||
];
|
||||
@@ -38,12 +38,7 @@ const Img = styled(Image)`
|
||||
height: 18px;
|
||||
`;
|
||||
|
||||
export default function blockMenuItems(
|
||||
dictionary: Dictionary,
|
||||
documentRef: React.RefObject<HTMLDivElement>
|
||||
): MenuItem[] {
|
||||
const documentWidth = documentRef.current?.clientWidth ?? 0;
|
||||
|
||||
export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
name: "heading",
|
||||
@@ -124,11 +119,7 @@ export default function blockMenuItems(
|
||||
name: "table",
|
||||
title: dictionary.table,
|
||||
icon: <TableIcon />,
|
||||
attrs: {
|
||||
rowsCount: 3,
|
||||
colsCount: 3,
|
||||
colWidth: documentWidth / 3,
|
||||
},
|
||||
attrs: { rowsCount: 3, colsCount: 3 },
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState, useLayoutEffect } from "react";
|
||||
|
||||
export default function useComponentSize(
|
||||
ref: React.RefObject<HTMLElement | null>
|
||||
): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sizeObserver = new ResizeObserver((entries) => {
|
||||
entries.forEach(({ target }) => {
|
||||
if (
|
||||
size.width !== target.clientWidth ||
|
||||
size.height !== target.clientHeight
|
||||
) {
|
||||
setSize({ width: target.clientWidth, height: target.clientHeight });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
setSize({
|
||||
width: ref.current?.clientWidth,
|
||||
height: ref.current?.clientHeight,
|
||||
});
|
||||
sizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => sizeObserver.disconnect();
|
||||
}, [ref, size.height, size.width]);
|
||||
|
||||
return size;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
|
||||
/**
|
||||
* Hook to calculate text statistics
|
||||
* @param text The string to calculate statistics for
|
||||
* @param selectedText A substring of the text to calculate statistics for
|
||||
* @returns An object containing total and selected statistics
|
||||
*/
|
||||
export function useTextStats(text: string, selectedText: string = "") {
|
||||
const numTotalWords = countWords(text);
|
||||
const regex = emojiRegex();
|
||||
const matches = Array.from(text.matchAll(regex));
|
||||
|
||||
return {
|
||||
total: {
|
||||
words: numTotalWords,
|
||||
characters: text.length,
|
||||
emoji: matches.length ?? 0,
|
||||
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
|
||||
},
|
||||
selected: {
|
||||
words: countWords(selectedText),
|
||||
characters: selectedText.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
|
||||
// Hyphenated words are counted as two words
|
||||
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
|
||||
const { model: document, menuState } = useMenuContext<Document>();
|
||||
|
||||
const { data, loading, error, request } = useRequest(() =>
|
||||
subscriptions.fetchOne({
|
||||
subscriptions.fetchPage({
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
})
|
||||
|
||||
@@ -6,16 +6,11 @@ import Field from "./decorators/Field";
|
||||
class ApiKey extends ParanoidModel {
|
||||
static modelName = "ApiKey";
|
||||
|
||||
/** The human-readable name of this API key */
|
||||
/** The user chosen name of the API key. */
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
/** A list of scopes that this API key has access to. If empty, the key has full access. */
|
||||
@Field
|
||||
@observable
|
||||
scope?: string[];
|
||||
|
||||
/** An optional datetime that the API key expires. */
|
||||
@Field
|
||||
@observable
|
||||
|
||||
@@ -181,11 +181,6 @@ export default class Collection extends ParanoidModel {
|
||||
return !this.isArchived && !this.isDeleted;
|
||||
}
|
||||
|
||||
@computed
|
||||
get hasDocuments() {
|
||||
return !!this.documents?.length;
|
||||
}
|
||||
|
||||
fetchDocuments = async (options?: { force: boolean }) => {
|
||||
if (this.isFetching) {
|
||||
return;
|
||||
@@ -263,36 +258,6 @@ export default class Collection extends ParanoidModel {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the document identified by the given id to the collection in
|
||||
* memory. Does not add the document to the database or store.
|
||||
*
|
||||
* @param document The document to add.
|
||||
* @param parentDocumentId The id of the document to add the new document to.
|
||||
*/
|
||||
@action
|
||||
addDocument(document: Document, parentDocumentId?: string) {
|
||||
if (!this.documents) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parentDocumentId) {
|
||||
this.documents.unshift(document.asNavigationNode);
|
||||
return;
|
||||
}
|
||||
|
||||
const travelNodes = (nodes: NavigationNode[]) =>
|
||||
nodes.forEach((node) => {
|
||||
if (node.id === parentDocumentId) {
|
||||
node.children = [document.asNavigationNode, ...(node.children ?? [])];
|
||||
} else {
|
||||
travelNodes(node.children);
|
||||
}
|
||||
});
|
||||
|
||||
travelNodes(this.documents);
|
||||
}
|
||||
|
||||
@action
|
||||
updateIndex(index: string) {
|
||||
this.index = index;
|
||||
|
||||
@@ -307,7 +307,9 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
*/
|
||||
@computed
|
||||
get isSubscribed(): boolean {
|
||||
return !!this.store.rootStore.subscriptions.getByDocumentId(this.id);
|
||||
return !!this.store.rootStore.subscriptions.orderedData.find(
|
||||
(subscription) => subscription.documentId === this.id
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,7 +501,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
* @returns A promise that resolves when the subscription is destroyed.
|
||||
*/
|
||||
@action
|
||||
unsubscribe = () => this.store.unsubscribe(this);
|
||||
unsubscribe = (userId: string) => this.store.unsubscribe(userId, this);
|
||||
|
||||
@action
|
||||
view = () => {
|
||||
|
||||
@@ -72,7 +72,8 @@ function AuthenticatedRoutes() {
|
||||
<Redirect exact from="/templates" to={settingsPath("templates")} />
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
<Route exact path="/collection/:id/:tab?" component={Collection} />
|
||||
<Route exact path="/collection/:id/:tab" component={Collection} />
|
||||
<Route exact path="/collection/:id" component={Collection} />
|
||||
<Route exact path="/doc/new" component={DocumentNew} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
|
||||
@@ -22,7 +22,6 @@ type Props = {
|
||||
|
||||
function ApiKeyNew({ onSubmit }: Props) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [scope, setScope] = React.useState("");
|
||||
const [expiryType, setExpiryType] = React.useState<ExpiryType>(
|
||||
ExpiryType.Week
|
||||
);
|
||||
@@ -52,10 +51,6 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
setName(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleScopeChange = React.useCallback((event) => {
|
||||
setScope(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleExpiryTypeChange = React.useCallback((value: string) => {
|
||||
const expiry = value as ExpiryType;
|
||||
setExpiryType(expiry);
|
||||
@@ -75,7 +70,6 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
await apiKeys.create({
|
||||
name,
|
||||
expiresAt: expiresAt?.toISOString(),
|
||||
scope: scope ? scope.split(" ") : undefined,
|
||||
});
|
||||
toast.success(
|
||||
t(
|
||||
@@ -89,16 +83,20 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[t, name, scope, expiresAt, onSubmit, apiKeys]
|
||||
[t, name, expiresAt, onSubmit, apiKeys]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
`Name your key something that will help you to remember it's use in the future, for example "local development" or "continuous integration".`
|
||||
)}
|
||||
</Text>
|
||||
<Flex column>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("Development")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
minLength={ApiKeyValidation.minNameLength}
|
||||
@@ -107,20 +105,6 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Scopes")}
|
||||
placeholder="documents.info"
|
||||
onChange={handleScopeChange}
|
||||
value={scope}
|
||||
flex
|
||||
/>
|
||||
<Text type="secondary" size="small" as="p">
|
||||
{t(
|
||||
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access"
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
<Flex align="center" gap={16}>
|
||||
<StyledExpirySelect
|
||||
ariaLabel={t("Expiration")}
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
|
||||
import Collection from "~/models/Collection";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -44,10 +44,10 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
||||
groupMemberships.fetchPage(options),
|
||||
]);
|
||||
if (users[PAGINATION_SYMBOL]) {
|
||||
setUsersCount(users[PAGINATION_SYMBOL].total ?? 0);
|
||||
setUsersCount(users[PAGINATION_SYMBOL].total);
|
||||
}
|
||||
if (groups[PAGINATION_SYMBOL]) {
|
||||
setGroupsCount(groups[PAGINATION_SYMBOL].total ?? 0);
|
||||
setGroupsCount(groups[PAGINATION_SYMBOL].total);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -101,10 +101,12 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
||||
>
|
||||
<Fade>
|
||||
<Facepile
|
||||
size={AvatarSize.Large}
|
||||
users={sortBy(collectionUsers, "lastActiveAt")}
|
||||
overflow={overflow}
|
||||
limit={limit}
|
||||
renderAvatar={(item) => (
|
||||
<Avatar model={item} size={AvatarSize.Large} />
|
||||
)}
|
||||
/>
|
||||
</Fade>
|
||||
</NudeButton>
|
||||
|
||||
+101
-187
@@ -3,12 +3,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useParams,
|
||||
Redirect,
|
||||
Switch,
|
||||
Route,
|
||||
useHistory,
|
||||
useRouteMatch,
|
||||
useLocation,
|
||||
Redirect,
|
||||
} from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -30,13 +30,12 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
||||
import PinnedDocuments from "~/components/PinnedDocuments";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import { editCollection } from "~/actions/definitions/collections";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -50,16 +49,7 @@ import ShareButton from "./components/ShareButton";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
|
||||
enum CollectionPath {
|
||||
Overview = "overview",
|
||||
Recent = "recent",
|
||||
Updated = "updated",
|
||||
Published = "published",
|
||||
Old = "old",
|
||||
Alphabetical = "alphabetical",
|
||||
}
|
||||
|
||||
const CollectionScene = observer(function _CollectionScene() {
|
||||
function CollectionScene() {
|
||||
const params = useParams<{ id?: string }>();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
@@ -70,26 +60,17 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
const [error, setError] = React.useState<Error | undefined>();
|
||||
const currentPath = location.pathname;
|
||||
const [, setLastVisitedPath] = useLastVisitedPath();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
|
||||
const id = params.id || "";
|
||||
const collection: Collection | null | undefined =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const can = usePolicy(collection);
|
||||
const { pins, count } = usePinnedDocuments(id, collection?.id);
|
||||
const [collectionTab, setCollectionTab] = usePersistedState<CollectionPath>(
|
||||
`collection-tab:${collection?.id}`,
|
||||
collection?.hasDescription
|
||||
? CollectionPath.Overview
|
||||
: CollectionPath.Recent,
|
||||
{
|
||||
listen: false,
|
||||
}
|
||||
);
|
||||
|
||||
const handleIconChange = React.useCallback(
|
||||
(icon: string | null, color: string | null) =>
|
||||
collection?.save({ icon, color }),
|
||||
async (icon: string | null, color: string | null) => {
|
||||
await collection?.save({ icon, color });
|
||||
},
|
||||
[collection]
|
||||
);
|
||||
|
||||
@@ -139,8 +120,6 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
return <Search notFound />;
|
||||
}
|
||||
|
||||
const hasOverview = can.update || collection?.hasDescription;
|
||||
|
||||
const fallbackIcon = collection ? (
|
||||
<Icon
|
||||
value={collection.icon ?? "collection"}
|
||||
@@ -149,17 +128,11 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const tabProps = (path: CollectionPath) => ({
|
||||
exact: true,
|
||||
onClick: () => setCollectionTab(path),
|
||||
to: {
|
||||
pathname: collectionPath(collection!.path, path),
|
||||
state: { sidebarContext },
|
||||
},
|
||||
});
|
||||
|
||||
return collection ? (
|
||||
<Scene
|
||||
// Forced mount prevents animation of pinned documents when navigating
|
||||
// _between_ collections, speeds up perceived performance.
|
||||
key={collection.id}
|
||||
centered={false}
|
||||
textTitle={collection.name}
|
||||
left={
|
||||
@@ -223,156 +196,105 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
canUpdate={can.update}
|
||||
placeholderCount={count}
|
||||
/>
|
||||
<CollectionDescription collection={collection} />
|
||||
|
||||
<Documents>
|
||||
<Tabs>
|
||||
{hasOverview && (
|
||||
<Tab {...tabProps(CollectionPath.Overview)}>
|
||||
{t("Overview")}
|
||||
{!collection.isArchived && (
|
||||
<Tabs>
|
||||
<Tab to={collectionPath(collection.path)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
)}
|
||||
<Tab {...tabProps(CollectionPath.Recent)}>{t("Documents")}</Tab>
|
||||
{!collection.isArchived && (
|
||||
<>
|
||||
<Tab {...tabProps(CollectionPath.Updated)}>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab {...tabProps(CollectionPath.Published)}>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab {...tabProps(CollectionPath.Old)}>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab {...tabProps(CollectionPath.Alphabetical)}>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: collectionPath(collection!.path, collectionTab),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(collection.path, CollectionPath.Overview)}
|
||||
>
|
||||
{hasOverview ? (
|
||||
<CollectionDescription collection={collection} />
|
||||
) : (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Recent
|
||||
),
|
||||
state: { sidebarContext },
|
||||
<Tab to={collectionPath(collection.path, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
)}
|
||||
{collection.isEmpty ? (
|
||||
<Empty collection={collection} />
|
||||
) : !collection.isArchived ? (
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Route>
|
||||
{collection.isEmpty ? (
|
||||
<Empty collection={collection} />
|
||||
) : !collection.isArchived ? (
|
||||
<>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Alphabetical
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(collection.path, CollectionPath.Old)}
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Published
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "recent")}>
|
||||
<Redirect to={collectionPath(collection.path, "published")} />
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPublished
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Updated
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPublished
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Recent
|
||||
)}
|
||||
exact
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: collection.sort.direction,
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
</>
|
||||
) : (
|
||||
<Route
|
||||
path={collectionPath(collection.path, CollectionPath.Recent)}
|
||||
exact
|
||||
>
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: collection.sort.direction,
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
) : (
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.archivedInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
heading={<Subheading sticky>{t("Documents")}</Subheading>}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
@@ -383,8 +305,8 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
)}
|
||||
</Switch>
|
||||
</Switch>
|
||||
)}
|
||||
</Documents>
|
||||
</CenteredContent>
|
||||
</DropToImport>
|
||||
@@ -397,15 +319,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
<PlaceholderList count={5} />
|
||||
</CenteredContent>
|
||||
);
|
||||
});
|
||||
|
||||
const KeyedCollection = () => {
|
||||
const params = useParams<{ id?: string }>();
|
||||
|
||||
// Forced mount prevents animation of pinned documents when navigating
|
||||
// _between_ collections, speeds up perceived performance.
|
||||
return <CollectionScene key={params.id} />;
|
||||
};
|
||||
}
|
||||
|
||||
const Documents = styled.div`
|
||||
position: relative;
|
||||
@@ -423,4 +337,4 @@ const CollectionHeading = styled(Heading)`
|
||||
`}
|
||||
`;
|
||||
|
||||
export default KeyedCollection;
|
||||
export default observer(CollectionScene);
|
||||
|
||||
@@ -217,31 +217,33 @@ function SharedDocumentScene(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const SharedDocument = observer(
|
||||
({ shareId, response }: { shareId?: string; response: Response }) => {
|
||||
const { hasHeadings, setDocument } = useDocumentContext();
|
||||
const SharedDocument = ({
|
||||
shareId,
|
||||
response,
|
||||
}: {
|
||||
shareId?: string;
|
||||
response: Response;
|
||||
}) => {
|
||||
const { setDocument } = useDocumentContext();
|
||||
|
||||
if (!response.document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tocPosition = hasHeadings
|
||||
? response.team?.tocPosition ?? TOCPosition.Left
|
||||
: false;
|
||||
setDocument(response.document);
|
||||
|
||||
return (
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
tocPosition={tocPosition}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
if (!response.document) {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
const tocPosition = response.team?.tocPosition ?? TOCPosition.Left;
|
||||
setDocument(response.document);
|
||||
|
||||
return (
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
tocPosition={tocPosition}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Content = styled(Text)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ProsemirrorData } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
@@ -54,7 +54,7 @@ function CommentThread({
|
||||
collapseThreshold = 5,
|
||||
collapseNumDisplayed = 3,
|
||||
}: Props) {
|
||||
const [scrollOnMount] = React.useState(focused && !window.location.hash);
|
||||
const [focusedOnMount] = React.useState(focused);
|
||||
const { editor } = useDocumentContext();
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -149,6 +149,9 @@ function CommentThread({
|
||||
limit={limit}
|
||||
overflow={overflow}
|
||||
size={AvatarSize.Medium}
|
||||
renderAvatar={(item) => (
|
||||
<Avatar size={AvatarSize.Medium} model={item} />
|
||||
)}
|
||||
/>
|
||||
</ShowMore>
|
||||
);
|
||||
@@ -162,7 +165,7 @@ function CommentThread({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focused) {
|
||||
if (scrollOnMount) {
|
||||
if (focusedOnMount) {
|
||||
setTimeout(() => {
|
||||
if (!topRef.current) {
|
||||
return;
|
||||
@@ -206,7 +209,7 @@ function CommentThread({
|
||||
isMarkVisible ? 0 : sidebarAppearDuration
|
||||
);
|
||||
}
|
||||
}, [focused, scrollOnMount, thread.id]);
|
||||
}, [focused, focusedOnMount, thread.id]);
|
||||
|
||||
return (
|
||||
<Thread
|
||||
|
||||
@@ -243,7 +243,7 @@ function CommentThreadItem({
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
size={28}
|
||||
$rounded
|
||||
rounded
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -264,7 +264,7 @@ function CommentThreadItem({
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
$rounded
|
||||
rounded
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -302,7 +302,7 @@ const ResolveButton = ({
|
||||
comment,
|
||||
onResolve: () => onUpdate({ resolved: true }),
|
||||
})}
|
||||
$rounded
|
||||
rounded
|
||||
>
|
||||
<DoneIcon size={22} outline />
|
||||
</Action>
|
||||
@@ -340,10 +340,10 @@ const Body = styled.form`
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const Action = styled.span<{ $rounded?: boolean }>`
|
||||
const Action = styled.span<{ rounded?: boolean }>`
|
||||
color: ${s("textSecondary")};
|
||||
${(props) =>
|
||||
props.$rounded &&
|
||||
props.rounded &&
|
||||
css`
|
||||
border-radius: 50%;
|
||||
`}
|
||||
|
||||
@@ -89,7 +89,7 @@ type Props = WithTranslation &
|
||||
revision?: Revision;
|
||||
readOnly: boolean;
|
||||
shareId?: string;
|
||||
tocPosition?: TOCPosition | false;
|
||||
tocPosition?: TOCPosition;
|
||||
onCreateLink?: (
|
||||
params: Properties<Document>,
|
||||
nested?: boolean
|
||||
@@ -380,23 +380,24 @@ class DocumentScene extends React.Component<Props> {
|
||||
AUTOSAVE_DELAY
|
||||
);
|
||||
|
||||
updateIsDirty = action(() => {
|
||||
updateIsDirty = () => {
|
||||
const { document } = this.props;
|
||||
const doc = this.editor.current?.view.state.doc;
|
||||
|
||||
this.isEditorDirty = !isEqual(doc?.toJSON(), document.data);
|
||||
|
||||
// a single hash is a doc with just an empty title
|
||||
this.isEmpty = (!doc || ProsemirrorHelper.isEmpty(doc)) && !this.title;
|
||||
});
|
||||
};
|
||||
|
||||
updateIsDirtyDebounced = debounce(this.updateIsDirty, 500);
|
||||
|
||||
onFileUploadStart = action(() => {
|
||||
onFileUploadStart = () => {
|
||||
this.isUploading = true;
|
||||
});
|
||||
};
|
||||
|
||||
onFileUploadStop = action(() => {
|
||||
onFileUploadStop = () => {
|
||||
this.isUploading = false;
|
||||
});
|
||||
};
|
||||
|
||||
handleChangeTitle = action((value: string) => {
|
||||
this.title = value;
|
||||
@@ -437,15 +438,13 @@ class DocumentScene extends React.Component<Props> {
|
||||
const embedsDisabled =
|
||||
(team && team.documentEmbeds === false) || document.embedsDisabled;
|
||||
|
||||
const showContents =
|
||||
(ui.tocVisible === true && !document.isTemplate) ||
|
||||
(isShare && ui.tocVisible !== false);
|
||||
const tocPos =
|
||||
tocPosition ??
|
||||
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
|
||||
TOCPosition.Left);
|
||||
const showContents =
|
||||
tocPos &&
|
||||
(isShare
|
||||
? ui.tocVisible !== false
|
||||
: !document.isTemplate && ui.tocVisible === true);
|
||||
const multiplayerEditor =
|
||||
!document.isArchived && !document.isDeleted && !revision && !isShare;
|
||||
|
||||
@@ -542,6 +541,14 @@ class DocumentScene extends React.Component<Props> {
|
||||
</RevisionContainer>
|
||||
) : (
|
||||
<>
|
||||
{showContents && (
|
||||
<ContentsContainer
|
||||
docFullWidth={document.fullWidth}
|
||||
position={tocPos}
|
||||
>
|
||||
<Contents />
|
||||
</ContentsContainer>
|
||||
)}
|
||||
<MeasuredContainer
|
||||
name="document"
|
||||
as={EditorContainer}
|
||||
@@ -575,7 +582,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
readOnly={readOnly}
|
||||
canUpdate={abilities.update}
|
||||
canComment={abilities.comment}
|
||||
autoFocus={document.createdAt === document.updatedAt}
|
||||
>
|
||||
{shareId ? (
|
||||
<ReferencesWrapper>
|
||||
@@ -592,14 +598,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
) : null}
|
||||
</Editor>
|
||||
</MeasuredContainer>
|
||||
{showContents && (
|
||||
<ContentsContainer
|
||||
docFullWidth={document.fullWidth}
|
||||
position={tocPos}
|
||||
>
|
||||
<Contents />
|
||||
</ContentsContainer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</React.Suspense>
|
||||
@@ -624,7 +622,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
type MainProps = {
|
||||
fullWidth: boolean;
|
||||
tocPosition: TOCPosition | false;
|
||||
tocPosition: TOCPosition;
|
||||
};
|
||||
|
||||
const Main = styled.div<MainProps>`
|
||||
@@ -652,7 +650,7 @@ const Main = styled.div<MainProps>`
|
||||
|
||||
type ContentsContainerProps = {
|
||||
docFullWidth: boolean;
|
||||
position: TOCPosition | false;
|
||||
position: TOCPosition;
|
||||
};
|
||||
|
||||
const ContentsContainer = styled.div<ContentsContainerProps>`
|
||||
@@ -670,7 +668,7 @@ const ContentsContainer = styled.div<ContentsContainerProps>`
|
||||
type EditorContainerProps = {
|
||||
docFullWidth: boolean;
|
||||
showContents: boolean;
|
||||
tocPosition: TOCPosition | false;
|
||||
tocPosition: TOCPosition;
|
||||
};
|
||||
|
||||
const EditorContainer = styled.div<EditorContainerProps>`
|
||||
|
||||
@@ -13,7 +13,16 @@ import { RefHandle } from "~/components/ContentEditable";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
|
||||
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
|
||||
import Keys from "~/editor/extensions/Keys";
|
||||
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
|
||||
import PasteHandler from "~/editor/extensions/PasteHandler";
|
||||
import PreventTab from "~/editor/extensions/PreventTab";
|
||||
import SmartText from "~/editor/extensions/SmartText";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
@@ -31,7 +40,20 @@ import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
||||
import DocumentMeta from "./DocumentMeta";
|
||||
import DocumentTitle from "./DocumentTitle";
|
||||
|
||||
const extensions = withUIExtensions(withComments(richExtensions));
|
||||
const extensions = [
|
||||
...withComments(richExtensions),
|
||||
SmartText,
|
||||
PasteHandler,
|
||||
ClipboardTextSerializer,
|
||||
BlockMenuExtension,
|
||||
EmojiMenuExtension,
|
||||
MentionMenuExtension,
|
||||
FindAndReplaceExtension,
|
||||
HoverPreviewsExtension,
|
||||
// Order these default key handlers last
|
||||
PreventTab,
|
||||
Keys,
|
||||
];
|
||||
|
||||
type Props = Omit<EditorProps, "editorStyle"> & {
|
||||
onChangeTitle: (title: string) => void;
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
@@ -32,6 +31,7 @@ import { publishDocument } from "~/actions/definitions/documents";
|
||||
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
|
||||
import { restoreRevision } from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
@@ -96,7 +96,6 @@ function DocumentHeader({
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
const size = useComponentSize(ref);
|
||||
const isMobile = isMobileMedia || size.width < 700;
|
||||
const isShare = !!shareId;
|
||||
|
||||
// We cache this value for as long as the component is mounted so that if you
|
||||
// apply a template there is still the option to replace it until the user
|
||||
@@ -110,13 +109,8 @@ function DocumentHeader({
|
||||
}, [onSave]);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
// Public shares, by default, show ToC on load.
|
||||
if (isShare && ui.tocVisible === undefined) {
|
||||
ui.set({ tocVisible: false });
|
||||
} else {
|
||||
ui.set({ tocVisible: !ui.tocVisible });
|
||||
}
|
||||
}, [ui, isShare]);
|
||||
ui.set({ tocVisible: !ui.tocVisible });
|
||||
}, [ui]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
@@ -126,6 +120,7 @@ function DocumentHeader({
|
||||
const { isDeleted, isTemplate } = document;
|
||||
const isTemplateEditable = can.update && isTemplate;
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const isShare = !!shareId;
|
||||
const showContents =
|
||||
(ui.tocVisible === true && !document.isTemplate) ||
|
||||
(isShare && ui.tocVisible !== false);
|
||||
@@ -217,9 +212,7 @@ function DocumentHeader({
|
||||
hasSidebar={sharedTree && sharedTree.children?.length > 0}
|
||||
left={
|
||||
isMobile ? (
|
||||
hasHeadings ? (
|
||||
<TableOfContentsMenu />
|
||||
) : null
|
||||
<TableOfContentsMenu />
|
||||
) : (
|
||||
<PublicBreadcrumb
|
||||
documentId={document.id}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -6,7 +7,7 @@ import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -19,7 +20,6 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useTextSelection from "~/hooks/useTextSelection";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import InsightsMenu from "~/menus/InsightsMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
@@ -136,7 +136,7 @@ function Insights() {
|
||||
avatarUrl: null,
|
||||
initial: document.sourceMetadata.createdByName[0],
|
||||
}}
|
||||
size={AvatarSize.Large}
|
||||
size={32}
|
||||
/>
|
||||
}
|
||||
subtitle={t("Creator")}
|
||||
@@ -213,6 +213,32 @@ function Insights() {
|
||||
);
|
||||
}
|
||||
|
||||
function useTextStats(text: string, selectedText: string) {
|
||||
const numTotalWords = countWords(text);
|
||||
const regex = emojiRegex();
|
||||
const matches = Array.from(text.matchAll(regex));
|
||||
|
||||
return {
|
||||
total: {
|
||||
words: numTotalWords,
|
||||
characters: text.length,
|
||||
emoji: matches.length ?? 0,
|
||||
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
|
||||
},
|
||||
selected: {
|
||||
words: countWords(selectedText),
|
||||
characters: selectedText.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
|
||||
// Hyphenated words are counted as two words
|
||||
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
|
||||
}
|
||||
|
||||
const ListSpacing = styled("div")`
|
||||
margin-top: -0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import useComponentSize from "@shared/hooks/useComponentSize";
|
||||
|
||||
export const MeasuredContainer = <T extends React.ElementType>({
|
||||
as: As,
|
||||
|
||||
@@ -50,7 +50,7 @@ function DocumentNew({ template }: Props) {
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
templateId: query.get("templateId") ?? undefined,
|
||||
template,
|
||||
title: query.get("title") ?? "",
|
||||
title: "",
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: collection?.id || parentDocumentId ? true : undefined }
|
||||
|
||||
@@ -457,32 +457,13 @@ function KeyboardShortcuts() {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("Triggers"),
|
||||
items: [
|
||||
{
|
||||
shortcut: "@",
|
||||
label: t("Mention user or document"),
|
||||
},
|
||||
{
|
||||
shortcut: ":",
|
||||
label: t("Emoji"),
|
||||
},
|
||||
{
|
||||
shortcut: "/",
|
||||
label: t("Insert block"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const normalizedSearchTerm = searchTerm.toLocaleLowerCase();
|
||||
const handleChange = React.useCallback((event) => {
|
||||
setSearchTerm(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback((event) => {
|
||||
if (event.currentTarget.value && event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -490,20 +471,17 @@ function KeyboardShortcuts() {
|
||||
setSearchTerm("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<StickySearch>
|
||||
<InputSearch
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={searchTerm}
|
||||
/>
|
||||
</StickySearch>
|
||||
<InputSearch
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={searchTerm}
|
||||
/>
|
||||
{categories.map((category, x) => {
|
||||
const filtered = searchTerm
|
||||
? category.items.filter((item) =>
|
||||
item.label.toLocaleLowerCase().includes(normalizedSearchTerm)
|
||||
item.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
: category.items;
|
||||
|
||||
@@ -531,16 +509,6 @@ function KeyboardShortcuts() {
|
||||
);
|
||||
}
|
||||
|
||||
const StickySearch = styled.div`
|
||||
position: sticky;
|
||||
top: -16px;
|
||||
z-index: 1;
|
||||
padding: 16px;
|
||||
margin: -16px;
|
||||
background: ${s("background")};
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
const Header = styled.h2`
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -10,7 +10,6 @@ import { s } from "@shared/styles";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { Config } from "~/stores/AuthStore";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import ChangeLanguage from "~/components/ChangeLanguage";
|
||||
import Fade from "~/components/Fade";
|
||||
@@ -250,9 +249,9 @@ function Login({ children }: Props) {
|
||||
/>
|
||||
<Logo>
|
||||
{config.logo && !isCreate ? (
|
||||
<TeamLogo size={AvatarSize.XXLarge} src={config.logo} />
|
||||
<TeamLogo size={48} src={config.logo} />
|
||||
) : (
|
||||
<OutlineIcon size={AvatarSize.XXLarge} />
|
||||
<OutlineIcon size={48} />
|
||||
)}
|
||||
</Logo>
|
||||
{isCreate ? (
|
||||
|
||||
@@ -25,7 +25,7 @@ function UserFilter(props: Props) {
|
||||
const userOptions = users.all.map((user) => ({
|
||||
key: user.id,
|
||||
label: user.name,
|
||||
icon: <Avatar model={user} size={AvatarSize.Small} />,
|
||||
icon: <Avatar model={user} showBorder={false} size={AvatarSize.Small} />,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -78,7 +78,7 @@ const Profile = () => {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
border={env.EMAIL_ENABLED}
|
||||
border={false}
|
||||
label={t("Name")}
|
||||
name="name"
|
||||
description={t(
|
||||
@@ -95,7 +95,7 @@ const Profile = () => {
|
||||
</SettingRow>
|
||||
|
||||
{env.EMAIL_ENABLED && (
|
||||
<SettingRow border={false} label={t("Email address")} name="email">
|
||||
<SettingRow label={t("Email address")} name="email">
|
||||
<Input
|
||||
type="email"
|
||||
value={user.email}
|
||||
|
||||
@@ -10,7 +10,6 @@ import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import ApiKeyMenu from "~/menus/ApiKeyMenu";
|
||||
@@ -36,7 +35,7 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
·{" "}
|
||||
</Text>
|
||||
{apiKey.lastActiveAt && (
|
||||
<Text type="tertiary">
|
||||
<Text type={"tertiary"}>
|
||||
{t("Last used")} <Time dateTime={apiKey.lastActiveAt} addSuffix />{" "}
|
||||
·{" "}
|
||||
</Text>
|
||||
@@ -45,20 +44,7 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
{apiKey.expiresAt
|
||||
? dateToExpiry(apiKey.expiresAt, t, userLocale)
|
||||
: t("No expiry")}
|
||||
{apiKey.scope && <> · </>}
|
||||
</Text>
|
||||
{apiKey.scope && (
|
||||
<Tooltip
|
||||
content={apiKey.scope.map((s) => (
|
||||
<>
|
||||
{s}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
>
|
||||
<Text type="tertiary">{t("Restricted scope")}</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -434,7 +434,7 @@ const GroupMemberListItem = observer(function ({
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
}
|
||||
image={<Avatar model={user} size={AvatarSize.Large} />}
|
||||
image={<Avatar model={user} size={32} />}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function ImageInput({ model, onSuccess, ...rest }: Props) {
|
||||
<Flex gap={8} justify="space-between">
|
||||
<ImageBox>
|
||||
<ImageUpload onSuccess={onSuccess} {...rest}>
|
||||
<StyledAvatar model={model} size={AvatarSize.Upload} />
|
||||
<StyledAvatar model={model} size={AvatarSize.XXLarge} />
|
||||
<Flex auto align="center" justify="center" className="upload">
|
||||
{t("Upload")}
|
||||
</Flex>
|
||||
@@ -34,8 +34,8 @@ export default function ImageInput({ model, onSuccess, ...rest }: Props) {
|
||||
}
|
||||
|
||||
const avatarStyles = `
|
||||
width: ${AvatarSize.Upload}px;
|
||||
height: ${AvatarSize.Upload}px;
|
||||
width: ${AvatarSize.XXLarge}px;
|
||||
height: ${AvatarSize.XXLarge}px;
|
||||
`;
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import Flex from "~/components/Flex";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
@@ -38,7 +38,7 @@ export function PeopleTable({ canManage, ...rest }: Props) {
|
||||
accessor: (user) => user.name,
|
||||
component: (user) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatar model={user} size={AvatarSize.Large} /> {user.name}{" "}
|
||||
<Avatar model={user} size={32} /> {user.name}{" "}
|
||||
{currentUser.id === user.id && `(${t("You")})`}
|
||||
</Flex>
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { observable, action } from "mobx";
|
||||
import { AwarenessChangeEvent } from "~/types";
|
||||
import RootStore from "./RootStore";
|
||||
|
||||
type DocumentPresence = Map<
|
||||
string,
|
||||
@@ -18,12 +17,6 @@ export default class PresenceStore {
|
||||
|
||||
offlineTimeout = 30000;
|
||||
|
||||
private rootStore: RootStore;
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
this.rootStore = rootStore;
|
||||
}
|
||||
|
||||
// called when a user leaves the document
|
||||
@action
|
||||
public leave(documentId: string, userId: string) {
|
||||
@@ -45,7 +38,7 @@ export default class PresenceStore {
|
||||
|
||||
event.states.forEach((state) => {
|
||||
const { user, cursor } = state;
|
||||
if (user && this.rootStore.auth.currentUserId !== user.id) {
|
||||
if (user) {
|
||||
this.update(documentId, user.id, !!cursor);
|
||||
existingUserIds = existingUserIds.filter((id) => id !== user.id);
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export default class DocumentsStore extends Store<Document> {
|
||||
event: "documents.update",
|
||||
});
|
||||
|
||||
unsubscribe = (document: Document) => {
|
||||
const subscription = this.rootStore.subscriptions.getByDocumentId(
|
||||
document.id
|
||||
unsubscribe = (userId: string, document: Document) => {
|
||||
const subscription = this.rootStore.subscriptions.orderedData.find(
|
||||
(s) => s.documentId === document.id && s.userId === userId
|
||||
);
|
||||
|
||||
return subscription?.delete();
|
||||
|
||||
@@ -106,6 +106,6 @@ export default class GroupsStore extends Store<Group> {
|
||||
|
||||
function queriedGroups(groups: Group[], query: string) {
|
||||
return groups.filter((group) =>
|
||||
group.name.toLocaleLowerCase().includes(query.toLocaleLowerCase())
|
||||
group.name.toLowerCase().match(query.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import invariant from "invariant";
|
||||
import { action } from "mobx";
|
||||
import Subscription from "~/models/Subscription";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
import RootStore from "./RootStore";
|
||||
import Store, { RPCAction } from "./base/Store";
|
||||
|
||||
@@ -12,34 +8,4 @@ export default class SubscriptionsStore extends Store<Subscription> {
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Subscription);
|
||||
}
|
||||
|
||||
@action
|
||||
async fetchOne({ documentId, event }: { documentId: string; event: string }) {
|
||||
const subscription = this.getByDocumentId(documentId);
|
||||
|
||||
if (subscription) {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/${this.apiEndpoint}.info`, {
|
||||
documentId,
|
||||
event,
|
||||
});
|
||||
invariant(res?.data, "Data should be available");
|
||||
return this.add(res.data);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
getByDocumentId = (documentId: string): Subscription | undefined =>
|
||||
this.find({ documentId });
|
||||
}
|
||||
|
||||
@@ -14,31 +14,6 @@ type UploadOptions = {
|
||||
onProgress?: (fractionComplete: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a file from a URL
|
||||
*
|
||||
* @param url The remote URL to download the file from
|
||||
* @param options The upload options
|
||||
* @returns The attachment object
|
||||
*/
|
||||
export const uploadFileFromUrl = async (
|
||||
url: string,
|
||||
options: UploadOptions
|
||||
) => {
|
||||
const response = await client.post("/attachments.createFromUrl", {
|
||||
documentId: options.documentId,
|
||||
url,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a file
|
||||
*
|
||||
* @param file The file to upload
|
||||
* @param options The upload options
|
||||
* @returns The attachment object
|
||||
*/
|
||||
export const uploadFile = async (
|
||||
file: File | Blob,
|
||||
options: UploadOptions = {
|
||||
@@ -99,12 +74,6 @@ export const uploadFile = async (
|
||||
return attachment;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a data URL to a Blob
|
||||
*
|
||||
* @param dataURL The data URL to convert
|
||||
* @returns The Blob
|
||||
*/
|
||||
export const dataUrlToBlob = (dataURL: string) => {
|
||||
const blobBin = atob(dataURL.split(",")[1]);
|
||||
const array = [];
|
||||
|
||||
+31
-31
@@ -48,30 +48,30 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.740.0",
|
||||
"@aws-sdk/lib-storage": "3.740.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.740.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.740.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.740.0",
|
||||
"@babel/core": "^7.26.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
"@babel/plugin-transform-destructuring": "^7.25.9",
|
||||
"@aws-sdk/client-s3": "3.693.0",
|
||||
"@aws-sdk/lib-storage": "3.693.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.693.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.693.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.693.0",
|
||||
"@babel/core": "^7.24.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.7",
|
||||
"@babel/plugin-transform-class-properties": "^7.24.7",
|
||||
"@babel/plugin-transform-destructuring": "^7.24.8",
|
||||
"@babel/plugin-transform-regenerator": "^7.25.9",
|
||||
"@babel/preset-env": "^7.26.7",
|
||||
"@babel/preset-env": "^7.25.8",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^4.2.2",
|
||||
"@bull-board/koa": "^4.12.2",
|
||||
"@css-inline/css-inline-wasm": "^0.14.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@fast-csv/parse": "^5.0.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@getoutline/react-roving-tabindex": "^3.2.4",
|
||||
"@hocuspocus/extension-throttle": "1.1.2",
|
||||
@@ -85,11 +85,11 @@
|
||||
"@renderlesskit/react": "^0.11.0",
|
||||
"@sentry/node": "^7.120.3",
|
||||
"@sentry/react": "^7.120.3",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tanstack/react-virtual": "^3.11.3",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@tanstack/react-virtual": "^3.10.9",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/form-data": "^2.5.0",
|
||||
"@types/mailparser": "^3.4.5",
|
||||
"@types/mailparser": "^3.4.4",
|
||||
"@types/sanitize-filename": "^1.6.3",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"addressparser": "^1.0.1",
|
||||
@@ -118,7 +118,7 @@
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fetch-retry": "^5.0.6",
|
||||
"fetch-with-proxy": "^3.0.1",
|
||||
"form-data": "^4.0.1",
|
||||
"form-data": "^4.0.0",
|
||||
"fractional-index": "^1.0.0",
|
||||
"framer-motion": "^4.1.17",
|
||||
"fs-extra": "^11.2.0",
|
||||
@@ -127,7 +127,7 @@
|
||||
"http-errors": "2.0.0",
|
||||
"i18next": "^22.5.1",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"i18next-http-backend": "^2.7.3",
|
||||
"i18next-http-backend": "^2.7.1",
|
||||
"invariant": "^2.2.4",
|
||||
"ioredis": "^5.4.1",
|
||||
"is-printable-key-event": "^1.0.0",
|
||||
@@ -136,7 +136,7 @@
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.21",
|
||||
"kbar": "0.1.0-beta.41",
|
||||
"koa": "^2.15.4",
|
||||
"koa": "^2.15.3",
|
||||
"koa-body": "^6.0.1",
|
||||
"koa-compress": "^5.1.1",
|
||||
"koa-helmet": "^6.1.0",
|
||||
@@ -147,7 +147,7 @@
|
||||
"koa-sslify": "5.0.1",
|
||||
"koa-useragent": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mailparser": "^3.7.2",
|
||||
"mailparser": "^3.7.1",
|
||||
"mammoth": "^1.8.0",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
@@ -159,7 +159,7 @@
|
||||
"mobx-utils": "^4.0.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^6.10.0",
|
||||
"nodemailer": "^6.9.16",
|
||||
"octokit": "^3.2.1",
|
||||
"outline-icons": "^3.10.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
@@ -199,7 +199,7 @@
|
||||
"react-dom": "^17.0.2",
|
||||
"react-dropzone": "^11.7.1",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-i18next": "^12.3.1",
|
||||
"react-medium-image-zoom": "5.2.10",
|
||||
"react-merge-refs": "^2.1.1",
|
||||
@@ -241,9 +241,9 @@
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vite": "^5.4.12",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"winston": "^3.17.0",
|
||||
"winston": "^3.13.0",
|
||||
"ws": "^7.5.10",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
"y-prosemirror": "^1.2.12",
|
||||
@@ -254,9 +254,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.26.4",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@babel/preset-typescript": "^7.24.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.2.14",
|
||||
"@relative-ci/agent": "^4.2.13",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
@@ -265,7 +265,7 @@
|
||||
"@types/dotenv": "^8.2.3",
|
||||
"@types/emoji-regex": "^9.2.0",
|
||||
"@types/escape-html": "^1.0.4",
|
||||
"@types/express-useragent": "^1.0.5",
|
||||
"@types/express-useragent": "^1.0.2",
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/fuzzy-search": "^2.1.5",
|
||||
@@ -289,7 +289,7 @@
|
||||
"@types/markdown-it-emoji": "^2.0.4",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/natural-sort": "^0.0.24",
|
||||
"@types/node": "20.17.16",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node-fetch": "^2.6.9",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-oauth2": "^1.4.17",
|
||||
@@ -353,8 +353,8 @@
|
||||
"prettier": "^2.8.8",
|
||||
"react-refresh": "^0.14.2",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.0.1",
|
||||
"terser": "^5.37.0",
|
||||
"rollup-plugin-webpack-stats": "^0.4.1",
|
||||
"terser": "^5.36.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
|
||||
@@ -57,14 +57,10 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
||||
const [profileResponse, organizationResponse] = await Promise.all([
|
||||
// Load the users profile from the Microsoft Graph API
|
||||
// https://docs.microsoft.com/en-us/graph/api/resources/users?view=graph-rest-1.0
|
||||
request("GET", `https://graph.microsoft.com/v1.0/me`, accessToken),
|
||||
request(`https://graph.microsoft.com/v1.0/me`, accessToken),
|
||||
// Load the organization profile from the Microsoft Graph API
|
||||
// https://docs.microsoft.com/en-us/graph/api/organization-get?view=graph-rest-1.0
|
||||
request(
|
||||
"GET",
|
||||
`https://graph.microsoft.com/v1.0/organization`,
|
||||
accessToken
|
||||
),
|
||||
request(`https://graph.microsoft.com/v1.0/organization`, accessToken),
|
||||
]);
|
||||
|
||||
if (!profileResponse) {
|
||||
|
||||
@@ -70,7 +70,6 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
const client = getClientFromContext(ctx);
|
||||
/** Fetch the user's profile */
|
||||
const profile: RESTGetAPICurrentUserResult = await request(
|
||||
"GET",
|
||||
"https://discord.com/api/users/@me",
|
||||
accessToken
|
||||
);
|
||||
@@ -106,7 +105,6 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
if (env.DISCORD_SERVER_ID) {
|
||||
/** Fetch the guilds a user is in */
|
||||
const guilds: RESTGetAPICurrentUserGuildsResult = await request(
|
||||
"GET",
|
||||
"https://discord.com/api/users/@me/guilds",
|
||||
accessToken
|
||||
);
|
||||
@@ -148,7 +146,6 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
/** Fetch the user's member object in the server for nickname and roles */
|
||||
const guildMember: RESTGetCurrentUserGuildMemberResult =
|
||||
await request(
|
||||
"GET",
|
||||
`https://discord.com/api/users/@me/guilds/${env.DISCORD_SERVER_ID}/member`,
|
||||
accessToken
|
||||
);
|
||||
|
||||
@@ -116,6 +116,7 @@ function GitHub() {
|
||||
<TeamLogo
|
||||
src={githubAccount?.avatarUrl}
|
||||
size={AvatarSize.Large}
|
||||
showBorder={false}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
|
||||
@@ -81,14 +81,8 @@ if (
|
||||
) => void
|
||||
) {
|
||||
try {
|
||||
// Some providers require a POST request to the userinfo endpoint, add them as exceptions here.
|
||||
const usePostMethod = [
|
||||
"https://api.dropboxapi.com/2/openid/userinfo",
|
||||
];
|
||||
|
||||
const profile = await request(
|
||||
usePostMethod.includes(env.OIDC_USERINFO_URI!) ? "POST" : "GET",
|
||||
env.OIDC_USERINFO_URI!,
|
||||
env.OIDC_USERINFO_URI ?? "",
|
||||
accessToken
|
||||
);
|
||||
|
||||
|
||||
@@ -102,7 +102,6 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
case "api_keys.create":
|
||||
case "api_keys.delete":
|
||||
case "attachments.create":
|
||||
case "attachments.update":
|
||||
case "attachments.delete":
|
||||
case "subscriptions.create":
|
||||
case "subscriptions.delete":
|
||||
|
||||
@@ -35,8 +35,8 @@ type Props = Optional<
|
||||
};
|
||||
|
||||
export default async function documentCreator({
|
||||
title,
|
||||
text,
|
||||
title = "",
|
||||
text = "",
|
||||
icon,
|
||||
color,
|
||||
state,
|
||||
@@ -101,20 +101,14 @@ export default async function documentCreator({
|
||||
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
|
||||
icon: templateDocument ? templateDocument.icon : icon,
|
||||
color: templateDocument ? templateDocument.color : color,
|
||||
title:
|
||||
title ??
|
||||
(templateDocument
|
||||
? template
|
||||
? templateDocument.title
|
||||
: TextHelper.replaceTemplateVariables(templateDocument.title, user)
|
||||
: ""),
|
||||
text:
|
||||
text ??
|
||||
(templateDocument
|
||||
? template
|
||||
? templateDocument.text
|
||||
: TextHelper.replaceTemplateVariables(templateDocument.text, user)
|
||||
: ""),
|
||||
title: TextHelper.replaceTemplateVariables(
|
||||
templateDocument ? templateDocument.title : title,
|
||||
user
|
||||
),
|
||||
text: TextHelper.replaceTemplateVariables(
|
||||
templateDocument ? templateDocument.text : text,
|
||||
user
|
||||
),
|
||||
content: templateDocument
|
||||
? ProsemirrorHelper.replaceTemplateVariables(
|
||||
await DocumentHelper.toJSON(templateDocument),
|
||||
|
||||
@@ -210,7 +210,6 @@ describe("subscriptionCreator", () => {
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
},
|
||||
order: [["createdAt", "ASC"]],
|
||||
});
|
||||
expect(events.length).toEqual(3);
|
||||
|
||||
|
||||
@@ -191,12 +191,9 @@ export default abstract class BaseEmail<
|
||||
|
||||
const parsedFrom = addressparser(env.SMTP_FROM_EMAIL)[0];
|
||||
const domain = parsedFrom.address.split("@")[1];
|
||||
const customFromName = this.fromName?.(props);
|
||||
|
||||
return {
|
||||
name: customFromName
|
||||
? `${customFromName} via ${env.APP_NAME}`
|
||||
: parsedFrom.name,
|
||||
name: this.fromName?.(props) ?? parsedFrom.name,
|
||||
address:
|
||||
env.isCloudHosted &&
|
||||
this.category === EmailMessageCategory.Authentication
|
||||
|
||||
@@ -103,7 +103,7 @@ export default class CommentCreatedEmail extends BaseEmail<
|
||||
: `${commentText.slice(0, MAX_SUBJECT_CONTENT)}...`;
|
||||
|
||||
return `${parentComment ? "Re: " : ""}New comment on “${
|
||||
document.titleWithDefault
|
||||
document.title
|
||||
}” - ${trimmedText}`;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export default class CommentCreatedEmail extends BaseEmail<
|
||||
}: Props): string {
|
||||
return `
|
||||
${actorName} ${isReply ? "replied to a thread in" : "commented on"} "${
|
||||
document.titleWithDefault
|
||||
document.title
|
||||
}"${collection?.name ? `in the ${collection.name} collection` : ""}.
|
||||
|
||||
Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
|
||||
@@ -164,10 +164,10 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<Heading>{document.title}</Heading>
|
||||
<p>
|
||||
{actorName} {isReply ? "replied to a thread in" : "commented on"}{" "}
|
||||
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
|
||||
<a href={threadLink}>{document.title}</a>{" "}
|
||||
{collection?.name ? `in the ${collection.name} collection` : ""}.
|
||||
</p>
|
||||
{body && (
|
||||
|
||||
@@ -92,7 +92,7 @@ export default class CommentMentionedEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
protected subject({ document }: Props) {
|
||||
return `Mentioned you in “${document.titleWithDefault}”`;
|
||||
return `Mentioned you in “${document.title}”`;
|
||||
}
|
||||
|
||||
protected preview({ actorName }: Props): string {
|
||||
@@ -111,7 +111,7 @@ export default class CommentMentionedEmail extends BaseEmail<
|
||||
collection,
|
||||
}: Props): string {
|
||||
return `
|
||||
${actorName} mentioned you in a comment on "${document.titleWithDefault}"${
|
||||
${actorName} mentioned you in a comment on "${document.title}"${
|
||||
collection.name ? `in the ${collection.name} collection` : ""
|
||||
}.
|
||||
|
||||
@@ -139,10 +139,10 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<Heading>{document.title}</Heading>
|
||||
<p>
|
||||
{actorName} mentioned you in a comment on{" "}
|
||||
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
|
||||
<a href={threadLink}>{document.title}</a>{" "}
|
||||
{collection.name ? `in the ${collection.name} collection` : ""}.
|
||||
</p>
|
||||
{body && (
|
||||
|
||||
@@ -92,7 +92,7 @@ export default class CommentResolvedEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
protected subject({ document }: Props) {
|
||||
return `Resolved a comment thread in “${document.titleWithDefault}”`;
|
||||
return `Resolved a comment thread in “${document.title}”`;
|
||||
}
|
||||
|
||||
protected preview({ actorName }: Props): string {
|
||||
@@ -110,7 +110,7 @@ export default class CommentResolvedEmail extends BaseEmail<
|
||||
commentId,
|
||||
collection,
|
||||
}: Props): string {
|
||||
const t1 = `${actorName} resolved a comment thread on "${document.titleWithDefault}"`;
|
||||
const t1 = `${actorName} resolved a comment thread on "${document.title}"`;
|
||||
const t2 = collection.name ? ` in the ${collection.name} collection` : "";
|
||||
const t3 = `Open Thread: ${teamUrl}${document.url}?commentId=${commentId}`;
|
||||
return `${t1}${t2}.\n\n${t3}`;
|
||||
@@ -136,10 +136,10 @@ export default class CommentResolvedEmail extends BaseEmail<
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<Heading>{document.title}</Heading>
|
||||
<p>
|
||||
{actorName} resolved a comment on{" "}
|
||||
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
|
||||
<a href={threadLink}>{document.title}</a>{" "}
|
||||
{collection.name ? `in the ${collection.name} collection` : ""}.
|
||||
</p>
|
||||
{body && (
|
||||
|
||||
@@ -92,7 +92,7 @@ export default class DocumentMentionedEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
protected subject({ document }: Props) {
|
||||
return `Mentioned you in “${document.titleWithDefault}”`;
|
||||
return `Mentioned you in “${document.title}”`;
|
||||
}
|
||||
|
||||
protected preview({ actorName }: Props): string {
|
||||
@@ -116,7 +116,7 @@ export default class DocumentMentionedEmail extends BaseEmail<
|
||||
return `
|
||||
You were mentioned
|
||||
|
||||
${actorName} mentioned you in the document “${document.titleWithDefault}”.
|
||||
${actorName} mentioned you in the document “${document.title}”.
|
||||
|
||||
Open Document: ${teamUrl}${document.url}
|
||||
`;
|
||||
@@ -137,7 +137,7 @@ Open Document: ${teamUrl}${document.url}
|
||||
<Heading>You were mentioned</Heading>
|
||||
<p>
|
||||
{actorName} mentioned you in the document{" "}
|
||||
<a href={documentLink}>{document.titleWithDefault}</a>.
|
||||
<a href={documentLink}>{document.title}</a>.
|
||||
</p>
|
||||
{body && (
|
||||
<>
|
||||
|
||||
@@ -114,7 +114,7 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
protected subject({ document, eventType }: Props) {
|
||||
return `“${document.titleWithDefault}” ${this.eventName(eventType)}`;
|
||||
return `“${document.title}” ${this.eventName(eventType)}`;
|
||||
}
|
||||
|
||||
protected preview({ actorName, eventType }: Props): string {
|
||||
@@ -144,9 +144,9 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
|
||||
const eventName = this.eventName(eventType);
|
||||
|
||||
return `
|
||||
"${document.titleWithDefault}" ${eventName}
|
||||
"${document.title}" ${eventName}
|
||||
|
||||
${actorName} ${eventName} the document "${document.titleWithDefault}"${
|
||||
${actorName} ${eventName} the document "${document.title}"${
|
||||
collection?.name ? `, in the ${collection.name} collection` : ""
|
||||
}.
|
||||
|
||||
@@ -176,11 +176,11 @@ Open Document: ${teamUrl}${document.url}
|
||||
|
||||
<Body>
|
||||
<Heading>
|
||||
“{document.titleWithDefault}” {eventName}
|
||||
“{document.title}” {eventName}
|
||||
</Heading>
|
||||
<p>
|
||||
{actorName} {eventName} the document{" "}
|
||||
<a href={documentLink}>{document.titleWithDefault}</a>
|
||||
<a href={documentLink}>{document.title}</a>
|
||||
{collection?.name ? <>, in the {collection.name} collection</> : ""}
|
||||
.
|
||||
</p>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default class DocumentSharedEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
protected subject({ actorName, document }: Props) {
|
||||
return `${actorName} shared “${document.titleWithDefault}” with you`;
|
||||
return `${actorName} shared “${document.title}” with you`;
|
||||
}
|
||||
|
||||
protected preview({ actorName }: Props): string {
|
||||
@@ -66,7 +66,7 @@ export default class DocumentSharedEmail extends BaseEmail<
|
||||
|
||||
protected renderAsText({ actorName, teamUrl, document }: Props): string {
|
||||
return `
|
||||
${actorName} shared “${document.titleWithDefault}” with you.
|
||||
${actorName} shared “${document.title}” with you.
|
||||
|
||||
View Document: ${teamUrl}${document.path}
|
||||
`;
|
||||
@@ -87,10 +87,10 @@ View Document: ${teamUrl}${document.path}
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<Heading>{document.title}</Heading>
|
||||
<p>
|
||||
{actorName} invited you to {permission} the{" "}
|
||||
<a href={documentUrl}>{document.titleWithDefault}</a> document.
|
||||
<a href={documentUrl}>{document.title}</a> document.
|
||||
</p>
|
||||
<p>
|
||||
<Button href={documentUrl}>View Document</Button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user