Compare commits

...

35 Commits

Author SHA1 Message Date
tommoor b3a1cdde00 chore: Compressed inefficient images automatically 2025-09-14 20:05:53 +00:00
codegen-sh[bot] bf68a1d2bf feat: use sourceMetadata column for collections in import processes (#10168)
* feat: use sourceMetadata column for collections in import processes

- Update ImportsProcessor to populate sourceMetadata with externalId, externalName, and createdByName for collections
- Add sourceMetadata to collection presenter for API responses
- Ensure collections follow same metadata methodology as documents during imports

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

* Update collection.ts

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-09-14 08:23:45 -04:00
Tom Moor e1b29bd854 chore: Store refresh tokens for Linear integration (#10047)
* wip

* Store expiry

* refreshTokenIfNeeded

* toDate

* self review

* refactor
2025-09-13 22:55:38 -04:00
Translate-O-Tron d07453d108 New Crowdin updates (#10066)
* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]
2025-09-13 20:28:43 -04:00
codegen-sh[bot] c40ccd32f5 Add sourceMetadata column to collections (#10165)
This adds the sourceMetadata column to the collections table to match
the one on documents. The column is defined as JSONB and allows null values.

Changes:
- Added migration to create sourceMetadata column on collections table
- Added sourceMetadata field to Collection model with SourceMetadata type
- Imported SourceMetadata type in Collection model

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-13 20:28:29 -04:00
Tom Moor 3ab3117e11 fix: Undefined in MediaDimensions (#10164) 2025-09-13 19:10:05 +00:00
Tom Moor 7d69198c91 fix: Single shared doc uncentered, closes #10162 (#10163) 2025-09-13 14:27:49 +00:00
Tom Moor d29089c2ae fix: Enforce share loads team (#10160) 2025-09-13 09:35:58 -04:00
Tom Moor b39f231927 fix: Inline math formatting should trigger on last $ only (#10159) 2025-09-13 09:14:36 -04:00
Salihu f57a189077 feat: Ordered alphabetical lists (#10079)
* feat: letter-list

* simplify list toggle

* use more common shortcuts for list toggle

* fix toggle list conflict

* wrap letter index to avoid overflow

* ensure the markdown letter representation matches the css representation on overflow

* improve list style validation

* fix list indexing

* fix: Toggling ordered lists from formatting menu

* fix: Ordered list in block menu

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-09-13 13:03:19 +00:00
Apoorv Mishra fc469ef9c2 Fix: Deleted image zooms out to (0, 0) upon closing Lightbox (#10154)
* fix: when the lightbox active image is deleted in editor, it zooms out to (0, 0) upon closing lightbox

* Update Lightbox.tsx

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-09-13 11:49:03 +00:00
dependabot[bot] 24c01b1a9a chore(deps): bump axios from 1.8.2 to 1.12.1 (#10157)
Bumps [axios](https://github.com/axios/axios) from 1.8.2 to 1.12.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.8.2...v1.12.1)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-13 07:47:10 -04:00
Apoorv Mishra f1bc5f6216 Enable swipe actions on "Image failed to load" component within Lightbox (#10153)
* fix: apply swipe actions on error component

* fix: enable copy and download action btns only when the image is loaded

* Revert "fix: enable copy and download action btns only when the image is loaded"

This reverts commit 9228d5e5df.
2025-09-11 21:16:54 +05:30
Hemachandar f3fe7283f8 fix: Allow pasting simple lists (#10150) 2025-09-10 16:48:29 -04:00
Apoorv Mishra 839bf5cb91 Regression in Lightbox swipe gestures (#10148)
* fix: swipe

* fix: noTouchEnd not needed

* Revert "fix: noTouchEnd not needed"

This reverts commit cda9a0e49b.

* Revert "fix: swipe"

This reverts commit c05a50be2c.

* fix: fire upon single swipe detection

* chore: `useSwipe`
2025-09-10 16:48:20 -04:00
Apoorv Mishra 6fa98ffe3a fix: unset animation as the lightbox opens (#10144) 2025-09-10 16:48:10 -04:00
Tom Moor a35d84976e feat: Add move commands for columns and rows (#10143)
* feat: Add move commands for columns and rows

closes #7673

* Reuse icon
2025-09-09 21:28:17 -04:00
Tom Moor 19f9245e17 fix: Table row selection logic with merged cells (#10142)
closes #10128
2025-09-09 21:28:06 -04:00
Tom Moor 1da18c3101 chore: Refactor useActionContext to use React context (#10140)
* chore: Refactor useActionContext to use React context

* Self review

* PR feedback
2025-09-09 23:22:46 +00:00
Tom Moor be194558bf chore: Restore type aware linting (#10138)
* wip

* Upgrade oxlint
2025-09-09 19:20:18 -04:00
Tom Moor b945ac8999 feat: Add additional copy link control to lightbox (#10139) 2025-09-09 11:53:56 -04:00
Tom Moor 6ec557cd20 fix: False matches on wrap-around strings (#10136)
closes #10135
2025-09-09 06:42:50 -04:00
Tom Moor 866d30638e fix: Scope incorrectly translated on API key creation (#10134) 2025-09-08 21:58:52 -04:00
Tom Moor 75df8fc18b chore: Migrate FilterOptions component to new primitives (#10127)
* chore: Migrate FilterOptions component to new primitives

* alignemnt
2025-09-08 11:58:20 +00:00
Tom Moor a44a612387 perf: Add missing indexes (#10124) 2025-09-07 20:15:49 -04:00
Tom Moor 97fc848044 fix: Input labels are misaligned on workspace setup in Chrome (#10121) 2025-09-07 16:06:41 -04:00
Tom Moor ec0e7aaba4 fix: Middle click read-only doc link in FF opens duplicate tabs (#10122)
closes #10083
2025-09-07 16:06:31 -04: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
197 changed files with 2846 additions and 1706 deletions
+2 -2
View File
@@ -27,8 +27,8 @@ export const createApiKey = createAction({
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu
name: ({ t, isMenu }) =>
isMenu
? apiKey.isExpired
? t("Delete")
: `${t("Revoke")}`
+3 -4
View File
@@ -81,8 +81,7 @@ export const createCollection = createAction({
});
export const editCollection = createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}` : t("Edit collection")),
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
@@ -107,8 +106,8 @@ export const editCollection = createActionV2({
});
export const editCollectionPermissions = createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
name: ({ t, isMenu }) =>
isMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
+11 -14
View File
@@ -384,8 +384,8 @@ export const subscribeDocument = createActionV2({
analyticsName: "Subscribe to document",
section: ActiveDocumentSection,
icon: <SubscribeIcon />,
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
if (!isContextMenu || !activeCollectionId) {
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
if (!isMenu || !activeCollectionId) {
return undefined;
}
@@ -393,8 +393,8 @@ export const subscribeDocument = createActionV2({
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
if (!isContextMenu || !activeCollectionId) {
disabled: ({ activeCollectionId, isMenu, stores }) => {
if (!isMenu || !activeCollectionId) {
return false;
}
@@ -430,8 +430,8 @@ export const unsubscribeDocument = createActionV2({
analyticsName: "Unsubscribe from document",
section: ActiveDocumentSection,
icon: <UnsubscribeIcon />,
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
if (!isContextMenu || !activeCollectionId) {
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
if (!isMenu || !activeCollectionId) {
return undefined;
}
@@ -439,8 +439,8 @@ export const unsubscribeDocument = createActionV2({
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
if (!isContextMenu || !activeCollectionId) {
disabled: ({ activeCollectionId, isMenu, stores }) => {
if (!isMenu || !activeCollectionId) {
return false;
}
@@ -571,8 +571,7 @@ export const downloadDocumentAsMarkdown = createActionV2({
});
export const downloadDocument = createActionV2WithChildren({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
analyticsName: "Download document",
section: ActiveDocumentSection,
icon: <DownloadIcon />,
@@ -678,8 +677,7 @@ export const copyDocument = createActionV2WithChildren({
});
export const duplicateDocument = createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
analyticsName: "Duplicate document",
section: ActiveDocumentSection,
icon: <DuplicateIcon />,
@@ -829,8 +827,7 @@ export const searchInDocument = createInternalLinkActionV2({
});
export const printDocument = createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
analyticsName: "Print document",
section: ActiveDocumentSection,
icon: <PrintIcon />,
+2 -2
View File
@@ -131,8 +131,8 @@ export const navigateToTemplateSettings = createAction({
});
export const navigateToNotificationSettings = createInternalLinkActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Notification settings") : t("Notifications"),
name: ({ t, isMenu }) =>
isMenu ? t("Notification settings") : t("Notifications"),
analyticsName: "Navigate to notification settings",
section: NavigationSection,
iconInContextMenu: false,
+1 -2
View File
@@ -37,8 +37,7 @@ export const changeToSystemTheme = createActionV2({
});
export const changeTheme = createActionV2WithChildren({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Appearance") : t("Change theme"),
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
icon: ({ stores }) =>
+10 -17
View File
@@ -3,12 +3,8 @@ import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction, performActionV2, resolve } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import {
Action,
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
} from "~/types";
import { Action, ActionV2Variant, ActionV2WithChildren } from "~/types";
import useActionContext from "~/hooks/useActionContext";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
@@ -17,8 +13,6 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
/** Context of action, must be provided with action */
context?: ActionContext;
/** If tooltip props are provided the button will be wrapped in a tooltip */
tooltip?: Omit<TooltipProps, "children">;
};
@@ -28,22 +22,20 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
*/
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function _ActionButton(
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) {
const actionContext = useActionContext({
isButton: true,
});
const isMounted = useIsMounted();
const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled;
if (action && !context) {
throw new Error("Context must be provided with action");
}
if (!context || !action) {
if (!actionContext || !action) {
return <button {...rest} ref={ref} />;
}
const actionContext = { ...context, isButton: true };
if (
action.visible &&
!resolve<boolean>(action.visible, actionContext) &&
@@ -53,9 +45,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
+1 -2
View File
@@ -6,7 +6,6 @@ import Flex from "~/components/Flex";
export const Action = styled(Flex)`
justify-content: center;
align-items: center;
padding: 0 0 0 12px;
height: 32px;
font-size: 15px;
flex-shrink: 0;
@@ -18,7 +17,6 @@ export const Action = styled(Flex)`
export const Separator = styled.div`
flex-shrink: 0;
margin-left: 12px;
width: 1px;
height: 28px;
background: ${s("divider")};
@@ -33,6 +31,7 @@ const Actions = styled(Flex)`
background: ${s("background")};
padding: 12px;
backdrop-filter: blur(20px);
gap: 12px;
@media print {
display: none;
+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 -1
View File
@@ -25,7 +25,7 @@ function Breadcrumb(
{ actions, highlightFirstItem, children, max = 2 }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const actionContext = useActionContext({ isContextMenu: true });
const actionContext = useActionContext({ isMenu: true });
const visibleActions = useComputed(
() =>
+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>
)}
+1 -1
View File
@@ -104,7 +104,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
function Template({ items, actions, context, showIcons, ...menu }: Props) {
const ctx = useActionContext({
isContextMenu: true,
isMenu: true,
});
const templateItems = actions
+90 -87
View File
@@ -25,7 +25,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
import useActionContext from "~/hooks/useActionContext";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { ContextMenu } from "./Menu/ContextMenu";
import useStores from "~/hooks/useStores";
@@ -94,98 +94,99 @@ function DocumentListItem(
currentContext: locationSidebarContext,
});
const actionContext = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId ? document.collectionId : undefined,
});
const contextMenuAction = useDocumentMenuAction({ document });
return (
<ContextMenu
action={contextMenuAction}
context={actionContext}
ariaLabel={t("Document options")}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
}}
>
<DocumentLink
ref={itemRef}
dir={document.dir}
role="menuitem"
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
<ContextMenu
action={contextMenuAction}
ariaLabel={t("Document options")}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
<DocumentLink
ref={itemRef}
dir={document.dir}
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Actions>
</DocumentLink>
</ContextMenu>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Actions>
</DocumentLink>
</ContextMenu>
</ActionContextProvider>
);
}
@@ -279,7 +280,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 +290,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")};
+49 -78
View File
@@ -1,22 +1,19 @@
import deburr from "lodash/deburr";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { FetchPageParams } from "~/stores/base/Store";
import Button, { Inner } from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Text from "~/components/Text";
import { useMenuState } from "~/hooks/useMenuState";
import Input, { NativeInput, Outline } from "./Input";
import PaginatedList, { PaginatedItem } from "./PaginatedList";
import { MenuProvider } from "./primitives/Menu/MenuContext";
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
interface TFilterOption extends PaginatedItem {
key: string;
label: string;
note?: string;
icon?: React.ReactNode;
}
@@ -34,19 +31,17 @@ type Props = {
const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
className,
onSelect,
showFilter,
fetchQuery,
fetchQueryOptions,
...rest
}: Props) => {
const { t } = useTranslation();
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const menu = useMenuState({
modal: false,
});
const [open, setOpen] = React.useState(false);
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
);
@@ -58,32 +53,26 @@ const FilterOptions = ({
const renderItem = React.useCallback(
(option) => (
<MenuItem
<MenuButton
key={option.key}
icon={option.icon}
label={option.label}
onClick={() => {
onSelect(option.key);
menu.hide();
setOpen(false);
}}
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon}
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
/>
),
[menu, onSelect, selectedKeys]
[onSelect, selectedKeys]
);
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
};
const handleFilter = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
},
[]
);
const filteredOptions = React.useMemo(() => {
const normalizedQuery = deburr(query.toLowerCase());
@@ -121,13 +110,13 @@ const FilterOptions = ({
switch (ev.key) {
case "Escape":
menu.hide();
setOpen(false);
break;
case "Enter":
if (filteredOptions.length === 1) {
ev.preventDefault();
onSelect(filteredOptions[0].key);
menu.hide();
setOpen(false);
}
break;
case "ArrowDown":
@@ -138,7 +127,7 @@ const FilterOptions = ({
break;
}
},
[filteredOptions, menu, onSelect]
[filteredOptions, onSelect]
);
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
@@ -150,21 +139,21 @@ const FilterOptions = ({
}, []);
React.useEffect(() => {
if (menu.visible) {
if (open) {
searchInputRef.current?.focus();
} else {
setQuery("");
}
}, [menu.visible]);
}, [open]);
const showFilterInput = showFilter || options.length > 10;
const defaultLabel = rest.defaultLabel || t("Filter options");
return (
<>
<MenuButton {...menu}>
{(props) => (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={setOpen}>
<MenuTrigger>
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
@@ -172,31 +161,31 @@ const FilterOptions = ({
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
/>
{showFilterInput && (
<SearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
autoFocus
</MenuTrigger>
<MenuContent aria-label={defaultLabel} align="start">
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
/>
)}
</ContextMenu>
</>
{showFilterInput && (
<SearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
autoFocus
/>
)}
</MenuContent>
</Menu>
</MenuProvider>
);
};
@@ -242,24 +231,6 @@ const SearchInput = styled(Input)`
}
`;
const Note = styled(Text)`
display: block;
margin: 2px 0;
line-height: 1.2em;
font-size: 14px;
font-weight: 500;
color: ${s("textTertiary")};
`;
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
&:hover ${Note} {
color: ${(props) => props.theme.white50};
}
`;
export const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
+1
View File
@@ -125,6 +125,7 @@ const Actions = styled(Flex)`
flex-basis: 0;
min-width: auto;
padding-left: 8px;
gap: 12px;
${breakpoint("tablet")`
position: unset;
+16
View File
@@ -0,0 +1,16 @@
import { ArrowIcon as ArrowRightIcon } from "outline-icons";
import styled from "styled-components";
export { ArrowIcon as ArrowRightIcon } from "outline-icons";
export const ArrowUpIcon = styled(ArrowRightIcon)`
transform: rotate(-90deg);
`;
export const ArrowDownIcon = styled(ArrowRightIcon)`
transform: rotate(90deg);
`;
export const ArrowLeftIcon = styled(ArrowRightIcon)`
transform: rotate(180deg);
`;
+39 -56
View File
@@ -12,6 +12,7 @@ import {
CloseIcon,
CrossIcon,
DownloadIcon,
LinkIcon,
NextIcon,
} from "outline-icons";
import { depths, extraArea, s } from "@shared/styles";
@@ -25,6 +26,9 @@ import Tooltip from "~/components/Tooltip";
import LoadingIndicator from "./LoadingIndicator";
import Fade from "./Fade";
import Button from "./Button";
import CopyToClipboard from "./CopyToClipboard";
import { Separator } from "./Actions";
import useSwipe from "~/hooks/useSwipe";
export enum LightboxStatus {
READY_TO_OPEN,
@@ -287,7 +291,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
// in editor
const editorImageEl = imageElements[currentImageIndex];
let to;
if (editorImageEl) {
if (editorImageEl?.isConnected) {
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
const {
top: editorImgTop,
@@ -439,6 +443,8 @@ function Lightbox({ onUpdate, activePos }: Props) {
if (animation.current?.fadeIn) {
animation.current = {
...(animation.current ?? {}),
zoomIn: undefined,
fadeIn: undefined,
startTime: undefined,
};
setStatus({
@@ -457,6 +463,8 @@ function Lightbox({ onUpdate, activePos }: Props) {
return null;
}
const src = sanitizeUrl(currentImageNode.attrs.src) ?? "";
return (
<Dialog.Root open={!!activePos}>
<Dialog.Portal>
@@ -474,6 +482,18 @@ function Lightbox({ onUpdate, activePos }: Props) {
</Dialog.Description>
</VisuallyHidden.Root>
<Actions animation={animation.current}>
<Tooltip content={t("Copy link")} placement="bottom">
<CopyToClipboard text={imgRef.current?.src ?? ""}>
<Button
tabIndex={-1}
aria-label={t("Copy link")}
size={32}
icon={<LinkIcon />}
borderOnHover
neutral
/>
</CopyToClipboard>
</Tooltip>
<Tooltip content={t("Download")} placement="bottom">
<Button
tabIndex={-1}
@@ -485,6 +505,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
neutral
/>
</Tooltip>
<Separator />
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
<Button
@@ -508,7 +529,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
)}
<Image
ref={imgRef}
src={sanitizeUrl(currentImageNode.attrs.src) ?? ""}
src={src}
alt={currentImageNode.attrs.alt ?? ""}
onLoading={() =>
setStatus({
@@ -530,7 +551,8 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
onSwipeRight={prev}
onSwipeLeft={next}
onSwipeUpOrDown={close}
onSwipeUp={close}
onSwipeDown={close}
status={status}
animation={animation.current}
/>
@@ -555,7 +577,8 @@ type ImageProps = {
onError: () => void;
onSwipeRight: () => void;
onSwipeLeft: () => void;
onSwipeUpOrDown: () => void;
onSwipeUp: () => void;
onSwipeDown: () => void;
status: Status;
animation: Animation | null;
};
@@ -569,59 +592,21 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
onError,
onSwipeRight,
onSwipeLeft,
onSwipeUpOrDown,
onSwipeUp,
onSwipeDown,
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 swipeHandlers = useSwipe({
onSwipeRight,
onSwipeLeft,
onSwipeUp,
onSwipeDown,
});
const [hidden, setHidden] = useState(
status.image === null || status.image === ImageStatus.LOADING
@@ -640,7 +625,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
}, [status.image]);
return status.image === ImageStatus.ERROR ? (
<StyledError animation={animation}>
<StyledError animation={animation} {...swipeHandlers}>
<CrossIcon size={16} /> {t("Image failed to load")}
</StyledError>
) : (
@@ -653,10 +638,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
alt={alt}
animation={animation}
onAnimationStart={() => setHidden(false)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchCancel}
{...swipeHandlers}
onError={onError}
onLoad={onLoad}
$hidden={hidden}
@@ -757,7 +739,8 @@ const Actions = styled.div<{
right: 0;
margin: 16px 12px;
display: flex;
gap: 4px;
align-items: center;
gap: 8px;
${(props) =>
props.animation === null
+6 -11
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import { ActionContext, ActionV2Variant, ActionV2WithChildren } from "~/types";
import { ActionV2Variant, ActionV2WithChildren } from "~/types";
import { toMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
@@ -12,8 +12,6 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
/** Action context to use - new context will be created if not provided */
context?: ActionContext;
/** Trigger for the menu */
children: React.ReactNode;
/** ARIA label for the menu */
@@ -25,15 +23,12 @@ type Props = {
};
export const ContextMenu = observer(
({ action, children, ariaLabel, context, onOpen, onClose }: Props) => {
({ action, children, ariaLabel, onOpen, onClose }: Props) => {
const isMobile = useMobile();
const contentRef = React.useRef<React.ElementRef<typeof MenuContent>>(null);
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
const actionContext = useActionContext({
isMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
@@ -80,7 +75,7 @@ export const ContextMenu = observer(
const content = toMenuItems(menuItems);
return (
<MenuProvider variant={"context"}>
<MenuProvider variant="context">
<Menu onOpenChange={handleOpenChange}>
<MenuTrigger aria-label={ariaLabel}>{children}</MenuTrigger>
<MenuContent
+4 -11
View File
@@ -14,7 +14,6 @@ import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import {
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
MenuItem,
@@ -27,8 +26,6 @@ import { useComputed } from "~/hooks/useComputed";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
/** Action context to use - new context will be created if not provided */
context?: ActionContext;
/** Trigger for the menu */
children: React.ReactNode;
/** Alignment w.r.t trigger - defaults to start */
@@ -49,7 +46,6 @@ export const DropdownMenu = observer(
(
{
action,
context,
children,
align = "start",
ariaLabel,
@@ -64,12 +60,9 @@ export const DropdownMenu = observer(
const isMobile = useMobile();
const contentRef =
React.useRef<React.ElementRef<typeof MenuContent>>(null);
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
const actionContext = useActionContext({
isMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
@@ -126,7 +119,7 @@ export const DropdownMenu = observer(
const content = toMenuItems(menuItems);
return (
<MenuProvider variant={"dropdown"}>
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={handleOpenChange}>
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
@@ -6,7 +6,6 @@ import styled from "styled-components";
import { s, hover } from "@shared/styles";
import Notification from "~/models/Notification";
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import NotificationMenu from "~/menus/NotificationMenu";
import Desktop from "~/utils/Desktop";
@@ -32,7 +31,6 @@ function Notifications(
{ onRequestClose }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const context = useActionContext();
const { notifications } = useStores();
const { t } = useTranslation();
const isEmpty = notifications.active.length === 0;
@@ -67,7 +65,10 @@ function Notifications(
<Flex gap={8}>
{notifications.approximateUnreadCount > 0 && (
<Tooltip content={t("Mark all as read")}>
<Button action={markNotificationsAsRead} context={context}>
<Button
action={markNotificationsAsRead}
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
/>
@@ -6,7 +6,6 @@ import { Inner } from "~/components/Button";
import ButtonSmall from "~/components/ButtonSmall";
import Fade from "~/components/Fade";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import useActionContext from "~/hooks/useActionContext";
import { Action, Permission } from "~/types";
export function PermissionAction({
@@ -21,7 +20,6 @@ export function PermissionAction({
onChange: (permission: CollectionPermission | DocumentPermission) => void;
}) {
const { t } = useTranslation();
const context = useActionContext();
return (
<Fade timing="150ms" key="invite">
@@ -31,9 +29,7 @@ export function PermissionAction({
onChange={onChange}
value={permission}
/>
<ButtonSmall action={action} context={context}>
{t("Add")}
</ButtonSmall>
<ButtonSmall action={action}>{t("Add")}</ButtonSmall>
</Flex>
</Fade>
);
+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;
}
@@ -12,7 +12,7 @@ type Props = {
function SidebarAction({ action, ...rest }: Props) {
const context = useActionContext({
isContextMenu: false,
isMenu: false,
isCommandBar: false,
activeCollectionId: undefined,
activeDocumentId: undefined,
@@ -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;
+36 -34
View File
@@ -10,7 +10,7 @@ import {
unstarCollection,
} from "~/actions/definitions/collections";
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import { ActionContextProvider } from "~/hooks/useActionContext";
import NudeButton from "./NudeButton";
type Props = {
@@ -27,10 +27,6 @@ type Props = {
function Star({ size, document, collection, color, ...rest }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const context = useActionContext({
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
});
const target = document || collection;
@@ -39,37 +35,43 @@ function Star({ size, document, collection, color, ...rest }: Props) {
}
return (
<NudeButton
context={context}
hideOnActionDisabled
tooltip={{
content: target.isStarred ? t("Unstar document") : t("Star document"),
delay: 500,
<ActionContextProvider
value={{
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
}}
action={
collection
? collection.isStarred
? unstarCollection
: starCollection
: document
? document.isStarred
? unstarDocument
: starDocument
: undefined
}
size={size}
{...rest}
>
{target.isStarred ? (
<AnimatedStar size={size} color={theme.yellow} />
) : (
<AnimatedStar
size={size}
color={color ?? theme.textTertiary}
as={UnstarredIcon}
/>
)}
</NudeButton>
<NudeButton
hideOnActionDisabled
tooltip={{
content: target.isStarred ? t("Unstar document") : t("Star document"),
delay: 500,
}}
action={
collection
? collection.isStarred
? unstarCollection
: starCollection
: document
? document.isStarred
? unstarDocument
: starDocument
: undefined
}
size={size}
{...rest}
>
{target.isStarred ? (
<AnimatedStar size={size} color={theme.yellow} />
) : (
<AnimatedStar
size={size}
color={color ?? theme.textTertiary}
as={UnstarredIcon}
/>
)}
</NudeButton>
</ActionContextProvider>
);
}
+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>
+13 -5
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;
@@ -31,8 +33,8 @@ export function MediaDimension() {
height = node.attrs.height as number;
const [localDimension, setLocalDimension] = useState<Dimension>(() => ({
width: String(width),
height: String(height),
width: width ? String(width) : "",
height: height ? String(height) : "",
changed: "none",
}));
const [error, setError] = useState<{ width: boolean; height: boolean }>({
@@ -57,8 +59,8 @@ export function MediaDimension() {
const reset = useCallback(() => {
setLocalDimension({
width: String(width),
height: String(height),
width: width ? String(width) : "",
height: height ? String(height) : "",
changed: "none",
});
setError({ width: false, height: false });
@@ -205,6 +207,9 @@ export function MediaDimension() {
return (
<StyledFlex ref={ref} align="center">
<StyledInput
label={t("Image width")}
labelHidden
placeholder={t("Width")}
value={localDimension.width}
onChange={handleChange("width")}
onBlur={handleBlur}
@@ -212,9 +217,12 @@ export function MediaDimension() {
$error={error.width}
/>
<Text size="xsmall" type="tertiary">
x
×
</Text>
<StyledInput
label={t("Image height")}
labelHidden
placeholder={t("Height")}
value={localDimension.height}
onChange={handleChange("height")}
onBlur={handleBlur}
+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}
+6
View File
@@ -291,6 +291,12 @@ export default class FindAndReplaceExtension extends Extension {
const from = type === "inline" ? pos + i : pos;
const to = from + (type === "inline" ? m[0].length : node.nodeSize);
// Prevent wrap around matches when the regex matches at the end of the deburred
// string and continues matching at the start of the original string
if (i + this.searchTerm.length > text.length) {
continue;
}
// Check if already exists in results, possible due to duplicated
// search string on L257
if (this.results.some((r) => r.from === from && r.to === to)) {
+15 -10
View File
@@ -459,6 +459,7 @@ export default class PasteHandler extends Extension {
const { view, schema } = this.editor;
const { state } = view;
const { from } = state.selection;
let tr = state.tr;
const links: string[] = [];
let allLinks = true;
@@ -480,22 +481,26 @@ export default class PasteHandler extends Extension {
return false;
});
if (!allLinks || !links.length) {
return;
}
const showPasteMenu = allLinks && links.length;
const placeholderId = links[0];
const to = from + listNode.nodeSize;
// it's possible that the links can be converted to mentions
if (showPasteMenu) {
const placeholderId = links[0];
const to = from + listNode.nodeSize;
const transaction = state.tr
.replaceSelectionWith(listNode)
.setMeta(this.key, {
tr = state.tr.replaceSelectionWith(listNode).setMeta(this.key, {
add: { from, to, id: placeholderId },
});
} else {
// Paste as simple list
tr = tr.replaceSelectionWith(listNode, this.shiftKey);
}
view.dispatch(transaction);
view.dispatch(tr);
this.showPasteMenu(links);
if (showPasteMenu) {
this.showPasteMenu(links);
}
}
private placeholderId = () =>
+1
View File
@@ -498,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;
}
+25 -13
View File
@@ -5,15 +5,15 @@ import {
AlignCenterIcon,
InsertLeftIcon,
InsertRightIcon,
ArrowIcon,
MoreIcon,
TableHeaderColumnIcon,
TableMergeCellsIcon,
TableSplitCellsIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import styled from "styled-components";
import { CellSelection, selectedRect } from "prosemirror-tables";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
isMergedCellSelection,
@@ -21,6 +21,7 @@ import {
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
export default function tableColMenuItems(
state: EditorState,
@@ -34,6 +35,8 @@ export default function tableColMenuItems(
return [];
}
const tableMap = selectedRect(state);
return [
{
name: "setColumnAttr",
@@ -75,13 +78,13 @@ export default function tableColMenuItems(
name: "sortTable",
tooltip: dictionary.sortAsc,
attrs: { index, direction: "asc" },
icon: <SortAscIcon />,
icon: <AlphabeticalSortIcon />,
},
{
name: "sortTable",
tooltip: dictionary.sortDesc,
attrs: { index, direction: "desc" },
icon: <SortDescIcon />,
icon: <AlphabeticalReverseSortIcon />,
},
{
name: "separator",
@@ -107,6 +110,23 @@ export default function tableColMenuItems(
icon: <InsertRightIcon />,
attrs: { index },
},
{
name: "moveTableColumn",
label: dictionary.moveColumnLeft,
icon: <ArrowLeftIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableColumn",
label: dictionary.moveColumnRight,
icon: <ArrowRightIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.width - 1,
},
{
name: "separator",
},
{
name: "mergeCells",
label: dictionary.mergeCells,
@@ -132,11 +152,3 @@ export default function tableColMenuItems(
},
];
}
const SortAscIcon = styled(ArrowIcon)`
transform: rotate(-90deg);
`;
const SortDescIcon = styled(ArrowIcon)`
transform: rotate(90deg);
`;
+22 -1
View File
@@ -8,13 +8,14 @@ import {
TableMergeCellsIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import { CellSelection, selectedRect } from "prosemirror-tables";
import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
export default function tableRowMenuItems(
state: EditorState,
@@ -22,10 +23,13 @@ export default function tableRowMenuItems(
dictionary: Dictionary
): MenuItem[] {
const { selection } = state;
if (!(selection instanceof CellSelection)) {
return [];
}
const tableMap = selectedRect(state);
return [
{
icon: <MoreIcon />,
@@ -48,6 +52,23 @@ export default function tableRowMenuItems(
icon: <InsertBelowIcon />,
attrs: { index },
},
{
name: "moveTableRow",
label: dictionary.moveRowUp,
icon: <ArrowUpIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableRow",
label: dictionary.moveRowDown,
icon: <ArrowDownIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.height - 1,
},
{
name: "separator",
},
{
name: "mergeCells",
label: dictionary.mergeCells,
-33
View File
@@ -1,33 +0,0 @@
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import useStores from "~/hooks/useStores";
import { ActionContext } from "~/types";
/**
* Hook to get the current action context, an object that is passed to all
* action definitions.
*
* @param overrides Overides of the default action context.
* @returns The current action context.
*/
export default function useActionContext(
overrides?: Partial<ActionContext>
): ActionContext {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
return {
isContextMenu: false,
isCommandBar: false,
isButton: false,
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId,
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
...overrides,
location,
stores,
t,
};
}
+102
View File
@@ -0,0 +1,102 @@
import { observer } from "mobx-react";
import React, { createContext, useContext, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import useStores from "~/hooks/useStores";
import { ActionContext as ActionContextType } from "~/types";
export const ActionContext = createContext<ActionContextType | undefined>(
undefined
);
type ActionContextProviderProps = {
children: ReactNode;
value?: Partial<ActionContextType>;
};
/**
* Provider that allows overriding the action context at different levels
* of the React component tree.
*
* @example
* ```tsx
* // Override context for a command bar
* <ActionContextProvider value={{ isCommandBar: true }}>
* <CommandBar />
* </ActionContextProvider>
*
* // Nested overrides
* <ActionContextProvider value={{ activeCollectionId: "collection-1" }}>
* <CollectionView />
* <ActionContextProvider value={{ activeDocumentId: "doc-1" }}>
* <DocumentView />
* </ActionContextProvider>
* </ActionContextProvider>
* ```
*/
export const ActionContextProvider = observer(function ActionContextProvider_({
children,
value = {},
}: ActionContextProviderProps) {
const parentContext = useContext(ActionContext);
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
// Create the base context if we don't have a parent context
const baseContext: ActionContextType = parentContext ?? {
isMenu: false,
isCommandBar: false,
isButton: false,
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
location,
stores,
t,
};
// Merge the parent context with the provided overrides
const contextValue: ActionContextType = {
...baseContext,
...value,
};
return (
<ActionContext.Provider value={contextValue}>
{children}
</ActionContext.Provider>
);
});
/**
* Hook to get the current action context, an object that is passed to all
* action definitions.
*
* This hook respects the ActionContextProvider hierarchy, merging values from:
* 1. Default system context (stores, location, translation)
* 2. Parent ActionContextProvider values (if any)
* 3. Local overrides parameter (highest priority)
*
* @param overrides Optional overrides of the action context. These will be
* merged with any provider context and take highest priority.
* @returns The current action context with all overrides applied.
*/
export default function useActionContext(
overrides?: Partial<ActionContextType>
): ActionContextType {
const contextValue = useContext(ActionContext);
// If we have a context value from a provider, use it as the base
if (contextValue) {
return {
...contextValue,
...overrides,
};
}
throw new Error(
"useActionContext must be used within an ActionContextProvider"
);
}
+9 -5
View File
@@ -11,10 +11,14 @@ export default function useDictionary() {
return useMemo(
() => ({
addColumnAfter: t("Add column after"),
addColumnBefore: t("Add column before"),
addRowAfter: t("Add row after"),
addRowBefore: t("Add row before"),
addColumnAfter: t("Insert after"),
addColumnBefore: t("Insert before"),
moveRowUp: t("Move up"),
moveRowDown: t("Move down"),
moveColumnLeft: t("Move left"),
moveColumnRight: t("Move right"),
addRowAfter: t("Insert after"),
addRowBefore: t("Insert before"),
alignCenter: t("Align center"),
alignLeft: t("Align left"),
alignRight: t("Align right"),
@@ -35,7 +39,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"),
+7
View File
@@ -5,6 +5,7 @@ import { isDocumentUrl, isInternalUrl } from "@shared/utils/urls";
import { sharedModelPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
import useStores from "./useStores";
import { isFirefox } from "@shared/utils/browser";
type Params = {
/** The share ID of the document being viewed, if any */
@@ -78,6 +79,12 @@ export default function useEditorClickHandlers({ shareId }: Params) {
window.open(navigateTo, "_blank");
}
} else {
// Middle-click events in Firefox are not prevented in the same way as other browsers
// so we need to explicitly return here to prevent two tabs from being opened when
// middle-clicking a link (#10083).
if (event?.button === 1 && isFirefox()) {
return;
}
window.open(href, "_blank");
}
},
+76
View File
@@ -0,0 +1,76 @@
import { isNumber } from "lodash";
import { useRef } from "react";
type Props = {
onSwipeRight: () => void;
onSwipeLeft: () => void;
onSwipeUp: () => void;
onSwipeDown: () => void;
};
export default function useSwipe({
onSwipeRight,
onSwipeLeft,
onSwipeUp,
onSwipeDown,
}: Props) {
const touchXStart = useRef<number>();
const touchXEnd = useRef<number>();
const touchYStart = useRef<number>();
const touchYEnd = useRef<number>();
const resetTouchPoints = () => {
touchXStart.current = undefined;
touchXEnd.current = undefined;
touchYStart.current = undefined;
touchYEnd.current = undefined;
};
const onTouchStart = (e: React.TouchEvent<HTMLImageElement>) => {
touchXStart.current = e.changedTouches[0].screenX;
touchYStart.current = e.changedTouches[0].screenY;
};
const onTouchMove = (e: React.TouchEvent<HTMLImageElement>) => {
if (isNumber(touchXStart.current) && isNumber(touchYStart.current)) {
touchXEnd.current = e.changedTouches[0].screenX;
touchYEnd.current = e.changedTouches[0].screenY;
const dx = touchXEnd.current - touchXStart.current;
const dy = touchYEnd.current - touchYStart.current;
const swipeRight = dx > 0 && Math.abs(dy) < Math.abs(dx);
if (swipeRight) {
resetTouchPoints();
return onSwipeRight();
}
const swipeLeft = dx < 0 && Math.abs(dy) < Math.abs(dx);
if (swipeLeft) {
resetTouchPoints();
return onSwipeLeft();
}
const swipeDown = dy > 0 && Math.abs(dy) > Math.abs(dx);
if (swipeDown) {
resetTouchPoints();
return onSwipeDown();
}
const swipeUp = dy < 0 && Math.abs(dy) > Math.abs(dx);
if (swipeUp) {
resetTouchPoints();
return onSwipeUp();
}
}
};
const onTouchCancel = () => {
resetTouchPoints();
};
return {
onTouchStart,
onTouchMove,
onTouchCancel,
};
}
+22 -19
View File
@@ -25,6 +25,7 @@ import Logger from "./utils/Logger";
import { PluginManager } from "./utils/PluginManager";
import history from "./utils/history";
import { initSentry } from "./utils/sentry";
import { ActionContextProvider } from "./hooks/useActionContext";
// Load plugins as soon as possible
void PluginManager.loadPlugins();
@@ -53,25 +54,27 @@ if (element) {
<Provider {...stores}>
<Analytics>
<Theme>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<PageScroll>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
<Dialogs />
<Desktop />
</PageScroll>
</Router>
</LazyMotion>
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
<Router history={history}>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<ActionContextProvider>
<PageScroll>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
<Dialogs />
<Desktop />
</PageScroll>
</ActionContextProvider>
</LazyMotion>
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
</Router>
</Theme>
</Analytics>
</Provider>
+3 -9
View File
@@ -36,7 +36,7 @@ import {
createDocument,
exportCollection,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import { ActionContextProvider } from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
@@ -130,11 +130,6 @@ function CollectionMenu({
);
const can = usePolicy(collection);
const context = useActionContext({
isContextMenu: true,
activeCollectionId: collection.id,
});
const sortAlphabetical = collection.sort.field === "title";
const sortDir = collection.sort.direction;
@@ -228,7 +223,7 @@ function CollectionMenu({
const rootAction = useMenuAction(actions);
return (
<>
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<VisuallyHidden.Root>
<label>
{t("Import document")}
@@ -244,7 +239,6 @@ function CollectionMenu({
</VisuallyHidden.Root>
<DropdownMenu
action={rootAction}
context={context}
align={align}
onOpen={onOpen}
onClose={onClose}
@@ -255,7 +249,7 @@ function CollectionMenu({
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
</>
</ActionContextProvider>
);
}
+23 -21
View File
@@ -10,7 +10,7 @@ import Document from "~/models/Document";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import Switch from "~/components/Switch";
import useActionContext from "~/hooks/useActionContext";
import { ActionContextProvider } from "~/hooks/useActionContext";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
@@ -132,13 +132,6 @@ function DocumentMenu({
onSelectTemplate,
});
const context = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId ? document.collectionId : undefined,
});
const toggleSwitches = React.useMemo<React.ReactNode>(() => {
if (!can.update || !(showDisplayOptions || showToggleEmbeds)) {
return;
@@ -203,20 +196,29 @@ function DocumentMenu({
]);
return (
<DropdownMenu
action={rootAction}
context={context}
align={align}
onOpen={onOpen}
onClose={onClose}
ariaLabel={t("Document options")}
append={toggleSwitches}
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
}}
>
<OverflowMenuButton
neutral={neutral}
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
<DropdownMenu
action={rootAction}
align={align}
onOpen={onOpen}
onClose={onClose}
ariaLabel={t("Document options")}
append={toggleSwitches}
>
<OverflowMenuButton
neutral={neutral}
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
</ActionContextProvider>
);
}
+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>
+10 -14
View File
@@ -8,9 +8,9 @@ import {
copyLinkToRevision,
restoreRevision,
} from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import { useMemo } from "react";
import { useMenuAction } from "~/hooks/useMenuAction";
import { ActionContextProvider } from "~/hooks/useActionContext";
type Props = {
document: Document;
@@ -19,11 +19,6 @@ type Props = {
function RevisionMenu({ document }: Props) {
const { t } = useTranslation();
const context = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
});
const actions = useMemo(
() => [restoreRevision, ActionV2Separator, copyLinkToRevision],
[]
@@ -32,14 +27,15 @@ function RevisionMenu({ document }: Props) {
const rootAction = useMenuAction(actions);
return (
<DropdownMenu
action={rootAction}
context={context}
align="end"
ariaLabel={t("Revision options")}
>
<OverflowMenuButton />
</DropdownMenu>
<ActionContextProvider value={{ activeDocumentId: document.id }}>
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Revision options")}
>
<OverflowMenuButton />
</DropdownMenu>
</ActionContextProvider>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ type Props = {
const TeamMenu: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const context = useActionContext({ isContextMenu: true });
const context = useActionContext({ isMenu: true });
// NOTE: it's useful to memoize on the team id and session because the action
// menu is not cached at all.
+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.
@@ -8,7 +8,6 @@ import { AvatarSize } from "~/components/Avatar";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
@@ -24,7 +23,6 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
const { t } = useTranslation();
const { memberships, groupMemberships, users } = useStores();
const collectionUsers = users.inCollection(collection.id);
const context = useActionContext();
const isMobile = useMobile();
useEffect(() => {
@@ -72,7 +70,6 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
return (
<NudeButton
context={context}
tooltip={{
content:
usersCount > 0
@@ -24,7 +24,6 @@ import Text from "~/components/Text";
import Time from "~/components/Time";
import Tooltip from "~/components/Tooltip";
import { resolveCommentFactory } from "~/actions/definitions/comments";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
@@ -312,14 +311,12 @@ const ResolveButton = ({
comment: Comment;
onUpdate: (attrs: { resolved: boolean }) => void;
}) => {
const context = useActionContext();
const { t } = useTranslation();
return (
<Tooltip content={t("Mark as resolved")} placement="top">
<Action
as={NudeButton}
context={context}
action={resolveCommentFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
+1 -5
View File
@@ -1,6 +1,5 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import { ArrowIcon } from "outline-icons";
import { useRef, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useRouteMatch } from "react-router-dom";
@@ -24,6 +23,7 @@ import CommentForm from "./CommentForm";
import CommentSortMenu from "./CommentSortMenu";
import CommentThread from "./CommentThread";
import Sidebar from "./SidebarLayout";
import { ArrowDownIcon } from "~/components/Icons/ArrowIcon";
function Comments() {
const { ui, comments, documents } = useStores();
@@ -230,10 +230,6 @@ const JumpToRecent = styled(ButtonSmall)`
}
`;
const ArrowDownIcon = styled(ArrowIcon)`
transform: rotate(90deg);
`;
const NewCommentForm = styled(CommentForm)<{ dir?: "ltr" | "rtl" }>`
padding: 12px;
padding-right: ${(props) => (props.dir !== "rtl" ? "18px" : "12px")};
@@ -11,12 +11,12 @@ import Revision from "~/models/Revision";
import { openDocumentInsights } from "~/actions/definitions/documents";
import DocumentMeta from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import NudeButton from "~/components/NudeButton";
type Props = {
/* The document to display meta data for */
@@ -36,9 +36,6 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const onlyYou = totalViewers === 1 && documentViews[0].userId;
const viewsLoadedOnMount = useRef(totalViewers > 0);
const can = usePolicy(document);
const actionContext = useActionContext({
activeDocumentId: document.id,
});
const Wrapper = viewsLoadedOnMount.current ? Fragment : Fade;
@@ -70,9 +67,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
!document.isTemplate ? (
<Wrapper>
&nbsp;&nbsp;
<InsightsButton
onClick={() => openDocumentInsights.perform(actionContext)}
>
<InsightsButton action={openDocumentInsights}>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
@@ -91,7 +86,7 @@ const CommentLink = styled(Link)`
align-items: center;
`;
const InsightsButton = styled.button`
const InsightsButton = styled(NudeButton)`
background: none;
border: none;
padding: 0;
@@ -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])}
>
+2 -13
View File
@@ -23,7 +23,6 @@ import Tooltip from "~/components/Tooltip";
import { publishDocument } from "~/actions/definitions/documents";
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
import { restoreRevision } from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useEditingFocus from "~/hooks/useEditingFocus";
@@ -109,10 +108,6 @@ function DocumentHeader({
}
}, [ui, isShare]);
const context = useActionContext({
activeDocumentId: document?.id,
});
const can = usePolicy(document);
const { isDeleted, isTemplate } = document;
const isTemplateEditable = can.update && isTemplate;
@@ -134,6 +129,7 @@ function DocumentHeader({
placement="bottom"
>
<Button
aria-label={t("Show contents")}
onClick={handleToggle}
icon={<TableOfContentsIcon />}
borderOnHover
@@ -278,7 +274,6 @@ function DocumentHeader({
placement="bottom"
>
<Button
context={context}
action={isTemplate ? navigateToTemplateSettings : undefined}
onClick={isTemplate ? undefined : handleSave}
disabled={savingIsDisabled}
@@ -307,12 +302,7 @@ function DocumentHeader({
{revision && revision.createdAt !== document.updatedAt && (
<Action>
<Tooltip content={t("Restore version")} placement="bottom">
<Button
action={restoreRevision}
context={context}
neutral
hideOnActionDisabled
>
<Button action={restoreRevision} neutral hideOnActionDisabled>
{t("Restore")}
</Button>
</Tooltip>
@@ -322,7 +312,6 @@ function DocumentHeader({
<Action>
<Button
action={publishDocument}
context={context}
disabled={publishingIsDisabled}
hideOnActionDisabled
hideIcon
@@ -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 -3
View File
@@ -6,12 +6,10 @@ import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import { navigateToHome } from "~/actions/definitions/navigation";
import useActionContext from "~/hooks/useActionContext";
const Error403 = () => {
const { t } = useTranslation();
const history = useHistory();
const context = useActionContext();
return (
<Scene title={t("No access to this doc")}>
@@ -24,7 +22,7 @@ const Error403 = () => {
{t("Please request access from the document owner.")}
</Empty>
<Flex gap={8}>
<Button action={navigateToHome} context={context} hideIcon>
<Button action={navigateToHome} hideIcon>
{t("Home")}
</Button>
<Button onClick={history.goBack} neutral>
+2 -4
View File
@@ -8,11 +8,9 @@ import {
navigateToHome,
navigateToSearch,
} from "~/actions/definitions/navigation";
import useActionContext from "~/hooks/useActionContext";
const Error404 = () => {
const { t } = useTranslation();
const context = useActionContext();
return (
<Scene title={t("Not found")}>
@@ -25,10 +23,10 @@ const Error404 = () => {
</Trans>
</Empty>
<Flex gap={8}>
<Button action={navigateToHome} context={context} neutral hideIcon>
<Button action={navigateToHome} neutral hideIcon>
{t("Home")}
</Button>
<Button action={navigateToSearch} context={context} neutral>
<Button action={navigateToSearch} neutral>
{t("Search")}
</Button>
</Flex>
+1 -3
View File
@@ -6,11 +6,9 @@ import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import { navigateToHome } from "~/actions/definitions/navigation";
import useActionContext from "~/hooks/useActionContext";
const ErrorUnknown = () => {
const { t } = useTranslation();
const context = useActionContext();
return (
<CenteredContent>
@@ -24,7 +22,7 @@ const ErrorUnknown = () => {
</Trans>
</Empty>
<Flex gap={8}>
<Button action={navigateToHome} context={context} neutral hideIcon>
<Button action={navigateToHome} neutral hideIcon>
{t("Home")}
</Button>
</Flex>
@@ -33,7 +33,7 @@ const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
"Setup your workspace by providing a name and details for admin login. You can change these later."
)}
</Content>
<Flex column gap={12} style={{ width: "100%" }}>
<Inputs column gap={12}>
<Input
name="teamName"
type="text"
@@ -57,7 +57,7 @@ const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
required
flex
/>
</Flex>
</Inputs>
<ButtonLarge type="submit" fullwidth>
{t("Continue")}
</ButtonLarge>
@@ -66,6 +66,11 @@ const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
);
};
const Inputs = styled(Flex)`
width: 100%;
text-align: left;
`;
const StyledHeading = styled(Heading)`
margin: 0;
`;
-3
View File
@@ -11,7 +11,6 @@ import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { createApiKey } from "~/actions/definitions/apiKeys";
import env from "~/env";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
@@ -25,7 +24,6 @@ function APIAndApps() {
const { t } = useTranslation();
const { apiKeys, oauthAuthentications } = useStores();
const can = usePolicy(team);
const context = useActionContext();
const appName = env.APP_NAME;
return (
@@ -40,7 +38,6 @@ function APIAndApps() {
type="submit"
value={`${t("New API key")}`}
action={createApiKey}
context={context}
/>
</Action>
)}
-3
View File
@@ -9,7 +9,6 @@ import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { createApiKey } from "~/actions/definitions/apiKeys";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -20,7 +19,6 @@ function ApiKeys() {
const { t } = useTranslation();
const { apiKeys } = useStores();
const can = usePolicy(team);
const context = useActionContext();
return (
<Scene
@@ -34,7 +32,6 @@ function ApiKeys() {
type="submit"
value={`${t("New API key")}`}
action={createApiKey}
context={context}
/>
</Action>
)}
+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={{
-3
View File
@@ -9,7 +9,6 @@ import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { createOAuthClient } from "~/actions/definitions/oauthClients";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -20,7 +19,6 @@ function Applications() {
const { t } = useTranslation();
const { oauthClients } = useStores();
const can = usePolicy(team);
const context = useActionContext();
return (
<Scene
@@ -34,7 +32,6 @@ function Applications() {
type="submit"
value={`${t("New App")}`}
action={createOAuthClient}
context={context}
/>
</Action>
)}
+1
View File
@@ -193,6 +193,7 @@ function Details() {
)}
>
<ImageInput
alt={t("Workspace logo")}
onSuccess={handleAvatarChange}
onError={handleAvatarError}
model={team}
-3
View File
@@ -16,7 +16,6 @@ import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { inviteUser } from "~/actions/definitions/users";
import env from "~/env";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
@@ -32,7 +31,6 @@ function Members() {
const location = useLocation();
const history = useHistory();
const team = useCurrentTeam();
const context = useActionContext();
const { users } = useStores();
const { t } = useTranslation();
const params = useQuery();
@@ -128,7 +126,6 @@ function Members() {
data-event-category="invite"
data-event-action="peoplePage"
action={inviteUser}
context={context}
icon={<PlusIcon />}
>
{t("Invite people")}
+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 />
+6 -1
View File
@@ -222,6 +222,8 @@ function SharedScene() {
);
}
const hasSidebar = !!share.tree?.children.length;
return (
<>
<Helmet>
@@ -238,7 +240,10 @@ function SharedScene() {
<TeamContext.Provider value={team}>
<ThemeProvider theme={theme}>
<DocumentContextProvider>
<Layout title={pageTitle} sidebar={<Sidebar share={share} />}>
<Layout
title={pageTitle}
sidebar={hasSidebar ? <Sidebar share={share} /> : null}
>
{model instanceof Document ? (
<DocumentScene
document={model}
+2 -7
View File
@@ -8,24 +8,19 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading";
import { permanentlyDeleteDocumentsInTrash } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
function Trash() {
const { t } = useTranslation();
const { documents } = useStores();
const context = useActionContext();
return (
<Scene
icon={<TrashIcon />}
title={t("Trash")}
actions={
documents.deleted.length > 0 && (
<Button
neutral
action={permanentlyDeleteDocumentsInTrash}
context={context}
>
<Button neutral action={permanentlyDeleteDocumentsInTrash}>
{t("Empty trash")}
</Button>
)
+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) {
+1 -1
View File
@@ -92,7 +92,7 @@ export type MenuItem =
| MenuGroup;
export type ActionContext = {
isContextMenu: boolean;
isMenu: boolean;
isCommandBar: boolean;
isButton: boolean;
sidebarContext?: SidebarContextType;
+1 -1
View File
@@ -3,7 +3,7 @@ export default {
// TypeScript files
"**/*.[tj]s?(x)": [
(f) => `prettier --write ${f.join(" ")}`,
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix`),
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix --type-aware`),
() => `yarn build:i18n`,
() => "git add shared/i18n/locales/en_US/translation.json",
],
+5 -5
View File
@@ -13,7 +13,7 @@
"dev": "NODE_ENV=development yarn concurrently -n api,collaboration -c \"blue,magenta\" \"node --inspect=0.0.0.0 build/server/index.js --services=cron,collaboration,websockets,admin,web,worker\"",
"dev:backend": "NODE_ENV=development nodemon --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --ignore *.test.ts --ignore data/ --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations",
"dev:watch": "NODE_ENV=development yarn concurrently -n backend,frontend \"yarn dev:backend\" \"yarn vite:dev\"",
"lint": "oxlint app server shared plugins",
"lint": "oxlint --type-aware app server shared plugins",
"lint:changed": "git diff --name-only --diff-filter=ACMRTUXB | grep -E '\\.(js|jsx|ts|tsx)$' | xargs -r oxlint",
"format": "prettier --write .",
"format:check": "prettier --check .",
@@ -84,7 +84,7 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@linear/sdk": "^39.2.1",
"@linear/sdk": "^58.1.0",
"@node-oauth/oauth2-server": "^5.2.0",
"@notionhq/client": "^2.3.0",
"@octokit/auth-app": "^6.1.4",
@@ -203,7 +203,7 @@
"prosemirror-model": "^1.25.2",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1",
"prosemirror-tables": "^1.8.1",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.40.1",
"proxy-from-env": "^1.1.0",
@@ -280,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",
@@ -360,7 +359,8 @@
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^13.3.0",
"nodemon": "^3.1.10",
"oxlint": "^1.7.0",
"oxlint": "1.11.2",
"oxlint-tsgolint": "^0.1.6",
"postinstall-postinstall": "^2.1.0",
"prettier": "^3.6.2",
"react-refresh": "^0.17.0",
@@ -15,7 +15,6 @@ import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { disconnectAnalyticsIntegrationFactory } from "~/actions/definitions/integrations";
import useActionContext from "~/hooks/useActionContext";
import Flex from "~/components/Flex";
import styled from "styled-components";
@@ -26,7 +25,6 @@ type FormData = {
function GoogleAnalytics() {
const { integrations } = useStores();
const { t } = useTranslation();
const context = useActionContext();
const integration = find(integrations.orderedData, {
type: IntegrationType.Analytics,
@@ -108,7 +106,6 @@ function GoogleAnalytics() {
<Button
action={disconnectAnalyticsIntegrationFactory(integration)}
context={context}
disabled={formState.isSubmitting}
neutral
hideIcon
+5
View File
@@ -11,6 +11,7 @@ import { Linear } from "../linear";
import UploadLinearWorkspaceLogoTask from "../tasks/UploadLinearWorkspaceLogoTask";
import * as T from "./schema";
import { LinearUtils } from "plugins/linear/shared/LinearUtils";
import { addSeconds } from "date-fns";
const router = new Router();
@@ -52,6 +53,10 @@ router.get(
userId: user.id,
teamId: user.teamId,
token: oauth.access_token,
refreshToken: oauth.refresh_token,
expiresAt: oauth.expires_in
? addSeconds(Date.now(), oauth.expires_in)
: undefined,
scopes: oauth.scope.split(" "),
},
{ transaction }
+38 -4
View File
@@ -12,9 +12,13 @@ import User from "@server/models/User";
import { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
import { LinearUtils } from "../shared/LinearUtils";
import env from "./env";
import { Minute } from "@shared/utils/time";
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
// Linear is in the process of switching to short-lived refresh tokens. Some apps
// may not return a refresh token before April 2026, hence it's optional here.
refresh_token: z.string().optional(),
token_type: z.string(),
expires_in: z.number(),
scope: z.string(),
@@ -51,6 +55,33 @@ export class Linear {
return AccessTokenResponseSchema.parse(await res.json());
}
static async refreshToken(refreshToken: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const body = new URLSearchParams();
body.set("refresh_token", refreshToken);
body.set("client_id", env.LINEAR_CLIENT_ID!);
body.set("client_secret", env.LINEAR_CLIENT_SECRET!);
body.set("grant_type", "refresh_token");
const res = await fetch(LinearUtils.tokenUrl, {
method: "POST",
headers,
body,
});
if (res.status !== 200) {
throw new Error(
`Error while refreshing access token from Linear; status: ${res.status}`
);
}
return AccessTokenResponseSchema.parse(await res.json());
}
static async revokeAccess(accessToken: string) {
const headers = {
Authorization: `Bearer ${accessToken}`,
@@ -93,9 +124,12 @@ export class Linear {
}
try {
const client = new LinearClient({
accessToken: integration.authentication.token,
});
const accessToken = await integration.authentication.refreshTokenIfNeeded(
async (refreshToken: string) => Linear.refreshToken(refreshToken),
5 * Minute.ms
);
const client = new LinearClient({ accessToken });
const issue = await client.issue(resource.id);
if (!issue) {
@@ -193,7 +227,7 @@ export class Linear {
* Parses a given URL and returns resource identifiers for Linear specific URLs
*
* @param url URL to parse
* @returns {object} Containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
* @returns An object containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
*/
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
-3
View File
@@ -15,7 +15,6 @@ import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
import { disconnectAnalyticsIntegrationFactory } from "~/actions/definitions/integrations";
import useActionContext from "~/hooks/useActionContext";
import Flex from "~/components/Flex";
import styled from "styled-components";
@@ -27,7 +26,6 @@ type FormData = {
function Matomo() {
const { integrations } = useStores();
const { t } = useTranslation();
const context = useActionContext();
const integration = find(integrations.orderedData, {
type: IntegrationType.Analytics,
@@ -129,7 +127,6 @@ function Matomo() {
<Button
action={disconnectAnalyticsIntegrationFactory(integration)}
context={context}
disabled={formState.isSubmitting}
neutral
hideIcon
+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(
-3
View File
@@ -16,7 +16,6 @@ import useStores from "~/hooks/useStores";
import Icon from "./Icon";
import Flex from "~/components/Flex";
import { disconnectAnalyticsIntegrationFactory } from "~/actions/definitions/integrations";
import useActionContext from "~/hooks/useActionContext";
import styled from "styled-components";
type FormData = {
@@ -28,7 +27,6 @@ type FormData = {
function Umami() {
const { integrations } = useStores();
const { t } = useTranslation();
const context = useActionContext();
const integration = find(integrations.orderedData, {
type: IntegrationType.Analytics,
@@ -149,7 +147,6 @@ function Umami() {
<Button
action={disconnectAnalyticsIntegrationFactory(integration)}
context={context}
disabled={formState.isSubmitting}
neutral
hideIcon
Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 534 B

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: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 B

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1003 B

After

Width:  |  Height:  |  Size: 893 B

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