Compare commits

..

28 Commits

Author SHA1 Message Date
tommoor 8a303282ba chore: Compressed inefficient images automatically 2025-09-07 20:05:39 +00:00
Tom Moor 5337770adb perf: Improve perf of findSourceDocumentIdsForUser (#10118)
* perf: Quick win to not join views table here

* Include views by default
2025-09-07 14:28:48 -04:00
Tom Moor b1b7b2b6fc fix: Truncation in sidebar links (#10120)
closes #10087
2025-09-07 11:10:33 -04:00
Tom Moor 1dcb8f8052 fix: Display column for admins on groups table (#10117) 2025-09-07 13:26:05 +00:00
Tom Moor 569c4b4849 fix: Incorrect translation (#10116) 2025-09-07 12:49:20 +00:00
Tom Moor 5d5bed8270 chore: Refactor auth/CSRF middleware (#10113)
* chore: Refactor auth/CSRF middleware

* sp
2025-09-07 08:36:46 -04:00
Tom Moor 58a41a6fde fix: Various accessibility issues (#10115)
* Round 1

* Round 2

* Shared page
2025-09-07 08:36:35 -04:00
Tom Moor 0bde1d5ef4 fix: Sidebar hidden editing outline (#10114) 2025-09-06 17:14:32 -04:00
Tom Moor 4a01fb7094 chore: Remove PaginatedList test (#10110)
* chore: Remove flaky,useless PaginatedList test

* test
2025-09-06 16:03:08 -04:00
github-actions[bot] a4ff9aa45c chore: Compressed inefficient images automatically (#10111)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2025-09-06 16:02:55 -04:00
Tom Moor 1777e9b556 chore: Embed branding (#10109)
* Remove InVision from embed menu, company shut down

* Update Instagram logo
Improve dark mode styling

* Optimised images with calibre/image-actions

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-06 16:00:48 -04:00
Tom Moor 59e57d6171 fix: Add additional guards around get methods (#10108) 2025-09-06 19:29:24 +00:00
Tom Moor 9b17f91c9a fix: CSRF missing during email callback (#10107)
* fix: CSRF missing during email callback

* refactor
2025-09-06 11:26:03 -04:00
Apoorv Mishra 9854ce7c31 Lightbox for image navigation in a doc (#9704)
* feat: POC

* fix: cleanup

* fix: cleanup

* fix: can use existing `UiStore` in favor of `LightboxStore`

* fix: style dialog overlay and content

* fix: style and position action buttons

* fix: keyboard nav

* fix: display caption

* fix: cleanup

* fix: force `Lightbox` to unmount if `DocumentScene` re-renders

* fix: cleanup

* fix: images going out of bounds–trying `object-fit` for first pass

* fix: making `Figure` a flexbox and setting its size to img dimensions,
gets rid of the `object-fit` "letterboxing" effect

* feat: image transition

* fix: match animation to that of `ImageZoom`

* fix: `fade-in` overlay

* fix: match overlay background to main background

* fix: fade out nav if idle for 3s

* fix: width & height transition in firefox

* fix: move nav buttons sideways

* fix: button sizes

* fix: don't let nav buttons become invisible due to image in backdrop

* feat: download btn

* feat: handle swipe left, right & down on mobile, also double tap to open lightbox

* fix: off-by-one

* fix: for img to be visible, if not animating

* fix: cleanup

* just a showcase commit, it's broken and will be reverted

* Revert "just a showcase commit, it's broken and will be reverted"

This reverts commit 63d7cbda9895f92a530cfb3a8402eaacb2414947.

* feat: `animateOnClose`

* fix: prevent body from shifting horizontally

* Revert "fix: prevent body from shifting horizontally"

This reverts commit fcc2d0d1daaab458325ece880d13903b42560dfd.

* fix: lint

* fix: make lightbox resilient to doc changes

* fix: `VisuallyHidden` `Dialog.Title`

* fix: fade in action and nav buttons too

* fix: `fadeOut` overlay, action & nav buttons upon close

* fix: `dom.querySelector` is not a function

* fix: let `objectFit` compute the img dimensions

* fix: flashing caption

* fix: hide nav keys based on whether it's a first or last img

* fix: tooltip for action buttons

* Fixes:
1. `Space` with the image selected should trigger the lightbox
2. There should be a "Zoom in" cursor on the image when selected so you know you can click to zoom

* fix: fade out on close if there's img loading err

* fix: pull animation duration into a const

* fix: upscaled editor image animating unreliably

* fix: lint

* fixes:
1. Model as state machine
2. Replace `transition` with `animation`

* fix: cleanup

* fix: DRY

* fix: cleanup

* fix: img loading indicator

* fix: swipe

* fix: types

* fix: Incorrect transitions when image nodes are duplicated

* Tweaks, translations, aria

* fix: Incorrect transition if image is added while lightbox is open

* fix: review

* Remove ImageZoom, react-medium-image-zoom

* Tweak styling

* fix: Quick fix for multiple open editors

* refactor: Move active lightbox state into editor

* Apply graphite suggestions

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-09-06 09:43:53 -04:00
codegen-sh[bot] e28dfbe0bc Fix file size display to use binary units instead of decimal (#10095)
* Fix file size display to use binary units instead of decimal

- Update bytesToHumanReadable function to use 1024-based units (KB, MB, GB)
- This matches user expectations from operating systems like Windows
- Fixes issue where 87.2MB file was displayed as 91.53MB
- Update unit tests to reflect binary unit calculations
- Resolves GitHub issue #10085

Co-authored-by: Tom Moor <tom@getoutline.com>

* Make file size display platform-aware

- Use decimal units (base 1000) on macOS to match Finder behavior
- Use binary units (base 1024) on Windows to match Explorer behavior
- Import isMac utility from shared/utils/browser
- Update comprehensive tests for both platforms
- Resolves platform-specific file size display discrepancies

Co-authored-by: Tom Moor <tom@getoutline.com>

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-09-05 06:17:43 -04:00
Tom Moor e0e00bd93d fix: Show list indent controls on touch devices (#10098) 2025-09-05 06:12:26 -04:00
Tom Moor 9df6b9d1a5 v0.87.3 2025-09-04 22:22:17 -04:00
codegen-sh[bot] e2dfc4dd00 Add ALLOWED_PRIVATE_IP_ADDRESSES environment variable (#10093)
* Add ALLOW_IP_ADDRESS_LIST environment variable

This adds support for allowing specific private IP addresses to be accessed
by the request-filtering-agent, which is useful for OIDC providers and
webhooks on private networks.

The environment variable accepts a comma-separated list of IP addresses
that should be allowed even if they are private IP addresses.

Example: ALLOW_IP_ADDRESS_LIST=10.0.0.1,192.168.1.100

Fixes issue with OIDC providers on private IP addresses being blocked.

* Rename environment variable to ALLOWED_PRIVATE_IP_ADDRESSES

Changed from ALLOW_IP_ADDRESS_LIST to ALLOWED_PRIVATE_IP_ADDRESSES
for better clarity and naming consistency.

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-04 07:36:50 -04:00
dependabot[bot] 80f48152de chore(deps): bump prosemirror-keymap from 1.2.2 to 1.2.3 (#10080)
Bumps [prosemirror-keymap](https://github.com/prosemirror/prosemirror-keymap) from 1.2.2 to 1.2.3.
- [Changelog](https://github.com/ProseMirror/prosemirror-keymap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-keymap/compare/1.2.2...1.2.3)

---
updated-dependencies:
- dependency-name: prosemirror-keymap
  dependency-version: 1.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-03 08:14:22 -04:00
dependabot[bot] 57ae4fd4fb chore(deps): bump turndown from 7.2.0 to 7.2.1 (#10078)
Bumps [turndown](https://github.com/mixmark-io/turndown) from 7.2.0 to 7.2.1.
- [Release notes](https://github.com/mixmark-io/turndown/releases)
- [Commits](https://github.com/mixmark-io/turndown/compare/v7.2.0...v7.2.1)

---
updated-dependencies:
- dependency-name: turndown
  dependency-version: 7.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 08:01:43 +00:00
dependabot[bot] be9a2b120b chore(deps): bump the aws group with 5 updates (#10075)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.873.0` | `3.879.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.873.0` | `3.879.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.873.0` | `3.879.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.873.0` | `3.879.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.873.0` | `3.879.0` |


Updates `@aws-sdk/client-s3` from 3.873.0 to 3.879.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.879.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.873.0 to 3.879.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.879.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.873.0 to 3.879.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.879.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.873.0 to 3.879.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.879.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.873.0 to 3.879.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.879.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.879.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.879.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.879.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.879.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.879.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 03:24:44 -04:00
dependabot[bot] 6c190ec308 chore(deps): bump dd-trace from 5.63.0 to 5.64.0 (#10074)
Bumps [dd-trace](https://github.com/DataDog/dd-trace-js) from 5.63.0 to 5.64.0.
- [Release notes](https://github.com/DataDog/dd-trace-js/releases)
- [Commits](https://github.com/DataDog/dd-trace-js/compare/v5.63.0...v5.64.0)

---
updated-dependencies:
- dependency-name: dd-trace
  dependency-version: 5.64.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 03:24:35 -04:00
dependabot[bot] e326e6c8f3 chore(deps): bump pg from 8.15.6 to 8.16.3 (#10076)
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.15.6 to 8.16.3.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.16.3/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  dependency-version: 8.16.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 03:24:25 -04:00
dependabot[bot] 46401701a0 chore(deps): bump emoji-regex from 10.4.0 to 10.5.0 (#10077)
Bumps [emoji-regex](https://github.com/mathiasbynens/emoji-regex) from 10.4.0 to 10.5.0.
- [Commits](https://github.com/mathiasbynens/emoji-regex/compare/v10.4.0...v10.5.0)

---
updated-dependencies:
- dependency-name: emoji-regex
  dependency-version: 10.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 03:24:13 -04:00
Tom Moor 2f2e7c3556 fix: One last spot that needs to allow private requests (#10069) 2025-09-01 11:03:25 -04:00
Tom Moor fedd983649 v0.87.2 2025-09-01 12:32:44 +02:00
Tom Moor 3b2833c752 Update sanitizeLists.ts (#10065)
closes #10042
2025-09-01 06:25:09 -04:00
Tom Moor f1dee53dc4 fix: Unable to access private OIDC server endpoints (#10062) 2025-09-01 05:12:49 -04:00
88 changed files with 1812 additions and 977 deletions
+4 -8
View File
@@ -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",
+2 -2
View File
@@ -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
+3 -2
View File
@@ -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
+4 -1
View File
@@ -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>
</>
+1
View File
@@ -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(
+3 -2
View File
@@ -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>
)}
+3 -2
View File
@@ -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)`
+1 -7
View File
@@ -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}
+7 -3
View File
@@ -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")};
+840
View File
@@ -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={{
-66
View File
@@ -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,
});
});
});
+1
View File
@@ -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}
+4
View File
@@ -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
/>
+5
View File
@@ -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();
+3
View File
@@ -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={() => {
+3
View File
@@ -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();
+8 -2
View File
@@ -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>
+6 -4
View File
@@ -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;
+14 -3
View File
@@ -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 -1
View File
@@ -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}
+1 -1
View File
@@ -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
+6 -1
View File
@@ -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}
+18
View File
@@ -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>
);
+10 -5
View File
@@ -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",
+1 -1
View File
@@ -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"),
+1 -1
View 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>
+13
View File
@@ -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>
+1
View File
@@ -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={{
+1
View File
@@ -193,6 +193,7 @@ function Details() {
)}
>
<ImageInput
alt={t("Workspace logo")}
onSuccess={handleAvatarChange}
onError={handleAvatarError}
model={team}
+1
View File
@@ -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,
},
{
+25 -1
View File
@@ -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 />
+2 -2
View File
@@ -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) {
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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;
}
}
+1 -1
View File
@@ -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
+11 -13
View File
@@ -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"
}
+17 -1
View File
@@ -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;
+1 -1
View File
@@ -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(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 922 B

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 759 B

After

Width:  |  Height:  |  Size: 713 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1010 B

+11
View File
@@ -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
*/
+206 -169
View File
@@ -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,
};
}
+3 -1
View File
@@ -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;
}
+28 -8
View File
@@ -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],
},
+6 -1
View File
@@ -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);
+1 -1
View File
@@ -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 }),
+1
View File
@@ -102,6 +102,7 @@ router.post(
where: {
groupId: group.id,
},
order: [["permission", "ASC"]],
limit: MAX_AVATAR_DISPLAY,
})
)
+9 -3
View File
@@ -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) {
+2
View File
@@ -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",
},
+1
View File
@@ -98,6 +98,7 @@ export async function request(
) {
const response = await fetch(endpoint, {
method,
allowPrivateIPAddress: true,
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
+1 -1
View File
@@ -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;
+3
View File
@@ -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
+32 -7
View File
@@ -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;
-176
View File
@@ -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;
}
}
`;
+3
View File
@@ -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;
-19
View File
@@ -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" />;
}
+16 -10
View File
@@ -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",
+4 -1
View File
@@ -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],
];
},
};
+12 -5
View File
@@ -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)}
>
+9 -1
View File
@@ -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 {
+23 -3
View File
@@ -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",
"Youre offline.": "Youre 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"
}
+66 -12
View File
@@ -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");
});
});
});
+15 -8
View File
@@ -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]}`;
}
/**
+282 -403
View File
File diff suppressed because it is too large Load Diff