Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a303282ba | |||
| 5337770adb | |||
| b1b7b2b6fc | |||
| 1dcb8f8052 | |||
| 569c4b4849 | |||
| 5d5bed8270 | |||
| 58a41a6fde | |||
| 0bde1d5ef4 | |||
| 4a01fb7094 | |||
| a4ff9aa45c | |||
| 1777e9b556 | |||
| 59e57d6171 | |||
| 9b17f91c9a | |||
| 9854ce7c31 | |||
| e28dfbe0bc | |||
| e0e00bd93d | |||
| 9df6b9d1a5 | |||
| e2dfc4dd00 | |||
| 80f48152de | |||
| 57ae4fd4fb | |||
| be9a2b120b | |||
| 6c190ec308 | |||
| e326e6c8f3 | |||
| 46401701a0 | |||
| 2f2e7c3556 | |||
| fedd983649 | |||
| 3b2833c752 | |||
| f1dee53dc4 |
@@ -7,8 +7,7 @@
|
||||
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
@@ -22,8 +21,7 @@
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
@@ -38,8 +36,7 @@
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
|
||||
@@ -52,8 +49,7 @@
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"testEnvironment": "jsdom",
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.87.1
|
||||
Licensed Work: Outline 0.87.3
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2029-08-31
|
||||
Change Date: 2029-09-01
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -53,9 +53,10 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
}
|
||||
|
||||
const label =
|
||||
typeof action.name === "function"
|
||||
rest["aria-label"] ??
|
||||
(typeof action.name === "function"
|
||||
? action.name(actionContext)
|
||||
: action.name;
|
||||
: action.name);
|
||||
|
||||
const button = (
|
||||
<button
|
||||
|
||||
@@ -25,6 +25,8 @@ type Props = {
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
/** Size of the avatar, defaults to AvatarSize.Large */
|
||||
size?: AvatarSize;
|
||||
/** Optional alt text for the avatar image */
|
||||
alt?: string;
|
||||
/** Optional inline styles to apply to the avatar wrapper */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
@@ -53,6 +55,7 @@ function AvatarWithPresence({
|
||||
isCurrentUser,
|
||||
size = AvatarSize.Large,
|
||||
style,
|
||||
alt,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const status = isPresent
|
||||
@@ -83,7 +86,7 @@ function AvatarWithPresence({
|
||||
$color={user.color}
|
||||
style={style}
|
||||
>
|
||||
<Avatar model={user} onClick={onClick} size={size} />
|
||||
<Avatar model={user} onClick={onClick} size={size} alt={alt} />
|
||||
</AvatarPresence>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
@@ -132,6 +132,7 @@ function Collaborators(props: Props) {
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
alt={t("Avatar of {{ name }}", { name: collaborator.name })}
|
||||
onClick={
|
||||
isObservable
|
||||
? handleAvatarClick(
|
||||
|
||||
@@ -143,13 +143,14 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
},
|
||||
[]
|
||||
);
|
||||
const contentEditable = !disabled && !readOnly;
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
|
||||
{children}
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
contentEditable={contentEditable}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onFocus={wrappedEvent(onFocus)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
@@ -157,7 +158,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
role={contentEditable ? "textbox" : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
@@ -8,10 +9,16 @@ type Props = React.ComponentProps<typeof MenuButton> & {
|
||||
};
|
||||
|
||||
export default function OverflowMenuButton({ className, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton className={className} {...props}>
|
||||
<NudeButton
|
||||
className={className}
|
||||
aria-label={t("More options")}
|
||||
{...props}
|
||||
>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
|
||||
@@ -114,7 +114,6 @@ function DocumentListItem(
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
role="menuitem"
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
@@ -279,7 +278,7 @@ const DocumentLink = styled(Link)<{
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
const Heading = styled.span<{ rtl?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
@@ -289,6 +288,8 @@ const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
color: ${s("text")};
|
||||
font-family: ${s("fontFamily")};
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
|
||||
@@ -168,13 +168,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Container
|
||||
align="center"
|
||||
rtl={document.dir === "rtl"}
|
||||
{...rest}
|
||||
dir="ltr"
|
||||
lang=""
|
||||
>
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{to ? (
|
||||
<Link to={to} replace={replace}>
|
||||
{content}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, truncateMultiline } from "@shared/styles";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
|
||||
/** A callback when the title is submitted. */
|
||||
@@ -128,17 +128,21 @@ function EditableTitle(
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<span
|
||||
<Text
|
||||
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
|
||||
className={rest.className}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Text = styled.span`
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
color: ${s("text")};
|
||||
background: ${s("background")};
|
||||
|
||||
@@ -0,0 +1,840 @@
|
||||
import { useEditor } from "~/editor/components/EditorContext";
|
||||
import { observer } from "mobx-react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { findChildren } from "@shared/editor/queries/findChildren";
|
||||
import findIndex from "lodash/findIndex";
|
||||
import styled, { css, Keyframes, keyframes } from "styled-components";
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { sanitizeUrl } from "@shared/utils/urls";
|
||||
import { Error } from "@shared/editor/components/Image";
|
||||
import {
|
||||
BackIcon,
|
||||
CloseIcon,
|
||||
CrossIcon,
|
||||
DownloadIcon,
|
||||
NextIcon,
|
||||
} from "outline-icons";
|
||||
import { depths, extraArea, s } from "@shared/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
import useIdle from "~/hooks/useIdle";
|
||||
import { Second } from "@shared/utils/time";
|
||||
import { downloadImageNode } from "@shared/editor/nodes/Image";
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
import Fade from "./Fade";
|
||||
import Button from "./Button";
|
||||
|
||||
export enum LightboxStatus {
|
||||
READY_TO_OPEN,
|
||||
OPENING,
|
||||
OPENED,
|
||||
READY_TO_CLOSE,
|
||||
CLOSING,
|
||||
CLOSED,
|
||||
}
|
||||
|
||||
export enum ImageStatus {
|
||||
LOADING,
|
||||
ERROR,
|
||||
LOADED,
|
||||
}
|
||||
type Status = {
|
||||
lightbox: LightboxStatus | null;
|
||||
image: ImageStatus | null;
|
||||
};
|
||||
|
||||
type Animation = {
|
||||
fadeIn?: { apply: () => Keyframes; duration: number };
|
||||
fadeOut?: { apply: () => Keyframes; duration: number };
|
||||
zoomIn?: { apply: () => Keyframes; duration: number };
|
||||
zoomOut?: { apply: () => Keyframes; duration: number };
|
||||
startTime?: number;
|
||||
};
|
||||
|
||||
const ANIMATION_DURATION = 0.3 * Second.ms;
|
||||
|
||||
type Props = {
|
||||
/** Callback triggered when the active image position is updated */
|
||||
onUpdate: (pos: number | null) => void;
|
||||
/** The position of the currently active image in the document */
|
||||
activePos: number | null;
|
||||
};
|
||||
|
||||
function Lightbox({ onUpdate, activePos }: Props) {
|
||||
const { view } = useEditor();
|
||||
const isIdle = useIdle(3 * Second.ms);
|
||||
const { t } = useTranslation();
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
|
||||
const [imageElements] = useState(
|
||||
view?.dom.querySelectorAll(".component-image img")
|
||||
);
|
||||
const animation = useRef<Animation | null>(null);
|
||||
const finalImage = useRef<{
|
||||
center: { x: number; y: number };
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
const imageNodes = useMemo(
|
||||
() =>
|
||||
view
|
||||
? findChildren(
|
||||
view.state.doc,
|
||||
(child) => child.type === view.state.schema.nodes.image,
|
||||
true
|
||||
)
|
||||
: [],
|
||||
[view]
|
||||
);
|
||||
const currentImageIndex = findIndex(
|
||||
imageNodes,
|
||||
(node) => node.pos === activePos
|
||||
);
|
||||
const currentImageNode =
|
||||
currentImageIndex >= 0 ? imageNodes[currentImageIndex].node : undefined;
|
||||
|
||||
// Debugging status changes
|
||||
// useEffect(() => {
|
||||
// console.log(
|
||||
// `lstat:${status.lightbox === null ? status.lightbox : LightboxStatus[status.lightbox]}, istat:${status.image === null ? status.image : ImageStatus[status.image]}`
|
||||
// );
|
||||
// }, [status]);
|
||||
|
||||
useEffect(() => () => view.focus(), []);
|
||||
|
||||
useEffect(() => {
|
||||
!!activePos &&
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.READY_TO_OPEN,
|
||||
image: status.image,
|
||||
});
|
||||
}, [!!activePos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.image === ImageStatus.LOADED) {
|
||||
rememberImagePosition();
|
||||
}
|
||||
}, [status.image]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(status.image === ImageStatus.ERROR ||
|
||||
status.image === ImageStatus.LOADED) &&
|
||||
status.lightbox === LightboxStatus.READY_TO_OPEN
|
||||
) {
|
||||
setupFadeIn();
|
||||
setupZoomIn();
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.OPENING,
|
||||
image: status.image,
|
||||
});
|
||||
}
|
||||
}, [status.image, status.lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.lightbox === LightboxStatus.READY_TO_CLOSE) {
|
||||
setupFadeOut();
|
||||
setupZoomOut();
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.CLOSING,
|
||||
image: status.image,
|
||||
});
|
||||
}
|
||||
}, [status.lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.lightbox === LightboxStatus.CLOSED) {
|
||||
onUpdate(null);
|
||||
}
|
||||
}, [status.lightbox]);
|
||||
|
||||
const rememberImagePosition = () => {
|
||||
if (imgRef.current) {
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
const {
|
||||
top: lightboxImgTop,
|
||||
left: lightboxImgLeft,
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
} = lightboxImgDOMRect;
|
||||
finalImage.current = {
|
||||
center: {
|
||||
x: lightboxImgLeft + lightboxImgWidth / 2,
|
||||
y: lightboxImgTop + lightboxImgHeight / 2,
|
||||
},
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const setupZoomIn = () => {
|
||||
if (imgRef.current) {
|
||||
// in editor
|
||||
const editorImageEl = imageElements[currentImageIndex];
|
||||
if (!editorImageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
|
||||
const {
|
||||
top: editorImgTop,
|
||||
left: editorImgLeft,
|
||||
width: editorImgWidth,
|
||||
height: editorImgHeight,
|
||||
} = editorImgDOMRect;
|
||||
|
||||
const from = {
|
||||
center: {
|
||||
x: editorImgLeft + editorImgWidth / 2,
|
||||
y: editorImgTop + editorImgHeight / 2,
|
||||
},
|
||||
width: editorImgWidth,
|
||||
height: editorImgHeight,
|
||||
};
|
||||
|
||||
// in lightbox
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
const {
|
||||
top: lightboxImgTop,
|
||||
left: lightboxImgLeft,
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
} = lightboxImgDOMRect;
|
||||
const to = {
|
||||
center: {
|
||||
x: lightboxImgLeft + lightboxImgWidth / 2,
|
||||
y: lightboxImgTop + lightboxImgHeight / 2,
|
||||
},
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
const tx = from.center.x - to.center.x;
|
||||
const ty = from.center.y - to.center.y;
|
||||
return keyframes`
|
||||
from {
|
||||
translate: ${tx}px ${ty}px;
|
||||
scale: ${from.width / to.width};
|
||||
}
|
||||
to {
|
||||
translate: 0;
|
||||
scale: 1;
|
||||
}
|
||||
`;
|
||||
};
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
zoomOut: undefined,
|
||||
zoomIn: { apply: zoomIn, duration: ANIMATION_DURATION },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const setupFadeIn = () => {
|
||||
const fadeIn = () => keyframes`
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
`;
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
fadeIn: { apply: fadeIn, duration: ANIMATION_DURATION },
|
||||
fadeOut: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const setupFadeOut = () => {
|
||||
const fadeOut = () => keyframes`
|
||||
from { opacity: ${overlayRef.current ? window.getComputedStyle(overlayRef.current).opacity : 1}; }
|
||||
to { opacity: 0; }
|
||||
`;
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
fadeIn: undefined,
|
||||
fadeOut: {
|
||||
apply: fadeOut,
|
||||
duration: animation.current?.startTime
|
||||
? Date.now() - animation.current.startTime
|
||||
: ANIMATION_DURATION,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const setupZoomOut = () => {
|
||||
if (imgRef.current) {
|
||||
// in lightbox
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
const {
|
||||
top: lightboxImgTop,
|
||||
left: lightboxImgLeft,
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
} = lightboxImgDOMRect;
|
||||
const from = {
|
||||
center: {
|
||||
x: lightboxImgLeft + lightboxImgWidth / 2,
|
||||
y: lightboxImgTop + lightboxImgHeight / 2,
|
||||
},
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
};
|
||||
|
||||
// in editor
|
||||
const editorImageEl = imageElements[currentImageIndex];
|
||||
let to;
|
||||
if (editorImageEl) {
|
||||
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
|
||||
const {
|
||||
top: editorImgTop,
|
||||
left: editorImgLeft,
|
||||
width: editorImgWidth,
|
||||
height: editorImgHeight,
|
||||
} = editorImgDOMRect;
|
||||
|
||||
to = {
|
||||
center: {
|
||||
x: editorImgLeft + editorImgWidth / 2,
|
||||
y:
|
||||
editorImgTop + editorImgHeight / 2 >
|
||||
window.innerHeight + editorImgHeight / 2
|
||||
? window.innerHeight + editorImgHeight / 2
|
||||
: editorImgTop + editorImgHeight / 2 < -editorImgHeight / 2
|
||||
? -editorImgHeight / 2
|
||||
: editorImgTop + editorImgHeight / 2,
|
||||
},
|
||||
width: editorImgWidth,
|
||||
height: editorImgHeight,
|
||||
};
|
||||
} else {
|
||||
to = {
|
||||
center: {
|
||||
x: from.center.x,
|
||||
y: window.innerHeight + lightboxImgHeight / 2,
|
||||
},
|
||||
width: lightboxImgWidth,
|
||||
height: lightboxImgHeight,
|
||||
};
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
const final = finalImage.current;
|
||||
if (!final) {
|
||||
return keyframes``;
|
||||
}
|
||||
|
||||
const fromTx = from.center.x - final.center.x;
|
||||
const fromTy = from.center.y - final.center.y;
|
||||
const toTx = to.center.x - final.center.x;
|
||||
const toTy = to.center.y - final.center.y;
|
||||
|
||||
const fromSx = from.width / final.width;
|
||||
const fromSy = from.height / final.height;
|
||||
const toSx = to.width / final.width;
|
||||
const toSy = to.height / final.height;
|
||||
return keyframes`
|
||||
from {
|
||||
translate: ${fromTx}px ${fromTy}px;
|
||||
scale: ${fromSx} ${fromSy};
|
||||
}
|
||||
to {
|
||||
translate: ${toTx}px ${toTy}px;
|
||||
scale: ${toSx} ${toSy};
|
||||
}
|
||||
`;
|
||||
};
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
zoomIn: undefined,
|
||||
zoomOut: {
|
||||
apply: zoomOut,
|
||||
duration: animation.current?.startTime
|
||||
? Date.now() - animation.current.startTime
|
||||
: ANIMATION_DURATION,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (!activePos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prev = () => {
|
||||
if (status.lightbox === LightboxStatus.OPENED) {
|
||||
if (!activePos) {
|
||||
return;
|
||||
}
|
||||
const prevIndex = currentImageIndex - 1;
|
||||
if (prevIndex < 0) {
|
||||
return;
|
||||
}
|
||||
onUpdate(imageNodes[prevIndex].pos);
|
||||
}
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (status.lightbox === LightboxStatus.OPENED) {
|
||||
if (!activePos) {
|
||||
return;
|
||||
}
|
||||
const nextIndex = currentImageIndex + 1;
|
||||
if (nextIndex >= imageNodes.length) {
|
||||
return;
|
||||
}
|
||||
onUpdate(imageNodes[nextIndex].pos);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (
|
||||
status.lightbox === LightboxStatus.OPENING ||
|
||||
status.lightbox === LightboxStatus.OPENED
|
||||
) {
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.READY_TO_CLOSE,
|
||||
image: status.image,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
if (currentImageNode && status.lightbox === LightboxStatus.OPENED) {
|
||||
void downloadImageNode(currentImageNode);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
ev.preventDefault();
|
||||
switch (ev.key) {
|
||||
case "ArrowLeft": {
|
||||
prev();
|
||||
break;
|
||||
}
|
||||
case "ArrowRight": {
|
||||
next();
|
||||
break;
|
||||
}
|
||||
case "Escape": {
|
||||
close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFadeStart = () => {
|
||||
if (animation.current?.fadeIn) {
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
startTime: Date.now(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleFadeEnd = () => {
|
||||
if (animation.current?.fadeIn) {
|
||||
animation.current = {
|
||||
...(animation.current ?? {}),
|
||||
startTime: undefined,
|
||||
};
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.OPENED,
|
||||
image: status.image,
|
||||
});
|
||||
} else if (animation.current?.fadeOut) {
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.CLOSED,
|
||||
image: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentImageNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root open={!!activePos}>
|
||||
<Dialog.Portal>
|
||||
<StyledOverlay
|
||||
ref={overlayRef}
|
||||
animation={animation.current}
|
||||
onAnimationStart={handleFadeStart}
|
||||
onAnimationEnd={handleFadeEnd}
|
||||
/>
|
||||
<StyledContent onKeyDown={handleKeyDown}>
|
||||
<VisuallyHidden.Root>
|
||||
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{t("View, navigate, or download images in the document")}
|
||||
</Dialog.Description>
|
||||
</VisuallyHidden.Root>
|
||||
<Actions animation={animation.current}>
|
||||
<Tooltip content={t("Download")} placement="bottom">
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
onClick={download}
|
||||
aria-label={t("Download")}
|
||||
size={32}
|
||||
icon={<DownloadIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dialog.Close asChild>
|
||||
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
onClick={close}
|
||||
aria-label={t("Close")}
|
||||
size={32}
|
||||
icon={<CloseIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dialog.Close>
|
||||
</Actions>
|
||||
{currentImageIndex > 0 && (
|
||||
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
|
||||
<BackIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
<Image
|
||||
ref={imgRef}
|
||||
src={sanitizeUrl(currentImageNode.attrs.src) ?? ""}
|
||||
alt={currentImageNode.attrs.alt ?? ""}
|
||||
onLoading={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADING,
|
||||
})
|
||||
}
|
||||
onLoad={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADED,
|
||||
})
|
||||
}
|
||||
onError={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.ERROR,
|
||||
})
|
||||
}
|
||||
onSwipeRight={prev}
|
||||
onSwipeLeft={next}
|
||||
onSwipeUpOrDown={close}
|
||||
status={status}
|
||||
animation={animation.current}
|
||||
/>
|
||||
{currentImageIndex < imageNodes.length - 1 && (
|
||||
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={next} size={32} aria-label={t("Next")}>
|
||||
<NextIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
</StyledContent>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
type ImageProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
onLoading: () => void;
|
||||
onLoad: () => void;
|
||||
onError: () => void;
|
||||
onSwipeRight: () => void;
|
||||
onSwipeLeft: () => void;
|
||||
onSwipeUpOrDown: () => void;
|
||||
status: Status;
|
||||
animation: Animation | null;
|
||||
};
|
||||
|
||||
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
{
|
||||
src,
|
||||
alt,
|
||||
onLoading,
|
||||
onLoad,
|
||||
onError,
|
||||
onSwipeRight,
|
||||
onSwipeLeft,
|
||||
onSwipeUpOrDown,
|
||||
status,
|
||||
animation,
|
||||
}: ImageProps,
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const touchXStart = useRef<number>();
|
||||
const touchXEnd = useRef<number>();
|
||||
const touchYStart = useRef<number>();
|
||||
const touchYEnd = useRef<number>();
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent<HTMLImageElement>) => {
|
||||
touchXStart.current = e.changedTouches[0].screenX;
|
||||
touchYStart.current = e.changedTouches[0].screenY;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent<HTMLImageElement>) => {
|
||||
touchXEnd.current = e.changedTouches[0].screenX;
|
||||
touchYEnd.current = e.changedTouches[0].screenY;
|
||||
const dx = touchXEnd.current - (touchXStart.current ?? 0);
|
||||
const dy = touchYEnd.current - (touchYStart.current ?? 0);
|
||||
|
||||
const swipeRight = dx > 0 && Math.abs(dy) < Math.abs(dx);
|
||||
if (swipeRight) {
|
||||
return onSwipeRight();
|
||||
}
|
||||
|
||||
const swipeLeft = dx < 0 && Math.abs(dy) < Math.abs(dx);
|
||||
if (swipeLeft) {
|
||||
return onSwipeLeft();
|
||||
}
|
||||
|
||||
const swipeDown = dy > 0 && Math.abs(dy) > Math.abs(dx);
|
||||
const swipeUp = dy < 0 && Math.abs(dy) > Math.abs(dx);
|
||||
if (swipeUp || swipeDown) {
|
||||
return onSwipeUpOrDown();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
touchXStart.current = undefined;
|
||||
touchXEnd.current = undefined;
|
||||
touchYStart.current = undefined;
|
||||
touchYEnd.current = undefined;
|
||||
};
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
touchXStart.current = undefined;
|
||||
touchXEnd.current = undefined;
|
||||
touchYStart.current = undefined;
|
||||
touchYEnd.current = undefined;
|
||||
};
|
||||
|
||||
const [hidden, setHidden] = useState(
|
||||
status.image === null || status.image === ImageStatus.LOADING
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLoading();
|
||||
}, [src]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.image === null || status.image === ImageStatus.LOADING) {
|
||||
setHidden(true);
|
||||
} else if (status.image === ImageStatus.LOADED) {
|
||||
setHidden(false);
|
||||
}
|
||||
}, [status.image]);
|
||||
|
||||
return status.image === ImageStatus.ERROR ? (
|
||||
<StyledError animation={animation}>
|
||||
<CrossIcon size={16} /> {t("Image failed to load")}
|
||||
</StyledError>
|
||||
) : (
|
||||
<>
|
||||
{status.image === ImageStatus.LOADING && <LoadingIndicator />}
|
||||
<Figure>
|
||||
<StyledImg
|
||||
ref={ref}
|
||||
src={src}
|
||||
alt={alt}
|
||||
animation={animation}
|
||||
onAnimationStart={() => setHidden(false)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchCancel}
|
||||
onError={onError}
|
||||
onLoad={onLoad}
|
||||
$hidden={hidden}
|
||||
/>
|
||||
<Caption>
|
||||
{status.image === ImageStatus.LOADED &&
|
||||
status.lightbox === LightboxStatus.OPENED ? (
|
||||
<Fade>{alt}</Fade>
|
||||
) : null}
|
||||
</Caption>
|
||||
</Figure>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const Figure = styled("figure")`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Caption = styled("figcaption")`
|
||||
font-size: 14px;
|
||||
min-height: 1.5em;
|
||||
font-weight: normal;
|
||||
margin-top: 8px;
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledOverlay = styled(Dialog.Overlay)<{
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: ${s("background")};
|
||||
z-index: ${depths.overlay};
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
? css`
|
||||
opacity: 0;
|
||||
`
|
||||
: props.animation.fadeIn
|
||||
? css`
|
||||
animation: ${props.animation.fadeIn.apply()}
|
||||
${props.animation.fadeIn.duration}ms;
|
||||
`
|
||||
: props.animation.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledImg = styled.img<{
|
||||
$hidden: boolean;
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
visibility: ${(props) => (props.$hidden ? "hidden" : "visible")};
|
||||
max-width: 100%;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
${(props) =>
|
||||
props.animation?.zoomIn
|
||||
? css`
|
||||
animation: ${props.animation.zoomIn.apply()}
|
||||
${props.animation.zoomIn.duration}ms;
|
||||
`
|
||||
: props.animation?.zoomOut
|
||||
? css`
|
||||
animation: ${props.animation.zoomOut.apply()}
|
||||
${props.animation.zoomOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(Dialog.Content)`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
padding: 56px;
|
||||
`;
|
||||
|
||||
const Actions = styled.div<{
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: 16px 12px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
? css`
|
||||
opacity: 0;
|
||||
`
|
||||
: props.animation.fadeIn
|
||||
? css`
|
||||
animation: ${props.animation.fadeIn.apply()}
|
||||
${props.animation.fadeIn.duration}ms;
|
||||
`
|
||||
: props.animation.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const Nav = styled.div<{
|
||||
$hidden: boolean;
|
||||
dir: "left" | "right";
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
position: absolute;
|
||||
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
|
||||
transition: opacity 500ms ease-in-out;
|
||||
${(props) => props.$hidden && "opacity: 0;"}
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
? css`
|
||||
opacity: 0;
|
||||
`
|
||||
: props.animation.fadeIn
|
||||
? css`
|
||||
animation: ${props.animation.fadeIn.apply()}
|
||||
${props.animation.fadeIn.duration}ms;
|
||||
`
|
||||
: props.animation.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledError = styled(Error)<{
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
? css`
|
||||
opacity: 0;
|
||||
`
|
||||
: props.animation.fadeIn
|
||||
? css`
|
||||
animation: ${props.animation.fadeIn.apply()}
|
||||
${props.animation.fadeIn.duration}ms;
|
||||
`
|
||||
: props.animation.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const NavButton = styled(NudeButton)`
|
||||
margin: 16px;
|
||||
opacity: 0.75;
|
||||
color: ${s("text")};
|
||||
outline: none;
|
||||
${extraArea(12)}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Lightbox);
|
||||
@@ -67,7 +67,11 @@ function Notifications(
|
||||
<Flex gap={8}>
|
||||
{notifications.approximateUnreadCount > 0 && (
|
||||
<Tooltip content={t("Mark all as read")}>
|
||||
<Button action={markNotificationsAsRead} context={context}>
|
||||
<Button
|
||||
action={markNotificationsAsRead}
|
||||
context={context}
|
||||
aria-label={t("Mark all as read")}
|
||||
>
|
||||
<MarkAsReadIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -63,6 +63,7 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
alt={t("OAuth client icon")}
|
||||
onSuccess={(url) => field.onChange(url)}
|
||||
onError={(err) => setError("avatarUrl", { message: err })}
|
||||
model={{
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import "../stores";
|
||||
import { render } from "@testing-library/react";
|
||||
import { TFunction } from "i18next";
|
||||
import { Provider } from "mobx-react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
|
||||
describe("PaginatedList", () => {
|
||||
const i18n = getI18n();
|
||||
const authStore = {};
|
||||
|
||||
const props = {
|
||||
i18n,
|
||||
tReady: true,
|
||||
t: ((key: string) => key) as TFunction,
|
||||
} as any;
|
||||
|
||||
it("with no items renders nothing", () => {
|
||||
const result = render(
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList items={[]} renderItem={render} {...props} />
|
||||
</Provider>
|
||||
);
|
||||
expect(result.container.innerHTML).toEqual("");
|
||||
});
|
||||
|
||||
it("with no items renders empty prop", async () => {
|
||||
const result = render(
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
empty={<p>Sorry, no results</p>}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>{" "}
|
||||
</Provider>
|
||||
);
|
||||
await expect(
|
||||
result.findAllByText("Sorry, no results")
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("calls fetch with options + pagination on mount", () => {
|
||||
const fetch = jest.fn();
|
||||
const options = {
|
||||
id: "one",
|
||||
};
|
||||
render(
|
||||
<Provider auth={authStore}>
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={render}
|
||||
{...props}
|
||||
/>{" "}
|
||||
</Provider>
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
...options,
|
||||
limit: Pagination.defaultLimit,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -255,6 +255,7 @@ const PaginatedList = <T extends PaginatedItem>({
|
||||
<React.Fragment>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
role={rest.role}
|
||||
aria-label={rest["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
className={className}
|
||||
|
||||
@@ -168,6 +168,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
<Popover open={open} onOpenChange={setOpen} modal={true}>
|
||||
<PopoverAnchor>
|
||||
<StyledInputSearch
|
||||
role="combobox"
|
||||
aria-controls="search-results"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
@@ -176,6 +177,8 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
onFocus={handleSearchInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={className}
|
||||
label={t("Search")}
|
||||
labelHidden
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
@@ -194,6 +197,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
}}
|
||||
>
|
||||
<PaginatedList<SearchResult>
|
||||
role="listbox"
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
fetch={performSearch}
|
||||
|
||||
@@ -25,6 +25,11 @@ export const AppearanceAction = observer(() => {
|
||||
onClick={() =>
|
||||
ui.setTheme(resolvedTheme === "light" ? Theme.Dark : Theme.Light)
|
||||
}
|
||||
aria-label={
|
||||
resolvedTheme === "light"
|
||||
? t("Switch to dark")
|
||||
: t("Switch to light")
|
||||
}
|
||||
neutral
|
||||
borderOnHover
|
||||
/>
|
||||
|
||||
@@ -81,6 +81,11 @@ function AppSidebar() {
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed
|
||||
? t("Expand sidebar")
|
||||
: t("Collapse sidebar")
|
||||
}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
|
||||
@@ -52,6 +52,9 @@ function SettingsSidebar() {
|
||||
>
|
||||
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
|
||||
<ToggleButton
|
||||
aria-label={
|
||||
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
|
||||
}
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={() => {
|
||||
|
||||
@@ -96,6 +96,9 @@ const ToggleSidebar = () => {
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
|
||||
}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
|
||||
@@ -21,6 +21,7 @@ import { TooltipProvider } from "../TooltipContext";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ANIMATION_MS = 250;
|
||||
|
||||
@@ -35,6 +36,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { ui } = useStores();
|
||||
const location = useLocation();
|
||||
@@ -237,7 +239,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
position="bottom"
|
||||
image={
|
||||
<Avatar
|
||||
alt={user.name}
|
||||
alt={t("Avatar of {{ name }}", { name: user.name })}
|
||||
model={user}
|
||||
size={24}
|
||||
style={{ marginLeft: 4 }}
|
||||
@@ -245,7 +247,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
<SidebarButton position="bottom" image={<NotificationIcon />} />
|
||||
<SidebarButton
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
aria-label={t("Notifications")}
|
||||
/>
|
||||
</NotificationsPopover>
|
||||
</SidebarButton>
|
||||
</AccountMenu>
|
||||
|
||||
@@ -150,6 +150,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
{can.createDocument && (
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
|
||||
@@ -364,7 +364,6 @@ function InnerDocumentLink(
|
||||
{can.createChildDocument && (
|
||||
<Tooltip content={t("New doc")}>
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
@@ -61,7 +62,12 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
$isDragActive={isDragActive}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<VisuallyHidden>
|
||||
<label>
|
||||
{t("Import files")}
|
||||
<input {...getInputProps()} />
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
{isImporting && <LoadingIndicator />}
|
||||
{children}
|
||||
</DropzoneContainer>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { extraArea, s } from "@shared/styles";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
@@ -71,17 +71,18 @@ const Button = styled.button`
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
color: ${s("textTertiary")};
|
||||
color: ${s("sidebarText")};
|
||||
position: relative;
|
||||
letter-spacing: 0.03em;
|
||||
margin: 0;
|
||||
padding: 4px 2px 4px 12px;
|
||||
height: 22px;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
-webkit-appearance: none;
|
||||
transition: all 100ms ease;
|
||||
${undraggableOnDesktop()}
|
||||
${extraArea(4)}
|
||||
|
||||
&:not(:disabled):hover,
|
||||
&:not(:disabled):active {
|
||||
@@ -102,7 +103,8 @@ const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
|
||||
const H3 = styled.h3`
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
${Disclosure} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import styled, { useTheme, css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s, truncateMultiline } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import { isMobile } from "@shared/utils/browser";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { UnreadBadge } from "~/components/UnreadBadge";
|
||||
@@ -273,7 +273,6 @@ const Label = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
line-height: 24px;
|
||||
${truncateMultiline(3)}
|
||||
|
||||
* {
|
||||
unicode-bidi: plaintext;
|
||||
|
||||
@@ -347,6 +347,7 @@ export default function FindAndReplace({
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.prevSearchMatch()}
|
||||
aria-label={t("Previous match")}
|
||||
>
|
||||
<CaretUpIcon />
|
||||
</ButtonLarge>
|
||||
@@ -355,6 +356,7 @@ export default function FindAndReplace({
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.nextSearchMatch()}
|
||||
aria-label={t("Next match")}
|
||||
>
|
||||
<CaretDownIcon />
|
||||
</ButtonLarge>
|
||||
@@ -390,7 +392,10 @@ export default function FindAndReplace({
|
||||
shortcut={`${altDisplay}+${metaDisplay}+c`}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall onClick={handleCaseSensitive}>
|
||||
<ButtonSmall
|
||||
onClick={handleCaseSensitive}
|
||||
aria-label={t("Match case")}
|
||||
>
|
||||
<CaseSensitiveIcon
|
||||
color={caseSensitive ? theme.accent : theme.textSecondary}
|
||||
/>
|
||||
@@ -401,7 +406,10 @@ export default function FindAndReplace({
|
||||
shortcut={`${altDisplay}+${metaDisplay}+r`}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall onClick={handleRegex}>
|
||||
<ButtonSmall
|
||||
onClick={handleRegex}
|
||||
aria-label={t("Enable regex")}
|
||||
>
|
||||
<RegexIcon
|
||||
color={regexEnabled ? theme.accent : theme.textSecondary}
|
||||
/>
|
||||
@@ -416,7 +424,10 @@ export default function FindAndReplace({
|
||||
shortcut={`${altDisplay}+${metaDisplay}+f`}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge onClick={handleMore}>
|
||||
<ButtonLarge
|
||||
onClick={handleMore}
|
||||
aria-label={t("Replace options")}
|
||||
>
|
||||
<ReplaceIcon color={theme.textSecondary} />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { extraArea } from "@shared/styles";
|
||||
import Input, { NativeInput, Outline } from "~/components/Input";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Dimension = {
|
||||
width: string;
|
||||
@@ -20,6 +21,7 @@ export function MediaDimension() {
|
||||
width: { min: number; max: number };
|
||||
height: { min: number; max: number };
|
||||
}>();
|
||||
const { t } = useTranslation();
|
||||
const { view, commands } = useEditor();
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
@@ -205,6 +207,8 @@ export function MediaDimension() {
|
||||
return (
|
||||
<StyledFlex ref={ref} align="center">
|
||||
<StyledInput
|
||||
label={t("Image width")}
|
||||
labelHidden
|
||||
value={localDimension.width}
|
||||
onChange={handleChange("width")}
|
||||
onBlur={handleBlur}
|
||||
@@ -212,9 +216,11 @@ export function MediaDimension() {
|
||||
$error={error.width}
|
||||
/>
|
||||
<Text size="xsmall" type="tertiary">
|
||||
x
|
||||
×
|
||||
</Text>
|
||||
<StyledInput
|
||||
label={t("Image height")}
|
||||
labelHidden
|
||||
value={localDimension.height}
|
||||
onChange={handleChange("height")}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -221,7 +221,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
} else if (isNoticeSelection && selection.empty) {
|
||||
items = getNoticeMenuItems(state, readOnly, dictionary);
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
|
||||
items = getFormattingMenuItems(state, isTemplate, dictionary);
|
||||
}
|
||||
|
||||
// Some extensions may be disabled, remove corresponding items
|
||||
|
||||
@@ -64,7 +64,11 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(buttonProps) => (
|
||||
<ToolbarButton {...buttonProps} hovering={menu.visible}>
|
||||
<ToolbarButton
|
||||
{...buttonProps}
|
||||
hovering={menu.visible}
|
||||
aria-label={item.tooltip}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
@@ -118,6 +122,7 @@ function ToolbarMenu(props: Props) {
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
active={isActive && !item.label}
|
||||
aria-label={item.label ? undefined : item.tooltip}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
|
||||
@@ -54,6 +54,7 @@ import EditorContext from "./components/EditorContext";
|
||||
import { NodeViewRenderer } from "./components/NodeViewRenderer";
|
||||
import SelectionToolbar from "./components/SelectionToolbar";
|
||||
import WithTheme from "./components/WithTheme";
|
||||
import Lightbox from "~/components/Lightbox";
|
||||
|
||||
export type Props = {
|
||||
/** An optional identifier for the editor context. It is used to persist local settings */
|
||||
@@ -145,6 +146,8 @@ type State = {
|
||||
isEditorFocused: boolean;
|
||||
/** If the toolbar for a text selection is visible */
|
||||
selectionToolbarOpen: boolean;
|
||||
/** Position of image in doc that's being currently viewed in Lightbox */
|
||||
activeLightboxImgPos: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -174,6 +177,7 @@ export class Editor extends React.PureComponent<
|
||||
isRTL: false,
|
||||
isEditorFocused: false,
|
||||
selectionToolbarOpen: false,
|
||||
activeLightboxImgPos: null,
|
||||
};
|
||||
|
||||
isInitialized = false;
|
||||
@@ -494,6 +498,7 @@ export class Editor extends React.PureComponent<
|
||||
|
||||
// Tell third-party libraries and screen-readers that this is an input
|
||||
view.dom.setAttribute("role", "textbox");
|
||||
view.dom.setAttribute("aria-label", "Editor content");
|
||||
|
||||
return view;
|
||||
}
|
||||
@@ -712,6 +717,13 @@ export class Editor extends React.PureComponent<
|
||||
dispatch(tr);
|
||||
};
|
||||
|
||||
public updateActiveLightbox = (pos: number | null) => {
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
activeLightboxImgPos: pos,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the plain text content of the current editor.
|
||||
*
|
||||
@@ -831,6 +843,12 @@ export class Editor extends React.PureComponent<
|
||||
)}
|
||||
</Observer>
|
||||
</Flex>
|
||||
{this.state.activeLightboxImgPos && (
|
||||
<Lightbox
|
||||
onUpdate={this.updateActiveLightbox}
|
||||
activePos={this.state.activeLightboxImgPos}
|
||||
/>
|
||||
)}
|
||||
</EditorContext.Provider>
|
||||
</PortalContext.Provider>
|
||||
);
|
||||
|
||||
@@ -30,17 +30,22 @@ import { MenuItem } from "@shared/editor/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import {
|
||||
isMobile as isMobileDevice,
|
||||
isTouchDevice,
|
||||
} from "@shared/utils/browser";
|
||||
|
||||
export default function formattingMenuItems(
|
||||
state: EditorState,
|
||||
isTemplate: boolean,
|
||||
isMobile: boolean,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const { schema } = state;
|
||||
const isCode = isInCode(state);
|
||||
const isCodeBlock = isInCode(state, { onlyBlock: true });
|
||||
const isEmpty = state.selection.empty;
|
||||
const isMobile = isMobileDevice();
|
||||
const isTouch = isTouchDevice();
|
||||
|
||||
const highlight = getMarksBetween(
|
||||
state.selection.from,
|
||||
@@ -198,7 +203,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `⇧+Tab`,
|
||||
icon: <OutdentIcon />,
|
||||
visible:
|
||||
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
isTouch && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
},
|
||||
{
|
||||
name: "indentList",
|
||||
@@ -206,21 +211,21 @@ export default function formattingMenuItems(
|
||||
shortcut: `Tab`,
|
||||
icon: <IndentIcon />,
|
||||
visible:
|
||||
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
isTouch && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
},
|
||||
{
|
||||
name: "outdentCheckboxList",
|
||||
tooltip: dictionary.outdent,
|
||||
shortcut: `⇧+Tab`,
|
||||
icon: <OutdentIcon />,
|
||||
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
|
||||
visible: isTouch && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
{
|
||||
name: "indentCheckboxList",
|
||||
tooltip: dictionary.indent,
|
||||
shortcut: `Tab`,
|
||||
icon: <IndentIcon />,
|
||||
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
|
||||
visible: isTouch && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function useDictionary() {
|
||||
deleteRow: t("Delete"),
|
||||
deleteTable: t("Delete table"),
|
||||
deleteAttachment: t("Delete file"),
|
||||
dimensions: t("Width x Height"),
|
||||
dimensions: `${t("Width")} × ${t("Height")}`,
|
||||
download: t("Download"),
|
||||
downloadAttachment: t("Download file"),
|
||||
replaceAttachment: t("Replace file"),
|
||||
|
||||
@@ -20,7 +20,7 @@ const NotificationMenu: React.FC = () => {
|
||||
|
||||
return (
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("Notifications")}>
|
||||
<Button>
|
||||
<Button aria-label={t("Notifications")}>
|
||||
<MoreIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, observable } from "mobx";
|
||||
import GroupMembership from "./GroupMembership";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
|
||||
class Group extends Model {
|
||||
static modelName = "Group";
|
||||
@@ -25,6 +26,18 @@ class Group extends Model {
|
||||
return users.inGroup(this.id);
|
||||
}
|
||||
|
||||
@computed
|
||||
get admins() {
|
||||
const { groupUsers } = this.store.rootStore;
|
||||
return groupUsers.orderedData
|
||||
.filter(
|
||||
(groupUser) =>
|
||||
groupUser.groupId === this.id &&
|
||||
groupUser.permission === GroupPermission.Admin
|
||||
)
|
||||
.map((groupUser) => groupUser.user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the direct memberships that this group has to documents. Documents that the current
|
||||
* user already has access to through a collection, archived, and trashed documents are not included.
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
|
||||
@@ -70,6 +71,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
}: Props,
|
||||
externalRef: React.RefObject<RefHandle>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const ref = React.useRef<RefHandle>(null);
|
||||
const [iconPickerIsOpen, handleOpen, setIconPickerClosed] = useBoolean();
|
||||
const { editor } = useDocumentContext();
|
||||
@@ -249,6 +251,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
autoFocus={!title}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
readOnly={readOnly}
|
||||
aria-label={t("Document title")}
|
||||
dir="auto"
|
||||
ref={mergeRefs([ref, externalRef])}
|
||||
>
|
||||
|
||||
@@ -134,6 +134,7 @@ function DocumentHeader({
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
aria-label={t("Show contents")}
|
||||
onClick={handleToggle}
|
||||
icon={<TableOfContentsIcon />}
|
||||
borderOnHover
|
||||
|
||||
@@ -23,7 +23,11 @@ function KeyboardShortcutsButton() {
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Keyboard shortcuts")} shortcut="?">
|
||||
<Button onClick={handleOpenKeyboardShortcuts} $hidden={isEditingFocus}>
|
||||
<Button
|
||||
onClick={handleOpenKeyboardShortcuts}
|
||||
$hidden={isEditingFocus}
|
||||
aria-label={t("Keyboard shortcuts")}
|
||||
>
|
||||
<KeyboardIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -161,6 +161,7 @@ const Application = observer(function Application({ oauthClient }: Props) {
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
alt={t("Application icon")}
|
||||
onSuccess={(url) => field.onChange(url)}
|
||||
onError={(err) => setError("avatarUrl", { message: err })}
|
||||
model={{
|
||||
|
||||
@@ -193,6 +193,7 @@ function Details() {
|
||||
)}
|
||||
>
|
||||
<ImageInput
|
||||
alt={t("Workspace logo")}
|
||||
onSuccess={handleAvatarChange}
|
||||
onError={handleAvatarError}
|
||||
model={team}
|
||||
|
||||
@@ -72,6 +72,7 @@ const Profile = () => {
|
||||
description={t("Choose a photo or image to represent yourself.")}
|
||||
>
|
||||
<ImageInput
|
||||
alt={t("Profile picture")}
|
||||
onSuccess={handleAvatarChange}
|
||||
onError={handleAvatarError}
|
||||
model={user}
|
||||
|
||||
@@ -430,7 +430,7 @@ const GroupMemberListItem = observer(function ({
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: t("Manage"),
|
||||
label: t("Group admin"),
|
||||
value: GroupPermission.Admin,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -59,7 +59,7 @@ export function GroupsTable(props: Props) {
|
||||
<Title onClick={() => handleViewMembers(group)}>
|
||||
{group.name}
|
||||
</Title>
|
||||
<Text type="tertiary" size="small">
|
||||
<Text type="tertiary" size="small" weight="normal">
|
||||
<Trans
|
||||
defaults="{{ count }} member"
|
||||
values={{ count: group.memberCount }}
|
||||
@@ -97,6 +97,30 @@ export function GroupsTable(props: Props) {
|
||||
width: "1fr",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "admins",
|
||||
header: t("Admins"),
|
||||
accessor: (group) => `${group.memberCount} admins`,
|
||||
component: (group) => {
|
||||
const users = group.admins.slice(0, MAX_AVATAR_DISPLAY);
|
||||
|
||||
if (users.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupMembers
|
||||
onClick={() => handleViewMembers(group)}
|
||||
width={users.length * AvatarSize.Large}
|
||||
>
|
||||
<Facepile users={users} />
|
||||
</GroupMembers>
|
||||
);
|
||||
},
|
||||
width: "1fr",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdAt",
|
||||
|
||||
@@ -10,9 +10,10 @@ import ImageUpload, { Props as ImageUploadProps } from "./ImageUpload";
|
||||
|
||||
type Props = ImageUploadProps & {
|
||||
model: IAvatar;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
export default function ImageInput({ model, onSuccess, ...rest }: Props) {
|
||||
export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -27,6 +28,7 @@ export default function ImageInput({ model, onSuccess, ...rest }: Props) {
|
||||
model={model}
|
||||
size={AvatarSize.Upload}
|
||||
variant={AvatarVariant.Square}
|
||||
alt={alt}
|
||||
/>
|
||||
<Flex auto align="center" justify="center" className="upload">
|
||||
<EditIcon />
|
||||
|
||||
@@ -242,8 +242,8 @@ export default class AuthStore extends Store<Team> {
|
||||
// Update the user's timezone if it has changed
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (data.user.timezone !== timezone) {
|
||||
const user = this.rootStore.users.get(data.user.id)!;
|
||||
void user.save({ timezone });
|
||||
const user = this.rootStore.users.get(data.user.id);
|
||||
void user?.save({ timezone });
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -204,10 +204,10 @@ export default class DocumentsStore extends Store<Document> {
|
||||
}
|
||||
|
||||
get(id: string): Document | undefined {
|
||||
return (
|
||||
this.data.get(id) ??
|
||||
this.orderedData.find((doc) => id.endsWith(doc.urlId))
|
||||
);
|
||||
return id
|
||||
? (this.data.get(id) ??
|
||||
this.orderedData.find((doc) => id.endsWith(doc.urlId)))
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -167,9 +167,9 @@ export default class SharesStore extends Store<Share> {
|
||||
find(this.orderedData, (share) => share.documentId === documentId);
|
||||
|
||||
get(id: string): Share | undefined {
|
||||
return (
|
||||
this.data.get(id) ??
|
||||
this.orderedData.find((share) => id.endsWith(share.urlId))
|
||||
);
|
||||
return id
|
||||
? (this.data.get(id) ??
|
||||
this.orderedData.find((share) => id.endsWith(share.urlId)))
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ export default abstract class Store<T extends Model> {
|
||||
* @param id The ID of the item to get.
|
||||
*/
|
||||
get(id: string): T | undefined {
|
||||
return this.data.get(id);
|
||||
return id ? this.data.get(id) : undefined;
|
||||
}
|
||||
|
||||
@action
|
||||
|
||||
@@ -51,11 +51,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.873.0",
|
||||
"@aws-sdk/lib-storage": "3.873.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.873.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.873.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.873.0",
|
||||
"@aws-sdk/client-s3": "3.879.0",
|
||||
"@aws-sdk/lib-storage": "3.879.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.879.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.879.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.879.0",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@babel/plugin-proposal-decorators": "^7.28.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
@@ -128,11 +128,11 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"datadog-metrics": "^0.12.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.63.0",
|
||||
"dd-trace": "^5.64.0",
|
||||
"diff": "^5.2.0",
|
||||
"email-providers": "^1.14.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"emoji-regex": "^10.5.0",
|
||||
"es6-error": "^4.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fetch-retry": "^5.0.6",
|
||||
@@ -187,7 +187,7 @@
|
||||
"passport-oauth2": "^1.8.0",
|
||||
"passport-slack-oauth2": "^1.2.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"pg": "^8.15.6",
|
||||
"pg": "^8.16.3",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"pluralize": "^8.0.0",
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
@@ -198,7 +198,7 @@
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-inputrules": "^1.5.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-markdown": "^1.13.2",
|
||||
"prosemirror-model": "^1.25.2",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
@@ -220,7 +220,6 @@
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-i18next": "^12.3.1",
|
||||
"react-medium-image-zoom": "5.2.14",
|
||||
"react-merge-refs": "^2.1.1",
|
||||
"react-portal": "^4.3.0",
|
||||
"react-router-dom": "^5.3.4",
|
||||
@@ -258,7 +257,7 @@
|
||||
"tiny-cookie": "^2.5.1",
|
||||
"tmp": "^0.2.5",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown": "^7.2.1",
|
||||
"ukkonen": "^2.2.0",
|
||||
"umzug": "^3.8.2",
|
||||
"utility-types": "^3.11.0",
|
||||
@@ -281,7 +280,6 @@
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.3.1",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
@@ -382,6 +380,6 @@
|
||||
"qs": "6.9.7",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.87.1",
|
||||
"version": "0.87.3",
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { VerificationCode } from "@server/utils/VerificationCode";
|
||||
import { signIn } from "@server/utils/authentication";
|
||||
import { getUserForEmailSigninToken } from "@server/utils/jwt";
|
||||
import * as T from "./schema";
|
||||
import { CSRF } from "@shared/constants";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
@@ -108,7 +109,22 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
|
||||
// and spending the token before the user clicks on it. Instead we redirect
|
||||
// to the same URL with the follow query param added from the client side.
|
||||
if (!follow) {
|
||||
return ctx.redirectOnClient(ctx.request.href + "&follow=true", "POST");
|
||||
const csrfToken = ctx.cookies.get(CSRF.cookieName);
|
||||
|
||||
// Parse the current URL to extract existing query parameters
|
||||
const url = new URL(ctx.request.href);
|
||||
const searchParams = url.searchParams;
|
||||
|
||||
// Add new parameters
|
||||
searchParams.set("follow", "true");
|
||||
if (csrfToken) {
|
||||
searchParams.set(CSRF.fieldName, csrfToken);
|
||||
}
|
||||
|
||||
// Reconstruct the URL with merged parameters
|
||||
url.search = searchParams.toString();
|
||||
|
||||
return ctx.redirectOnClient(url.toString(), "POST");
|
||||
}
|
||||
|
||||
let user!: User;
|
||||
|
||||
@@ -28,7 +28,7 @@ const router = new Router();
|
||||
router.post(
|
||||
"files.create",
|
||||
rateLimiter(RateLimiterStrategy.TenPerMinute),
|
||||
auth({ allowMultipart: true }),
|
||||
auth(),
|
||||
validate(T.FilesCreateSchema),
|
||||
multipart({
|
||||
maximumFileSize: Math.max(
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 357 B After Width: | Height: | Size: 339 B |
|
Before Width: | Height: | Size: 777 B After Width: | Height: | Size: 729 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1017 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 741 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 964 B After Width: | Height: | Size: 871 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 980 B After Width: | Height: | Size: 874 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 817 B After Width: | Height: | Size: 713 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1010 B |
@@ -755,6 +755,17 @@ export class Environment {
|
||||
public WEBHOOK_FAILURE_RATE_THRESHOLD =
|
||||
this.toOptionalNumber(environment.WEBHOOK_FAILURE_RATE_THRESHOLD) ?? 80;
|
||||
|
||||
/**
|
||||
* Comma-separated list of IP addresses that are allowed to be accessed
|
||||
* even if they are private IP addresses. This is useful for allowing
|
||||
* connections to OIDC providers or webhooks on private networks.
|
||||
* Example: "10.0.0.1,192.168.1.100"
|
||||
*/
|
||||
@IsOptional()
|
||||
public ALLOWED_PRIVATE_IP_ADDRESSES = this.toOptionalCommaList(
|
||||
environment.ALLOWED_PRIVATE_IP_ADDRESSES
|
||||
);
|
||||
|
||||
/**
|
||||
* The product name
|
||||
*/
|
||||
|
||||
@@ -17,185 +17,29 @@ import {
|
||||
} from "../errors";
|
||||
|
||||
type AuthenticationOptions = {
|
||||
/** Role requuired to access the route. */
|
||||
/** Role required to access the route. */
|
||||
role?: UserRole;
|
||||
/** Type of authentication required to access the route. */
|
||||
type?: AuthenticationType | AuthenticationType[];
|
||||
/** Authentication is parsed, but optional. */
|
||||
optional?: boolean;
|
||||
/**
|
||||
* Allow multipart requests with cookie authentication, otherwise
|
||||
* the request will fail if the content type is not application/json.
|
||||
* This is useful for file uploads where the cookie is used to authenticate.
|
||||
*/
|
||||
allowMultipart?: boolean;
|
||||
};
|
||||
|
||||
type AuthTransport = "cookie" | "header" | "body" | "query";
|
||||
|
||||
type AuthInput = {
|
||||
/** The authentication token extracted from the request, if any. */
|
||||
token?: string;
|
||||
/** The method used to receive the authentication token. */
|
||||
transport?: AuthTransport;
|
||||
};
|
||||
|
||||
export default function auth(options: AuthenticationOptions = {}) {
|
||||
return async function authMiddleware(ctx: AppContext, next: Next) {
|
||||
let token;
|
||||
const authorizationHeader = ctx.request.get("authorization");
|
||||
|
||||
if (authorizationHeader) {
|
||||
const parts = authorizationHeader.split(" ");
|
||||
|
||||
if (parts.length === 2) {
|
||||
const scheme = parts[0];
|
||||
const credentials = parts[1];
|
||||
|
||||
if (/^Bearer$/i.test(scheme)) {
|
||||
token = credentials;
|
||||
}
|
||||
} else {
|
||||
throw AuthenticationError(
|
||||
`Bad Authorization header format. Format is "Authorization: Bearer <token>"`
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
ctx.request.body &&
|
||||
typeof ctx.request.body === "object" &&
|
||||
"token" in ctx.request.body
|
||||
) {
|
||||
token = ctx.request.body.token;
|
||||
} else if (ctx.request.query?.token) {
|
||||
token = ctx.request.query.token;
|
||||
} else {
|
||||
token = ctx.cookies.get("accessToken");
|
||||
|
||||
// check if the request is application/json encoded
|
||||
// TODO: Enable once clients have updated
|
||||
// if (
|
||||
// token &&
|
||||
// !ctx.request.is("application/json") &&
|
||||
// !options.allowMultipart
|
||||
// ) {
|
||||
// throw AuthenticationError(
|
||||
// "Mismatched content type. Expected application/json"
|
||||
// );
|
||||
// }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!token) {
|
||||
throw AuthenticationError("Authentication required");
|
||||
}
|
||||
const { type, token, user } = await validateAuthentication(ctx, options);
|
||||
|
||||
let user: User | null;
|
||||
let type: AuthenticationType;
|
||||
|
||||
if (OAuthAuthentication.match(String(token))) {
|
||||
if (!authorizationHeader) {
|
||||
throw AuthenticationError(
|
||||
"OAuth access token must be passed in the Authorization header"
|
||||
);
|
||||
}
|
||||
|
||||
type = AuthenticationType.OAUTH;
|
||||
|
||||
let authentication;
|
||||
try {
|
||||
authentication = await OAuthAuthentication.findByAccessToken(token, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
} catch (_err) {
|
||||
throw AuthenticationError("Invalid access token");
|
||||
}
|
||||
if (!authentication) {
|
||||
throw AuthenticationError("Invalid access token");
|
||||
}
|
||||
if (authentication.accessTokenExpiresAt < new Date()) {
|
||||
throw AuthenticationError("Access token is expired");
|
||||
}
|
||||
if (!authentication.canAccess(ctx.request.url)) {
|
||||
throw AuthenticationError(
|
||||
"Access token does not have access to this resource"
|
||||
);
|
||||
}
|
||||
|
||||
user = await User.findByPk(authentication.userId, {
|
||||
include: [
|
||||
{
|
||||
model: Team,
|
||||
as: "team",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!user) {
|
||||
throw AuthenticationError("Invalid access token");
|
||||
}
|
||||
|
||||
await authentication.updateActiveAt();
|
||||
} else if (ApiKey.match(String(token))) {
|
||||
type = AuthenticationType.API;
|
||||
let apiKey;
|
||||
|
||||
try {
|
||||
apiKey = await ApiKey.findByToken(token);
|
||||
} catch (_err) {
|
||||
throw AuthenticationError("Invalid API key");
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw AuthenticationError("Invalid API key");
|
||||
}
|
||||
|
||||
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
|
||||
throw AuthenticationError("API key is expired");
|
||||
}
|
||||
|
||||
if (!apiKey.canAccess(ctx.request.url)) {
|
||||
throw AuthenticationError(
|
||||
"API key does not have access to this resource"
|
||||
);
|
||||
}
|
||||
|
||||
user = await User.findByPk(apiKey.userId, {
|
||||
include: [
|
||||
{
|
||||
model: Team,
|
||||
as: "team",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw AuthenticationError("Invalid API key");
|
||||
}
|
||||
|
||||
await apiKey.updateActiveAt();
|
||||
} else {
|
||||
type = AuthenticationType.APP;
|
||||
user = await getUserForJWT(String(token));
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
const suspendingAdmin = await User.findOne({
|
||||
where: {
|
||||
id: user.suspendedById!,
|
||||
},
|
||||
paranoid: false,
|
||||
});
|
||||
throw UserSuspendedError({
|
||||
adminEmail: suspendingAdmin?.email || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.role && UserRoleHelper.isRoleLower(user.role, options.role)) {
|
||||
throw AuthorizationError(`${capitalize(options.role)} role required`);
|
||||
}
|
||||
|
||||
if (
|
||||
options.type &&
|
||||
(Array.isArray(options.type)
|
||||
? !options.type.includes(type)
|
||||
: type !== options.type)
|
||||
) {
|
||||
throw AuthorizationError(`Invalid authentication type`);
|
||||
}
|
||||
|
||||
// not awaiting the promises here so that the request is not blocked
|
||||
// We are not awaiting the promises here so that the request is not blocked
|
||||
user.updateActiveAt(ctx).catch((err) => {
|
||||
Logger.error("Failed to update user activeAt", err);
|
||||
});
|
||||
@@ -205,7 +49,7 @@ export default function auth(options: AuthenticationOptions = {}) {
|
||||
|
||||
ctx.state.auth = {
|
||||
user,
|
||||
token: String(token),
|
||||
token,
|
||||
type,
|
||||
};
|
||||
|
||||
@@ -240,3 +84,196 @@ export default function auth(options: AuthenticationOptions = {}) {
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the authentication token from the request context.
|
||||
*
|
||||
* @param ctx The application context containing the request information.
|
||||
* @returns An object containing the token and its transport method.
|
||||
*/
|
||||
export function parseAuthentication(ctx: AppContext): AuthInput {
|
||||
const authorizationHeader = ctx.request.get("authorization");
|
||||
|
||||
if (authorizationHeader) {
|
||||
const parts = authorizationHeader.split(" ");
|
||||
|
||||
if (parts.length === 2) {
|
||||
const scheme = parts[0];
|
||||
const credentials = parts[1];
|
||||
|
||||
if (/^Bearer$/i.test(scheme)) {
|
||||
return {
|
||||
token: credentials,
|
||||
transport: "header",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw AuthenticationError(
|
||||
`Bad Authorization header format. Format is "Authorization: Bearer <token>"`
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
ctx.request.body &&
|
||||
typeof ctx.request.body === "object" &&
|
||||
"token" in ctx.request.body
|
||||
) {
|
||||
return {
|
||||
token: String(ctx.request.body.token),
|
||||
transport: "body",
|
||||
};
|
||||
} else if (ctx.request.query?.token) {
|
||||
return {
|
||||
token: String(ctx.request.query.token),
|
||||
transport: "query",
|
||||
};
|
||||
} else {
|
||||
const accessToken = ctx.cookies.get("accessToken");
|
||||
if (accessToken) {
|
||||
return {
|
||||
token: accessToken,
|
||||
transport: "cookie",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token: undefined,
|
||||
transport: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function validateAuthentication(
|
||||
ctx: AppContext,
|
||||
options: AuthenticationOptions
|
||||
): Promise<{ user: User; token: string; type: AuthenticationType }> {
|
||||
const { token, transport } = parseAuthentication(ctx);
|
||||
|
||||
if (!token) {
|
||||
throw AuthenticationError("Authentication required");
|
||||
}
|
||||
|
||||
let user: User | null;
|
||||
let type: AuthenticationType;
|
||||
|
||||
if (OAuthAuthentication.match(token)) {
|
||||
if (transport !== "header") {
|
||||
throw AuthenticationError(
|
||||
"OAuth access token must be passed in the Authorization header"
|
||||
);
|
||||
}
|
||||
|
||||
type = AuthenticationType.OAUTH;
|
||||
|
||||
let authentication;
|
||||
try {
|
||||
authentication = await OAuthAuthentication.findByAccessToken(token, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
} catch (_err) {
|
||||
throw AuthenticationError("Invalid access token");
|
||||
}
|
||||
if (!authentication) {
|
||||
throw AuthenticationError("Invalid access token");
|
||||
}
|
||||
if (authentication.accessTokenExpiresAt < new Date()) {
|
||||
throw AuthenticationError("Access token is expired");
|
||||
}
|
||||
if (!authentication.canAccess(ctx.request.url)) {
|
||||
throw AuthenticationError(
|
||||
"Access token does not have access to this resource"
|
||||
);
|
||||
}
|
||||
|
||||
user = await User.findByPk(authentication.userId, {
|
||||
include: [
|
||||
{
|
||||
model: Team,
|
||||
as: "team",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!user) {
|
||||
throw AuthenticationError("Invalid access token");
|
||||
}
|
||||
|
||||
await authentication.updateActiveAt();
|
||||
} else if (ApiKey.match(token)) {
|
||||
if (transport === "cookie") {
|
||||
throw AuthenticationError("API key must not be passed in the cookie");
|
||||
}
|
||||
|
||||
type = AuthenticationType.API;
|
||||
let apiKey;
|
||||
|
||||
try {
|
||||
apiKey = await ApiKey.findByToken(token);
|
||||
} catch (_err) {
|
||||
throw AuthenticationError("Invalid API key");
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw AuthenticationError("Invalid API key");
|
||||
}
|
||||
|
||||
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
|
||||
throw AuthenticationError("API key is expired");
|
||||
}
|
||||
|
||||
if (!apiKey.canAccess(ctx.request.url)) {
|
||||
throw AuthenticationError(
|
||||
"API key does not have access to this resource"
|
||||
);
|
||||
}
|
||||
|
||||
user = await User.findByPk(apiKey.userId, {
|
||||
include: [
|
||||
{
|
||||
model: Team,
|
||||
as: "team",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw AuthenticationError("Invalid API key");
|
||||
}
|
||||
|
||||
await apiKey.updateActiveAt();
|
||||
} else {
|
||||
type = AuthenticationType.APP;
|
||||
user = await getUserForJWT(token);
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
const suspendingAdmin = await User.findOne({
|
||||
where: {
|
||||
id: user.suspendedById!,
|
||||
},
|
||||
paranoid: false,
|
||||
});
|
||||
throw UserSuspendedError({
|
||||
adminEmail: suspendingAdmin?.email || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.role && UserRoleHelper.isRoleLower(user.role, options.role)) {
|
||||
throw AuthorizationError(`${capitalize(options.role)} role required`);
|
||||
}
|
||||
|
||||
if (
|
||||
options.type &&
|
||||
(Array.isArray(options.type)
|
||||
? !options.type.includes(type)
|
||||
: type !== options.type)
|
||||
) {
|
||||
throw AuthorizationError(`Invalid authentication type`);
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
type,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { getCookieDomain } from "@shared/utils/domains";
|
||||
import { CSRF } from "@shared/constants";
|
||||
import { CSRFError } from "@server/errors";
|
||||
import { parseAuthentication } from "./authentication";
|
||||
|
||||
/**
|
||||
* Middleware that generates and attaches CSRF tokens for safe methods
|
||||
@@ -48,7 +49,8 @@ export function verifyCSRFToken() {
|
||||
}
|
||||
|
||||
// If not using cookie-based auth, skip CSRF protection
|
||||
if (!ctx.cookies.get("accessToken")) {
|
||||
const { transport } = parseAuthentication(ctx);
|
||||
if (transport !== "cookie") {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
InferCreationAttributes,
|
||||
NonNullFindOptions,
|
||||
SaveOptions,
|
||||
ScopeOptions,
|
||||
} from "sequelize";
|
||||
import {
|
||||
Transaction,
|
||||
@@ -80,8 +81,13 @@ const stateIfContentEmpty = Sequelize.literal(
|
||||
);
|
||||
|
||||
type AdditionalFindOptions = {
|
||||
/** The user ID to load associated permissions for. */
|
||||
userId?: string;
|
||||
/** Whether to include the state column in the attributes. */
|
||||
includeState?: boolean;
|
||||
/** Whether to views (default: true). */
|
||||
includeViews?: boolean;
|
||||
/** Whether to reject the query if no document is found. */
|
||||
rejectOnEmpty?: boolean | Error;
|
||||
};
|
||||
|
||||
@@ -701,16 +707,25 @@ class Document extends ArchivableModel<
|
||||
return null;
|
||||
}
|
||||
|
||||
const { includeState, userId, ...rest } = options;
|
||||
const {
|
||||
includeViews = true,
|
||||
includeState = false,
|
||||
userId,
|
||||
...rest
|
||||
} = options;
|
||||
|
||||
// allow default preloading of collection membership if `userId` is passed in find options
|
||||
// almost every endpoint needs the collection membership to determine policy permissions.
|
||||
const scope = this.scope([
|
||||
"withDrafts",
|
||||
includeState ? "withState" : "withoutState",
|
||||
{
|
||||
method: ["withViews", userId],
|
||||
},
|
||||
...((includeViews
|
||||
? [
|
||||
{
|
||||
method: ["withViews", userId],
|
||||
},
|
||||
]
|
||||
: []) as ScopeOptions[]),
|
||||
{
|
||||
method: ["withMembership", userId, rest.paranoid],
|
||||
},
|
||||
@@ -765,14 +780,19 @@ class Document extends ArchivableModel<
|
||||
options: Omit<FindOptions<Document>, "where"> &
|
||||
Omit<AdditionalFindOptions, "rejectOnEmpty"> = {}
|
||||
): Promise<Document[]> {
|
||||
const { userId, ...rest } = options;
|
||||
const { userId, includeViews = true, includeState, ...rest } = options;
|
||||
|
||||
const user = userId ? await User.findByPk(userId) : null;
|
||||
const documents = await this.scope([
|
||||
"withDrafts",
|
||||
{
|
||||
method: ["withViews", userId],
|
||||
},
|
||||
includeState ? "withState" : "withoutState",
|
||||
...((includeViews
|
||||
? [
|
||||
{
|
||||
method: ["withViews", userId],
|
||||
},
|
||||
]
|
||||
: []) as ScopeOptions[]),
|
||||
{
|
||||
method: ["withMembership", userId],
|
||||
},
|
||||
|
||||
@@ -71,7 +71,12 @@ class Relationship extends IdModel<
|
||||
|
||||
const documents = await Document.findByIds(
|
||||
relationships.map((relationship) => relationship.reverseDocumentId),
|
||||
{ userId: user.id }
|
||||
{
|
||||
attributes: ["id"],
|
||||
userId: user.id,
|
||||
includeState: false,
|
||||
includeViews: false,
|
||||
}
|
||||
);
|
||||
|
||||
return documents.map((doc) => doc.id);
|
||||
|
||||
@@ -1575,7 +1575,7 @@ router.post(
|
||||
|
||||
router.post(
|
||||
"documents.import",
|
||||
auth({ allowMultipart: true }),
|
||||
auth(),
|
||||
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
|
||||
validate(T.DocumentsImportSchema),
|
||||
multipart({ maximumFileSize: env.FILE_STORAGE_IMPORT_MAX_SIZE }),
|
||||
|
||||
@@ -102,6 +102,7 @@ router.post(
|
||||
where: {
|
||||
groupId: group.id,
|
||||
},
|
||||
order: [["permission", "ASC"]],
|
||||
limit: MAX_AVATAR_DISPLAY,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -150,6 +150,12 @@ function buildAgent(
|
||||
const proxyURL = getProxyForUrl(parsedURL.href);
|
||||
let agent: https.Agent | http.Agent | undefined;
|
||||
|
||||
// Add allowIPAddressList from environment configuration
|
||||
const filteringOptions = {
|
||||
...agentOptions,
|
||||
allowIPAddressList: env.ALLOWED_PRIVATE_IP_ADDRESSES,
|
||||
};
|
||||
|
||||
if (proxyURL) {
|
||||
const parsedProxyURL = parseProxy(parsedURL, proxyURL);
|
||||
|
||||
@@ -171,15 +177,15 @@ function buildAgent(
|
||||
proxyURL.username = parsedProxyURL.username;
|
||||
proxyURL.password = parsedProxyURL.password;
|
||||
}
|
||||
agent = useFilteringAgent(proxyURL.toString(), agentOptions);
|
||||
agent = useFilteringAgent(proxyURL.toString(), filteringOptions);
|
||||
} else {
|
||||
// Note request filtering agent does not support https tunneling via a proxy
|
||||
agent =
|
||||
buildTunnel(parsedProxyURL, agentOptions) ||
|
||||
useFilteringAgent(parsedURL.toString(), agentOptions);
|
||||
useFilteringAgent(parsedURL.toString(), filteringOptions);
|
||||
}
|
||||
} else {
|
||||
agent = useFilteringAgent(parsedURL.toString(), agentOptions);
|
||||
agent = useFilteringAgent(parsedURL.toString(), filteringOptions);
|
||||
}
|
||||
|
||||
if (options.signal) {
|
||||
|
||||
@@ -24,6 +24,7 @@ export default abstract class OAuthClient {
|
||||
try {
|
||||
response = await fetch(this.endpoints.userinfo, {
|
||||
method: "GET",
|
||||
allowPrivateIPAddress: true,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
@@ -59,6 +60,7 @@ export default abstract class OAuthClient {
|
||||
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
allowPrivateIPAddress: true,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
|
||||
@@ -98,6 +98,7 @@ export async function request(
|
||||
) {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
allowPrivateIPAddress: true,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function sanitizeLists(turndownService: TurndownService) {
|
||||
content = content
|
||||
.replace(/^\n+/, "") // remove leading newlines
|
||||
.replace(/\n+$/, "\n") // replace trailing newlines with just a single one
|
||||
.replace(/\n/gm, "\n "); // 2 space indent
|
||||
.replace(/\n/gm, "\n "); // 4 space indent
|
||||
|
||||
let prefix = options.bulletListMarker + " ";
|
||||
const parent = node.parentNode;
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "../../styles";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
/** Callback triggered when the caption is blurred */
|
||||
@@ -23,6 +24,7 @@ type Props = {
|
||||
* A component that renders a caption for an image or video.
|
||||
*/
|
||||
function Caption({ placeholder, children, isSelected, width, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLParagraphElement>) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
@@ -42,6 +44,7 @@ function Caption({ placeholder, children, isSelected, width, ...rest }: Props) {
|
||||
onPaste={handlePaste}
|
||||
className={EditorStyleHelper.imageCaption}
|
||||
tabIndex={-1}
|
||||
aria-label={t("Caption")}
|
||||
role="textbox"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
|
||||
@@ -7,11 +7,13 @@ import { s } from "../../styles";
|
||||
import { isExternalUrl, sanitizeUrl } from "../../utils/urls";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
import { ComponentProps } from "../types";
|
||||
import { ImageZoom } from "./ImageZoom";
|
||||
import { ResizeLeft, ResizeRight } from "./ResizeHandle";
|
||||
import useDragResize from "./hooks/useDragResize";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Props = ComponentProps & {
|
||||
/** Callback triggered when the image is clicked */
|
||||
onClick: () => void;
|
||||
/** Callback triggered when the download button is clicked */
|
||||
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
/** Callback triggered when the image is resized */
|
||||
@@ -22,13 +24,15 @@ type Props = ComponentProps & {
|
||||
};
|
||||
|
||||
const Image = (props: Props) => {
|
||||
const { isSelected, node, isEditable, onChangeSize } = props;
|
||||
const { isSelected, node, isEditable, onChangeSize, onClick } = props;
|
||||
const { src, layoutClass } = node.attrs;
|
||||
const { t } = useTranslation();
|
||||
const className = layoutClass ? `image image-${layoutClass}` : "image";
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [error, setError] = React.useState(false);
|
||||
const [naturalWidth, setNaturalWidth] = React.useState(node.attrs.width);
|
||||
const [naturalHeight, setNaturalHeight] = React.useState(node.attrs.height);
|
||||
const lastTapTimeRef = React.useRef(0);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const { width, height, setSize, handlePointerDown, dragging } = useDragResize(
|
||||
{
|
||||
@@ -65,6 +69,25 @@ const Image = (props: Props) => {
|
||||
? { width: "var(--container-width)" }
|
||||
: { width: width || "auto" };
|
||||
|
||||
const handleImageTouchStart = (ev: React.TouchEvent<HTMLDivElement>) => {
|
||||
const currentTime = Date.now();
|
||||
const timeSinceLastTap = currentTime - lastTapTimeRef.current;
|
||||
|
||||
if (timeSinceLastTap < 300 && isSelected) {
|
||||
ev.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
|
||||
lastTapTimeRef.current = currentTime;
|
||||
};
|
||||
|
||||
const handleImageClick = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isEditable || isSelected) {
|
||||
ev.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div contentEditable={false} className={className} ref={ref}>
|
||||
<ImageWrapper
|
||||
@@ -75,11 +98,11 @@ const Image = (props: Props) => {
|
||||
{!dragging && width > 60 && isDownloadable && (
|
||||
<Actions>
|
||||
{isExternalUrl(src) && (
|
||||
<Button onClick={handleOpen}>
|
||||
<Button onClick={handleOpen} aria-label={t("Open")}>
|
||||
<GlobeIcon />
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={props.onDownload}>
|
||||
<Button onClick={props.onDownload} aria-label={t("Download")}>
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
</Actions>
|
||||
@@ -89,7 +112,7 @@ const Image = (props: Props) => {
|
||||
<CrossIcon size={16} /> Image failed to load
|
||||
</Error>
|
||||
) : (
|
||||
<ImageZoom caption={props.node.attrs.alt}>
|
||||
<>
|
||||
<img
|
||||
className={EditorStyleHelper.imageHandle}
|
||||
style={{
|
||||
@@ -123,6 +146,8 @@ const Image = (props: Props) => {
|
||||
}));
|
||||
}
|
||||
}}
|
||||
onClick={handleImageClick}
|
||||
onTouchStart={handleImageTouchStart}
|
||||
/>
|
||||
{!loaded && width && height && (
|
||||
<img
|
||||
@@ -135,7 +160,7 @@ const Image = (props: Props) => {
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
</ImageZoom>
|
||||
</>
|
||||
)}
|
||||
{isEditable && !isFullWidth && isResizable && (
|
||||
<>
|
||||
@@ -161,7 +186,7 @@ function getPlaceholder(width: number, height: number) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" />`;
|
||||
}
|
||||
|
||||
const Error = styled(Flex)`
|
||||
export const Error = styled(Flex)`
|
||||
max-width: 100%;
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 14px;
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { createGlobalStyle } from "styled-components";
|
||||
import EventBoundary from "../../components/EventBoundary";
|
||||
import { s } from "../../styles";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
|
||||
const Zoom = React.lazy(() => import("react-medium-image-zoom"));
|
||||
|
||||
type Props = {
|
||||
/** An optional caption to display below the image */
|
||||
caption?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component that wraps an image with the ability to zoom in
|
||||
*/
|
||||
export const ImageZoom = ({ caption, children }: Props) => {
|
||||
const [isActivated, setIsActivated] = React.useState(false);
|
||||
|
||||
const handleActivated = React.useCallback(() => {
|
||||
setIsActivated(true);
|
||||
}, []);
|
||||
|
||||
const fallback = (
|
||||
<span onPointerEnter={handleActivated} onFocus={handleActivated}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
const ZoomContent = React.useMemo(
|
||||
() =>
|
||||
function ZoomContentComponent(
|
||||
props: Omit<React.ComponentProps<typeof Lightbox>, "caption">
|
||||
) {
|
||||
return <Lightbox caption={caption} {...props} />;
|
||||
},
|
||||
[caption]
|
||||
);
|
||||
|
||||
if (!isActivated) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={fallback}>
|
||||
<Styles />
|
||||
<EventBoundary captureEvents="click">
|
||||
<Zoom zoomMargin={EditorStyleHelper.padding} ZoomContent={ZoomContent}>
|
||||
<div>{children}</div>
|
||||
</Zoom>
|
||||
</EventBoundary>
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const Lightbox = ({
|
||||
caption,
|
||||
modalState,
|
||||
img,
|
||||
}: {
|
||||
caption: string | undefined;
|
||||
modalState: string;
|
||||
img: React.ReactNode;
|
||||
}) => (
|
||||
<Figure>
|
||||
{img}
|
||||
<Caption $loaded={modalState === "LOADED"}>{caption}</Caption>
|
||||
</Figure>
|
||||
);
|
||||
|
||||
const Figure = styled("figure")`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Caption = styled("figcaption")<{ $loaded: boolean }>`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
opacity: ${(props) => (props.$loaded ? 1 : 0)};
|
||||
transition: opacity 250ms;
|
||||
|
||||
font-weight: normal;
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
const Styles = createGlobalStyle`
|
||||
[data-rmiz-ghost] {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-rmiz-btn-zoom],
|
||||
[data-rmiz-btn-unzoom] {
|
||||
display: none;
|
||||
}
|
||||
[data-rmiz-btn-zoom]:not(:focus):not(:active) {
|
||||
position: absolute;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
[data-rmiz-btn-zoom] {
|
||||
position: absolute;
|
||||
inset: 10px 10px auto auto;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
[data-rmiz-btn-unzoom] {
|
||||
position: absolute;
|
||||
inset: 20px 20px auto auto;
|
||||
cursor: zoom-out;
|
||||
z-index: 1;
|
||||
}
|
||||
[data-rmiz-content="found"] img,
|
||||
[data-rmiz-content="found"] svg,
|
||||
[data-rmiz-content="found"] [role="img"],
|
||||
[data-rmiz-content="found"] [data-zoom] {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
[data-rmiz-modal] {
|
||||
outline: none;
|
||||
}
|
||||
[data-rmiz-modal]::backdrop {
|
||||
display: none;
|
||||
}
|
||||
[data-rmiz-modal][open] {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
width: 100dvw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-rmiz-modal-overlay] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
[data-rmiz-modal-overlay="hidden"] {
|
||||
background-color: ${(props) => transparentize(1, props.theme.background)};
|
||||
}
|
||||
[data-rmiz-modal-overlay="visible"] {
|
||||
background-color: ${s("background")};
|
||||
}
|
||||
[data-rmiz-modal-content] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
[data-rmiz-modal-img] {
|
||||
position: absolute;
|
||||
cursor: zoom-out;
|
||||
image-rendering: high-quality;
|
||||
transform-origin: top left;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-rmiz-modal-overlay],
|
||||
[data-rmiz-modal-img] {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -693,6 +693,9 @@ img.ProseMirror-separator {
|
||||
.component-image + img.ProseMirror-separator + br.ProseMirror-trailingBreak {
|
||||
display: none;
|
||||
}
|
||||
.component-image img {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.${EditorStyleHelper.imageCaption} {
|
||||
border: 0;
|
||||
|
||||
@@ -1,26 +1,7 @@
|
||||
import * as React from "react";
|
||||
import Frame from "../components/Frame";
|
||||
import { ImageZoom } from "../components/ImageZoom";
|
||||
import { EmbedProps as Props } from ".";
|
||||
|
||||
function InVision({ matches, ...props }: Props) {
|
||||
if (/opal\.invisionapp\.com/.test(props.attrs.href)) {
|
||||
return (
|
||||
<div className={props.isSelected ? "ProseMirror-selectednode" : ""}>
|
||||
<ImageZoom>
|
||||
<img
|
||||
src={props.attrs.href}
|
||||
alt="InVision Embed"
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "75vh",
|
||||
}}
|
||||
/>
|
||||
</ImageZoom>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Frame {...props} src={props.attrs.href} title="InVision Embed" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,13 +31,18 @@ export type EmbedProps = {
|
||||
};
|
||||
};
|
||||
|
||||
const Img = styled(Image)`
|
||||
const Img = styled(Image)<{ invertable?: boolean }>`
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px ${(props) => props.theme.divider};
|
||||
margin: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
${(props) =>
|
||||
props.invertable &&
|
||||
props.theme.isDark &&
|
||||
`
|
||||
filter: invert(1);
|
||||
`}
|
||||
`;
|
||||
|
||||
export class EmbedDescriptor {
|
||||
@@ -225,7 +230,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
regexMatch: [new RegExp("^https://codepen.io/(.*?)/(pen|embed)/(.*)$")],
|
||||
transformMatch: (matches) =>
|
||||
`https://codepen.io/${matches[1]}/embed/${matches[3]}`,
|
||||
icon: <Img src="/images/codepen.png" alt="Codepen" />,
|
||||
icon: <Img src="/images/codepen.png" alt="Codepen" invertable />,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "DBDiagram",
|
||||
@@ -288,7 +293,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
keywords: "design prototyping",
|
||||
regexMatch: [new RegExp("^https://framer.cloud/(.*)$")],
|
||||
transformMatch: (matches) => matches[0],
|
||||
icon: <Img src="/images/framer.png" alt="Framer" />,
|
||||
icon: <Img src="/images/framer.png" alt="Framer" invertable />,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "GitHub Gist",
|
||||
@@ -298,7 +303,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
"^https://gist\\.github\\.com/([a-zA-Z\\d](?:[a-zA-Z\\d]|-(?=[a-zA-Z\\d])){0,38})/(.*)$"
|
||||
),
|
||||
],
|
||||
icon: <Img src="/images/github-gist.png" alt="GitHub" />,
|
||||
icon: <Img src="/images/github-gist.png" alt="GitHub" invertable />,
|
||||
component: Gist,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
@@ -446,6 +451,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
title: "InVision",
|
||||
keywords: "design prototype",
|
||||
defaultHidden: true,
|
||||
visible: false,
|
||||
regexMatch: [
|
||||
/^https:\/\/(invis\.io\/.*)|(projects\.invisionapp\.com\/share\/.*)$/,
|
||||
/^https:\/\/(opal\.invisionapp\.com\/static-signed\/live-embed\/.*)$/,
|
||||
@@ -458,7 +464,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
keywords: "code",
|
||||
defaultHidden: true,
|
||||
regexMatch: [new RegExp("^https?://jsfiddle\\.net/(.*)/(.*)$")],
|
||||
icon: <Img src="/images/jsfiddle.png" alt="JSFiddle" />,
|
||||
icon: <Img src="/images/jsfiddle.png" alt="JSFiddle" invertable />,
|
||||
component: JSFiddle,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
@@ -603,7 +609,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
new RegExp("^https?://(beta|www|old)\\.tldraw\\.com/[rsvopf]+/(.*)"),
|
||||
],
|
||||
transformMatch: (matches: RegExpMatchArray) => matches[0],
|
||||
icon: <Img src="/images/tldraw.png" alt="Tldraw" />,
|
||||
icon: <Img src="/images/tldraw.png" alt="Tldraw" invertable />,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Trello",
|
||||
@@ -621,7 +627,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
),
|
||||
],
|
||||
transformMatch: (matches: RegExpMatchArray) => matches[0],
|
||||
icon: <Img src="/images/typeform.png" alt="Typeform" />,
|
||||
icon: <Img src="/images/typeform.png" alt="Typeform" invertable />,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Valtown",
|
||||
@@ -629,7 +635,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
regexMatch: [/^https?:\/\/(?:www.)?val\.town\/(?:v|embed)\/(.*)$/],
|
||||
transformMatch: (matches: RegExpMatchArray) =>
|
||||
`https://www.val.town/embed/${matches[1]}`,
|
||||
icon: <Img src="/images/valtown.png" alt="Valtown" />,
|
||||
icon: <Img src="/images/valtown.png" alt="Valtown" invertable />,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Vimeo",
|
||||
|
||||
@@ -9,6 +9,7 @@ import toggleCheckboxItem from "../commands/toggleCheckboxItem";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import checkboxRule from "../rules/checkboxes";
|
||||
import Node from "./Node";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
export default class CheckboxItem extends Node {
|
||||
get name() {
|
||||
@@ -34,6 +35,7 @@ export default class CheckboxItem extends Node {
|
||||
},
|
||||
],
|
||||
toDOM: (node) => {
|
||||
const id = `checkbox-${v4()}`;
|
||||
const checked = node.attrs.checked.toString();
|
||||
let input;
|
||||
if (typeof document !== "undefined") {
|
||||
@@ -41,6 +43,7 @@ export default class CheckboxItem extends Node {
|
||||
input.tabIndex = -1;
|
||||
input.className = "checkbox";
|
||||
input.setAttribute("aria-checked", checked);
|
||||
input.setAttribute("aria-labelledby", id);
|
||||
input.setAttribute("role", "checkbox");
|
||||
input.addEventListener("click", this.handleClick);
|
||||
}
|
||||
@@ -60,7 +63,7 @@ export default class CheckboxItem extends Node {
|
||||
? [input]
|
||||
: [["span", { class: "checkbox", "aria-checked": checked }]]),
|
||||
],
|
||||
["div", 0],
|
||||
["div", { id }, 0],
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ const parseTitleAttribute = (tokenTitle: string): TitleAttributes => {
|
||||
return attributes;
|
||||
};
|
||||
|
||||
const downloadImageNode = async (node: ProsemirrorNode) => {
|
||||
export const downloadImageNode = async (node: ProsemirrorNode) => {
|
||||
const image = await fetch(node.attrs.src);
|
||||
const imageBlob = await image.blob();
|
||||
const imageURL = URL.createObjectURL(imageBlob);
|
||||
@@ -240,7 +240,7 @@ export default class Image extends SimpleImage {
|
||||
}
|
||||
|
||||
handleChangeSize =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
({ node, getPos }: ComponentProps) =>
|
||||
({ width, height }: { width: number; height?: number }) => {
|
||||
const { view, commands } = this.editor;
|
||||
const { doc, tr } = view.state;
|
||||
@@ -256,7 +256,7 @@ export default class Image extends SimpleImage {
|
||||
};
|
||||
|
||||
handleDownload =
|
||||
({ node }: { node: ProsemirrorNode }) =>
|
||||
({ node }: ComponentProps) =>
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -264,7 +264,7 @@ export default class Image extends SimpleImage {
|
||||
};
|
||||
|
||||
handleCaptionKeyDown =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
({ node, getPos }: ComponentProps) =>
|
||||
(event: React.KeyboardEvent<HTMLParagraphElement>) => {
|
||||
// Pressing Enter in the caption field should move the cursor/selection
|
||||
// below the image and create a new paragraph.
|
||||
@@ -297,7 +297,7 @@ export default class Image extends SimpleImage {
|
||||
};
|
||||
|
||||
handleCaptionBlur =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
({ node, getPos }: ComponentProps) =>
|
||||
(event: React.FocusEvent<HTMLParagraphElement>) => {
|
||||
const caption = event.currentTarget.innerText;
|
||||
if (caption === node.attrs.alt) {
|
||||
@@ -316,9 +316,16 @@ export default class Image extends SimpleImage {
|
||||
view.dispatch(transaction);
|
||||
};
|
||||
|
||||
handleClick =
|
||||
({ getPos }: ComponentProps) =>
|
||||
() => {
|
||||
this.editor.updateActiveLightbox(getPos());
|
||||
};
|
||||
|
||||
component = (props: ComponentProps) => (
|
||||
<ImageComponent
|
||||
{...props}
|
||||
onClick={this.handleClick(props)}
|
||||
onDownload={this.handleDownload(props)}
|
||||
onChangeSize={this.handleChangeSize(props)}
|
||||
>
|
||||
|
||||
@@ -76,7 +76,15 @@ export default class SimpleImage extends Node {
|
||||
};
|
||||
}
|
||||
|
||||
component = (props: ComponentProps) => <ImageComponent {...props} />;
|
||||
handleClick =
|
||||
({ getPos }: ComponentProps) =>
|
||||
() => {
|
||||
this.editor.updateActiveLightbox(getPos());
|
||||
};
|
||||
|
||||
component = (props: ComponentProps) => (
|
||||
<ImageComponent {...props} onClick={this.handleClick(props)} />
|
||||
);
|
||||
|
||||
keys(): Record<string, Command> {
|
||||
return {
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"currently viewing": "currently viewing",
|
||||
"previously edited": "previously edited",
|
||||
"You": "You",
|
||||
"Avatar of {{ name }}": "Avatar of {{ name }}",
|
||||
"Viewers": "Viewers",
|
||||
"Collections are used to group documents and choose permissions": "Collections are used to group documents and choose permissions",
|
||||
"Name": "Name",
|
||||
@@ -205,6 +206,7 @@
|
||||
"Move document": "Move document",
|
||||
"Moving": "Moving",
|
||||
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.",
|
||||
"More options": "More options",
|
||||
"Submenu": "Submenu",
|
||||
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
|
||||
"Start view": "Start view",
|
||||
@@ -315,6 +317,12 @@
|
||||
"Permission": "Permission",
|
||||
"Change Language": "Change Language",
|
||||
"Dismiss": "Dismiss",
|
||||
"Lightbox": "Lightbox",
|
||||
"View, navigate, or download images in the document": "View, navigate, or download images in the document",
|
||||
"Close": "Close",
|
||||
"Previous": "Previous",
|
||||
"Next": "Next",
|
||||
"Image failed to load": "Image failed to load",
|
||||
"You’re offline.": "You’re offline.",
|
||||
"Sorry, an error occurred.": "Sorry, an error occurred.",
|
||||
"Click to retry": "Click to retry",
|
||||
@@ -323,6 +331,7 @@
|
||||
"Mark all as read": "Mark all as read",
|
||||
"You're all caught up": "You're all caught up",
|
||||
"Icon": "Icon",
|
||||
"OAuth client icon": "OAuth client icon",
|
||||
"My App": "My App",
|
||||
"Tagline": "Tagline",
|
||||
"A short description": "A short description",
|
||||
@@ -399,12 +408,15 @@
|
||||
"{{ count }} groups added to the document": "{{ count }} groups added to the document",
|
||||
"{{ count }} groups added to the document_plural": "{{ count }} groups added to the document",
|
||||
"Logo": "Logo",
|
||||
"Expand sidebar": "Expand sidebar",
|
||||
"Collapse sidebar": "Collapse sidebar",
|
||||
"Archived collections": "Archived collections",
|
||||
"New doc": "New doc",
|
||||
"Empty": "Empty",
|
||||
"Collapse": "Collapse",
|
||||
"Expand": "Expand",
|
||||
"Document not supported – try Markdown, Plain text, HTML, or Word": "Document not supported – try Markdown, Plain text, HTML, or Word",
|
||||
"Import files": "Import files",
|
||||
"Go back": "Go back",
|
||||
"Go forward": "Go forward",
|
||||
"Could not load shared documents": "Could not load shared documents",
|
||||
@@ -454,6 +466,8 @@
|
||||
"Replacement": "Replacement",
|
||||
"Replace": "Replace",
|
||||
"Replace all": "Replace all",
|
||||
"Image width": "Image width",
|
||||
"Image height": "Image height",
|
||||
"Profile picture": "Profile picture",
|
||||
"Create a new doc": "Create a new doc",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
|
||||
@@ -480,7 +494,8 @@
|
||||
"Create a new child doc": "Create a new child doc",
|
||||
"Delete table": "Delete table",
|
||||
"Delete file": "Delete file",
|
||||
"Width x Height": "Width x Height",
|
||||
"Width": "Width",
|
||||
"Height": "Height",
|
||||
"Download file": "Download file",
|
||||
"Replace file": "Replace file",
|
||||
"Delete image": "Delete image",
|
||||
@@ -674,6 +689,7 @@
|
||||
"only you": "only you",
|
||||
"person": "person",
|
||||
"people": "people",
|
||||
"Document title": "Document title",
|
||||
"Last updated": "Last updated",
|
||||
"Type '/' to insert, or start writing…": "Type '/' to insert, or start writing…",
|
||||
"Hide contents": "Hide contents",
|
||||
@@ -714,7 +730,6 @@
|
||||
"Deleted by {{userName}}": "Deleted by {{userName}}",
|
||||
"Observing {{ userName }}": "Observing {{ userName }}",
|
||||
"Backlinks": "Backlinks",
|
||||
"Close": "Close",
|
||||
"This document is large which may affect performance": "This document is large which may affect performance",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.",
|
||||
@@ -919,6 +934,7 @@
|
||||
"Rotate secret": "Rotate secret",
|
||||
"Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.": "Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.",
|
||||
"Displayed to users when authorizing": "Displayed to users when authorizing",
|
||||
"Application icon": "Application icon",
|
||||
"Developer information shown to users when authorizing": "Developer information shown to users when authorizing",
|
||||
"Developer name": "Developer name",
|
||||
"Developer URL": "Developer URL",
|
||||
@@ -982,7 +998,9 @@
|
||||
"Search people": "Search people",
|
||||
"No people matching your search": "No people matching your search",
|
||||
"No people left to add": "No people left to add",
|
||||
"Group admin": "Group admin",
|
||||
"Member": "Member",
|
||||
"Admins": "Admins",
|
||||
"Date created": "Date created",
|
||||
"Crop Image": "Crop Image",
|
||||
"Crop image": "Crop image",
|
||||
@@ -1012,7 +1030,6 @@
|
||||
"Domain": "Domain",
|
||||
"Views": "Views",
|
||||
"All roles": "All roles",
|
||||
"Admins": "Admins",
|
||||
"Editors": "Editors",
|
||||
"All status": "All status",
|
||||
"Active": "Active",
|
||||
@@ -1025,6 +1042,7 @@
|
||||
"These settings affect the way that your workspace appears to everyone on the team.": "These settings affect the way that your workspace appears to everyone on the team.",
|
||||
"Display": "Display",
|
||||
"The logo is displayed at the top left of the application.": "The logo is displayed at the top left of the application.",
|
||||
"Workspace logo": "Workspace logo",
|
||||
"The workspace name, usually the same as your company name.": "The workspace name, usually the same as your company name.",
|
||||
"Description": "Description",
|
||||
"A short description of your workspace.": "A short description of your workspace.",
|
||||
@@ -1255,5 +1273,7 @@
|
||||
"{{ user }} updated {{ timeAgo }}": "{{ user }} updated {{ timeAgo }}",
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Caption": "Caption",
|
||||
"Open": "Open",
|
||||
"Error loading data": "Error loading data"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,72 @@
|
||||
import { bytesToHumanReadable, getFileNameFromUrl } from "./files";
|
||||
import * as browser from "./browser";
|
||||
|
||||
// Mock the browser detection
|
||||
jest.mock("./browser", () => ({
|
||||
isMac: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockIsMac = browser.isMac as jest.MockedFunction<typeof browser.isMac>;
|
||||
|
||||
describe("bytesToHumanReadable", () => {
|
||||
it("outputs readable string", () => {
|
||||
expect(bytesToHumanReadable(0)).toBe("0 Bytes");
|
||||
expect(bytesToHumanReadable(0.0)).toBe("0 Bytes");
|
||||
expect(bytesToHumanReadable(33)).toBe("33 Bytes");
|
||||
expect(bytesToHumanReadable(500)).toBe("500 Bytes");
|
||||
expect(bytesToHumanReadable(1000)).toBe("1 kB");
|
||||
expect(bytesToHumanReadable(15000)).toBe("15 kB");
|
||||
expect(bytesToHumanReadable(12345)).toBe("12.34 kB");
|
||||
expect(bytesToHumanReadable(123456)).toBe("123.45 kB");
|
||||
expect(bytesToHumanReadable(1234567)).toBe("1.23 MB");
|
||||
expect(bytesToHumanReadable(1234567890)).toBe("1.23 GB");
|
||||
expect(bytesToHumanReadable(undefined)).toBe("0 Bytes");
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("on macOS (decimal units)", () => {
|
||||
beforeEach(() => {
|
||||
mockIsMac.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("outputs readable string using decimal units", () => {
|
||||
expect(bytesToHumanReadable(0)).toBe("0 Bytes");
|
||||
expect(bytesToHumanReadable(0.0)).toBe("0 Bytes");
|
||||
expect(bytesToHumanReadable(33)).toBe("33 Bytes");
|
||||
expect(bytesToHumanReadable(500)).toBe("500 Bytes");
|
||||
expect(bytesToHumanReadable(1000)).toBe("1 KB");
|
||||
expect(bytesToHumanReadable(15000)).toBe("15 KB");
|
||||
expect(bytesToHumanReadable(12345)).toBe("12.35 KB");
|
||||
expect(bytesToHumanReadable(123456)).toBe("123.46 KB");
|
||||
expect(bytesToHumanReadable(1234567)).toBe("1.23 MB");
|
||||
expect(bytesToHumanReadable(1234567890)).toBe("1.23 GB");
|
||||
expect(bytesToHumanReadable(undefined)).toBe("0 Bytes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("on Windows/other platforms (binary units)", () => {
|
||||
beforeEach(() => {
|
||||
mockIsMac.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("outputs readable string using binary units", () => {
|
||||
expect(bytesToHumanReadable(0)).toBe("0 Bytes");
|
||||
expect(bytesToHumanReadable(0.0)).toBe("0 Bytes");
|
||||
expect(bytesToHumanReadable(33)).toBe("33 Bytes");
|
||||
expect(bytesToHumanReadable(500)).toBe("500 Bytes");
|
||||
expect(bytesToHumanReadable(1000)).toBe("1000 Bytes");
|
||||
expect(bytesToHumanReadable(1024)).toBe("1 KB");
|
||||
expect(bytesToHumanReadable(1536)).toBe("1.5 KB");
|
||||
expect(bytesToHumanReadable(15360)).toBe("15 KB");
|
||||
expect(bytesToHumanReadable(12345)).toBe("12.06 KB");
|
||||
expect(bytesToHumanReadable(126464)).toBe("123.5 KB");
|
||||
expect(bytesToHumanReadable(1048576)).toBe("1 MB");
|
||||
expect(bytesToHumanReadable(1073741824)).toBe("1 GB");
|
||||
expect(bytesToHumanReadable(undefined)).toBe("0 Bytes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform-specific behavior for issue #10085", () => {
|
||||
const fileSize = 91435827; // 87.2MB in binary, ~91.44MB in decimal
|
||||
|
||||
it("displays correctly on macOS (decimal)", () => {
|
||||
mockIsMac.mockReturnValue(true);
|
||||
expect(bytesToHumanReadable(fileSize)).toBe("91.44 MB");
|
||||
});
|
||||
|
||||
it("displays correctly on Windows (binary)", () => {
|
||||
mockIsMac.mockReturnValue(false);
|
||||
expect(bytesToHumanReadable(fileSize)).toBe("87.2 MB");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { isMac } from "./browser";
|
||||
|
||||
/**
|
||||
* Converts bytes to human readable string for display
|
||||
* Uses binary units (1024-based) on Windows and decimal units (1000-based) on macOS
|
||||
*
|
||||
* @param bytes filesize in bytes
|
||||
* @returns Human readable filesize as a string
|
||||
@@ -9,19 +12,23 @@ export function bytesToHumanReadable(bytes: number | undefined) {
|
||||
return "0 Bytes";
|
||||
}
|
||||
|
||||
const out = ("0".repeat((bytes.toString().length * 2) % 3) + bytes).match(
|
||||
/.{3}/g
|
||||
);
|
||||
// Use decimal units (base 1000) on macOS, binary units (base 1024) on other platforms
|
||||
const useMacUnits = isMac();
|
||||
const base = useMacUnits ? 1000 : 1024;
|
||||
const threshold = useMacUnits ? 1000 : 1024;
|
||||
|
||||
if (!out || bytes < 1000) {
|
||||
if (bytes < threshold) {
|
||||
return bytes + " Bytes";
|
||||
}
|
||||
|
||||
const f = (out[1] ?? "").substring(0, 2);
|
||||
const units = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const exponent = Math.floor(Math.log(bytes) / Math.log(base));
|
||||
const value = bytes / Math.pow(base, exponent);
|
||||
|
||||
return `${Number(out[0])}${f === "00" ? "" : `.${f}`} ${
|
||||
" kMGTPEZY"[out.length]
|
||||
}B`;
|
||||
// Format to 2 decimal places and remove trailing zeros
|
||||
const formatted = parseFloat(value.toFixed(2));
|
||||
|
||||
return `${formatted} ${units[exponent]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||