Compare commits

..

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 554f42cb4f Improve comment explaining tunnel agent usage for proxy scenarios
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-01-16 00:17:51 +00:00
copilot-swe-agent[bot] 52de8f9588 Update mock for request-filtering-agent to validate protocols
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-01-16 00:15:16 +00:00
copilot-swe-agent[bot] 9dbb84f966 Fix protocol error by using tunnel agent for httpOver proxy scenarios
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-01-16 00:09:55 +00:00
copilot-swe-agent[bot] aed0f8c584 Initial plan 2026-01-16 00:04:21 +00:00
333 changed files with 5388 additions and 14896 deletions
+2 -6
View File
@@ -203,7 +203,7 @@ RATE_LIMITER_DURATION_WINDOW=60
# ––––––––––– INTEGRATIONS –––––––––––
# ––––––––––––––––––––––––––––––––––––––
# GitHub integration allows previewing issue and pull request links
# The GitHub integration allows previewing issue and pull request links
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
@@ -212,7 +212,7 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# Linear integration allows previewing issue links as rich mentions
# The Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
@@ -223,10 +223,6 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Figma integration allows previewing design files as rich mentions
FIGMA_CLIENT_ID=
FIGMA_CLIENT_SECRET=
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.4.0
Licensed Work: Outline 1.2.0
The Licensed Work is (c) 2026 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2030-01-27
Change Date: 2030-01-06
Change License: Apache License, Version 2.0
+3 -23
View File
@@ -21,23 +21,7 @@
}
],
"scripts": {
"postdeploy": "yarn sequelize db:migrate",
"pr-predeploy": "yarn sequelize db:migrate"
},
"environments": {
"review": {
"scripts": {
"postdeploy": "yarn sequelize db:migrate"
},
"addons": [
{
"plan": "heroku-redis:mini"
},
{
"plan": "heroku-postgresql:essential-0"
}
]
}
"postdeploy": "yarn sequelize db:migrate"
},
"env": {
"NODE_ENV": {
@@ -59,12 +43,8 @@
"required": true
},
"URL": {
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to. For review apps, this is auto-generated.",
"required": false
},
"HEROKU_APP_NAME": {
"description": "Automatically set by Heroku for review apps",
"required": false
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
"required": true
},
"GOOGLE_CLIENT_ID": {
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
+172 -118
View File
@@ -1,12 +1,12 @@
import {
SortAlphabeticalReverseIcon,
SortAlphabeticalIcon,
AlphabeticalReverseSortIcon,
AlphabeticalSortIcon,
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
ImportIcon,
SortManualIcon,
ManualSortIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
@@ -20,7 +20,7 @@ import {
UnsubscribeIcon,
} from "outline-icons";
import { toast } from "sonner";
import Collection from "~/models/Collection";
import type Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
@@ -96,11 +96,11 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -109,7 +109,7 @@ export const editCollection = createAction({
content: (
<CollectionEdit
onSubmit={stores.dialogs.closeAllModals}
collectionId={collection.id}
collectionId={activeCollectionId}
/>
),
});
@@ -122,10 +122,14 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
@@ -148,16 +152,15 @@ export const importDocument = createAction({
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
perform: ({ getActiveModel, stores }) => {
const { documents } = stores;
const collection = getActiveModel(Collection);
if (!collection) {
return;
visible: ({ activeCollectionId, stores }) => {
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
},
perform: ({ activeCollectionId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypesString;
@@ -167,10 +170,15 @@ export const importDocument = createAction({
const file = files[0];
try {
const document = await documents.import(file, null, collection.id, {
publish: true,
});
history.push(document.path);
const document = await documents.import(
file,
null,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toast.error(err.message);
}
@@ -183,36 +191,37 @@ export const importDocument = createAction({
export const sortCollection = createActionWithChildren({
name: ({ t }) => t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
icon: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
!!stores.policies.abilities(activeCollectionId).update,
icon: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
const sortAlphabetical = collection?.sort.field === "title";
const sortDir = collection?.sort.direction;
return sortAlphabetical ? (
sortDir === "asc" ? (
<SortAlphabeticalIcon />
<AlphabeticalSortIcon />
) : (
<SortAlphabeticalReverseIcon />
<AlphabeticalReverseSortIcon />
)
) : (
<SortManualIcon />
<ManualSortIcon />
);
},
children: [
createAction({
name: ({ t }) => t("A-Z sort"),
section: ActiveCollectionSection,
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "asc"
);
},
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
@@ -224,15 +233,15 @@ export const sortCollection = createActionWithChildren({
createAction({
name: ({ t }) => t("Z-A sort"),
section: ActiveCollectionSection,
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "desc"
);
},
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
@@ -244,12 +253,12 @@ export const sortCollection = createActionWithChildren({
createAction({
name: ({ t }) => t("Manual sort"),
section: ActiveCollectionSection,
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.sort.field !== "title";
},
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "index",
@@ -266,19 +275,22 @@ export const searchInCollection = createInternalLinkAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection?.isActive) {
return false;
}
return stores.policies.abilities(collection.id).readDocument;
return stores.policies.abilities(activeCollectionId).readDocument;
},
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = searchPath({
collectionId: collection?.id,
collectionId: activeCollectionId,
}).split("?");
return {
@@ -295,22 +307,23 @@ export const starCollection = createAction({
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection.isStarred && stores.policies.abilities(collection.id).star
!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
if (!collection) {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
await collection.star();
const collection = stores.collections.get(activeCollectionId);
await collection?.star();
setPersistedState(getHeaderExpandedKey("starred"), true);
},
});
@@ -321,18 +334,22 @@ export const unstarCollection = createAction({
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection.isStarred && stores.policies.abilities(collection.id).unstar
!!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unstar();
},
});
@@ -342,25 +359,28 @@ export const subscribeCollection = createAction({
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection.isActive &&
!collection.isSubscribed &&
stores.policies.abilities(collection.id).subscribe
!!collection?.isActive &&
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
},
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
await collection.subscribe();
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
@@ -370,25 +390,28 @@ export const unsubscribeCollection = createAction({
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection.isActive &&
!!collection.isSubscribed &&
stores.policies.abilities(collection.id).unsubscribe
!!collection?.isActive &&
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
},
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
return;
}
await collection.unsubscribe();
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
@@ -398,15 +421,23 @@ export const archiveCollection = createAction({
analyticsName: "Archive collection",
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.archive),
perform: async ({ getActiveModel, stores, t }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).archive;
},
perform: async ({ activeCollectionId, stores, t }) => {
const { dialogs, collections } = stores;
if (!activeCollectionId) {
return;
}
const collection = collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({
dialogs.openModal({
title: t("Archive collection"),
content: (
<ConfirmationDialog
@@ -431,10 +462,17 @@ export const restoreCollection = createAction({
analyticsName: "Restore collection",
section: CollectionSection,
icon: <RestoreIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.restore),
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).restore;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
@@ -450,10 +488,18 @@ export const deleteCollection = createAction({
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, t, stores }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
@@ -475,10 +521,18 @@ export const exportCollection = createAction({
analyticsName: "Export collection",
section: ActiveCollectionSection,
icon: <ExportIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.export),
perform: async ({ getActiveModel, stores, t }) => {
const collection = getActiveModel(Collection);
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (!currentTeamId || !activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).export;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
@@ -501,13 +555,13 @@ export const createDocument = createInternalLinkAction({
section: ActiveCollectionSection,
icon: <NewDocumentIcon />,
keywords: "new create document",
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newDocumentPath(collection?.id).split("?");
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
return {
pathname,
@@ -523,13 +577,13 @@ export const createTemplate = createInternalLinkAction({
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newTemplatePath(collection?.id).split("?");
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
return {
pathname,
+2 -3
View File
@@ -38,7 +38,6 @@ import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { ExportContentType, TeamPreference } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import { Week } from "@shared/utils/time";
import type UserMembership from "~/models/UserMembership";
import { client } from "~/utils/ApiClient";
import DocumentDelete from "~/scenes/DocumentDelete";
@@ -550,7 +549,7 @@ export const downloadDocument = createAction({
});
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Download as Markdown"),
name: ({ t }) => t("Downloas as Markdown"),
analyticsName: "Download document as Markdown",
section: ActiveDocumentSection,
keywords: "md markdown export",
@@ -631,7 +630,7 @@ export const copyDocumentAsMarkdown = createAction({
if (document) {
const res = await client.post("/documents.export", {
id: document.id,
signedUrls: Week.seconds, // 7 days (AWS S3 max for presigned URLs)
signedUrls: 3600 * 24 * 30, // 30 days
});
copy(res.data);
toast.success(t("Markdown copied to clipboard"));
-3
View File
@@ -121,9 +121,6 @@ function DocumentListItem(
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
search: highlight
? `?q=${encodeURIComponent(highlight)}`
: undefined,
state: {
title: document.titleWithDefault,
sidebarContext,
+3 -7
View File
@@ -26,10 +26,8 @@ type Props = {
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
showIcons?: boolean;
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
fetchQueryOptions?: Record<string, string>;
disclosure?: boolean;
};
const FilterOptions = ({
@@ -38,10 +36,8 @@ const FilterOptions = ({
className,
onSelect,
showFilter,
showIcons = true,
fetchQuery,
fetchQueryOptions,
disclosure = true,
...rest
}: Props) => {
const { t } = useTranslation();
@@ -62,7 +58,7 @@ const FilterOptions = ({
<MenuButton
key={option.key}
icon={
option.icon && showIcons ? (
option.icon ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : undefined
}
@@ -74,7 +70,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
/>
),
[onSelect, showIcons, selectedKeys]
[onSelect, selectedKeys]
);
const handleFilter = React.useCallback(
@@ -185,8 +181,8 @@ const FilterOptions = ({
<StyledButton
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
disclosure={disclosure}
neutral
disclosure
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
@@ -1,4 +1,5 @@
import * as React from "react";
import debounce from "lodash/debounce";
import styled from "styled-components";
import { s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
@@ -12,23 +13,37 @@ type Props = {
onSelect: (color: string) => void;
};
const IconColorPicker = ({ activeColor, onSelect }: Props) => {
const ColorPicker = ({ activeColor, onSelect }: Props) => {
const [selectedColor, setSelectedColor] = React.useState(activeColor);
const isBuiltInColor = colorPalette.includes(selectedColor);
const color = isBuiltInColor ? undefined : selectedColor;
const debouncedOnSelect = React.useMemo(
() =>
debounce((color: string) => {
onSelect(color);
}, 250),
[onSelect]
);
React.useEffect(
() => () => {
debouncedOnSelect.cancel();
},
[debouncedOnSelect]
);
React.useEffect(() => {
setSelectedColor(activeColor);
}, [activeColor]);
const handleSelect = (color: string) => {
setSelectedColor(color);
onSelect(color);
debouncedOnSelect(color);
};
return (
<Container justify="space-between" align="center" auto>
<PresetColors activeColor={selectedColor} onClick={handleSelect} />
<BuiltinColors activeColor={selectedColor} onClick={handleSelect}>
<Divider />
<SwatchButton
color={color}
@@ -36,7 +51,7 @@ const IconColorPicker = ({ activeColor, onSelect }: Props) => {
onChange={handleSelect}
pickerInModal
/>
</Container>
</BuiltinColors>
);
};
@@ -46,14 +61,18 @@ const Divider = styled.div`
background-color: ${s("inputBorder")};
`;
const PresetColors = ({
const BuiltinColors = ({
activeColor,
onClick,
className,
children,
}: {
activeColor: string;
onClick: (color: string) => void;
className?: string;
children?: React.ReactNode;
}) => (
<>
<Container className={className} justify="space-between" align="center" auto>
{colorPalette.map((color) => (
<ColorButton
key={color}
@@ -62,7 +81,8 @@ const PresetColors = ({
onClick={() => onClick(color)}
/>
))}
</>
{children}
</Container>
);
const Container = styled(Flex)`
@@ -71,4 +91,4 @@ const Container = styled(Flex)`
border-bottom: 1px solid ${s("inputBorder")};
`;
export default IconColorPicker;
export default ColorPicker;
@@ -6,7 +6,7 @@ import { IconLibrary } from "@shared/utils/IconLibrary";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import { DisplayCategory } from "../utils";
import IconColorPicker from "./IconColorPicker";
import ColorPicker from "./ColorPicker";
import type { DataNode } from "./GridTemplate";
import GridTemplate from "./GridTemplate";
import { useIconState } from "../useIconState";
@@ -122,7 +122,7 @@ const IconPanel = ({
onChange={handleFilter}
/>
</InputSearchContainer>
<IconColorPicker
<ColorPicker
width={panelWidth}
activeColor={color}
onSelect={onColorChange}
+3 -21
View File
@@ -13,35 +13,17 @@ export default function CircleIcon({
retainColor,
...rest
}: Props) {
const isGradient = color === "rainbow";
const fillValue = isGradient ? "url(#circleIconGradient)" : color;
return (
<svg
fill={fillValue}
fill={color}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
style={retainColor ? { fill: fillValue } : undefined}
style={retainColor ? { fill: color } : undefined}
{...rest}
>
{isGradient && (
<defs>
<linearGradient
id="circleIconGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor="#ff5858" />
<stop offset="50%" stopColor="#fbcc34" />
<stop offset="100%" stopColor="#00c6ff" />
</linearGradient>
</defs>
)}
<circle cx="12" cy="12" r="8" />
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
</svg>
);
}
@@ -1,9 +0,0 @@
import styled from "styled-components";
import CircleIcon from "./CircleIcon";
export const DottedCircleIcon = styled(CircleIcon)`
circle {
stroke: ${(props) => props.theme.textSecondary};
stroke-dasharray: 2, 2;
}
`;
+4 -9
View File
@@ -97,8 +97,6 @@ type Props = {
onUpdate: (activeImage: LightboxImage | null) => void;
/** Callback triggered when Lightbox closes */
onClose: () => void;
/** Whether the editor is read only */
readOnly?: boolean;
};
const ZoomPanPinchContext = createContext({ isImagePanning: false });
@@ -218,7 +216,7 @@ function usePanning() {
};
}
function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
const isIdle = useIdle(3 * Second.ms);
const { t } = useTranslation();
const imgRef = useRef<HTMLImageElement | null>(null);
@@ -573,10 +571,8 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
};
const svgDataURLToBlob = (dataURL: string) => {
// Match the SVG data URL format (with or without charset)
const match = dataURL.match(
/^data:image\/svg\+xml(?:;charset=utf-8)?,(.*)$/i
);
// Match the SVG data URL format
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
if (!match) {
return;
}
@@ -773,8 +769,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
/>
</Tooltip>
{activeImage.source === ImageSource.DiagramsNet &&
!Desktop.isElectron() &&
!readOnly && (
!Desktop.isElectron() && (
<Tooltip content={t("Edit diagram")} placement="bottom">
<ActionButton
tabIndex={-1}
+1 -18
View File
@@ -27,7 +27,6 @@ export function toMenuItems(items: MenuItem[]) {
item.type !== "separator" &&
item.type !== "heading" &&
item.type !== "group" &&
item.type !== "custom" &&
!!item.icon
);
@@ -85,12 +84,6 @@ export function toMenuItems(items: MenuItem[]) {
return null;
}
const preventCloseHandler = (ev: Event) => {
if (item.preventCloseCondition && item.preventCloseCondition()) {
ev.preventDefault();
}
};
return (
<SubMenu key={`${item.type}-${item.title}-${index}`}>
<SubMenuTrigger
@@ -98,10 +91,7 @@ export function toMenuItems(items: MenuItem[]) {
icon={icon}
disabled={item.disabled}
/>
<SubMenuContent
ref={parentRef}
onFocusOutside={preventCloseHandler}
>
<SubMenuContent ref={parentRef}>
<MouseSafeArea parentRef={parentRef} />
{submenuItems}
</SubMenuContent>
@@ -128,9 +118,6 @@ export function toMenuItems(items: MenuItem[]) {
case "separator":
return <MenuSeparator key={`${item.type}-${index}`} />;
case "custom":
return <div key={`${item.type}-${index}`}>{item.content}</div>;
default:
return null;
}
@@ -153,7 +140,6 @@ export function toMobileMenuItems(
item.type !== "separator" &&
item.type !== "heading" &&
item.type !== "group" &&
item.type !== "custom" &&
!!item.icon
);
@@ -263,9 +249,6 @@ export function toMobileMenuItems(
case "separator":
return <Components.MenuSeparator key={`${item.type}-${index}`} />;
case "custom":
return <div key={`${item.type}-${index}`}>{item.content}</div>;
default:
return null;
}
-1
View File
@@ -53,7 +53,6 @@ function DocumentListItem(
pathname: shareId
? sharedModelPath(shareId, document.url)
: document.url,
search: highlight ? `?q=${encodeURIComponent(highlight)}` : undefined,
state: {
title: document.titleWithDefault,
},
@@ -20,12 +20,11 @@ import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import { Separator, GroupMembersPopover } from "../components";
import { Separator } from "../components";
import { ListItem } from "../components/ListItem";
import { Placeholder } from "../components/Placeholder";
import { PublicAccess } from "./PublicAccess";
import Flex from "@shared/components/Flex";
import ButtonLink from "~/components/ButtonLink";
type Props = {
/** Collection to which team members are supposed to be invited */
@@ -175,15 +174,9 @@ export const AccessControlList = observer(
/>
}
title={membership.group.name}
subtitle={
<GroupMembersPopover group={membership.group}>
<StyledButtonLink>
{t("{{ count }} member", {
count: membership.group.memberCount,
})}
</StyledButtonLink>
</GroupMembersPopover>
}
subtitle={t("{{ count }} member", {
count: membership.group.memberCount,
})}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
@@ -292,13 +285,6 @@ export const AccessControlList = observer(
}
);
const StyledButtonLink = styled(ButtonLink)`
color: ${s("textTertiary")};
&:hover {
text-decoration: underline;
}
`;
const Wrapper = styled(Flex)`
flex-direction: column;
`;
@@ -119,27 +119,7 @@ export const AccessControlList = observer(
maxHeight: maxHeight ? maxHeight - publicAccessHeight : undefined,
}}
>
{document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
{showLoading ? (
<Placeholder />
) : (
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
)}
</>
) : collection && canCollection.readDocument ? (
{collection && canCollection.readDocument ? (
<>
{collection.permission ? (
<ListItem
@@ -182,6 +162,26 @@ export const AccessControlList = observer(
/>
)}
</>
) : document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
{showLoading ? (
<Placeholder />
) : (
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
)}
</>
) : (
<>
{showLoading ? (
@@ -18,9 +18,7 @@ import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import { homePath } from "~/utils/routeHelpers";
import { ListItem } from "../components/ListItem";
import { GroupMembersPopover } from "../components";
import DocumentMemberListItem from "./DocumentMemberListItem";
import ButtonLink from "~/components/ButtonLink";
type Props = {
/** Document to which team members are supposed to be invited */
@@ -155,13 +153,9 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
</MaybeLink>
</Trans>
) : (
<GroupMembersPopover group={membership.group}>
<StyledButtonLink>
{t("{{ count }} member", {
count: membership.group.memberCount,
})}
</StyledButtonLink>
</GroupMembersPopover>
t("{{ count }} member", {
count: membership.group.memberCount,
})
)
}
actions={
@@ -212,13 +206,6 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
);
}
const StyledButtonLink = styled(ButtonLink)`
color: ${s("textTertiary")};
&:hover {
text-decoration: underline;
}
`;
const StyledLink = styled(Link)`
color: ${s("textTertiary")};
text-decoration: underline;
@@ -144,10 +144,9 @@ function PublicAccess(
toast.success(t("Public link copied to clipboard"));
}, [t]);
const shareUrl =
sharedParent?.url && !document.isDraft
? `${sharedParent.url}${document.url}`
: (share?.url ?? "");
const shareUrl = sharedParent?.url
? `${sharedParent.url}${document.url}`
: (share?.url ?? "");
const copyButton = (
<Tooltip content={t("Copy public link")} placement="top">
@@ -291,7 +290,7 @@ function PublicAccess(
</>
)}
{sharedParent?.published && !document.isDraft ? (
{sharedParent?.published ? (
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
{copyButton}
</ShareLinkInput>
@@ -1,93 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import type Group from "~/models/Group";
import type GroupUser from "~/models/GroupUser";
import { Avatar, AvatarSize } from "~/components/Avatar";
import PaginatedList from "~/components/PaginatedList";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/primitives/Popover";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { ListItem } from "./ListItem";
import Flex from "@shared/components/Flex";
import { useTranslation } from "react-i18next";
type Props = {
/** The group to display members for */
group: Group;
/** The trigger element that opens the popover */
children: React.ReactElement;
};
export const GroupMembersPopover = observer(({ group, children }: Props) => {
const { t } = useTranslation();
const { groupUsers } = useStores();
const [open, setOpen] = React.useState(false);
const members = React.useMemo(
() => groupUsers.inGroup(group.id),
[groupUsers.orderedData, group.id]
);
const fetchOptions = React.useMemo(
() => ({
id: group.id,
}),
[group.id]
);
const renderItem = React.useCallback(
(groupUser: GroupUser) => (
<ListItem
key={groupUser.id}
image={<Avatar model={groupUser.user} size={AvatarSize.Medium} />}
title={groupUser.user.name}
subtitle={groupUser.user.email}
/>
),
[]
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent
align="start"
side="right"
sideOffset={8}
width={320}
scrollable
shrink
>
<Container>
<Flex style={{ marginBottom: 8 }} column>
<Text size="medium" weight="bold">
{group.name}
</Text>
<Text size="small" type="tertiary">
{t(`{{ count }} members`, { count: group.memberCount })}
</Text>
</Flex>
{open && (
<PaginatedList<GroupUser>
items={members}
fetch={groupUsers.fetchPage}
options={fetchOptions}
renderItem={renderItem}
/>
)}
</Container>
</PopoverContent>
</Popover>
);
});
const Container = styled.div`
display: flex;
flex-direction: column;
margin: 12px 24px;
`;
@@ -166,9 +166,8 @@ export const Suggestions = observer(
}
const isEmpty = suggestions.length === 0;
const pendingIdSet = new Set(pendingIds);
const suggestionsWithPending = suggestions.filter(
(u) => !pendingIdSet.has(u.id)
(u) => !pendingIds.includes(u.id)
);
if (users.isFetching && isEmpty && neverRenderedList.current) {
@@ -7,8 +7,6 @@ import Input, { NativeInput } from "~/components/Input";
import { InfoIcon } from "outline-icons";
import { Link } from "react-router-dom";
export { GroupMembersPopover } from "./GroupMembersPopover";
// TODO: Temp until Button/NudeButton styles are normalized
export const Wrapper = styled.div`
${NudeButton}:${hover},
+30
View File
@@ -1,11 +1,14 @@
import { observer } from "mobx-react";
import { SidebarIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { hover } from "@shared/styles";
import { metaDisplay } from "@shared/utils/keyboard";
import type Share from "~/models/Share";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import SearchPopover from "~/components/SearchPopover";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -18,6 +21,7 @@ import Section from "./components/Section";
import { SharedCollectionLink } from "./components/SharedCollectionLink";
import { SharedDocumentLink } from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useEffect } from "react";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
@@ -68,6 +72,11 @@ function SharedSidebar({ share }: Props) {
<SearchWrapper>
<StyledSearchPopover shareId={shareId} />
</SearchWrapper>
{!teamAvailable && (
<ToggleWrapper>
<ToggleSidebar />
</ToggleWrapper>
)}
</TopSection>
<Section>
{share.collectionId ? (
@@ -94,6 +103,27 @@ function SharedSidebar({ share }: Props) {
);
}
const ToggleSidebar = () => {
const { t } = useTranslation();
const { ui } = useStores();
return (
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
</Tooltip>
);
};
const ScrollContainer = styled(Scrollable)`
padding-bottom: 16px;
`;
@@ -157,7 +157,6 @@ const CollectionLink: React.FC<Props> = ({
ref={editableTitleRef}
/>
}
ellipsis={!isEditing}
exact={false}
depth={depth ? depth : 0}
menu={
@@ -198,7 +197,6 @@ const CollectionLink: React.FC<Props> = ({
<SidebarLink
depth={2}
isActive={() => true}
ellipsis={false}
label={
<EditableTitle
title=""
@@ -106,7 +106,8 @@ function InnerDocumentLink(
membership?.pathToDocument(activeDocument.id);
return !!(
pathToDocument?.some((entry) => entry.id === node.id) || isActiveDocument
pathToDocument?.map((entry) => entry.id).includes(node.id) ||
isActiveDocument
);
}, [
hasChildDocuments,
@@ -427,7 +428,6 @@ function InnerDocumentLink(
to={toPath}
icon={iconElement}
label={labelElement}
ellipsis={!isEditing}
isActive={isActiveCheck}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
@@ -449,7 +449,6 @@ function InnerDocumentLink(
<SidebarLink
isActive={() => true}
depth={depth + 1}
ellipsis={false}
label={
<EditableTitle
title=""
@@ -53,8 +53,6 @@ type Props = Omit<NavLinkProps, "to"> & {
isDraft?: boolean;
/** Nesting depth level for indentation (0-based) */
depth?: number;
/** Whether to truncate the label text (default: true, causes overflow: hidden) */
ellipsis?: boolean;
/** Whether to automatically scroll this link into view if needed */
scrollIntoViewIfNeeded?: boolean;
/** Optional context menu action to display */
@@ -91,7 +89,6 @@ function SidebarLink(
disabled,
unreadBadge,
contextAction,
ellipsis = true,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -142,7 +139,7 @@ function SidebarLink(
ev.stopPropagation();
onDisclosureClick?.(ev);
},
[onDisclosureClick, hasDisclosure]
[onDisclosureClick]
);
const DisclosureComponent = icon ? HiddenDisclosure : Disclosure;
@@ -179,7 +176,7 @@ function SidebarLink(
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label $ellipsis={ellipsis}>{label}</Label>
<Label $ellipsis={typeof label === "string"}>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</ContextMenu>
@@ -202,7 +199,6 @@ const Content = styled.span`
align-items: start;
position: relative;
width: 100%;
min-width: 0;
`;
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
@@ -351,7 +347,6 @@ const Label = styled.div<{ $ellipsis: boolean }>`
width: 100%;
line-height: 24px;
margin-left: 2px;
min-width: 0;
${(props) => props.$ellipsis && ellipsis()}
* {
@@ -63,7 +63,7 @@ type StarredCollectionLinkProps = {
reorderStarProps: any;
};
const StarredDocumentLink = observer(function StarredDocumentLink({
function StarredDocumentLink({
star,
documentId,
expanded,
@@ -156,9 +156,9 @@ const StarredDocumentLink = observer(function StarredDocumentLink({
</SidebarContext.Provider>
</ActionContextProvider>
);
});
}
const StarredCollectionLink = observer(function StarredCollectionLink({
function StarredCollectionLink({
star,
collection,
sidebarContext,
@@ -185,7 +185,7 @@ const StarredCollectionLink = observer(function StarredCollectionLink({
<Relative>{cursor}</Relative>
</SidebarContext.Provider>
);
});
}
function StarredLink({ star }: Props) {
const theme = useTheme();
@@ -240,16 +240,10 @@ function StarredLink({ star }: Props) {
[]
);
const handlePrefetch = React.useCallback(() => {
if (documentId) {
void documents.prefetchDocument(documentId);
const document = documents.get(documentId);
const documentCollection = document?.collectionId
? collections.get(document.collectionId)
: undefined;
void documentCollection?.fetchDocuments();
}
}, [documents, documentId, collections]);
const handlePrefetch = React.useCallback(
() => documentId && documents.prefetchDocument(documentId),
[documents, documentId]
);
const getIndex = () => {
const next = star?.next();
+22 -15
View File
@@ -2,17 +2,13 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import lazyWithRetry from "~/utils/lazyWithRetry";
import useMobile from "~/hooks/useMobile";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerTrigger,
} from "./primitives/Drawer";
import DelayedMount from "./DelayedMount";
import { Drawer, DrawerContent, DrawerTrigger } from "./primitives/Drawer";
import { Popover, PopoverTrigger, PopoverContent } from "./primitives/Popover";
import Text from "./Text";
import { ColorButton } from "./ColorButton";
import ColorPicker from "@shared/components/ColorPicker";
import EventBoundary from "@shared/components/EventBoundary";
/**
* Props for the SwatchButton component.
@@ -54,11 +50,19 @@ export const SwatchButton: React.FC<SwatchButtonProps> = ({
);
const pickerContent = (
<StyledColorPicker
alpha={false}
activeColor={color}
onSelect={(c) => onChange(c)}
/>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={color}
onChange={(c) => onChange(c.hex)}
/>
</React.Suspense>
);
if (isMobile) {
@@ -66,8 +70,7 @@ export const SwatchButton: React.FC<SwatchButtonProps> = ({
<Drawer>
<DrawerTrigger asChild>{pickerTrigger}</DrawerTrigger>
<DrawerContent aria-label={t("Select a color")}>
<DrawerHandle />
<EventBoundary>{pickerContent}</EventBoundary>
{pickerContent}
</DrawerContent>
</Drawer>
);
@@ -93,6 +96,10 @@ const StyledContent = styled(PopoverContent)`
padding: 8px;
`;
const ColorPicker = lazyWithRetry(() =>
import("react-color").then((mod) => ({ default: mod.ChromePicker }))
);
const StyledColorPicker = styled(ColorPicker)`
background: inherit !important;
box-shadow: none !important;
+4 -1
View File
@@ -372,7 +372,10 @@ class WebsocketProvider extends Component<Props> {
const group = groups.get(event.groupId!);
// Any existing child policies are now invalid
if (currentUserId && group?.users.some((u) => u.id === currentUserId)) {
if (
currentUserId &&
group?.users.map((u) => u.id).includes(currentUserId)
) {
const document = documents.get(event.documentId!);
if (document) {
document.childDocuments.forEach((childDocument) => {
@@ -118,7 +118,7 @@ export const MenuLabel = styled.div`
flex-grow: 1;
display: flex;
align-items: center;
gap: 4px;
gap: 8px;
`;
export const MenuHeader = styled.h3`
@@ -1,28 +0,0 @@
import { useCallback } from "react";
import ColorPicker from "@shared/components/ColorPicker";
import { useEditor } from "./EditorContext";
type Props = {
/** The currently active color */
activeColor: string;
command: string;
};
function CellBackgroundColorPicker({ activeColor, command }: Props) {
const { commands } = useEditor();
const handleSelect = useCallback(
(color: string) => {
if (commands[command]) {
commands[command]({ color });
}
},
[commands, command]
);
return (
<ColorPicker alpha activeColor={activeColor} onSelect={handleSelect} />
);
}
export default CellBackgroundColorPicker;
+1 -14
View File
@@ -84,15 +84,6 @@ export default class ComponentView {
return false;
}
// Ensure we don't reuse NodeViews for different nodes that have a distinct identity
// This prevents attribute swapping during drag operations.
if (
this.node.attrs.id !== undefined &&
node.attrs.id !== this.node.attrs.id
) {
return false;
}
this.node = node;
this.decorations = decorations;
this.applyDecorationClasses();
@@ -146,11 +137,7 @@ export default class ComponentView {
}
stopEvent(event: Event) {
return (
event.type !== "mousedown" &&
!event.type.startsWith("drag") &&
!event.type.startsWith("drop")
);
return event.type !== "mousedown" && !event.type.startsWith("drag");
}
destroy() {
@@ -1,27 +0,0 @@
import { useCallback } from "react";
import ColorPicker from "@shared/components/ColorPicker";
import { useEditor } from "./EditorContext";
type Props = {
/** The currently active color */
activeColor: string;
};
function HighlightColorPicker({ activeColor }: Props) {
const { commands } = useEditor();
const handleSelect = useCallback(
(color: string) => {
if (commands.highlight) {
commands.highlight({ color });
}
},
[commands]
);
return (
<ColorPicker alpha activeColor={activeColor} onSelect={handleSelect} />
);
}
export default HighlightColorPicker;
+5 -5
View File
@@ -70,7 +70,7 @@ const LinkEditor: React.FC<Props> = ({
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [documents, query])
}, [query])
);
useEffect(() => {
@@ -201,7 +201,7 @@ const LinkEditor: React.FC<Props> = ({
return (
<div ref={wrapperRef}>
<InputWrapper>
<InputWrapper ref={wrapperRef}>
<Input
ref={inputRef}
value={query}
@@ -235,8 +235,8 @@ const LinkEditor: React.FC<Props> = ({
<>
{results.map((doc, index) => (
<SuggestionsMenuItem
onClick={() => {
!mark ? addLink(doc.path) : updateLink(doc.path);
onPointerDown={() => {
!mark ? addLink(doc.url) : updateLink(doc.url);
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
@@ -274,7 +274,7 @@ const LinkEditor: React.FC<Props> = ({
const InputWrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 6px;
padding: 4px 6px;
align-items: center;
`;
+2 -2
View File
@@ -151,14 +151,14 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
<DocumentIcon />
),
title: doc.title,
subtitle: doc.collectionId ? (
subtitle: (
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
) : undefined,
),
section: DocumentsSection,
appendSpace: true,
attrs: {
+17 -54
View File
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { EmailIcon, LinkIcon } from "outline-icons";
import { useCallback, useEffect, useState } from "react";
import React, { useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { EmbedDescriptor } from "@shared/editor/embeds";
import type { MenuItem } from "@shared/editor/types";
@@ -10,12 +10,10 @@ import { isUrl } from "@shared/utils/urls";
import type Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import { determineMentionType, isURLMentionable } from "~/utils/mention";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
type Props = Omit<
SuggestionsMenuProps,
@@ -25,16 +23,13 @@ type Props = Omit<
embeds: EmbedDescriptor[];
};
interface EmbedCheckState {
loading: boolean;
embeddable?: boolean;
}
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const items = useItems({ pastedText, embeds });
const renderMenuItem = useCallback(
(item, _index, options) => <SuggestionsMenuItem {...options} {...item} />,
(item, _index, options) => (
<SuggestionsMenuItem {...options} title={item.title} icon={item.icon} />
),
[]
);
@@ -61,44 +56,18 @@ function useItems({
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const [embedCheck, setEmbedCheck] = useState<EmbedCheckState>({
loading: false,
});
const singleUrl =
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
const embed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null;
// Check embeddability for single URL
useEffect(() => {
if (!singleUrl || !embed) {
setEmbedCheck({ loading: false });
return;
const embed = React.useMemo(() => {
if (typeof pastedText === "string") {
for (const e of embeds) {
const matches = e.matcher(pastedText);
if (matches) {
return e;
}
}
}
let cancelled = false;
setEmbedCheck({ loading: true });
client
.post<{ embeddable: boolean; reason?: string }>("/urls.checkEmbed", {
url: singleUrl,
})
.then((res) => {
if (!cancelled) {
setEmbedCheck({ loading: false, embeddable: res.embeddable });
}
})
.catch(() => {
if (!cancelled) {
// Optimistic on error - allow embedding attempt
setEmbedCheck({ loading: false, embeddable: true });
}
});
return () => {
cancelled = true;
};
}, [singleUrl, embed]);
return;
}, [embeds, pastedText]);
// single item is pasted.
if (typeof pastedText === "string") {
@@ -139,19 +108,14 @@ function useItems({
{
name: "embed",
title: t("Embed"),
subtitle:
embedCheck.embeddable === false ? t("Not supported") : undefined,
disabled: embedCheck.loading || !embedCheck.embeddable,
icon: embed?.icon,
keywords: embed?.keywords,
},
];
}
const linksToMentionType: Record<string, MentionType> = {};
// list is pasted.
// Check if the links can be converted to mentions.
const linksToMentionType: Record<string, MentionType> = {};
const convertibleToMentionList = pastedText.every((text) => {
if (!isUrl(text)) {
return false;
@@ -164,7 +128,7 @@ function useItems({
const mentionType = integration
? determineMentionType({ url, integration })
: MentionType.URL;
: undefined;
if (mentionType) {
linksToMentionType[text] = mentionType;
@@ -173,7 +137,7 @@ function useItems({
return !!mentionType;
});
// don't render the menu when it can't be converted to mentions.
// don't render the menu when it can't be converted to mention.
if (!convertibleToMentionList) {
return;
}
@@ -187,7 +151,6 @@ function useItems({
{
name: "mention_list",
title: t("Mention"),
visible: !!convertibleToMentionList,
icon: <EmailIcon />,
attrs: { actorId: user?.id, ...linksToMentionType },
},
+4 -33
View File
@@ -1,5 +1,4 @@
import type { EditorState, Selection } from "prosemirror-state";
import Suggestion from "~/editor/extensions/Suggestion";
import { NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
@@ -81,7 +80,7 @@ enum Toolbar {
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
const { view, extensions, commands } = useEditor();
const { view, commands } = useEditor();
const dictionary = useDictionary();
const menuRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useMobile();
@@ -109,11 +108,7 @@ export function SelectionToolbar(props: Props) {
if (isEmbedSelection && !readOnly) {
setActiveToolbar(Toolbar.Media);
} else if (
linkMark &&
(activeToolbar === null || activeToolbar === Toolbar.Link) &&
!readOnly
) {
} else if (linkMark && !activeToolbar && !readOnly) {
setActiveToolbar(Toolbar.Link);
} else if (isCodeSelection) {
setActiveToolbar(Toolbar.Menu);
@@ -126,19 +121,6 @@ export function SelectionToolbar(props: Props) {
}
}, [readOnly, selection]);
// Refocus the editor when the link toolbar closes to prevent focus loss
const prevActiveToolbar = React.useRef(activeToolbar);
React.useEffect(() => {
if (
prevActiveToolbar.current === Toolbar.Link &&
activeToolbar !== Toolbar.Link &&
!readOnly
) {
view.focus();
}
prevActiveToolbar.current = activeToolbar;
}, [activeToolbar, readOnly, view]);
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
if (
@@ -156,23 +138,13 @@ export function SelectionToolbar(props: Props) {
return;
}
// Don't collapse selection if any suggestion menu is open
const isSuggestionMenuOpen = extensions.extensions.some(
(ext) => ext instanceof Suggestion && ext.isOpen
);
if (isSuggestionMenuOpen) {
return;
}
if (!window.getSelection()?.isCollapsed) {
return;
}
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(
TextSelection.near(view.state.doc.resolve(0))
)
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
);
};
@@ -191,7 +163,6 @@ export function SelectionToolbar(props: Props) {
ev.key.toLowerCase() === "k" &&
!view.state.selection.empty
) {
ev.preventDefault();
ev.stopPropagation();
if (activeToolbar === Toolbar.Link) {
setActiveToolbar(Toolbar.Menu);
@@ -276,7 +247,7 @@ export function SelectionToolbar(props: Props) {
items = filterExcessSeparators(items);
items = items.map((item) => {
if (item.children && Array.isArray(item.children)) {
if (item.children) {
item.children = item.children.map((child) => {
if (child.name === "editImageUrl") {
child.onClick = () => {
+116 -222
View File
@@ -2,7 +2,6 @@ import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import commandScore from "command-score";
import capitalize from "lodash/capitalize";
import orderBy from "lodash/orderBy";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -16,14 +15,8 @@ import { depths, s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import { Portal } from "~/components/Portal";
import {
Drawer,
DrawerContent,
DrawerTitle,
} from "~/components/primitives/Drawer";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
import useMobile from "~/hooks/useMobile";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
import Input from "./Input";
@@ -80,6 +73,7 @@ export type Props<T extends MenuItem = MenuItem> = {
index: number,
options: {
selected: boolean;
onPointerDown: (event: React.SyntheticEvent) => void;
onClick: (event: React.SyntheticEvent) => void;
}
) => React.ReactNode;
@@ -91,7 +85,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { view, commands, props: editorProps } = useEditor();
const dictionary = useDictionary();
const { t } = useTranslation();
const isMobile = useMobile();
const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
@@ -99,7 +92,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
});
const menuRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const selectionRef = React.useRef<{ from: number; to: number } | null>(null);
const [position, setPosition] = React.useState<Position>(defaultPosition);
const [insertItem, setInsertItem] = React.useState<
MenuItem | EmbedDescriptor
@@ -109,16 +101,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
React.useEffect(() => {
if (props.isActive) {
hasActivated.current = true;
// Save the selection position when the menu opens. On mobile, the editor
// may lose focus/selection when tapping on menu items, so we restore it.
requestAnimationFrame(() => {
const { from, to } = view.state.selection;
selectionRef.current = { from, to };
});
} else {
selectionRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isActive]);
const calculatePosition = React.useCallback(
@@ -199,11 +182,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleClearSearch = React.useCallback(() => {
const { state, dispatch } = view;
const selection =
isMobile && selectionRef.current ? selectionRef.current : state.selection;
const poss = state.doc.cut(
selection.from - (props.search ?? "").length - props.trigger.length,
selection.from
state.selection.from - (props.search ?? "").length - props.trigger.length,
state.selection.from
);
const trimTrigger = poss.textContent.startsWith(props.trigger);
@@ -217,11 +198,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
"",
Math.max(
0,
selection.from -
state.selection.from -
(props.search ?? "").length -
(trimTrigger ? props.trigger.length : 0)
),
selection.to
state.selection.to
)
);
}, [props.search, props.trigger, view]);
@@ -246,27 +227,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
setSelectedIndex(0);
}, [props.search]);
const restoreSelection = React.useCallback(() => {
if (!isMobile) {
return;
}
// Restore the saved selection position. On mobile, the editor selection may be
// lost when the drawer opens or when tapping on menu items.
if (selectionRef.current) {
const { from, to } = selectionRef.current;
const { tr, doc } = view.state;
const selection = TextSelection.create(doc, from, to);
view.dispatch(tr.setSelection(selection));
// Re-focus the editor post-click
requestAnimationFrame(() => view.focus());
}
}, [isMobile, view]);
const insertNode = React.useCallback(
(item: MenuItem | EmbedDescriptor) => {
restoreSelection();
handleClearSearch();
const command = item.name ? commands[item.name] : undefined;
@@ -287,15 +249,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
props.onClose();
},
[commands, handleClearSearch, props, restoreSelection, view]
[commands, handleClearSearch, props, view]
);
const handleClickItem = React.useCallback(
(item) => {
if (item.disabled) {
return;
}
props.onSelect?.(item);
switch (item.name) {
@@ -416,7 +374,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleFilesPicked = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
restoreSelection();
// Re-focus the editor as it loses focus when file picker is opened on iOS
view.focus();
const {
uploadFile,
@@ -582,20 +541,12 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
event.stopPropagation();
if (filtered.length) {
let prevIndex = selectedIndex - 1;
while (prevIndex >= 0) {
const item = filtered[prevIndex];
if (
item?.name !== "separator" &&
!("disabled" in item && item.disabled)
) {
break;
}
prevIndex--;
}
if (prevIndex >= 0) {
setSelectedIndex(prevIndex);
}
const prevIndex = selectedIndex - 1;
const prev = filtered[prevIndex];
setSelectedIndex(
Math.max(0, prev?.name === "separator" ? prevIndex - 1 : prevIndex)
);
} else {
close();
}
@@ -611,20 +562,15 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
if (filtered.length) {
const total = filtered.length - 1;
let nextIndex = selectedIndex + 1;
while (nextIndex <= total) {
const item = filtered[nextIndex];
if (
item?.name !== "separator" &&
!("disabled" in item && item.disabled)
) {
break;
}
nextIndex++;
}
if (nextIndex <= total) {
setSelectedIndex(nextIndex);
}
const nextIndex = selectedIndex + 1;
const next = filtered[nextIndex];
setSelectedIndex(
Math.min(
next?.name === "separator" ? nextIndex + 1 : nextIndex,
total
)
);
} else {
close();
}
@@ -651,145 +597,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { isActive, uploadFile } = props;
const items = filtered;
const handleOpenChange = React.useCallback(
(open: boolean) => {
if (!open) {
close();
}
},
[close]
);
const fileInput = uploadFile && (
<VisuallyHidden.Root>
<label>
<Trans>Import document</Trans>
<input
type="file"
ref={inputRef}
onChange={handleFilesPicked}
multiple
/>
</label>
</VisuallyHidden.Root>
);
const renderItems = () => {
let prevHeading: string | undefined;
return (
<>
{items.map((item, index) => {
if (item.name === "separator") {
return (
<ListItem key={index}>
<hr />
</ListItem>
);
}
if (!item.title) {
return null;
}
const handlePointerMove = (ev: React.PointerEvent) => {
if (
!("disabled" in item && item.disabled) &&
selectedIndex !== index &&
// Safari triggers pointermove with identical coordinates when the pointer has not moved.
// This causes the menu selection to flicker when the pointer is over the menu but not moving.
(pointerRef.current.clientX !== ev.clientX ||
pointerRef.current.clientY !== ev.clientY)
) {
setSelectedIndex(index);
}
pointerRef.current = {
clientX: ev.clientX,
clientY: ev.clientY,
};
};
const handlePointerDown = () => {
if (
!("disabled" in item && item.disabled) &&
selectedIndex !== index
) {
setSelectedIndex(index);
}
};
const handleOnClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(item);
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== prevHeading && (
<MenuHeader key={currentHeading}>{currentHeading}</MenuHeader>
)}
<ListItem
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: handleOnClick,
})}
</ListItem>
</React.Fragment>
);
prevHeading = currentHeading;
return response;
})}
{items.length === 0 && (
<ListItem>
<Empty>{dictionary.noResults}</Empty>
</ListItem>
)}
</>
);
};
if (isMobile) {
return (
<>
<Drawer open={isActive} onOpenChange={handleOpenChange}>
<DrawerContent aria-describedby={undefined}>
<DrawerTitle hidden>{props.trigger}</DrawerTitle>
<MobileScrollable hiddenScrollbars>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
"placeholder" in insertItem && !!insertItem.placeholder
? insertItem.placeholder
: insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
}
onKeyDown={handleLinkInputKeydown}
onPaste={handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>{renderItems()}</List>
)}
</MobileScrollable>
</DrawerContent>
</Drawer>
{fileInput}
</>
);
}
let previousHeading: string | undefined;
return (
<Portal>
@@ -813,9 +621,99 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
/>
</LinkInputWrapper>
) : (
<List>{renderItems()}</List>
<List>
{items.map((item, index) => {
if (item.name === "separator") {
return (
<ListItem key={index}>
<hr />
</ListItem>
);
}
if (!item.title) {
return null;
}
const handlePointerMove = (ev: React.PointerEvent) => {
if (
selectedIndex !== index &&
// Safari triggers pointermove with identical coordinates when the pointer has not moved.
// This causes the menu selection to flicker when the pointer is over the menu but not moving.
(pointerRef.current.clientX !== ev.clientX ||
pointerRef.current.clientY !== ev.clientY)
) {
setSelectedIndex(index);
}
pointerRef.current = {
clientX: ev.clientX,
clientY: ev.clientY,
};
};
const handlePointerDown = () => {
if (selectedIndex !== index) {
setSelectedIndex(index);
}
};
const handleOnClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(item);
};
const stopPropagation = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== previousHeading && (
<MenuHeader key={currentHeading}>
{currentHeading}
</MenuHeader>
)}
<ListItem
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onPointerDown: handleOnClick,
onClick: stopPropagation,
})}
</ListItem>
</React.Fragment>
);
previousHeading = currentHeading;
return response;
})}
{items.length === 0 && (
<ListItem>
<Empty>{dictionary.noResults}</Empty>
</ListItem>
)}
</List>
)}
{uploadFile && (
<VisuallyHidden.Root>
<label>
<Trans>Import document</Trans>
<input
type="file"
ref={inputRef}
onChange={handleFilesPicked}
multiple
/>
</label>
</VisuallyHidden.Root>
)}
{fileInput}
</>
)}
</Wrapper>
@@ -856,10 +754,6 @@ const Empty = styled.div`
padding: 0 16px;
`;
const MobileScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export const Wrapper = styled(Scrollable)<{
active: boolean;
top?: number;
@@ -15,7 +15,7 @@ export type Props = {
/** Whether the item is disabled */
disabled?: boolean;
/** Callback when the item is clicked */
onClick: (event: React.SyntheticEvent) => void;
onPointerDown: (event: React.SyntheticEvent) => void;
/** Callback when the item is hovered */
onPointerMove?: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
@@ -31,7 +31,7 @@ export type Props = {
function SuggestionsMenuItem({
selected,
disabled,
onClick,
onPointerDown,
onPointerMove,
title,
subtitle,
@@ -60,7 +60,7 @@ function SuggestionsMenuItem({
<MenuButton
ref={ref}
disabled={disabled}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerMove={disabled ? undefined : onPointerMove}
$active={selected}
>
@@ -68,10 +68,7 @@ function SuggestionsMenuItem({
<MenuLabel>
{title}
{subtitle && (
<>
<Subtitle $active={selected}>&middot;</Subtitle>
<Subtitle $active={selected}>{subtitle}</Subtitle>
</>
<Subtitle $active={selected}>&middot; {subtitle}</Subtitle>
)}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuLabel>
+39 -98
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Toolbar from "@radix-ui/react-toolbar";
@@ -22,32 +22,16 @@ type Props = {
items: MenuItem[];
};
type ToolbarDropdownProps = {
active: boolean;
item: MenuItem;
tooltip?: string;
shortcut?: string;
};
/**
/*
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: ToolbarDropdownProps) {
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item, shortcut, tooltip } = props;
const { item } = props;
const { state } = view;
const [isOpen, setIsOpen] = useState<boolean>(false);
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open);
}, []);
const items: TMenuItem[] = useMemo(() => {
if (!isOpen) {
return [];
}
const handleClick = (menuItem: MenuItem) => () => {
if (!menuItem.name) {
return;
@@ -64,80 +48,47 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
}
};
const resolveChildren = (
children: MenuItem[] | (() => MenuItem[]) | undefined
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
children.map((child) => {
if (child.name === "separator") {
return { type: "separator", visible: child.visible };
}
if ("content" in child) {
return item.children
? item.children.map((child) => {
if (child.name === "separator") {
return { type: "separator", visible: child.visible };
}
return {
type: "custom",
visible: child.visible,
content: child.content,
};
}
const resolvedChildren = resolveChildren(child.children);
if (resolvedChildren) {
const childWithPreventClose = resolvedChildren.find(
(c) => "preventCloseCondition" in c
);
return {
type: "submenu",
type: "button",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapChildren(resolvedChildren),
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
};
}
return {
type: "button",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
};
});
const resolvedItemChildren = resolveChildren(item.children);
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
}, [isOpen, commands]);
})
: [];
}, [item.children, commands, state]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
ev.stopImmediatePropagation();
}, []);
return (
<Tooltip shortcut={shortcut} content={tooltip} disabled={isOpen}>
<MenuProvider variant="dropdown">
<Menu open={isOpen} onOpenChange={handleOpenChange}>
<MenuTrigger>
<ToolbarButton
aria-label={item.label ? undefined : item.tooltip}
disabled={item.disabled}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</MenuTrigger>
<MenuContent
align="end"
aria-label={item.tooltip || t("More options")}
onCloseAutoFocus={handleCloseAutoFocus}
>
<EventBoundary>{toMenuItems(items)}</EventBoundary>
</MenuContent>
</Menu>
</MenuProvider>
</Tooltip>
<MenuProvider variant="dropdown">
<Menu>
<MenuTrigger>
<ToolbarButton aria-label={item.label ? undefined : item.tooltip}>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</MenuTrigger>
<MenuContent
align="end"
aria-label={item.tooltip || t("More options")}
onCloseAutoFocus={handleCloseAutoFocus}
>
<EventBoundary>{toMenuItems(items)}</EventBoundary>
</MenuContent>
</Menu>
</MenuProvider>
);
}
@@ -176,20 +127,6 @@ function ToolbarMenu(props: Props) {
}
const isActive = item.active ? item.active(state) : false;
if (item.children) {
return (
<ToolbarDropdown
key={index}
active={isActive && !item.label}
item={item}
tooltip={
item.label === item.tooltip ? undefined : item.tooltip
}
shortcut={item.shortcut}
/>
);
}
return (
<Tooltip
key={index}
@@ -198,13 +135,17 @@ function ToolbarMenu(props: Props) {
>
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
active={isActive && !item.label}
item={item}
/>
) : (
<Toolbar.Button asChild>
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
aria-label={item.label ? undefined : item.tooltip}
disabled={item.disabled}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
+10 -73
View File
@@ -8,9 +8,6 @@ import { Decoration, DecorationSet } from "prosemirror-view";
import scrollIntoView from "scroll-into-view-if-needed";
import type { WidgetProps } from "@shared/editor/lib/Extension";
import Extension from "@shared/editor/lib/Extension";
import { Action, toggleFoldPluginKey } from "@shared/editor/nodes/ToggleBlock";
import { isToggleBlock } from "@shared/editor/queries/toggleBlock";
import { ancestors } from "@shared/editor/utils";
import FindAndReplace from "../components/FindAndReplace";
const pluginKey = new PluginKey("find-and-replace");
@@ -150,9 +147,6 @@ export default class FindAndReplaceExtension extends Extension {
this.currentResultIndex = 0;
dispatch?.(state.tr.setMeta(pluginKey, {}));
this.expandFoldedTogglesForCurrentMatch();
this.scrollToCurrentMatch();
return true;
};
}
@@ -198,77 +192,20 @@ export default class FindAndReplaceExtension extends Extension {
}
dispatch?.(state.tr.setMeta(pluginKey, {}));
this.expandFoldedTogglesForCurrentMatch();
this.scrollToCurrentMatch();
const element = window.document.querySelector(
`.${this.options.resultCurrentClassName}`
);
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
return true;
};
}
private scrollToCurrentMatch() {
const element = window.document.querySelector(
`.${this.options.resultCurrentClassName}`
);
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
}
/**
* Expand any folded toggle blocks that contain the current match.
*/
private expandFoldedTogglesForCurrentMatch() {
const result = this.results[this.currentResultIndex];
if (!result) {
return;
}
const state = this.editor.view.state;
const pluginState = toggleFoldPluginKey.getState(state);
if (!pluginState) {
return;
}
const $pos = state.doc.resolve(result.from);
const isToggle = isToggleBlock(state);
// Find all ancestor toggle block IDs that are folded
const foldedToggleIds = ancestors($pos)
.filter(
(node) => isToggle(node) && pluginState.foldedIds.has(node.attrs.id)
)
.map((node) => node.attrs.id as string);
// Unfold each toggle by ID (getting fresh state after each dispatch)
foldedToggleIds.forEach((toggleId) => {
const currentState = this.editor.view.state;
// Find the position of this toggle in the current document
let togglePos: number | null = null;
currentState.doc.descendants((node, pos) => {
if (
node.type.name === "container_toggle" &&
node.attrs.id === toggleId
) {
togglePos = pos;
return false;
}
return true;
});
if (togglePos !== null) {
this.editor.view.dispatch(
currentState.tr.setMeta(toggleFoldPluginKey, {
type: Action.UNFOLD,
at: togglePos,
})
);
}
});
}
private rebaseNextResult(replace: string, index: number, lastOffset = 0) {
const nextIndex = index + 1;
-24
View File
@@ -447,25 +447,6 @@ export default class PasteHandler extends Extension {
}
};
// Not a list of embeds technically, but inserts many embeds at once.
private insertEmbedList = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.placeholderId());
// Remove just the placeholder here.
// Embed list will be created by SuggestionsMenu.
if (result) {
const tr = state.tr.setMeta(this.key, {
remove: { id: this.placeholderId() },
});
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
};
private handleList(listNode: Node) {
const { view, schema } = this.editor;
const { state } = view;
@@ -566,11 +547,6 @@ export default class PasteHandler extends Extension {
this.insertMentionList();
break;
}
case "embed_list": {
this.hidePasteMenu();
this.insertEmbedList();
break;
}
default:
break;
}
-5
View File
@@ -75,9 +75,4 @@ export default class Suggestion extends Extension {
open: false,
query: "",
});
/** Whether the suggestion menu is currently open. */
get isOpen(): boolean {
return this.state.open;
}
}
+3 -14
View File
@@ -9,11 +9,11 @@ import { gapCursor } from "prosemirror-gapcursor";
import type { InputRule } from "prosemirror-inputrules";
import { inputRules } from "prosemirror-inputrules";
import { keymap } from "prosemirror-keymap";
import type { MarkdownParser } from "prosemirror-markdown";
import type { NodeSpec, MarkSpec } from "prosemirror-model";
import { Schema, Node as ProsemirrorNode } from "prosemirror-model";
import type { Plugin, Transaction } from "prosemirror-state";
import { EditorState, Selection, TextSelection } from "prosemirror-state";
import type { MarkdownParser } from "prosemirror-markdown";
import { EditorState, Selection } from "prosemirror-state";
import {
AddMarkStep,
RemoveMarkStep,
@@ -119,8 +119,6 @@ export type Props = {
onCreateCommentMark?: (commentId: string, userId: string) => void;
/** Callback when a comment mark is removed */
onDeleteCommentMark?: (commentId: string) => void;
/** Callback when comments sidebar should be opened */
onOpenCommentsSidebar?: () => void;
/** Callback when a file upload begins */
onFileUploadStart?: () => void;
/** Callback when a file upload ends */
@@ -172,7 +170,6 @@ export class Editor extends React.PureComponent<
defaultValue: "",
dir: "auto",
placeholder: "Write something nice…",
readOnly: false,
onFileUploadStart: () => {
// no default behavior
},
@@ -531,13 +528,6 @@ export class Editor extends React.PureComponent<
this.mutationObserver = observe(
hash,
(element) => {
const pos = this.view.posAtDOM(element, 0, 1);
this.view.dispatch(
this.view.state.tr.setSelection(
TextSelection.near(this.view.state.doc.resolve(pos), 1)
)
);
if (isVisible(element)) {
element.scrollIntoView();
}
@@ -883,11 +873,10 @@ export class Editor extends React.PureComponent<
</Flex>
{!isNull(this.state.activeLightboxImage) && (
<Lightbox
readOnly={readOnly}
images={this.getLightboxImages()}
activeImage={this.state.activeLightboxImage}
onUpdate={this.updateActiveLightboxImage}
onClose={this.view.focus}
onClose={() => this.view.focus()}
/>
)}
</EditorContext.Provider>
-7
View File
@@ -22,7 +22,6 @@ import {
MathIcon,
DoneIcon,
EmbedIcon,
CollapseIcon,
} from "outline-icons";
import * as React from "react";
import styled from "styled-components";
@@ -243,12 +242,6 @@ export default function blockMenuItems(
icon: <Img src="/images/diagrams.png" alt="Diagrams.net Diagram" />,
keywords: "diagram flowchart draw.io",
},
{
name: "container_toggle",
title: dictionary.toggleBlock,
icon: <CollapseIcon />,
keywords: "toggle collapsible collapse fold",
},
];
// Filter out diagrams.net in desktop app
+30 -209
View File
@@ -19,15 +19,10 @@ import {
Heading3Icon,
TableMergeCellsIcon,
TableSplitCellsIcon,
PaletteIcon,
CollapseIcon,
} from "outline-icons";
import { v4 as uuidv4 } from "uuid";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
import HighlightColorPicker from "../components/HighlightColorPicker";
import type { EditorState } from "prosemirror-state";
import { getDocumentHighlightColors } from "@shared/editor/queries/getDocumentHighlightColors";
import styled from "styled-components";
import Highlight from "@shared/editor/marks/Highlight";
import { getMarksBetween } from "@shared/editor/queries/getMarksBetween";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
@@ -42,17 +37,10 @@ import {
isTouchDevice,
} from "@shared/utils/browser";
import {
getColorSetForSelectedCells,
getDocumentTableBackgroundColors,
hasNodeAttrMarkCellSelection,
hasNodeAttrMarkWithAttrsCellSelection,
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { CellSelection } from "prosemirror-tables";
import TableCell from "@shared/editor/nodes/TableCell";
import Highlight from "@shared/editor/marks/Highlight";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
export default function formattingMenuItems(
state: EditorState,
@@ -72,16 +60,7 @@ export default function formattingMenuItems(
state.selection.from,
state.selection.to,
state
).find(({ mark }) => mark.type === state.schema.marks.highlight);
const cellSelectionHasBackground = isTableCell
? hasNodeAttrMarkCellSelection(
state.selection as CellSelection,
"background"
)
: false;
const selectedCellsColorSet = getColorSetForSelectedCells(state.selection);
).find(({ mark }) => mark.type.name === "highlight");
return [
{
@@ -119,193 +98,36 @@ export default function formattingMenuItems(
active: isMarkActive(schema.marks.strikethrough),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
tooltip: dictionary.background,
icon:
getColorSetForSelectedCells(state.selection).size > 1 ? (
<CircleIcon color="rainbow" />
) : getColorSetForSelectedCells(state.selection).size === 1 ? (
<CircleIcon
color={
getColorSetForSelectedCells(state.selection).values().next().value
}
/>
) : (
<PaletteIcon />
),
visible: !isCode && (!isMobile || !isEmpty) && isTableCell,
children: (): MenuItem[] => {
// Get all unique background colors used in table cells (lazily computed when menu opens)
const documentTableColors = getDocumentTableBackgroundColors(state);
// Filter out preset colors and currently selected colors
const nonPresetDocumentColors = documentTableColors.filter(
(color: string) =>
!TableCell.isPresetColor(color) && !selectedCellsColorSet.has(color)
);
return [
{
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (cellSelectionHasBackground ? false : true),
attrs: { color: null },
},
...TableCell.presetColors.map((preset) => ({
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () =>
hasNodeAttrMarkWithAttrsCellSelection(
state.selection as CellSelection,
"background",
{ color: preset.hex }
),
attrs: { color: preset.hex },
})),
...(selectedCellsColorSet.size === 1 &&
!TableCell.isPresetColor(selectedCellsColorSet.values().next().value)
? [
{
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: selectedCellsColorSet.values().next().value,
icon: (
<CircleIcon
retainColor
color={selectedCellsColorSet.values().next().value}
/>
),
active: () => true,
attrs: { color: selectedCellsColorSet.values().next().value },
},
]
: []),
// Add all other document table background colors
...nonPresetDocumentColors.map((color: string) => ({
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: color,
icon: <CircleIcon retainColor color={color} />,
active: () => selectedCellsColorSet.has(color),
attrs: { color },
})),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
command="toggleCellSelectionBackground"
activeColor={
selectedCellsColorSet.size === 1
? selectedCellsColorSet.values().next().value
: ""
}
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
];
},
},
{
tooltip: dictionary.mark,
shortcut: `${metaDisplay}+⇧+H`,
icon: highlight ? (
<CircleIcon
color={highlight.mark.attrs.color || Highlight.presetColors[0].hex}
/>
<CircleIcon color={highlight.mark.attrs.color || Highlight.colors[0]} />
) : (
<HighlightIcon />
),
active: () => !!highlight,
visible: !isCode && (!isMobile || !isEmpty) && !isTableCell,
children: (): MenuItem[] => {
// Get all unique highlight colors used in the document (lazily computed when menu opens)
const documentHighlightColors = getDocumentHighlightColors(state);
// Filter out preset colors and the currently selected color
const currentHighlightColor = highlight?.mark.attrs.color;
const nonPresetDocumentColors = documentHighlightColors.filter(
(color: string) =>
!Highlight.isPresetColor(color) && color !== currentHighlightColor
);
return [
...(highlight
? [
{
name: "highlight",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => false,
attrs: { color: highlight.mark.attrs.color },
},
]
: []),
...Highlight.presetColors.map((preset) => ({
name: "highlight",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: isMarkActive(schema.marks.highlight, { color: preset.hex }),
attrs: { color: preset.hex },
})),
...(highlight &&
highlight.mark.attrs.color &&
!Highlight.isPresetColor(highlight.mark.attrs.color)
? [
{
name: "highlight",
label: highlight.mark.attrs.color,
icon: (
<CircleIcon
retainColor
color={highlight.mark.attrs.color}
/>
),
active: isMarkActive(schema.marks.highlight, {
color: highlight.mark.attrs.color,
}),
attrs: { color: highlight.mark.attrs.color },
},
]
: []),
// Add all other document highlight colors
...nonPresetDocumentColors.map((color: string) => ({
name: "highlight",
label: color,
icon: <CircleIcon retainColor color={color} />,
active: () => currentHighlightColor === color,
attrs: { color },
})),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
visible: !isCode && (!isMobile || !isEmpty),
children: [
...(highlight
? [
{
content: (
<HighlightColorPicker
activeColor={
highlight?.mark.attrs.color ||
Highlight.presetColors[0].hex
}
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
name: "highlight",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => false,
attrs: { color: highlight.mark.attrs.color },
},
],
},
];
},
]
: []),
...Highlight.colors.map((color, index) => ({
name: "highlight",
label: Highlight.colorNames[index],
icon: <CircleIcon retainColor color={color} />,
active: isMarkActive(schema.marks.highlight, { color }),
attrs: { color },
})),
],
},
{
name: "code_inline",
@@ -370,14 +192,6 @@ export default function formattingMenuItems(
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "container_toggle",
icon: <CollapseIcon />,
tooltip: dictionary.toggleBlock,
active: isNodeActive(schema.nodes.container_toggle),
attrs: { id: uuidv4() },
visible: !isCodeBlock && (!isMobile || isEmpty),
},
{
name: "separator",
},
@@ -473,3 +287,10 @@ export default function formattingMenuItems(
},
];
}
const DottedCircleIcon = styled(CircleIcon)`
circle {
stroke: ${(props) => props.theme.textSecondary};
stroke-dasharray: 2, 2;
}
`;
+5 -103
View File
@@ -6,12 +6,11 @@ import {
InsertLeftIcon,
InsertRightIcon,
MoreIcon,
PaletteIcon,
TableHeaderColumnIcon,
TableMergeCellsIcon,
TableSplitCellsIcon,
SortAscendingIcon,
SortDescendingIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
TableColumnsDistributeIcon,
} from "outline-icons";
import type { EditorState } from "prosemirror-state";
@@ -19,41 +18,12 @@ import { CellSelection, selectedRect } from "prosemirror-tables";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
getAllSelectedColumns,
getCellsInColumn,
isMergedCellSelection,
isMultipleCellSelection,
tableHasRowspan,
} from "@shared/editor/queries/table";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import type { MenuItem } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
import TableCell from "@shared/editor/nodes/TableCell";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
/**
* Get the set of background colors used in a column
*/
function getColumnColors(state: EditorState, colIndex: number): Set<string> {
const colors = new Set<string>();
const cells = getCellsInColumn(colIndex)(state) || [];
cells.forEach((pos) => {
const node = state.doc.nodeAt(pos);
if (!node) {
return;
}
const backgroundMark = (node.attrs.marks ?? []).find(
(mark: NodeAttrMark) => mark.type === "background"
);
if (backgroundMark && backgroundMark.attrs.color) {
colors.add(backgroundMark.attrs.color);
}
});
return colors;
}
export default function tableColMenuItems(
state: EditorState,
@@ -77,14 +47,6 @@ export default function tableColMenuItems(
}
const tableMap = selectedRect(state);
const colColors = getColumnColors(state, index);
const hasBackground = colColors.size > 0;
const activeColor =
colColors.size === 1 ? colColors.values().next().value : null;
const customColor =
colColors.size === 1 && !TableCell.isPresetColor(activeColor)
? activeColor
: undefined;
return [
{
@@ -127,77 +89,17 @@ export default function tableColMenuItems(
name: "sortTable",
tooltip: dictionary.sortAsc,
attrs: { index, direction: "asc" },
icon: <SortAscendingIcon />,
disabled: tableHasRowspan(state),
icon: <AlphabeticalSortIcon />,
},
{
name: "sortTable",
tooltip: dictionary.sortDesc,
attrs: { index, direction: "desc" },
icon: <SortDescendingIcon />,
disabled: tableHasRowspan(state),
icon: <AlphabeticalReverseSortIcon />,
},
{
name: "separator",
},
{
tooltip: dictionary.background,
icon:
colColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : colColors.size === 1 ? (
<CircleIcon color={colColors.values().next().value} />
) : (
<PaletteIcon />
),
children: [
...[
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
],
...TableCell.presetColors.map((preset) => ({
name: "toggleColumnBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () => colColors.size === 1 && colColors.has(preset.hex),
attrs: { color: preset.hex },
})),
...(customColor
? [
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleColumnBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
},
{
icon: <MoreIcon />,
children: [
+1 -96
View File
@@ -3,7 +3,6 @@ import {
InsertAboveIcon,
InsertBelowIcon,
MoreIcon,
PaletteIcon,
TableHeaderRowIcon,
TableSplitCellsIcon,
TableMergeCellsIcon,
@@ -11,40 +10,12 @@ import {
import type { EditorState } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import {
getCellsInRow,
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import type { MenuItem } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
import TableCell from "@shared/editor/nodes/TableCell";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
/**
* Get the set of background colors used in a row
*/
function getRowColors(state: EditorState, rowIndex: number): Set<string> {
const colors = new Set<string>();
const cells = getCellsInRow(rowIndex)(state) || [];
cells.forEach((pos) => {
const node = state.doc.nodeAt(pos);
if (!node) {
return;
}
const backgroundMark = (node.attrs.marks ?? []).find(
(mark: NodeAttrMark) => mark.type === "background"
);
if (backgroundMark && backgroundMark.attrs.color) {
colors.add(backgroundMark.attrs.color);
}
});
return colors;
}
export default function tableRowMenuItems(
state: EditorState,
@@ -66,74 +37,8 @@ export default function tableRowMenuItems(
}
const tableMap = selectedRect(state);
const rowColors = getRowColors(state, index);
const hasBackground = rowColors.size > 0;
const activeColor =
rowColors.size === 1 ? rowColors.values().next().value : null;
const customColor =
rowColors.size === 1
? [...rowColors].find((c) => !TableCell.isPresetColor(c))
: undefined;
return [
{
tooltip: dictionary.background,
icon:
rowColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : rowColors.size === 1 ? (
<CircleIcon color={rowColors.values().next().value} />
) : (
<PaletteIcon />
),
children: [
...[
{
name: "toggleRowBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
],
...TableCell.presetColors.map((preset) => ({
name: "toggleRowBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
attrs: { color: preset.hex },
})),
...(customColor
? [
{
name: "toggleRowBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleRowBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
},
{
icon: <MoreIcon />,
children: [
+1 -1
View File
@@ -6,7 +6,7 @@ declare global {
if (!window.env) {
throw new Error(
"Config could not be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed"
"Config could not be be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed"
);
}
-25
View File
@@ -4,8 +4,6 @@ import React, { createContext, useContext } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import useStores from "~/hooks/useStores";
import type Model from "~/models/base/Model";
import type Policy from "~/models/Policy";
import type { ActionContext as ActionContextType } from "~/types";
export const ActionContext = createContext<ActionContextType | undefined>(
@@ -51,31 +49,8 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
isMenu: false,
isCommandBar: false,
isButton: false,
// Legacy (backward compatibility)
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
// New API
getActiveModels: <T extends Model>(
modelClass: new (...args: any[]) => T
): T[] => stores.ui.getActiveModels<T>(modelClass),
getActiveModel: <T extends Model>(
modelClass: new (...args: any[]) => T
): T | undefined => stores.ui.getActiveModels<T>(modelClass)[0],
getActivePolicies: <T extends Model>(
modelClass: new (...args: any[]) => T
): Policy[] =>
stores.ui
.getActiveModels<T>(modelClass)
.map((node) => stores.policies.get(node.id))
.filter((policy): policy is Policy => policy !== undefined),
isModelActive: (model: Model): boolean => stores.ui.isModelActive(model),
activeModels: stores.ui.activeModels,
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
location,
-4
View File
@@ -69,7 +69,6 @@ export default function useDictionary() {
link: t("Link"),
linkCopied: t("Link copied to clipboard"),
mark: t("Highlight"),
background: t("Background color"),
newLineEmpty: `${t("Type '/' to insert")}`,
newLineWithSlash: `${t("Keep typing to filter")}`,
noResults: t("No results"),
@@ -113,9 +112,6 @@ export default function useDictionary() {
video: t("Video"),
untitled: t("Untitled"),
none: t("None"),
toggleBlock: t("Toggle block"),
emptyToggleBlockHead: `${t("Add title")}`,
emptyToggleBlockBody: `${t("Add content")}`,
deleteEmbed: t("Delete embed"),
uploadImage: t("Upload an image"),
formattingControls: t("Formatting controls"),
+2 -3
View File
@@ -3,7 +3,6 @@ import { TableOfContentsIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { EmojiText } from "@shared/components/EmojiText";
import { createAction, createActionGroup } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import Button from "~/components/Button";
@@ -27,7 +26,7 @@ function TableOfContentsMenu() {
createAction({
name: (
<HeadingWrapper $level={heading.level - minHeading}>
<EmojiText>{heading.title}</EmojiText>
{t(heading.title)}
</HeadingWrapper>
),
section: ActiveDocumentSection,
@@ -39,7 +38,7 @@ function TableOfContentsMenu() {
),
})
),
[headings, minHeading]
[t, headings, minHeading]
);
const actions = useMemo(() => {
+1 -4
View File
@@ -4,7 +4,6 @@ import { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EmojiText } from "@shared/components/EmojiText";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, hideScrollbars, s } from "@shared/styles";
import { useDocumentContext } from "~/components/DocumentContext";
@@ -81,9 +80,7 @@ function Contents() {
level={heading.level - headingAdjustment}
active={activeSlug === heading.id}
>
<Link href={`#${heading.id}`}>
<EmojiText>{heading.title}</EmojiText>
</Link>
<Link href={`#${heading.id}`}>{heading.title}</Link>
</ListItem>
))}
</List>
+1 -19
View File
@@ -199,18 +199,7 @@ class DocumentScene extends React.Component<Props> {
const revisionId = location.state?.revisionId;
const editorRef = this.editor.current;
if (!editorRef) {
return;
}
// Highlight search term when navigating from search results
const params = new URLSearchParams(location.search);
const searchTerm = params.get("q");
if (searchTerm) {
editorRef.commands.find({ text: searchTerm });
}
if (!restore) {
if (!editorRef || !restore) {
return;
}
@@ -669,13 +658,6 @@ const Main = styled.div<MainProps>`
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
: `1fr minmax(0, ${`calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})`}) 1fr`};
`};
@media print {
display: block;
max-width: calc(
${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter}
);
}
`;
type ContentsContainerProps = {
@@ -25,7 +25,9 @@ import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import { useTranslation } from "react-i18next";
import IconPicker from "~/components/IconPicker";
import lazyWithRetry from "~/utils/lazyWithRetry";
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
type Props = {
/** ID of the associated document */
@@ -248,9 +248,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
onDeleteCommentMark={
commentingEnabled && can.comment ? handleRemoveComment : undefined
}
onOpenCommentsSidebar={
commentingEnabled ? ui.toggleComments : undefined
}
onInit={handleInit}
onDestroy={handleDestroy}
onChange={updateDocState}
+1 -10
View File
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import { TableOfContentsIcon, EditIcon } from "outline-icons";
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
@@ -82,15 +82,6 @@ function DocumentHeader({
const isMobileMedia = useMobile();
const isRevision = !!revision;
const isEditingFocus = useEditingFocus();
// Set CSS variable for header offset (used by sticky table headers)
useEffect(() => {
window.document.documentElement.style.setProperty(
"--header-offset",
isEditingFocus ? "0px" : "64px"
);
}, [isEditingFocus]);
const { hasHeadings, editor } = useDocumentContext();
const sidebarContext = useLocationSidebarContext();
const [measureRef, size] = useMeasure();
@@ -1,6 +1,5 @@
import { observer } from "mobx-react";
import { KeyboardIcon } from "outline-icons";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -8,33 +7,24 @@ import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useEditingFocus from "~/hooks/useEditingFocus";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
function KeyboardShortcutsButton() {
const { t } = useTranslation();
const { dialogs } = useStores();
const isEditingFocus = useEditingFocus();
const query = useQuery();
const shortcutsQuery = query.get("shortcuts");
const handleOpenKeyboardShortcuts = (defaultQuery?: string) => {
const handleOpenKeyboardShortcuts = () => {
dialogs.openGuide({
title: t("Keyboard shortcuts"),
content: <KeyboardShortcuts defaultQuery={defaultQuery} />,
content: <KeyboardShortcuts />,
});
};
useEffect(() => {
if (shortcutsQuery !== null) {
handleOpenKeyboardShortcuts(shortcutsQuery);
}
}, [shortcutsQuery]);
return (
<Tooltip content={t("Keyboard shortcuts")} shortcut="?">
<Button
onClick={() => handleOpenKeyboardShortcuts()}
onClick={handleOpenKeyboardShortcuts}
$hidden={isEditingFocus}
aria-label={t("Keyboard shortcuts")}
>
+1 -2
View File
@@ -79,8 +79,7 @@ function DocumentNew({ template }: Props) {
}
void createDocument();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
});
return (
<Flex column auto>
+5 -48
View File
@@ -8,12 +8,7 @@ import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import Key from "~/components/Key";
type Props = {
/** Initial search query to filter shortcuts */
defaultQuery?: string;
};
function KeyboardShortcuts({ defaultQuery = "" }: Props) {
function KeyboardShortcuts() {
const { t } = useTranslation();
const categories = useMemo(
() => [
@@ -351,31 +346,6 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) {
},
],
},
{
title: t("Toggle blocks"),
items: [
{
shortcut: (
<>
<Key symbol>{metaDisplay}</Key> + <Key>Enter</Key>
</>
),
label: t("Open / close"),
},
{
shortcut: <Key>{t("Tab")}</Key>,
label: t("Indent item"),
},
{
shortcut: (
<>
<Key symbol></Key> + <Key>{t("Tab")}</Key>
</>
),
label: t("Outdent item"),
},
],
},
{
title: t("Tables"),
items: [
@@ -480,14 +450,6 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) {
),
label: t("LaTeX block"),
},
{
shortcut: (
<>
<Key>+++</Key> <Key>{t("Space")}</Key>
</>
),
label: t("Toggle block"),
},
{
shortcut: <Key>{":::"}</Key>,
label: t("Info notice"),
@@ -538,7 +500,7 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) {
],
[t]
);
const [searchTerm, setSearchTerm] = useState(defaultQuery);
const [searchTerm, setSearchTerm] = useState("");
const normalizedSearchTerm = searchTerm.toLocaleLowerCase();
const handleChange = useCallback((event) => {
setSearchTerm(event.target.value);
@@ -562,15 +524,10 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) {
/>
</StickySearch>
{categories.map((category, x) => {
const titleMatches = category.title
.toLocaleLowerCase()
.includes(normalizedSearchTerm);
const filtered = searchTerm
? titleMatches
? category.items
: category.items.filter((item) =>
item.label.toLocaleLowerCase().includes(normalizedSearchTerm)
)
? category.items.filter((item) =>
item.label.toLocaleLowerCase().includes(normalizedSearchTerm)
)
: category.items;
if (!filtered.length) {
+46 -77
View File
@@ -8,11 +8,7 @@ import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { Pagination } from "@shared/constants";
import type {
SortFilter as TSortFilter,
DirectionFilter as TDirectionFilter,
DateFilter as TDateFilter,
} from "@shared/types";
import type { DateFilter as TDateFilter } from "@shared/types";
import { StatusFilter as TStatusFilter } from "@shared/types";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DocumentListItem from "~/components/DocumentListItem";
@@ -36,7 +32,6 @@ import { DocumentFilter } from "./components/DocumentFilter";
import DocumentTypeFilter from "./components/DocumentTypeFilter";
import RecentSearches from "./components/RecentSearches";
import SearchInput from "./components/SearchInput";
import { SortInput } from "./components/SortInput";
import UserFilter from "./components/UserFilter";
import { HStack } from "~/components/primitives/HStack";
@@ -68,8 +63,6 @@ function Search() {
? (params.getAll("statusFilter") as TStatusFilter[])
: [TStatusFilter.Published, TStatusFilter.Draft];
const titleFilter = params.get("titleFilter") === "true";
const sort = (params.get("sort") as TSortFilter) ?? "";
const direction = (params.get("direction") as TDirectionFilter) ?? "";
const isSearchable = !!(query || collectionId || userId);
@@ -82,7 +75,6 @@ function Search() {
documentType: isSearchable,
date: isSearchable,
title: !!query && !document,
sort: isSearchable,
};
const filters = React.useMemo(
@@ -94,8 +86,6 @@ function Search() {
dateFilter,
titleFilter,
documentId,
sort,
direction,
}),
[
query,
@@ -105,8 +95,6 @@ function Search() {
dateFilter,
titleFilter,
documentId,
sort,
direction,
]
);
@@ -159,14 +147,7 @@ function Search() {
dateFilter?: TDateFilter;
statusFilter?: TStatusFilter[];
titleFilter?: boolean | undefined;
sort?: string | undefined;
direction?: string | undefined;
}) => {
if (search.sort === "relevance") {
search.sort = undefined;
search.direction = undefined;
}
history.replace({
pathname: location.pathname,
search: queryString.stringify(
@@ -250,64 +231,53 @@ function Search() {
/>
<Filters>
<Flex align="center" gap={4}>
{filterVisibility.document && (
<DocumentFilter
document={document!}
onClick={() => {
handleFilterChange({ documentId: undefined });
}}
/>
)}
{filterVisibility.collection && (
<CollectionFilter
collectionId={collectionId}
onSelect={(collectionId) =>
handleFilterChange({ collectionId })
}
/>
)}
{filterVisibility.user && (
<UserFilter
userId={userId}
onSelect={(userId) => handleFilterChange({ userId })}
/>
)}
{filterVisibility.documentType && (
<DocumentTypeFilter
statusFilter={statusFilter}
onSelect={({ statusFilter }) =>
handleFilterChange({ statusFilter })
}
/>
)}
{filterVisibility.date && (
<DateFilter
dateFilter={dateFilter}
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
/>
)}
{filterVisibility.title && (
<SearchTitlesFilter
width={26}
height={14}
label={t("Search titles only")}
onChange={(checked: boolean) => {
handleFilterChange({ titleFilter: checked });
}}
checked={titleFilter}
/>
)}
</Flex>
{filterVisibility.sort && (
<SortInput
sort={sort}
direction={direction}
onSelect={(sort, direction) =>
handleFilterChange({ sort, direction })
{filterVisibility.document && (
<DocumentFilter
document={document!}
onClick={() => {
handleFilterChange({ documentId: undefined });
}}
/>
)}
{filterVisibility.collection && (
<CollectionFilter
collectionId={collectionId}
onSelect={(collectionId) =>
handleFilterChange({ collectionId })
}
/>
)}
{filterVisibility.user && (
<UserFilter
userId={userId}
onSelect={(userId) => handleFilterChange({ userId })}
/>
)}
{filterVisibility.documentType && (
<DocumentTypeFilter
statusFilter={statusFilter}
onSelect={({ statusFilter }) =>
handleFilterChange({ statusFilter })
}
/>
)}
{filterVisibility.date && (
<DateFilter
dateFilter={dateFilter}
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
/>
)}
{filterVisibility.title && (
<SearchTitlesFilter
width={26}
height={14}
label={t("Search titles only")}
onChange={(checked: boolean) => {
handleFilterChange({ titleFilter: checked });
}}
checked={titleFilter}
/>
)}
</Filters>
</form>
{isSearchable ? (
@@ -395,7 +365,6 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
const Filters = styled(HStack)`
flex-wrap: wrap;
justify-content: space-between;
margin-bottom: 12px;
transition: opacity 100ms ease-in-out;
padding: 8px 0;
@@ -408,7 +377,7 @@ const Filters = styled(HStack)`
const SearchTitlesFilter = styled(Switch)`
white-space: nowrap;
margin-left: 8px;
margin-top: 8px;
margin-top: 4px;
font-size: 14px;
font-weight: 400;
`;
@@ -1,78 +0,0 @@
import type { DirectionFilter, SortFilter as TSortFilter } from "@shared/types";
import { SortAscendingIcon, SortDescendingIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "~/components/FilterOptions";
type Props = {
/** The selected sort field */
sort?: TSortFilter | null;
/** The selected sort direction */
direction?: DirectionFilter | null;
/** Callback when a sort option is selected */
onSelect: (sort: string, direction: string) => void;
};
export const SortInput = ({ sort, direction, onSelect }: Props) => {
const { t } = useTranslation();
const options = useMemo(
() => [
{
key: "relevance-DESC",
label: t("Relevance"),
icon: <SortDescendingIcon size={20} />,
},
{
key: "updatedAt-DESC",
label: t("Recently updated"),
icon: <SortDescendingIcon size={20} />,
},
{
key: "updatedAt-ASC",
label: t("Least recently updated"),
icon: <SortAscendingIcon size={20} />,
},
{
key: "createdAt-DESC",
label: t("Newest"),
icon: <SortDescendingIcon size={20} />,
},
{
key: "createdAt-ASC",
label: t("Oldest"),
icon: <SortAscendingIcon size={20} />,
},
{
key: "title-ASC",
label: t("A → Z"),
icon: <SortAscendingIcon size={20} />,
},
{
key: "title-DESC",
label: t("Z → A"),
icon: <SortDescendingIcon size={20} />,
},
],
[t]
);
const selectedKey =
sort && direction ? `${sort}-${direction}` : "relevance-DESC";
const handleSelect = (key: string) => {
const [sortField, sortDirection] = key.split("-");
onSelect(sortField, sortDirection);
};
return (
<FilterOptions
showFilter={false}
showIcons={false}
disclosure={false}
options={options}
selectedKeys={[selectedKey]}
onSelect={handleSelect}
defaultLabel={t("Relevance")}
/>
);
};
@@ -20,7 +20,7 @@ function DomainManagement({ onSuccess }: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const [allowedDomains, setAllowedDomains] = React.useState(() => [
const [allowedDomains, setAllowedDomains] = React.useState([
...(team.allowedDomains ?? []),
]);
const [lastKnownDomainCount, updateLastKnownDomainCount] = React.useState(
+2 -20
View File
@@ -5,12 +5,11 @@ import type DocumentModel from "~/models/Document";
import DocumentComponent from "~/scenes/Document/components/Document";
import { useDocumentContext } from "~/components/DocumentContext";
import { useTeamContext } from "~/components/TeamContext";
import { useEffect, useMemo, useRef } from "react";
import { useMemo } from "react";
import { parseDomain } from "@shared/utils/domains";
import useCurrentUser from "~/hooks/useCurrentUser";
import Branding from "~/components/Branding";
import useShare from "@shared/hooks/useShare";
import useQuery from "~/hooks/useQuery";
type Props = {
document: DocumentModel;
@@ -18,38 +17,21 @@ type Props = {
function SharedDocument({ document }: Props) {
const { shareId } = useShare();
const query = useQuery();
const searchTerm = query.get("q") || undefined;
const team = useTeamContext() as PublicTeam | undefined;
const user = useCurrentUser({ rejectOnEmpty: false });
const { hasHeadings, setDocument, isEditorInitialized, editor } =
useDocumentContext();
const { hasHeadings, setDocument } = useDocumentContext();
const abilities = useMemo(() => ({}), []);
const isCustomDomain = useMemo(
() => parseDomain(window.location.origin).custom,
[]
);
const showBranding = !isCustomDomain && !user;
const searchTermProcessed = useRef<string | null>(null);
const tocPosition = hasHeadings
? (team?.tocPosition ?? TOCPosition.Left)
: false;
setDocument(document);
// Highlight search term when navigating from search results
useEffect(() => {
if (
isEditorInitialized &&
editor &&
searchTerm &&
searchTermProcessed.current !== searchTerm
) {
searchTermProcessed.current = searchTerm;
editor.commands.find({ text: searchTerm });
}
}, [isEditorInitialized, editor, searchTerm]);
return (
<>
<DocumentComponent
+5 -12
View File
@@ -116,7 +116,6 @@ export default class AuthStore extends Store<Team> {
if (isNil(newData.user)) {
void this.logout({
savePath: false,
clearCache: false,
revokeToken: false,
userInitiated: true,
});
@@ -307,22 +306,18 @@ export default class AuthStore extends Store<Team> {
/**
* Logs the user out and optionally revokes the authentication token.
*
* @param clearCache Whether to clear the IndexedDB databases used for document caching.
* @param revokeToken Whether the auth token should attempt to be revoked, this should be
* @param savePath Whether the current path should be saved and returned to after login.
* @param userInitiated Whether the logout was initiated by the user.
* @param revokeToken Whether the auth token should attempt to be revoked, this should be
* disabled with requests from ApiClient to prevent infinite loops.
*/
@action
logout = async ({
clearCache = true,
revokeToken = true,
savePath = false,
revokeToken = true,
userInitiated = false,
}: {
clearCache?: boolean;
revokeToken?: boolean;
savePath?: boolean;
revokeToken?: boolean;
userInitiated?: boolean;
}) => {
// if this logout was forced from an authenticated route then
@@ -355,10 +350,8 @@ export default class AuthStore extends Store<Team> {
this.logoutRedirectUri = env.OIDC_LOGOUT_URI;
}
if (clearCache) {
// clear IndexedDB databases used for document caching
await deleteAllDatabases();
}
// clear IndexedDB databases used for document caching
await deleteAllDatabases();
// clear all credentials from cache (and local storage via autorun)
this.currentUserId = null;
-15
View File
@@ -4,7 +4,6 @@ import filter from "lodash/filter";
import omitBy from "lodash/omitBy";
import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx";
import type { DirectionFilter, SortFilter } from "@shared/types";
import {
SubscriptionType,
type DateFilter,
@@ -40,8 +39,6 @@ export type SearchParams = {
collectionId?: string;
userId?: string;
shareId?: string;
sort?: SortFilter;
direction?: DirectionFilter;
};
type ImportOptions = {
@@ -653,14 +650,6 @@ export default class DocumentsStore extends Store<Document> {
}
) {
await super.delete(document, options);
// For permanent deletion, we need to actually remove the document from the
// local store data Map, as the base Store's remove() method only soft-deletes
// ParanoidModel instances by setting deletedAt.
if (options?.permanent) {
this.data.delete(document.id);
}
// check to see if we have any shares related to this document already
// loaded in local state. If so we can go ahead and remove those too.
const share = this.rootStore.shares.getByDocumentId(document.id);
@@ -748,11 +737,7 @@ export default class DocumentsStore extends Store<Document> {
await client.post("/documents.empty_trash");
const documentIdsSet = new Set(this.deleted.map((doc) => doc.id));
// Call removeAll to handle inverse relations, policies, and lifecycle hooks
this.removeAll((doc: Document) => documentIdsSet.has(doc.id));
// For permanent deletion (empty trash), we need to hard delete from the store
// after the cleanup is done, as removeAll only soft-deletes ParanoidModel instances
documentIdsSet.forEach((id) => this.data.delete(id));
};
star = (document: Document, index?: string) =>
+13 -112
View File
@@ -2,9 +2,7 @@ import { action, computed, observable } from "mobx";
import { flushSync } from "react-dom";
import { light as defaultTheme } from "@shared/styles/theme";
import Storage from "@shared/utils/Storage";
import type Model from "~/models/base/Model";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import type Document from "~/models/Document";
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
import { startViewTransition } from "~/utils/viewTransition";
import type RootStore from "./RootStore";
@@ -54,7 +52,10 @@ class UiStore {
systemTheme: SystemTheme;
@observable
activeModels = new Set<Model>();
activeDocumentId: string | undefined;
@observable
activeCollectionId?: string | null;
@observable
observingUserId: string | undefined;
@@ -149,86 +150,6 @@ class UiStore {
});
}
/**
* Add a model instance to the active set.
*
* @param model the model instance to add.
*/
@action
addActiveModel = (model: Model): void => {
this.activeModels.add(model);
};
/**
* Remove a model instance from the active set.
*
* @param model the model instance to remove.
*/
@action
removeActiveModel = (model: Model): void => {
this.activeModels.delete(model);
};
/**
* Get all active models of a specific type.
*
* @param modelClass the model class to filter by.
* @returns array of active models of the specified type.
*/
getActiveModels<T extends Model>(modelClass: new (...args: any[]) => T): T[] {
return Array.from(this.activeModels).filter(
(model) => model.constructor === modelClass
) as T[];
}
/**
* Check if a model instance is in the active set.
*
* @param model the model instance to check.
* @returns true if the model is active.
*/
isModelActive(model: Model): boolean {
return this.activeModels.has(model);
}
/**
* Clear all active models, or only models of a specific type.
*
* @param modelClass optional model class to filter by.
*/
@action
clearActiveModels(modelClass?: new (...args: any[]) => Model): void {
if (modelClass) {
const modelsToRemove = this.getActiveModels(modelClass);
modelsToRemove.forEach((model) => this.activeModels.delete(model));
} else {
this.activeModels.clear();
}
}
/**
* Get the most recently added model of a specific type (primary).
*
* @param modelClass the model class to filter by.
* @returns the most recently added model of the specified type.
*/
getPrimaryActiveModel<T extends Model>(
modelClass: new (...args: any[]) => T
): T | undefined {
const models = this.getActiveModels<T>(modelClass);
return models[models.length - 1];
}
@computed
get activeDocumentId(): string | undefined {
return this.getPrimaryActiveModel<Document>(Document)?.id;
}
@computed
get activeCollectionId(): string | undefined {
return this.getPrimaryActiveModel<Collection>(Collection)?.id;
}
@action
setTheme = (theme: Theme) => {
startViewTransition(() => {
@@ -252,28 +173,17 @@ class UiStore {
@action
setActiveDocument = (document: Document | string): void => {
let model: Document | undefined;
if (typeof document === "string") {
model = this.rootStore.documents.get(document);
} else {
model = document;
}
if (!model) {
this.activeDocumentId = document;
this.observingUserId = undefined;
return;
}
this.clearActiveModels(Document);
this.addActiveModel(model);
this.activeDocumentId = document.id;
this.observingUserId = undefined;
if (model.isActive && model.collectionId) {
const collection = this.rootStore.collections.get(model.collectionId);
if (collection) {
this.clearActiveModels(Collection);
this.addActiveModel(collection);
}
if (document.isActive) {
this.activeCollectionId = document.collectionId;
}
};
@@ -293,16 +203,7 @@ class UiStore {
@action
setActiveCollection = (collectionId: string | undefined): void => {
if (collectionId === undefined || collectionId === null) {
this.clearActiveModels(Collection);
return;
}
const model = this.rootStore.collections.get(collectionId);
if (model) {
this.clearActiveModels(Collection);
this.addActiveModel(model);
}
this.activeCollectionId = collectionId;
};
@action
@@ -312,12 +213,12 @@ class UiStore {
@action
clearActiveDocument = (): void => {
this.clearActiveModels(Document);
this.activeDocumentId = undefined;
this.observingUserId = undefined;
// Unset when navigating away from a document (e.g. to another document, home, settings, etc.)
// Next document's onMount will set the right activeCollectionId.
this.clearActiveModels(Collection);
this.activeCollectionId = undefined;
};
@action
+2 -28
View File
@@ -8,14 +8,12 @@ import type {
} from "@shared/types";
import type RootStore from "~/stores/RootStore";
import type { SidebarContextType } from "./components/Sidebar/components/SidebarContext";
import type Model from "./models/base/Model";
import type Document from "./models/Document";
import type FileOperation from "./models/FileOperation";
import type Pin from "./models/Pin";
import type Star from "./models/Star";
import type User from "./models/User";
import type UserMembership from "./models/UserMembership";
import type Policy from "./models/Policy";
export type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> &
Required<Pick<T, K>>;
@@ -39,8 +37,7 @@ export type MenuItemWithChildren = {
disabled?: boolean;
style?: React.CSSProperties;
hover?: boolean;
/** Condition to check before preventing the submenu from closing */
preventCloseCondition?: () => boolean;
items: MenuItem[];
icon?: React.ReactNode;
};
@@ -85,12 +82,6 @@ export type MenuGroup = {
items: MenuItem[];
};
export type MenuCustomContent = {
type: "custom";
visible?: boolean;
content: React.ReactNode;
};
export type MenuItem =
| MenuInternalLink
| MenuItemButton
@@ -98,32 +89,15 @@ export type MenuItem =
| MenuItemWithChildren
| MenuSeparator
| MenuHeading
| MenuGroup
| MenuCustomContent;
| MenuGroup;
export type ActionContext = {
isMenu: boolean;
isCommandBar: boolean;
isButton: boolean;
sidebarContext?: SidebarContextType;
// Legacy (backward compatibility) - returns primary active model's ID
activeCollectionId?: string | undefined;
activeDocumentId: string | undefined;
// New API - work directly with Model instances
getActiveModels: <T extends Model>(
modelClass: new (...args: any[]) => T
) => T[];
getActiveModel: <T extends Model>(
modelClass: new (...args: any[]) => T
) => T | undefined;
getActivePolicies: <T extends Model>(
modelClass: new (...args: any[]) => T
) => Policy[];
isModelActive: (model: Model) => boolean;
activeModels: ReadonlySet<Model>;
currentUserId: string | undefined;
currentTeamId: string | undefined;
location: Location;
-1
View File
@@ -176,7 +176,6 @@ class ApiClient {
if (!this.shareId) {
await stores.auth.logout({
savePath: true,
clearCache: false,
revokeToken: false,
});
}
+6 -11
View File
@@ -113,19 +113,13 @@ export function newDocumentPath(
templateId?: string;
} = {}
): string {
const search = queryString.stringify(params);
return collectionId
? `/collection/${collectionId}/new${search ? `?${search}` : ""}`
: `/doc/new${search ? `?${search}` : ""}`;
? `/collection/${collectionId}/new?${queryString.stringify(params)}`
: `/doc/new?${queryString.stringify(params)}`;
}
export function newNestedDocumentPath(parentDocumentId?: string): string {
const search = parentDocumentId
? `?${queryString.stringify({ parentDocumentId })}`
: "";
return `/doc/new${search}`;
return `/doc/new?${queryString.stringify({ parentDocumentId })}`;
}
export function searchPath({
@@ -139,14 +133,15 @@ export function searchPath({
documentId?: string;
ref?: string;
} = {}): string {
const search = queryString.stringify({
let search = queryString.stringify({
q: query,
collectionId,
documentId,
ref,
});
return `/search${search ? `?${search}` : ""}`;
search = search ? `?${search}` : "";
return `/search${search}`;
}
export function sharedModelPath(shareId: string, modelPath?: string) {
+17 -10
View File
@@ -56,6 +56,13 @@
"@aws-sdk/s3-presigned-post": "3.956.0",
"@aws-sdk/s3-request-presigner": "3.956.0",
"@aws-sdk/signature-v4-crt": "^3.956.0",
"@babel/core": "^7.28.5",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.5",
"@babel/plugin-transform-regenerator": "^7.28.4",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.13.0",
@@ -75,6 +82,7 @@
"@hocuspocus/extension-throttle": "1.1.2",
"@hocuspocus/provider": "1.1.2",
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@linear/sdk": "^58.1.0",
"@node-oauth/oauth2-server": "^5.2.0",
@@ -108,6 +116,8 @@
"addressparser": "^1.0.1",
"async-sema": "^3.1.1",
"autotrack": "^2.4.1",
"babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-class-properties": "^6.24.1",
"body-scroll-lock": "^4.0.0-beta.0",
"bull": "^4.16.5",
"class-validator": "^0.14.3",
@@ -120,7 +130,7 @@
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.12.1",
"date-fns": "^3.6.0",
"dd-trace": "^5.82.0",
"dd-trace": "^5.76.0",
"diff": "^5.2.0",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
@@ -174,7 +184,7 @@
"node-fetch": "2.7.0",
"nodemailer": "^7.0.11",
"octokit": "^3.2.2",
"outline-icons": "^4.0.0",
"outline-icons": "^3.18.0",
"oy-vey": "^0.12.1",
"pako": "^2.1.0",
"passport": "^0.7.0",
@@ -207,7 +217,7 @@
"rate-limiter-flexible": "^2.4.2",
"react": "^17.0.2",
"react-avatar-editor": "^13.0.2",
"react-colorful": "^5.6.1",
"react-color": "^2.17.3",
"react-day-picker": "^8.10.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
@@ -252,6 +262,7 @@
"tiny-cookie": "^2.5.1",
"tmp": "^0.2.5",
"tunnel-agent": "^0.6.0",
"turndown": "^7.2.2",
"ukkonen": "^2.2.0",
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
@@ -271,11 +282,6 @@
},
"devDependencies": {
"@babel/cli": "^7.28.3",
"@babel/core": "^7.28.5",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.1",
@@ -322,6 +328,7 @@
"@types/quoted-printable": "^1.0.2",
"@types/react": "17.0.75",
"@types/react-avatar-editor": "^13.0.4",
"@types/react-color": "^3.0.13",
"@types/react-dom": "^17.0.11",
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
@@ -339,11 +346,11 @@
"@types/styled-components": "^5.1.32",
"@types/throng": "^5.0.7",
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.6",
"@types/utf8": "^3.0.3",
"@types/validator": "^13.15.3",
"@types/yauzl": "^2.10.3",
"babel-jest": "^29.7.0",
"babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
"babel-plugin-transform-typescript-metadata": "^0.4.0",
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
@@ -383,6 +390,6 @@
"prismjs": "1.30.0",
"cheerio": "1.0.0-rc.12"
},
"version": "1.4.0",
"version": "1.2.0",
"packageManager": "yarn@4.11.0"
}
-2
View File
@@ -5,7 +5,6 @@ export function DiscordGuildError(
) {
return httpErrors(400, message, {
id: "discord_guild_error",
isReportable: false,
});
}
@@ -14,6 +13,5 @@ export function DiscordGuildRoleError(
) {
return httpErrors(400, message, {
id: "discord_guild_role_error",
isReportable: false,
});
}
-26
View File
@@ -1,26 +0,0 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.3259 5.24514H9.3371C8.23873 5.24514 7.34832 6.0674 7.34832 7.08171C7.34832 8.09602 8.23873 8.91828 9.3371 8.91828H11.3259V5.24514ZM11.3259 4H12.6742H14.663C16.5061 4 18 5.37972 18 7.08171C18 8.08609 17.4798 8.97825 16.6745 9.54085C17.4798 10.1035 18 10.9956 18 12C18 13.702 16.5061 15.0817 14.663 15.0817C13.9178 15.0817 13.2296 14.8561 12.6742 14.4749V15.0817V16.9183C12.6742 18.6203 11.1801 20 9.3371 20C7.49406 20 6 18.6203 6 16.9183C6 15.9138 6.52029 15.0218 7.32556 14.4591C6.52029 13.8965 6 13.0044 6 12C6 10.9956 6.5203 10.1035 7.32559 9.54086C6.5203 8.97825 6 8.08609 6 7.08171C6 5.37972 7.49406 4 9.3371 4H11.3259ZM12.6742 5.24514V8.91828H14.663C15.7614 8.91828 16.6517 8.09602 16.6517 7.08171C16.6517 6.0674 15.7614 5.24514 14.663 5.24514H12.6742ZM9.3371 13.8366H11.3259V12.0047V12V11.9953V10.1634H9.3371C8.23873 10.1634 7.34832 10.9857 7.34832 12C7.34832 13.0119 8.23447 13.8326 9.32921 13.8366L9.3371 13.8366ZM7.34832 16.9183C7.34832 15.9064 8.23447 15.0856 9.32921 15.0817L9.3371 15.0817H11.3259V16.9183C11.3259 17.9326 10.4355 18.7549 9.3371 18.7549C8.23873 18.7549 7.34832 17.9326 7.34832 16.9183ZM12.6742 11.9963C12.6763 10.9837 13.5659 10.1634 14.663 10.1634C15.7614 10.1634 16.6517 10.9857 16.6517 12C16.6517 13.0143 15.7614 13.8366 14.663 13.8366C13.5659 13.8366 12.6763 13.0163 12.6742 12.0037V11.9963Z"
/>
</svg>
);
}
-125
View File
@@ -1,125 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import { AvatarSize } from "~/components/Avatar";
import Heading from "~/components/Heading";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import env from "~/env";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import FigmaIcon from "./Icon";
import { FigmaConnectButton } from "./components/FigmaButton";
import { IntegrationService, IntegrationType } from "@shared/types";
import type Integration from "~/models/Integration";
import Time from "~/components/Time";
function Figma() {
const { integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
const appName = env.APP_NAME;
const linkedAccountIntegration = integrations.find({
type: IntegrationType.LinkedAccount,
service: IntegrationService.Figma,
}) as Integration<IntegrationType.LinkedAccount> | undefined;
const figmaAccount = linkedAccountIntegration?.settings?.figma?.account;
return (
<IntegrationScene title="Figma" icon={<FigmaIcon />}>
<Heading>Figma</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in Figma to connect{" "}
{{ appName }} to your workspace. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again.
</Trans>
</Notice>
)}
{error === "unknown" && (
<Notice>
<Trans>
Something went wrong while processing your request. Please try
again.
</Trans>
</Notice>
)}
{env.FIGMA_CLIENT_ID ? (
<>
<Text as="p">
<Trans>
Link your {{ appName }} account to Figma to enable previews of
design files you have access to, directly within documents.
</Trans>
</Text>
{linkedAccountIntegration ? (
<List>
<ListItem
small
title={`${figmaAccount?.name} (${figmaAccount?.email})`}
subtitle={
<>
<Trans>Enabled on</Trans>{" "}
<Time
dateTime={linkedAccountIntegration.createdAt}
relative={false}
format={{ en_US: "MMMM d, y" }}
/>
</>
}
image={
<TeamLogo
src={
linkedAccountIntegration.settings?.figma?.account
?.avatarUrl
}
size={AvatarSize.Large}
/>
}
actions={
<ConnectedButton
onClick={linkedAccountIntegration.delete}
confirmationMessage={t(
"Disconnecting will prevent previewing Figma design files from this account in documents. Are you sure?"
)}
/>
}
/>
</List>
) : (
<p>
<FigmaConnectButton icon={<FigmaIcon />} />
</p>
)}
</>
) : (
<Notice>
<Trans>
The Figma integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</IntegrationScene>
);
}
export default observer(Figma);
@@ -1,23 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Button, { type Props } from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { redirectTo } from "~/utils/urls";
import { FigmaUtils } from "../../shared/FigmaUtils";
export function FigmaConnectButton(props: Props<HTMLButtonElement>) {
const { t } = useTranslation();
const team = useCurrentTeam();
return (
<Button
onClick={() =>
redirectTo(FigmaUtils.authUrl({ state: { teamId: team.id } }))
}
neutral
{...props}
>
{t("Connect")}
</Button>
);
}
-18
View File
@@ -1,18 +0,0 @@
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
import { createLazyComponent } from "~/components/LazyLoad";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
description:
"Connect your Figma account to Outline to enable rich design file previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
-7
View File
@@ -1,7 +0,0 @@
{
"id": "figma",
"name": "Figma",
"priority": 15,
"description": "Adds a Figma integration for link unfurling and converting links to mentions.",
"after": "linear"
}
-99
View File
@@ -1,99 +0,0 @@
import auth from "@server/middlewares/authentication";
import Router from "koa-router";
import * as T from "./schema";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import type { APIContext } from "@server/types";
import validate from "@server/middlewares/validate";
import { FigmaUtils } from "plugins/figma/shared/FigmaUtils";
import { transaction } from "@server/middlewares/transaction";
import Logger from "@server/logging/Logger";
import { IntegrationService, IntegrationType } from "@shared/types";
import { Integration, IntegrationAuthentication } from "@server/models";
import { addSeconds } from "date-fns";
import { Figma } from "../figma";
import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask";
const router = new Router();
router.get(
"figma.callback",
auth({ optional: true }),
validate(T.FigmaCallbackSchema),
apexAuthRedirect<T.FigmaCallbackReq>({
getTeamId: (ctx) => FigmaUtils.parseState(ctx.input.query.state)?.teamId,
getRedirectPath: (ctx, team) =>
FigmaUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
}),
getErrorPath: () => FigmaUtils.errorUrl("unauthenticated"),
}),
transaction(),
async (ctx: APIContext<T.FigmaCallbackReq>) => {
const { code, error } = ctx.input.query;
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(FigmaUtils.errorUrl(error));
return;
}
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
try {
// validation middleware ensures that code is non-null at this point.
const oauth = await Figma.oauthAccess(code!);
const figmaAccount = await Figma.getInstalledAccount(oauth.access_token);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.Figma,
userId: user.id,
teamId: user.teamId,
token: oauth.access_token,
refreshToken: oauth.refresh_token,
expiresAt: addSeconds(Date.now(), oauth.expires_in),
scopes: FigmaUtils.oauthScopes,
},
{ transaction }
);
const integration = await Integration.create<
Integration<IntegrationType.LinkedAccount>
>(
{
service: IntegrationService.Figma,
type: IntegrationType.LinkedAccount,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
figma: {
account: {
id: figmaAccount.id,
name: figmaAccount.handle,
email: figmaAccount.email,
avatarUrl: figmaAccount.img_url,
},
},
},
},
{ transaction }
);
transaction.afterCommit(async () => {
await new UploadIntegrationLogoTask().schedule({
integrationId: integration.id,
logoUrl: figmaAccount.img_url,
});
});
ctx.redirect(FigmaUtils.successUrl());
} catch (err) {
Logger.error("Encountered error during Figma OAuth callback", err);
ctx.redirect(FigmaUtils.errorUrl("unknown"));
}
}
);
export default router;
-20
View File
@@ -1,20 +0,0 @@
import { BaseSchema } from "@server/routes/api/schema";
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
export const FigmaCallbackSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string(),
error: z.string().nullish(),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
}),
});
export type FigmaCallbackReq = z.infer<typeof FigmaCallbackSchema>;
-25
View File
@@ -1,25 +0,0 @@
import { Environment } from "@server/env";
import { Public } from "@server/utils/decorators/Public";
import environment from "@server/utils/environment";
import { CannotUseWithout } from "@server/utils/validators";
import { IsOptional } from "class-validator";
class FigmaPluginEnvironment extends Environment {
/**
* Figma OAuth2 app client id. To enable integration with Figma.
*/
@Public
@IsOptional()
public FIGMA_CLIENT_ID = this.toOptionalString(environment.FIGMA_CLIENT_ID);
/**
* Figma OAuth2 app client secret. To enable integration with Figma.
*/
@IsOptional()
@CannotUseWithout("FIGMA_CLIENT_ID")
public FIGMA_CLIENT_SECRET = this.toOptionalString(
environment.FIGMA_CLIENT_SECRET
);
}
export default new FigmaPluginEnvironment();
-208
View File
@@ -1,208 +0,0 @@
import { z } from "zod";
import env from "./env";
import { FigmaUtils } from "../shared/FigmaUtils";
import type { UnfurlSignature } from "@server/types";
import isEmpty from "lodash/isEmpty";
import type { User } from "@server/models";
import { Integration } from "@server/models";
import { IntegrationType } from "@shared/types";
import { IntegrationService, UnfurlResourceType } from "@shared/types";
import { cdnPath } from "@shared/utils/urls";
import Logger from "@server/logging/Logger";
import { Minute } from "@shared/utils/time";
const Credentials = Buffer.from(
`${env.FIGMA_CLIENT_ID}:${env.FIGMA_CLIENT_SECRET}`
).toString("base64");
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_in: z.number(),
});
const RefreshTokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z.number(),
});
const AccountResponseSchema = z.object({
id: z.string(),
handle: z.string(),
email: z.string(),
img_url: z.string(),
});
export class Figma {
private static supportedHosts = ["www.figma.com", "figma.com"];
private static supportedFileTypes = [
"design", // Design files
"board", // Figjam
"slides",
"buzz",
"site",
"make",
];
/**
* Exchange an OAuth code for an access token
*
* @param code OAuth code to exchange for an access token
* @returns An object containing the access token and refresh token
*/
static async oauthAccess(code: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${Credentials}`,
};
const body = new URLSearchParams();
body.set("code", code);
body.set("redirect_uri", FigmaUtils.callbackUrl());
body.set("grant_type", "authorization_code");
const res = await fetch(FigmaUtils.tokenUrl, {
method: "POST",
headers,
body,
});
if (res.status !== 200) {
throw new Error(
`Error exchanging Figma OAuth code; status: ${res.status}, ${await res.text()}`
);
}
return AccessTokenResponseSchema.parse(await res.json());
}
static async refreshToken(refreshToken: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${Credentials}`,
};
const body = new URLSearchParams();
body.set("refresh_token", refreshToken);
const res = await fetch(FigmaUtils.refreshUrl, {
method: "POST",
headers,
body,
});
if (res.status !== 200) {
throw new Error(
`Error while refreshing access token from Figma; status: ${res.status}, ${await res.text()}`
);
}
return RefreshTokenResponseSchema.parse(await res.json());
}
static async getInstalledAccount(accessToken: string) {
const res = await fetch(FigmaUtils.accountUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (res.status !== 200) {
throw new Error(
`Error getting Figma current account; status: ${res.status}, ${await res.text()}`
);
}
return AccountResponseSchema.parse(await res.json());
}
static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
const resource = Figma.parseUrl(url);
if (!resource || !actor) {
return;
}
const integrations = (await Integration.scope("withAuthentication").findAll(
{
where: {
type: IntegrationType.LinkedAccount,
service: IntegrationService.Figma,
userId: actor.id,
teamId: actor.teamId,
},
}
)) as Integration<IntegrationType.LinkedAccount>[];
if (integrations.length === 0) {
return;
}
// Try to unfurl with any of the linked accounts
// Note: We support only one figma account per team for now.
for (const integration of integrations) {
try {
const accessToken =
await integration.authentication.refreshTokenIfNeeded(
async (refreshToken: string) => Figma.refreshToken(refreshToken),
5 * Minute.ms
);
const res = await fetch(Figma.fileMetadataUrl(resource.key), {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
// This connected account has access to the file.
if (res.status === 200) {
const data = await res.json();
return {
type: UnfurlResourceType.URL,
url,
title: data.file.name,
description: `Created by ${data.file.creator.handle}`,
thumbnailUrl: data.file.thumbnail_url,
faviconUrl: cdnPath("/images/figma.png"),
transformedUnfurl: true,
};
}
} catch (err) {
Logger.error(
`Error fetching Figma file metadata for integration ${integration.id}`,
err
);
}
}
// Either no linked accounts have access to the file, or we faced an error.
// Fallback to iframely unfurl either way.
return;
};
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
if (!Figma.supportedHosts.includes(hostname)) {
return;
}
const parts = pathname.split("/");
const type = parts[1];
const key = parts[2];
if (!Figma.supportedFileTypes.includes(type) || isEmpty(key)) {
return;
}
return {
type,
key,
};
}
private static fileMetadataUrl(key: string) {
return `https://api.figma.com/v1/files/${key}/meta`;
}
}
-22
View File
@@ -1,22 +0,0 @@
import { Hook, PluginManager } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./api/figma";
import env from "./env";
import { Figma } from "./figma";
import { Minute } from "@shared/utils/time";
const enabled = !!env.FIGMA_CLIENT_ID && !!env.FIGMA_CLIENT_SECRET;
if (enabled) {
PluginManager.add([
{
...config,
type: Hook.API,
value: router,
},
{
type: Hook.UnfurlProvider,
value: { unfurl: Figma.unfurl, cacheExpiry: 10 * Minute.seconds },
},
]);
}
-52
View File
@@ -1,52 +0,0 @@
import queryString from "query-string";
import env from "@shared/env";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
export type OAuthState = {
teamId: string;
};
export class FigmaUtils {
public static oauthScopes = ["current_user:read", "file_metadata:read"];
public static accountUrl = "https://api.figma.com/v1/me";
public static tokenUrl = "https://api.figma.com/v1/oauth/token";
public static refreshUrl = "https://api.figma.com/v1/oauth/refresh";
private static authBaseUrl = "https://www.figma.com/oauth";
private static settingsUrl = integrationSettingsPath("figma");
static parseState(state: string): OAuthState {
return JSON.parse(state);
}
static successUrl() {
return this.settingsUrl;
}
static errorUrl(error: string) {
return `${this.settingsUrl}?error=${error}`;
}
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: env.URL,
params: undefined,
}
) {
return params
? `${baseUrl}/api/figma.callback?${params}`
: `${baseUrl}/api/figma.callback`;
}
static authUrl({ state }: { state: OAuthState }) {
const params = {
client_id: env.FIGMA_CLIENT_ID,
redirect_uri: this.callbackUrl(),
state: JSON.stringify(state),
scope: this.oauthScopes.join(","),
response_type: "code",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
}
-3
View File
@@ -20,9 +20,6 @@ export const GitHubCallbackSchema = BaseSchema.extend({
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
})
.refine(
(req) =>
!(
+3 -2
View File
@@ -226,10 +226,11 @@ export class GitHub {
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a GitHub Pull Request details
*/
public static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
public static unfurl: UnfurlSignature = async (url: string, actor: User) => {
// Early return if URL doesn't match GitHub pattern (before any DB queries)
const resource = GitHub.parseUrl(url);
if (!resource || !actor) {
if (!resource) {
return;
}
+3 -20
View File
@@ -4,7 +4,6 @@ import Logger from "@server/logging/Logger";
import type { UnfurlError, UnfurlSignature } from "@server/types";
import fetch from "@server/utils/fetch";
import env from "./env";
import { cdnPath } from "@shared/utils/urls";
class Iframely {
public static defaultUrl = "https://iframe.ly";
@@ -41,25 +40,9 @@ class Iframely {
*/
public static unfurl: UnfurlSignature = async (url: string) => {
const data = await Iframely.requestResource(url);
if ("error" in data) {
return { error: data.error } as UnfurlError; // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
}
const parsedData = data as Record<string, any>;
return {
type: UnfurlResourceType.URL,
url: parsedData.url,
title: parsedData.meta.title,
description: parsedData.meta.description,
thumbnailUrl: (parsedData.links.thumbnail ?? [])[0]?.href ?? "",
faviconUrl:
parsedData.meta.site === "Figma"
? cdnPath("/images/figma.png")
: ((parsedData.links.icon ?? [])[0]?.href ?? ""),
transformedUnfurl: true,
};
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
? ({ error: data.error } as UnfurlError)
: { ...data, type: UnfurlResourceType.URL };
};
}
+1 -1
View File
@@ -68,7 +68,7 @@ function Linear() {
<Text as="p">
<Trans>
Enable previews of Linear issues in documents by connecting a
Linear workspace to {{ appName }}.
Linear workspace to {appName}.
</Trans>
</Text>
{integrations.linear.length ? (
+2 -2
View File
@@ -8,7 +8,7 @@ import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration } from "@server/models";
import type { APIContext } from "@server/types";
import { Linear } from "../linear";
import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask";
import UploadLinearWorkspaceLogoTask from "../tasks/UploadLinearWorkspaceLogoTask";
import * as T from "./schema";
import { LinearUtils } from "plugins/linear/shared/LinearUtils";
import { addSeconds } from "date-fns";
@@ -86,7 +86,7 @@ router.get(
transaction.afterCommit(async () => {
if (workspace.logoUrl) {
await new UploadIntegrationLogoTask().schedule({
await new UploadLinearWorkspaceLogoTask().schedule({
integrationId: integration.id,
logoUrl: workspace.logoUrl,
});
-3
View File
@@ -11,9 +11,6 @@ export const LinearCallbackSchema = BaseSchema.extend({
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
}),
});
+2 -2
View File
@@ -4,7 +4,7 @@ import config from "../plugin.json";
import router from "./api/linear";
import env from "./env";
import { Linear } from "./linear";
import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask";
import UploadLinearWorkspaceLogoTask from "./tasks/UploadLinearWorkspaceLogoTask";
import { uninstall } from "./uninstall";
const enabled = !!env.LINEAR_CLIENT_ID && !!env.LINEAR_CLIENT_SECRET;
@@ -18,7 +18,7 @@ if (enabled) {
},
{
type: Hook.Task,
value: UploadIntegrationLogoTask,
value: UploadLinearWorkspaceLogoTask,
},
{
type: Hook.UnfurlProvider,
+3 -2
View File
@@ -104,10 +104,11 @@ export class Linear {
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a Linear issue details
*/
static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
// Early return if URL doesn't match Linear pattern (before any DB queries)
const resource = Linear.parseUrl(url);
if (!resource || !actor) {
if (!resource) {
return;
}
@@ -5,28 +5,23 @@ import { createContext } from "@server/context";
import { Integration, User } from "@server/models";
import { BaseTask, TaskPriority } from "@server/queues/tasks/base/BaseTask";
const SupportedIntegrations = [
IntegrationService.Linear,
IntegrationService.Figma,
];
type Props = {
/** The integrationId to operate on */
integrationId: string;
/** The original logoUrl from third-party service */
/** The original logoUrl from Linear */
logoUrl: string;
};
/**
* A task that uploads the provided logoUrl to storage and updates the
* associated integration record with the new url.
* Linear integration record with the new url.
*/
export default class UploadIntegrationLogoTask extends BaseTask<Props> {
export default class UploadLinearWorkspaceLogoTask extends BaseTask<Props> {
public async perform(props: Props) {
const integration = await Integration.scope("withAuthentication").findByPk(
props.integrationId
);
if (!integration || !SupportedIntegrations.includes(integration.service)) {
const integration = await Integration.scope("withAuthentication").findByPk<
Integration<IntegrationType.Embed>
>(props.integrationId);
if (!integration || integration.service !== IntegrationService.Linear) {
return;
}
@@ -48,29 +43,11 @@ export default class UploadIntegrationLogoTask extends BaseTask<Props> {
},
});
if (!attachment) {
return;
if (attachment) {
integration.settings.linear!.workspace.logoUrl = attachment.url;
integration.changed("settings", true);
await integration.save();
}
switch (integration.service) {
case IntegrationService.Linear:
(
integration as Integration<IntegrationType.Embed>
).settings.linear!.workspace.logoUrl = attachment.url;
break;
case IntegrationService.Figma:
(
integration as Integration<IntegrationType.LinkedAccount>
).settings.figma!.account.avatarUrl = attachment.url;
break;
default:
throw new Error(
`Unsupported integration service: ${integration.service}`
); // This should never happen
}
integration.changed("settings", true);
await integration.save();
}
public get options() {
-3
View File
@@ -11,9 +11,6 @@ export const NotionCallbackSchema = BaseSchema.extend({
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
}),
});
-1
View File
@@ -19,7 +19,6 @@ PluginManager.add([
description:
"Manage your passkeys for passwordless authentication using biometrics or security keys.",
component: createLazyComponent(() => import("./Settings")),
enabled: () => true,
},
},
]);
@@ -1,88 +0,0 @@
import type { APIContext } from "@server/types";
import { getExpectedOrigin } from "./passkeys";
describe("getExpectedOrigin", () => {
// Helper to mock APIContext for testing
const createMockContext = (options: {
protocol: string;
hostname: string;
host: string;
forwardedPort?: string;
}): APIContext => ({
protocol: options.protocol,
request: {
hostname: options.hostname,
host: options.host,
get: (header: string) => {
if (header === "X-Forwarded-Port" && options.forwardedPort) {
return options.forwardedPort;
}
return undefined;
},
} as unknown,
}) as unknown as APIContext;
it("should construct origin with non-standard HTTPS port from X-Forwarded-Port", () => {
const ctx = createMockContext({
protocol: "https",
hostname: "outline.example.com",
host: "outline.example.com", // Without port (from X-Forwarded-Host)
forwardedPort: "10081",
});
expect(getExpectedOrigin(ctx)).toBe("https://outline.example.com:10081");
});
it("should construct origin without port for standard HTTPS port (443)", () => {
const ctx = createMockContext({
protocol: "https",
hostname: "outline.example.com",
host: "outline.example.com",
forwardedPort: "443",
});
expect(getExpectedOrigin(ctx)).toBe("https://outline.example.com");
});
it("should construct origin without port for standard HTTP port (80)", () => {
const ctx = createMockContext({
protocol: "http",
hostname: "outline.example.com",
host: "outline.example.com",
forwardedPort: "80",
});
expect(getExpectedOrigin(ctx)).toBe("http://outline.example.com");
});
it("should use host with port when X-Forwarded-Port is not present", () => {
const ctx = createMockContext({
protocol: "https",
hostname: "outline.example.com",
host: "outline.example.com:8443",
});
expect(getExpectedOrigin(ctx)).toBe("https://outline.example.com:8443");
});
it("should construct origin without port when not in host and no X-Forwarded-Port", () => {
const ctx = createMockContext({
protocol: "https",
hostname: "outline.example.com",
host: "outline.example.com",
});
expect(getExpectedOrigin(ctx)).toBe("https://outline.example.com");
});
it("should handle HTTP with non-standard port", () => {
const ctx = createMockContext({
protocol: "http",
hostname: "outline.example.com",
host: "outline.example.com",
forwardedPort: "8080",
});
expect(getExpectedOrigin(ctx)).toBe("http://outline.example.com:8080");
});
});
+5 -46
View File
@@ -7,7 +7,7 @@ import {
import { isoBase64URL } from "@simplewebauthn/server/helpers";
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
import Router from "koa-router";
import { randomBytes } from "node:crypto";
import { randomBytes } from "crypto";
import { User, UserPasskey, Team } from "@server/models";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
@@ -30,43 +30,6 @@ const CHALLENGE_EXPIRY_MS = Minute.ms * 5;
// Helper to get RP ID (domain) - for simplicity, we can use the hostname but strip port.
const getRpID = (ctx: APIContext) => ctx.request.hostname;
/**
* Helper to get the expected origin for WebAuthn.
* Properly handles non-standard ports by checking X-Forwarded-Port header.
*
* @param ctx - the API context.
* @returns the expected origin (protocol://host:port).
*/
export const getExpectedOrigin = (ctx: APIContext): string => {
const protocol = ctx.protocol;
const hostname = ctx.request.hostname;
// When behind a proxy with app.proxy = true, Koa uses X-Forwarded-Host
// which typically doesn't include the port. We need to check X-Forwarded-Port.
const forwardedPort = ctx.request.get("X-Forwarded-Port");
// ctx.request.host includes port if present (e.g., "example.com:3000")
// ctx.request.hostname excludes port (e.g., "example.com")
const hostWithPort = ctx.request.host;
// Determine if we need to add a port to the origin
let origin = `${protocol}://${hostname}`;
// Check if X-Forwarded-Port exists (when behind a proxy)
if (forwardedPort) {
const port = parseInt(forwardedPort, 10);
// Only add port if it's not the default for the protocol
if ((protocol === "https" && port !== 443) || (protocol === "http" && port !== 80)) {
origin = `${protocol}://${hostname}:${port}`;
}
} else if (hostWithPort !== hostname) {
// hostWithPort includes port, use it directly
origin = `${protocol}://${hostWithPort}`;
}
return origin;
};
/**
* Generate Redis key for registration challenge.
*
@@ -101,6 +64,7 @@ router.post(
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
authenticatorAttachment: "platform",
},
});
@@ -141,7 +105,7 @@ router.post(
verification = await verifyRegistrationResponse({
response: body,
expectedChallenge,
expectedOrigin: getExpectedOrigin(ctx),
expectedOrigin: `${ctx.protocol}://${ctx.request.host}`, // Origin includes port
expectedRPID: getRpID(ctx),
});
} catch (error) {
@@ -251,14 +215,9 @@ router.post(
include: [{ model: Team, as: "team", required: true }],
},
],
rejectOnEmpty: true,
});
if (!passkey) {
throw ValidationError(
"Passkey not found. It may have been removed or registered on a different account."
);
}
const user = passkey.user;
const team = user.team;
@@ -267,7 +226,7 @@ router.post(
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: getExpectedOrigin(ctx),
expectedOrigin: `${ctx.protocol}://${ctx.request.host}`,
expectedRPID: getRpID(ctx),
credential: {
id: passkey.credentialId,
-3
View File
@@ -11,9 +11,6 @@ export const SlackPostSchema = BaseSchema.extend({
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
}),
});
+3 -3
View File
@@ -188,7 +188,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
},
{ transaction }
);
await Integration.create<Integration<IntegrationType.Post>>(
await Integration.create(
{
service: IntegrationService.Slack,
type: IntegrationType.Post,
@@ -226,7 +226,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
},
{ transaction }
);
await Integration.create<Integration<IntegrationType.Command>>(
await Integration.create(
{
service: IntegrationService.Slack,
type: IntegrationType.Command,
@@ -246,7 +246,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
case IntegrationType.LinkedAccount: {
// validation middleware ensures that code is non-null at this point
const data = await Slack.oauthAccess(code!, SlackUtils.connectUrl());
await Integration.create<Integration<IntegrationType.LinkedAccount>>({
await Integration.create({
service: IntegrationService.Slack,
type: IntegrationType.LinkedAccount,
userId: user.id,
+1 -1
View File
@@ -1,4 +1,4 @@
import querystring from "node:querystring";
import querystring from "querystring";
import { InvalidRequestError } from "@server/errors";
import fetch from "@server/utils/fetch";
import { SlackUtils } from "../shared/SlackUtils";
+4 -73
View File
@@ -1,6 +1,6 @@
import { existsSync, copyFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { existsSync, copyFileSync } from "fs";
import { readFile } from "fs/promises";
import path from "path";
import FormData from "form-data";
import { ensureDirSync } from "fs-extra";
import { FileOperationState, FileOperationType } from "@shared/types";
@@ -15,7 +15,7 @@ import {
buildUser,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
import { randomUUID } from "node:crypto";
import { randomUUID } from "crypto";
const server = getTestServer();
@@ -340,75 +340,6 @@ describe("#files.get", () => {
expect(res.headers.get("Content-Disposition")).toEqual("attachment");
});
it("should succeed with status 200 ok when public-read avatar in uploads bucket is requested by non-owner", async () => {
const owner = await buildUser();
const otherUser = await buildUser({ teamId: owner.teamId });
const key = AttachmentHelper.getKey({
id: randomUUID(),
name: "avatar.jpg",
userId: owner.id,
});
await buildAttachment({
key,
teamId: owner.teamId,
userId: owner.id,
contentType: "image/jpg",
acl: "public-read",
});
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
);
copyFileSync(
path.resolve(__dirname, "..", "test", "fixtures", "avatar.jpg"),
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
);
// Non-owner user should be able to access public-read attachment
const res = await server.get(`/api/files.get?key=${key}`, {
headers: {
Authorization: `Bearer ${otherUser.getJwtToken()}`,
},
});
expect(res.status).toEqual(200);
expect(res.headers.get("Content-Type")).toEqual("image/jpg");
});
it("should fail with status 403 when private attachment in uploads bucket is requested by non-owner", async () => {
const owner = await buildUser();
const otherUser = await buildUser({ teamId: owner.teamId });
const key = AttachmentHelper.getKey({
id: randomUUID(),
name: "document.pdf",
userId: owner.id,
});
await buildAttachment({
key,
teamId: owner.teamId,
userId: owner.id,
contentType: "application/pdf",
acl: "private",
});
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
);
copyFileSync(
path.resolve(__dirname, "..", "test", "fixtures", "avatar.jpg"),
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
);
// Non-owner user should NOT be able to access private attachment
const res = await server.get(`/api/files.get?key=${key}`, {
headers: {
Authorization: `Bearer ${otherUser.getJwtToken()}`,
},
});
expect(res.status).toEqual(403);
});
it("should succeed with status 200 ok when exported file is requested using signature", async () => {
const user = await buildUser();
const fileName = "export-markdown.zip";
+1 -6
View File
@@ -77,15 +77,10 @@ router.get(
const forceDownload = !!ctx.input.query.download;
const isSignedRequest = !!ctx.input.query.sig;
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
const skipAuthorize = isPublicBucket || isSignedRequest;
const cacheHeader = "max-age=604800, immutable";
const attachment = await Attachment.findByKey(key);
// Skip authorization for public bucket, signed requests, or public-read ACL attachments
const skipAuthorize =
isPublicBucket ||
isSignedRequest ||
(attachment && !attachment.isPrivate);
if (!skipAuthorize) {
if (!attachment && !!ctx.input.query.key) {
throw NotFoundError();

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