Compare commits

..

1 Commits

Author SHA1 Message Date
tommoor 5c7b84ff0e chore: Compressed inefficient images automatically 2025-09-06 20:03:17 +00:00
375 changed files with 5591 additions and 7954 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ require("@dotenvx/dotenvx").config({
var path = require('path');
module.exports = {
'config': path.resolve('server/config', 'database.js'),
'config': path.resolve('server/config', 'database.json'),
'migrations-path': path.resolve('server', 'migrations'),
'models-path': path.resolve('server', 'models'),
}
+8 -7
View File
@@ -14,13 +14,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV=production
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
COPY --from=base $APP_PATH/build ./build
COPY --from=base $APP_PATH/server ./server
COPY --from=base $APP_PATH/public ./public
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
@@ -32,6 +26,13 @@ RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:22 AS deps
FROM node:20 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.87.4
Licensed Work: Outline 0.87.3
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-09-18
Change Date: 2029-09-01
Change License: Apache License, Version 2.0
+11 -12
View File
@@ -7,13 +7,14 @@
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
</p>
<p align="center">
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
</p>
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, A hosted version of the app is offered at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
@@ -50,14 +51,13 @@ please refer to the [architecture document](docs/ARCHITECTURE.md) first for a hi
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable. logging
can be enabled for all categories by setting `DEBUG=*` or for specific categories such as `DEBUG=database` and `LOG_LEVEL=debug`, or `LOG_LEVEL=silly` for very verbose logging.
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
## Tests
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
```shell
# To run all tests
@@ -68,14 +68,14 @@ make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly with jest:
frontend and backend tests directly.
```shell
# To run backend tests
yarn test:server
# To run a specific backend test in watch mode
yarn test path/to/file.test.ts --watch
# To run a specific backend test
yarn test:server myTestFile
# To run frontend tests
yarn test:app
@@ -86,15 +86,14 @@ yarn test:app
Sequelize is used to create and run migrations, for example:
```shell
yarn db:create-migration --name my-migration
yarn db:migrate
yarn db:rollback
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
```
Or, to run migrations on test database:
Or to run migrations on test database:
```shell
yarn db:migrate --env test
yarn sequelize db:migrate --env test
```
# Activity
+7
View File
@@ -13,6 +13,13 @@
"group": ["mime-types"],
"message": "Do not use the mime-types package in the browser."
}
],
"paths": [
{
"name": "reakit/Menu",
"importNames": ["useMenuState"],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
]
+2 -2
View File
@@ -27,8 +27,8 @@ export const createApiKey = createAction({
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createActionV2({
name: ({ t, isMenu }) =>
isMenu
name: ({ t, isContextMenu }) =>
isContextMenu
? apiKey.isExpired
? t("Delete")
: `${t("Revoke")}`
+5 -137
View File
@@ -1,12 +1,8 @@
import {
AlphabeticalReverseSortIcon,
AlphabeticalSortIcon,
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
ImportIcon,
ManualSortIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
@@ -26,11 +22,11 @@ import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createActionV2,
createActionV2WithChildren,
createInternalLinkActionV2,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
@@ -41,16 +37,10 @@ import {
searchPath,
} from "~/utils/routeHelpers";
import ExportDialog from "~/components/ExportDialog";
import { getEventFiles } from "@shared/utils/files";
import history from "~/utils/history";
import lazyWithRetry from "~/utils/lazyWithRetry";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} />
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Collection/SharePopover")
);
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
@@ -91,7 +81,8 @@ export const createCollection = createAction({
});
export const editCollection = createActionV2({
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}` : t("Edit collection")),
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
@@ -116,8 +107,8 @@ export const editCollection = createActionV2({
});
export const editCollectionPermissions = createActionV2({
name: ({ t, isMenu }) =>
isMenu ? `${t("Permissions")}` : t("Collection permissions"),
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
@@ -147,129 +138,6 @@ export const editCollectionPermissions = createActionV2({
},
});
export const importDocument = createActionV2({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
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.importFileTypes.join(", ");
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
try {
const document = await documents.import(
file,
null,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toast.error(err.message);
}
};
input.click();
},
});
export const sortCollection = createActionV2WithChildren({
name: ({ t }) => t("Sort in sidebar"),
section: ActiveCollectionSection,
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" ? (
<AlphabeticalSortIcon />
) : (
<AlphabeticalReverseSortIcon />
)
) : (
<ManualSortIcon />
);
},
children: [
createActionV2({
name: ({ t }) => t("A-Z sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "asc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
direction: "asc",
},
});
},
}),
createActionV2({
name: ({ t }) => t("Z-A sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "desc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
direction: "desc",
},
});
},
}),
createActionV2({
name: ({ t }) => t("Manual sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.sort.field !== "title";
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "index",
direction: "asc",
},
});
},
}),
],
});
export const searchInCollection = createInternalLinkActionV2({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
+22 -30
View File
@@ -50,6 +50,7 @@ import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInT
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import {
@@ -81,14 +82,7 @@ import {
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Document/SharePopover")
);
import Insights from "~/scenes/Document/components/Insights";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -390,8 +384,8 @@ export const subscribeDocument = createActionV2({
analyticsName: "Subscribe to document",
section: ActiveDocumentSection,
icon: <SubscribeIcon />,
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
if (!isMenu || !activeCollectionId) {
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
if (!isContextMenu || !activeCollectionId) {
return undefined;
}
@@ -399,8 +393,8 @@ export const subscribeDocument = createActionV2({
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isMenu, stores }) => {
if (!isMenu || !activeCollectionId) {
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
if (!isContextMenu || !activeCollectionId) {
return false;
}
@@ -436,8 +430,8 @@ export const unsubscribeDocument = createActionV2({
analyticsName: "Unsubscribe from document",
section: ActiveDocumentSection,
icon: <UnsubscribeIcon />,
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
if (!isMenu || !activeCollectionId) {
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
if (!isContextMenu || !activeCollectionId) {
return undefined;
}
@@ -445,8 +439,8 @@ export const unsubscribeDocument = createActionV2({
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isMenu, stores }) => {
if (!isMenu || !activeCollectionId) {
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
if (!isContextMenu || !activeCollectionId) {
return false;
}
@@ -577,7 +571,8 @@ export const downloadDocumentAsMarkdown = createActionV2({
});
export const downloadDocument = createActionV2WithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
analyticsName: "Download document",
section: ActiveDocumentSection,
icon: <DownloadIcon />,
@@ -599,15 +594,12 @@ export const copyDocumentAsMarkdown = createActionV2({
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ stores, activeDocumentId, t }) => {
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toMarkdown(document));
copy(document.toMarkdown());
toast.success(t("Markdown copied to clipboard"));
}
},
@@ -621,15 +613,12 @@ export const copyDocumentAsPlainText = createActionV2({
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ stores, activeDocumentId, t }) => {
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toPlainText(document));
copy(document.toPlainText());
toast.success(t("Text copied to clipboard"));
}
},
@@ -689,7 +678,8 @@ export const copyDocument = createActionV2WithChildren({
});
export const duplicateDocument = createActionV2({
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
analyticsName: "Duplicate document",
section: ActiveDocumentSection,
icon: <DuplicateIcon />,
@@ -839,7 +829,8 @@ export const searchInDocument = createInternalLinkActionV2({
});
export const printDocument = createActionV2({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
analyticsName: "Print document",
section: ActiveDocumentSection,
icon: <PrintIcon />,
@@ -861,7 +852,7 @@ export const importDocument = createActionV2({
}
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
return !!stores.policies.abilities(activeCollectionId).update;
}
return false;
@@ -874,6 +865,7 @@ export const importDocument = createActionV2({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
try {
+2 -2
View File
@@ -131,8 +131,8 @@ export const navigateToTemplateSettings = createAction({
});
export const navigateToNotificationSettings = createInternalLinkActionV2({
name: ({ t, isMenu }) =>
isMenu ? t("Notification settings") : t("Notifications"),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Notification settings") : t("Notifications"),
analyticsName: "Navigate to notification settings",
section: NavigationSection,
iconInContextMenu: false,
+2 -1
View File
@@ -37,7 +37,8 @@ export const changeToSystemTheme = createActionV2({
});
export const changeTheme = createActionV2WithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Appearance") : t("Change theme"),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
icon: ({ stores }) =>
+7 -6
View File
@@ -2,6 +2,7 @@ import { LocationDescriptor } from "history";
import flattenDeep from "lodash/flattenDeep";
import { toast } from "sonner";
import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import {
Action,
ActionContext,
@@ -45,7 +46,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
return definition.perform?.(context);
}
: undefined,
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -201,7 +202,7 @@ export function createActionV2(
return definition.perform(context);
}
: () => {},
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -212,7 +213,7 @@ export function createInternalLinkActionV2(
...definition,
type: "action",
variant: "internal_link",
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -223,7 +224,7 @@ export function createExternalLinkActionV2(
...definition,
type: "action",
variant: "external_link",
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -234,7 +235,7 @@ export function createActionV2WithChildren(
...definition,
type: "action",
variant: "action_with_children",
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -251,7 +252,7 @@ export function createRootMenuAction(
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
): ActionV2WithChildren {
return {
id: crypto.randomUUID(),
id: uuidv4(),
type: "action",
variant: "action_with_children",
name: "root_action",
+17 -10
View File
@@ -3,8 +3,12 @@ import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction, performActionV2, resolve } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import { Action, ActionV2Variant, ActionV2WithChildren } from "~/types";
import useActionContext from "~/hooks/useActionContext";
import {
Action,
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
} from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
@@ -13,6 +17,8 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
/** Context of action, must be provided with action */
context?: ActionContext;
/** If tooltip props are provided the button will be wrapped in a tooltip */
tooltip?: Omit<TooltipProps, "children">;
};
@@ -22,20 +28,22 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
*/
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function _ActionButton(
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) {
const actionContext = useActionContext({
isButton: true,
});
const isMounted = useIsMounted();
const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled;
if (!actionContext || !action) {
if (action && !context) {
throw new Error("Context must be provided with action");
}
if (!context || !action) {
return <button {...rest} ref={ref} />;
}
const actionContext = { ...context, isButton: true };
if (
action.visible &&
!resolve<boolean>(action.visible, actionContext) &&
@@ -45,10 +53,9 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
}
const label =
rest["aria-label"] ??
(typeof action.name === "function"
typeof action.name === "function"
? action.name(actionContext)
: action.name);
: action.name;
const button = (
<button
+2 -1
View File
@@ -6,6 +6,7 @@ import Flex from "~/components/Flex";
export const Action = styled(Flex)`
justify-content: center;
align-items: center;
padding: 0 0 0 12px;
height: 32px;
font-size: 15px;
flex-shrink: 0;
@@ -17,6 +18,7 @@ export const Action = styled(Flex)`
export const Separator = styled.div`
flex-shrink: 0;
margin-left: 12px;
width: 1px;
height: 28px;
background: ${s("divider")};
@@ -31,7 +33,6 @@ const Actions = styled(Flex)`
background: ${s("background")};
padding: 12px;
backdrop-filter: blur(20px);
gap: 12px;
@media print {
display: none;
+13 -9
View File
@@ -13,6 +13,8 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
@@ -29,7 +31,6 @@ import {
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import { PortalContext } from "./Portal";
import CommandBar from "./CommandBar";
const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments")
@@ -37,9 +38,8 @@ const DocumentComments = lazyWithRetry(
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const SettingsSidebar = lazyWithRetry(
() => import("~/components/Sidebar/Settings")
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
type Props = {
children?: React.ReactNode;
@@ -109,10 +109,12 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
>
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
</Route>
)}
</AnimatePresence>
@@ -131,7 +133,9 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<React.Suspense fallback={null}>
<CommandBar />
</React.Suspense>
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
+1 -4
View File
@@ -25,8 +25,6 @@ type Props = {
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Size of the avatar, defaults to AvatarSize.Large */
size?: AvatarSize;
/** Optional alt text for the avatar image */
alt?: string;
/** Optional inline styles to apply to the avatar wrapper */
style?: React.CSSProperties;
};
@@ -55,7 +53,6 @@ function AvatarWithPresence({
isCurrentUser,
size = AvatarSize.Large,
style,
alt,
}: Props) {
const { t } = useTranslation();
const status = isPresent
@@ -86,7 +83,7 @@ function AvatarWithPresence({
$color={user.color}
style={style}
>
<Avatar model={user} onClick={onClick} size={size} alt={alt} />
<Avatar model={user} onClick={onClick} size={size} />
</AvatarPresence>
</Tooltip>
</>
+1 -2
View File
@@ -1,4 +1,3 @@
import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import env from "~/env";
@@ -45,4 +44,4 @@ const Link = styled.a`
}
`;
export default React.memo(Branding);
export default Branding;
+1 -1
View File
@@ -25,7 +25,7 @@ function Breadcrumb(
{ actions, highlightFirstItem, children, max = 2 }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const actionContext = useActionContext({ isMenu: true });
const actionContext = useActionContext({ isContextMenu: true });
const visibleActions = useComputed(
() =>
-1
View File
@@ -132,7 +132,6 @@ function Collaborators(props: Props) {
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
alt={t("Avatar of {{ name }}", { name: collaborator.name })}
onClick={
isObservable
? handleAvatarClick(
+2 -3
View File
@@ -143,14 +143,13 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
},
[]
);
const contentEditable = !disabled && !readOnly;
return (
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
{children}
<Content
ref={contentRef}
contentEditable={contentEditable}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onFocus={wrappedEvent(onFocus)}
onBlur={wrappedEvent(onBlur)}
@@ -158,7 +157,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
onPaste={handlePaste}
data-placeholder={placeholder}
suppressContentEditableWarning
role={contentEditable ? "textbox" : undefined}
role="textbox"
{...rest}
>
{innerValue}
+13
View File
@@ -0,0 +1,13 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${s("sidebarText")};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default Header;
@@ -0,0 +1,13 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
color: ${s("textSecondary")};
flex-shrink: 0;
`;
export default MenuIconWrapper;
+217
View File
@@ -0,0 +1,217 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import { ellipsis, transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
to?: LocationDescriptor;
href?: string;
target?: string;
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactNode;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
disabled,
as,
hide,
icon,
...rest
}: Props,
ref: React.Ref<HTMLAnchorElement>
) => {
const content = React.useCallback(
(props) => {
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const preventDefault = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const handleClick = async (ev: React.MouseEvent) => {
hide?.();
if (onClick) {
preventDefault(ev);
await onClick(ev);
}
};
return (
<MenuAnchor
{...props}
$active={active}
as={onClick ? "button" : as}
onClick={handleClick}
onPointerDown={preventDefault}
onMouseDown={preventDefault}
ref={mergeRefs([
ref,
props.ref as React.RefObject<HTMLAnchorElement>,
])}
>
{selected !== undefined && (
<SelectedWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : <Spacer />}
</SelectedWrapper>
)}
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
<Title>{children}</Title>
</MenuAnchor>
);
},
[active, as, hide, icon, onClick, ref, children, selected]
);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
>
{content}
</BaseMenuItem>
);
};
const Spacer = styled.svg`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = {
level?: number;
disabled?: boolean;
dangerous?: boolean;
disclosure?: boolean;
$active?: boolean;
};
export const MenuAnchorCSS = css<MenuAnchorProps>`
display: flex;
margin: 0;
border: 0;
padding: 12px;
border-radius: 4px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
width: 100%;
min-height: 32px;
background: none;
color: ${(props) =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left;
align-items: center;
font-size: 16px;
cursor: default;
user-select: none;
white-space: nowrap;
position: relative;
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
${(props) => props.disabled && "pointer-events: none;"}
${(props) =>
props.$active === undefined &&
!props.disabled &&
`
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
}
}
`}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
box-shadow: none;
cursor: var(--pointer);
svg {
fill: ${props.theme.accentText};
}
`}
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`}
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
const SelectedWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 4px;
margin-left: -8px;
flex-shrink: 0;
color: ${s("textSecondary")};
`;
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
@@ -0,0 +1,70 @@
import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
type Positions = {
/** Sub-menu x */
x: number;
/** Sub-menu y */
y: number;
/** Sub-menu height */
h: number;
/** Sub-menu width */
w: number;
/** Mouse x */
mouseX: number;
/** Mouse y */
mouseY: number;
};
/**
* Component to cover the area between the mouse cursor and the sub-menu, to
* allow moving cursor to lower parts of sub-menu without the sub-menu
* disappearing.
*/
export default function MouseSafeArea(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const {
x = 0,
y = 0,
height: h = 0,
width: w = 0,
} = props.parentRef.current?.getBoundingClientRect() || {};
const [mouseX, mouseY] = useMousePosition();
const positions = { x, y, h, w, mouseX, mouseY };
return (
<div
style={{
position: "absolute",
top: 0,
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
right: getRight(positions),
left: getLeft(positions),
height: h,
width: getWidth(positions),
clipPath: getClipPath(positions),
}}
/>
);
}
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? Math.max(mouseX - (x + w), 10) + "px"
: Math.max(x - mouseX, 10) + "px";
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
mouseX > x
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
(100 * (mouseY - y)) / h + 5
}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
@@ -0,0 +1,20 @@
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { MenuButton } from "reakit/Menu";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof MenuButton> & {
className?: string;
};
export default function OverflowMenuButton({ className, ...rest }: Props) {
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton className={className} {...props}>
<MoreIcon />
</NudeButton>
)}
</MenuButton>
);
}
+15
View File
@@ -0,0 +1,15 @@
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
</MenuSeparator>
);
}
const HorizontalRule = styled.hr`
margin: 6px 0;
`;
+264
View File
@@ -0,0 +1,264 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
MenuButton,
MenuItem as BaseMenuItem,
MenuStateReturn,
} from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
import Flex from "~/components/Flex";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import {
Action,
ActionContext,
MenuSeparator,
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
import Separator from "./Separator";
import ContextMenu from ".";
type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>;
items?: TMenuItem[];
showIcons?: boolean;
};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
`;
type SubMenuProps = MenuStateReturn & {
templateItems: TMenuItem[];
parentMenuState: Omit<MenuStateReturn, "items">;
title: React.ReactNode;
};
const SubMenu = React.forwardRef(function _Template(
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
ref: React.LegacyRef<HTMLButtonElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState({
parentId: parentMenuState.baseId,
});
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu
{...menu}
aria-label={t("Submenu")}
onClick={parentMenuState.hide}
parentMenuState={parentMenuState}
>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator if the previous item was a separator
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as TMenuItem[])
.filter((item, index, arr) => {
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
}
function Template({ items, actions, context, showIcons, ...menu }: Props) {
const ctx = useActionContext({
isContextMenu: true,
});
const templateItems = actions
? actions.map((item) =>
item.type === "separator" || item.type === "heading"
? item
: actionToMenuItem(item, ctx)
)
: items || [];
const filteredTemplates = filterTemplateItems(templateItems);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) =>
item.type !== "separator" && item.type !== "heading" && !!item.icon
);
return (
<>
{filteredTemplates.map((item, index) => {
if (
iconIsPresentInAnyMenuItem &&
item.type !== "separator" &&
item.type !== "heading" &&
showIcons !== false
) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
}
if (item.type === "route") {
return (
<MenuItem
as={Link}
id={`${item.title}-${index}`}
to={item.to}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "link") {
return (
<MenuItem
id={`${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={
typeof item.href === "string" ? undefined : item.href.target
}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "button") {
const menuItem = (
<MenuItem
as="button"
id={`${item.title}-${index}`}
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
dangerous={item.dangerous}
key={`${item.type}-${item.title}-${index}`}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip
content={item.tooltip}
placement={"bottom"}
key={`tooltip-${item.title}-${index}`}
>
<div>{menuItem}</div>
</Tooltip>
) : (
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
{menuItem}
</React.Fragment>
);
}
if (item.type === "submenu") {
// Skip rendering empty submenus
return item.items.length > 0 ? (
<BaseMenuItem
key={`${item.type}-${item.title}-${index}`}
as={SubMenu}
id={`${item.title}-${index}`}
templateItems={item.items}
parentMenuState={menu}
title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu}
/>
) : null;
}
if (item.type === "separator") {
return <Separator key={`separator-${index}`} />;
}
if (item.type === "heading") {
return (
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
);
}
// This should never be reached for Reakit dropdown menu.
// Added for exhaustiveness check.
if (item.type === "group") {
return null;
}
const _exhaustiveCheck: never = item;
return _exhaustiveCheck;
})}
</>
);
}
function Title({
title,
icon,
}: {
title: React.ReactNode;
icon?: React.ReactNode;
}) {
return (
<Flex align="center">
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);
+317
View File
@@ -0,0 +1,317 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuStateReturn } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useEventListener from "~/hooks/useEventListener";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "~/styles/animations";
export type Placement =
| "auto-start"
| "auto"
| "auto-end"
| "top-start"
| "top"
| "top-end"
| "right-start"
| "right"
| "right-end"
| "bottom-end"
| "bottom"
| "bottom-start"
| "left-end"
| "left"
| "left-start";
type Props = MenuStateReturn & {
"aria-label"?: string;
/** Reference to the rendered menu div element */
menuRef?: React.RefObject<HTMLDivElement>;
/** The parent menu state if this is a submenu. */
parentMenuState?: Omit<MenuStateReturn, "items">;
/** Called when the context menu is opened. */
onOpen?: () => void;
/** Called when the context menu is closed. */
onClose?: () => void;
/** Called when the context menu is clicked. */
onClick?: (ev: React.MouseEvent) => void;
/** The maximum width of the context menu. */
maxWidth?: number;
/** The minimum height of the context menu. */
minHeight?: number;
children?: React.ReactNode;
};
const ContextMenu: React.FC<Props> = ({
menuRef,
children,
onOpen,
onClose,
parentMenuState,
...rest
}: Props) => {
const previousVisible = usePrevious(rest.visible);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
const isMobile = useMobile();
const isSubMenu = !!parentMenuState;
useUnmount(() => {
setIsMenuOpen(false);
});
React.useEffect(() => {
if (rest.visible && !previousVisible) {
onOpen?.();
if (!isSubMenu) {
setIsMenuOpen(true);
}
}
if (!rest.visible && previousVisible) {
onClose?.();
if (!isSubMenu) {
setIsMenuOpen(false);
}
}
}, [
onOpen,
onClose,
previousVisible,
rest.visible,
ui.sidebarCollapsed,
setIsMenuOpen,
isSubMenu,
t,
]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
return null;
}
// sets the menu height based on the available space between the disclosure/
// trigger and the bottom of the window
return (
<>
<Menu
ref={menuRef}
hideOnClickOutside={!isMobile}
preventBodyScroll={false}
{...rest}
>
{(props) => (
<InnerContextMenu
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any}
{...rest}
isSubMenu={isSubMenu}
>
{children}
</InnerContextMenu>
)}
</Menu>
</>
);
};
type InnerContextMenuProps = MenuStateReturn & {
isSubMenu: boolean;
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
minHeight?: number;
};
/**
* Inner context menu allows deferring expensive window measurement hooks etc
* until the menu is actually opened.
*/
const InnerContextMenu = (props: InnerContextMenuProps) => {
const { menuProps } = props;
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor =
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
const rightAnchor = menuProps.placement === "bottom-end";
const backgroundRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMobile();
const maxHeight = useMenuHeight({
visible: props.visible,
elementRef: props.unstable_disclosureRef,
});
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (props.visible && scrollElement && !props.isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
if (scrollElement && !props.isSubMenu) {
enableBodyScroll(scrollElement);
}
};
}, [props.isSubMenu, props.visible]);
useEventListener(
"animationstart",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "none";
}
}
},
backgroundRef.current
);
useEventListener(
"animationend",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "auto";
}
}
},
backgroundRef.current
);
const style =
topAnchor && !isMobile
? {
maxHeight,
}
: undefined;
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
props.hide?.();
}}
/>
)}
<Position {...menuProps}>
<Background
dir="auto"
maxWidth={props.maxWidth}
minHeight={props.minHeight}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={style}
>
{props.visible || props.animating ? props.children : null}
</Background>
</Position>
</>
);
};
export default ContextMenu;
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${s("backdrop")};
z-index: ${depths.menu - 1};
`;
export const Position = styled.div`
position: absolute;
z-index: ${depths.menu};
// Note: pointer events are re-enabled after the animation ends, see event listeners above
pointer-events: none;
&:focus-visible {
transition-delay: 250ms;
transition-property: outline-width;
transition-duration: 0;
outline: none;
}
/*
* overrides make mobile-first coding style challenging
* so we explicitly define mobile breakpoint here
*/
${breakpoint("mobile", "tablet")`
position: fixed !important;
transform: none !important;
top: auto !important;
right: 8px !important;
bottom: 16px !important;
left: 8px !important;
`};
`;
type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
minHeight?: number;
theme: DefaultTheme;
};
export const Background = styled(Scrollable)<BackgroundProps>`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${s("menuBackground")};
border-radius: 6px;
padding: 6px;
min-width: 180px;
min-height: ${(props) => props.minHeight || 44}px;
max-height: 75vh;
font-weight: normal;
@media print {
display: none;
}
${breakpoint("tablet")`
animation: ${(props: BackgroundProps) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
max-height: 100vh;
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
`};
`;
+4 -7
View File
@@ -1,10 +1,7 @@
import { observer } from "mobx-react";
import { Suspense } from "react";
import Guide from "~/components/Guide";
import Modal from "~/components/Modal";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Guide = lazyWithRetry(() => import("~/components/Guide"));
const Modal = lazyWithRetry(() => import("~/components/Modal"));
function Dialogs() {
const { dialogs } = useStores();
@@ -12,7 +9,7 @@ function Dialogs() {
const modals = [...modalStack];
return (
<Suspense fallback={null}>
<>
{guide ? (
<Guide
isOpen={guide.isOpen}
@@ -36,7 +33,7 @@ function Dialogs() {
{modal.content}
</Modal>
))}
</Suspense>
</>
);
}
+23 -16
View File
@@ -3,8 +3,8 @@ import { CSS } from "@dnd-kit/utilities";
import { subDays } from "date-fns";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { useRef, useCallback, Suspense } from "react";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
import { useRef, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
@@ -19,12 +19,10 @@ import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { useTextStats } from "~/hooks/useTextStats";
import CollectionIcon from "./Icons/CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
import lazyWithRetry from "~/utils/lazyWithRetry";
const ReadingTime = lazyWithRetry(() => import("./ReadingTime"));
type Props = {
/** The pin record */
@@ -78,13 +76,6 @@ function DocumentCard(props: Props) {
const isRecentlyUpdated =
new Date(document.updatedAt) > subDays(new Date(), 7);
const updatedAt = (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
);
return (
<Reorderable
ref={setNodeRef}
@@ -159,11 +150,12 @@ function DocumentCard(props: Props) {
</Heading>
<DocumentMeta size="xsmall">
{isRecentlyUpdated ? (
updatedAt
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
) : (
<Suspense fallback={updatedAt}>
<ReadingTime document={document} />
</Suspense>
<ReadingTime document={document} />
)}
</DocumentMeta>
</div>
@@ -185,6 +177,21 @@ function DocumentCard(props: Props) {
);
}
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
const DocumentSquircle = ({
icon,
color,
+17 -24
View File
@@ -3,7 +3,6 @@ import concat from "lodash/concat";
import difference from "lodash/difference";
import fill from "lodash/fill";
import filter from "lodash/filter";
import flatten from "lodash/flatten";
import includes from "lodash/includes";
import map from "lodash/map";
import { observer } from "mobx-react";
@@ -28,6 +27,7 @@ import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { ancestors, descendants, flattenTree } from "~/utils/tree";
import flatten from "lodash/flatten";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
@@ -49,13 +49,8 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
() => {
if (!defaultValue) {
return null;
}
// Search through all nodes in the tree, not just top-level items
const allNodes = flatten(items.map(flattenTree));
const node = allNodes.find((item) => item.id === defaultValue);
const node =
defaultValue && items.find((item) => item.id === defaultValue);
return node || null;
}
);
@@ -64,9 +59,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
// Search through all nodes in the tree, not just top-level items
const allNodes = flatten(items.map(flattenTree));
const node = allNodes.find((item) => item.id === defaultValue);
const node = items.find((item) => item.id === defaultValue);
if (node) {
return ancestors(node).map((ancestorNode) => ancestorNode.id);
}
@@ -111,6 +104,19 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
);
}, [items.length]);
React.useEffect(() => {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
React.useEffect(() => {
if (defaultValue && selectedNode && listRef) {
const index = nodes.findIndex((node) => node.id === selectedNode.id);
if (index > 0) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, []);
function getNodes() {
function includeDescendants(item: NavigationNode): NavigationNode[] {
return expandedNodes.includes(item.id)
@@ -124,19 +130,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
}
const nodes = getNodes();
React.useEffect(() => {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
React.useEffect(() => {
if (defaultValue && selectedNode && listRef) {
const index = nodes.findIndex((node) => node.id === selectedNode.id);
if (index > 0) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, [defaultValue, selectedNode, nodes]);
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
+87 -90
View File
@@ -25,7 +25,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
import { ActionContextProvider } from "~/hooks/useActionContext";
import useActionContext from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { ContextMenu } from "./Menu/ContextMenu";
import useStores from "~/hooks/useStores";
@@ -94,99 +94,98 @@ function DocumentListItem(
currentContext: locationSidebarContext,
});
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
const actionContext = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId ? document.collectionId : undefined,
});
const contextMenuAction = useDocumentMenuAction({ document });
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
}}
<ContextMenu
action={contextMenuAction}
context={actionContext}
ariaLabel={t("Document options")}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
>
<ContextMenu
action={contextMenuAction}
ariaLabel={t("Document options")}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
<DocumentLink
ref={itemRef}
dir={document.dir}
role="menuitem"
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
>
<DocumentLink
ref={itemRef}
dir={document.dir}
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
</Actions>
</DocumentLink>
</ContextMenu>
</ActionContextProvider>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Actions>
</DocumentLink>
</ContextMenu>
);
}
@@ -280,7 +279,7 @@ const DocumentLink = styled(Link)<{
`}
`;
const Heading = styled.span<{ rtl?: boolean }>`
const Heading = styled.h3<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
@@ -290,8 +289,6 @@ const Heading = styled.span<{ rtl?: boolean }>`
color: ${s("text")};
font-family: ${s("fontFamily")};
font-weight: 500;
font-size: 20px;
line-height: 1.2;
`;
const StarPositioner = styled(Flex)`
+12 -17
View File
@@ -155,22 +155,26 @@ const DocumentMeta: React.FC<Props> = ({
}
return (
<Viewed>
<Separator />
<Modified highlight>{t("Never viewed")}</Modified>
&nbsp;<Modified highlight>{t("Never viewed")}</Modified>
</Viewed>
);
}
return (
<Viewed>
<Separator />
{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
&nbsp;{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
</Viewed>
);
};
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
<Container
align="center"
rtl={document.dir === "rtl"}
{...rest}
dir="ltr"
lang=""
>
{to ? (
<Link to={to} replace={replace}>
{content}
@@ -188,17 +192,16 @@ const DocumentMeta: React.FC<Props> = ({
)}
{showParentDocuments && nestedDocumentsCount > 0 && (
<span>
<Separator />
{nestedDocumentsCount}{" "}
&nbsp; {nestedDocumentsCount}{" "}
{t("nested document", {
count: nestedDocumentsCount,
})}
</span>
)}
{timeSinceNow()}
&nbsp;{timeSinceNow()}
{canShowProgressBar && (
<>
<Separator />
&nbsp;&nbsp;
<DocumentTasks document={document} />
</>
)}
@@ -207,14 +210,6 @@ const DocumentMeta: React.FC<Props> = ({
);
};
export const Separator = styled.span`
padding: 0 0.4em;
&::after {
content: "•";
}
`;
const Strong = styled.strong`
font-weight: 550;
`;
-1
View File
@@ -39,7 +39,6 @@ function DocumentTasks({ document }: Props) {
const done = completed === total;
const previousDone = usePrevious(done);
const message = getMessage(t, total, completed);
return (
<>
{completed === total ? (
+4 -15
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { s, truncateMultiline } from "@shared/styles";
import { s } from "@shared/styles";
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
/** A callback when the title is submitted. */
@@ -32,7 +32,6 @@ function EditableTitle(
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
const [isSubmitting, setIsSubmitting] = React.useState(false);
React.useImperativeHandle(ref, () => ({
setIsEditing,
@@ -66,10 +65,6 @@ function EditableTitle(
ev.preventDefault();
ev.stopPropagation();
if (isSubmitting) {
return;
}
const trimmedValue = value.trim();
if (trimmedValue === originalValue || trimmedValue.length === 0) {
@@ -79,7 +74,6 @@ function EditableTitle(
return;
}
setIsSubmitting(true);
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
@@ -88,11 +82,10 @@ function EditableTitle(
toast.error(error.message);
throw error;
} finally {
setIsSubmitting(false);
setIsEditing(false);
}
},
[originalValue, value, onCancel, onSubmit, isSubmitting]
[originalValue, value, onCancel, onSubmit]
);
const handleKeyDown = React.useCallback(
@@ -135,21 +128,17 @@ function EditableTitle(
/>
</form>
) : (
<Text
<span
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
className={rest.className}
>
{value}
</Text>
</span>
)}
</>
);
}
const Text = styled.span`
${truncateMultiline(3)}
`;
const Input = styled.input`
color: ${s("text")};
background: ${s("background")};
+78 -49
View File
@@ -1,19 +1,22 @@
import deburr from "lodash/deburr";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { FetchPageParams } from "~/stores/base/Store";
import Button, { Inner } from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Text from "~/components/Text";
import { useMenuState } from "~/hooks/useMenuState";
import Input, { NativeInput, Outline } from "./Input";
import PaginatedList, { PaginatedItem } from "./PaginatedList";
import { MenuProvider } from "./primitives/Menu/MenuContext";
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
interface TFilterOption extends PaginatedItem {
key: string;
label: string;
note?: string;
icon?: React.ReactNode;
}
@@ -31,17 +34,19 @@ type Props = {
const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
className,
onSelect,
showFilter,
fetchQuery,
fetchQueryOptions,
...rest
}: Props) => {
const { t } = useTranslation();
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const [open, setOpen] = React.useState(false);
const menu = useMenuState({
modal: false,
});
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
);
@@ -53,26 +58,32 @@ const FilterOptions = ({
const renderItem = React.useCallback(
(option) => (
<MenuButton
<MenuItem
key={option.key}
icon={option.icon}
label={option.label}
onClick={() => {
onSelect(option.key);
setOpen(false);
menu.hide();
}}
selected={selectedKeys.includes(option.key)}
/>
{...menu}
>
{option.icon}
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
),
[onSelect, selectedKeys]
[menu, onSelect, selectedKeys]
);
const handleFilter = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
},
[]
);
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
};
const filteredOptions = React.useMemo(() => {
const normalizedQuery = deburr(query.toLowerCase());
@@ -110,13 +121,13 @@ const FilterOptions = ({
switch (ev.key) {
case "Escape":
setOpen(false);
menu.hide();
break;
case "Enter":
if (filteredOptions.length === 1) {
ev.preventDefault();
onSelect(filteredOptions[0].key);
setOpen(false);
menu.hide();
}
break;
case "ArrowDown":
@@ -127,7 +138,7 @@ const FilterOptions = ({
break;
}
},
[filteredOptions, onSelect]
[filteredOptions, menu, onSelect]
);
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
@@ -139,21 +150,21 @@ const FilterOptions = ({
}, []);
React.useEffect(() => {
if (open) {
if (menu.visible) {
searchInputRef.current?.focus();
} else {
setQuery("");
}
}, [open]);
}, [menu.visible]);
const showFilterInput = showFilter || options.length > 10;
const defaultLabel = rest.defaultLabel || t("Filter options");
return (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={setOpen}>
<MenuTrigger>
<>
<MenuButton {...menu}>
{(props) => (
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
@@ -161,31 +172,31 @@ const FilterOptions = ({
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
</MenuTrigger>
<MenuContent aria-label={defaultLabel} align="start">
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
/>
{showFilterInput && (
<SearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
autoFocus
/>
{showFilterInput && (
<SearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
autoFocus
/>
)}
</MenuContent>
</Menu>
</MenuProvider>
)}
</ContextMenu>
</>
);
};
@@ -231,6 +242,24 @@ const SearchInput = styled(Input)`
}
`;
const Note = styled(Text)`
display: block;
margin: 2px 0;
line-height: 1.2em;
font-size: 14px;
font-weight: 500;
color: ${s("textTertiary")};
`;
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
&:hover ${Note} {
color: ${(props) => props.theme.white50};
}
`;
export const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
-1
View File
@@ -125,7 +125,6 @@ const Actions = styled(Flex)`
flex-basis: 0;
min-width: auto;
padding-left: 8px;
gap: 12px;
${breakpoint("tablet")`
position: unset;
-16
View File
@@ -1,16 +0,0 @@
import { ArrowIcon as ArrowRightIcon } from "outline-icons";
import styled from "styled-components";
export { ArrowIcon as ArrowRightIcon } from "outline-icons";
export const ArrowUpIcon = styled(ArrowRightIcon)`
transform: rotate(-90deg);
`;
export const ArrowDownIcon = styled(ArrowRightIcon)`
transform: rotate(90deg);
`;
export const ArrowLeftIcon = styled(ArrowRightIcon)`
transform: rotate(180deg);
`;
+3 -2
View File
@@ -6,6 +6,7 @@ import styled from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import NudeButton from "./NudeButton";
@@ -218,9 +219,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
);
const renderOption = React.useCallback(
(option: Option, idx: number) => {
(option: Option) => {
if (option.type === "separator") {
return <InputSelectSeparator key={`separator-${idx}`} />;
return <Separator />;
}
const isSelected = option === selectedOption;
+30 -27
View File
@@ -12,6 +12,7 @@ import SkipNavLink from "~/components/SkipNavLink";
import env from "~/env";
import useAutoRefresh from "~/hooks/useAutoRefresh";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
type Props = {
@@ -37,39 +38,41 @@ const Layout = React.forwardRef(function Layout_(
});
return (
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
</Helmet>
<MenuProvider>
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
</Helmet>
<SkipNavLink />
<SkipNavLink />
{ui.progressBarVisible && <LoadingIndicatorBar />}
{ui.progressBarVisible && <LoadingIndicatorBar />}
<Container auto>
{sidebar}
<Container auto>
<MenuProvider>{sidebar}</MenuProvider>
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
style={
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
style={
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
{sidebarRight}
{sidebarRight}
</Container>
</Container>
</Container>
</MenuProvider>
);
});
+165 -460
View File
@@ -1,52 +1,30 @@
import { useEditor } from "~/editor/components/EditorContext";
import { observer } from "mobx-react";
import * as Dialog from "@radix-ui/react-dialog";
import { findChildren } from "@shared/editor/queries/findChildren";
import findIndex from "lodash/findIndex";
import styled, { css, Keyframes, keyframes } from "styled-components";
import {
ComponentProps,
createContext,
forwardRef,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { isInternalUrl } from "@shared/utils/urls";
import { Error as ImageError } from "@shared/editor/components/Image";
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import { sanitizeUrl } from "@shared/utils/urls";
import { Error } from "@shared/editor/components/Image";
import {
BackIcon,
CloseIcon,
CrossIcon,
DownloadIcon,
LinkIcon,
NextIcon,
ZoomInIcon,
ZoomOutIcon,
} from "outline-icons";
import { depths, extraArea, s } from "@shared/styles";
import NudeButton from "./NudeButton";
import useIdle from "~/hooks/useIdle";
import { Second } from "@shared/utils/time";
import { downloadImageNode } from "@shared/editor/nodes/Image";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { useTranslation } from "react-i18next";
import Tooltip from "~/components/Tooltip";
import LoadingIndicator from "./LoadingIndicator";
import Fade from "./Fade";
import Button from "./Button";
import CopyToClipboard from "./CopyToClipboard";
import { Separator } from "./Actions";
import useSwipe from "~/hooks/useSwipe";
import { toast } from "sonner";
import { findIndex } from "lodash";
import { LightboxImage } from "@shared/editor/lib/Lightbox";
import {
TransformWrapper,
TransformComponent,
useTransformEffect,
ReactZoomPanPinchRef,
} from "react-zoom-pan-pinch";
import { transparentize } from "polished";
export enum LightboxStatus {
READY_TO_OPEN,
@@ -61,9 +39,6 @@ export enum ImageStatus {
LOADING,
ERROR,
LOADED,
MIN_ZOOM,
MAX_ZOOM,
ZOOMED,
}
type Status = {
lightbox: LightboxStatus | null;
@@ -81,125 +56,46 @@ type Animation = {
const ANIMATION_DURATION = 0.3 * Second.ms;
type Props = {
/** List of allowed images */
images: LightboxImage[];
/** Callback triggered when the active image position is updated */
onUpdate: (pos: number | null) => void;
/** The position of the currently active image in the document */
activeImage: LightboxImage;
/** Callback triggered when the active image is updated */
onUpdate: (activeImage: LightboxImage | null) => void;
/** Callback triggered when Lightbox closes */
onClose: () => void;
activePos: number | null;
};
const ZoomPanPinchContext = createContext({ isImagePanning: false });
type ZoomablePannablePinchableProps = {
children: ReactNode;
panningDisabled: boolean;
disabled: boolean;
};
const ZoomablePannablePinchable = forwardRef<
ReactZoomPanPinchRef,
ZoomablePannablePinchableProps
>(({ children, panningDisabled, disabled }, ref) => {
const { isPanning, ...panningHandlers } = usePanning();
return (
<ZoomPanPinchContext.Provider value={{ isImagePanning: isPanning }}>
<TransformWrapper
ref={ref}
disabled={disabled}
doubleClick={{ disabled: true }}
minScale={1}
maxScale={8}
panning={{
disabled: panningDisabled,
}}
{...panningHandlers}
>
<TransformComponent
wrapperStyle={{ width: "100%", height: "100%" }}
contentStyle={{
width: "100%",
height: "100%",
padding: "56px",
justifyContent: "center",
alignItems: "center",
}}
>
{children}
</TransformComponent>
</TransformWrapper>
</ZoomPanPinchContext.Provider>
);
});
function usePanning() {
const [isPanning, setPanning] = useState(false);
const dragged = useRef(false);
const onPanningStart: ComponentProps<
typeof TransformWrapper
>["onPanningStart"] = (ref, event) => {
if (!(event.target instanceof HTMLImageElement)) {
return;
}
const zoomedIn = ref.state.scale > 1;
if (zoomedIn) {
setPanning(ref.instance.isPanning);
}
};
const onPanning: ComponentProps<
typeof TransformWrapper
>["onPanning"] = () => {
dragged.current = true;
};
const onPanningStop: ComponentProps<
typeof TransformWrapper
>["onPanningStop"] = (ref, event) => {
if (!(event.target instanceof HTMLImageElement)) {
return;
}
setPanning(ref.instance.isPanning);
if (dragged.current) {
dragged.current = false;
} else {
const zoomedOut = Math.abs(ref.state.scale - 1) < 0.001;
if (zoomedOut) {
ref.zoomIn();
} else {
ref.resetTransform();
}
}
};
return {
isPanning,
onPanningStart,
onPanning,
onPanningStop,
};
}
function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
function Lightbox({ onUpdate, activePos }: Props) {
const { view } = useEditor();
const isIdle = useIdle(3 * Second.ms);
const { t } = useTranslation();
const imgRef = useRef<HTMLImageElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
const [imageElements] = useState(
view?.dom.querySelectorAll(".component-image img")
);
const animation = useRef<Animation | null>(null);
const finalImage = useRef<{
center: { x: number; y: number };
width: number;
height: number;
} | null>(null);
const zoomPanPinchRef = useRef<ReactZoomPanPinchRef>(null);
const currentImageIndex = findIndex(
images,
(img) => img.getPos() === activeImage.getPos()
const imageNodes = useMemo(
() =>
view
? findChildren(
view.state.doc,
(child) => child.type === view.state.schema.nodes.image,
true
)
: [],
[view]
);
const currentImageIndex = findIndex(
imageNodes,
(node) => node.pos === activePos
);
const currentImageNode =
currentImageIndex >= 0 ? imageNodes[currentImageIndex].node : undefined;
// Debugging status changes
// useEffect(() => {
@@ -208,21 +104,15 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
// );
// }, [status]);
useEffect(
() => () => {
if (status.lightbox === LightboxStatus.CLOSED) {
onClose();
}
},
[status.lightbox]
);
useEffect(() => () => view.focus(), []);
useEffect(() => {
setStatus({
lightbox: LightboxStatus.READY_TO_OPEN,
image: status.image,
});
}, []);
!!activePos &&
setStatus({
lightbox: LightboxStatus.READY_TO_OPEN,
image: status.image,
});
}, [!!activePos]);
useEffect(() => {
if (status.image === ImageStatus.LOADED) {
@@ -245,18 +135,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
}, [status.image, status.lightbox]);
useEffect(() => {
if (
status.lightbox === LightboxStatus.OPENED &&
status.image === ImageStatus.LOADED
) {
setStatus({
lightbox: LightboxStatus.OPENED,
image: ImageStatus.MIN_ZOOM,
});
}
}, [status.lightbox, status.image]);
useEffect(() => {
if (status.lightbox === LightboxStatus.READY_TO_CLOSE) {
setupFadeOut();
@@ -274,15 +152,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
}, [status.lightbox]);
useEffect(() => {
if (status.image === ImageStatus.MIN_ZOOM) {
// It was observed that focus went to `body` as the zoom out button was disabled
// upon clicking it. This stopped navigating to next/previous image using arrow keys.
// So focusing the content div here to restore the functionality.
contentRef.current?.focus();
}
}, [status.image]);
const rememberImagePosition = () => {
if (imgRef.current) {
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
@@ -306,10 +175,11 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
const setupZoomIn = () => {
if (imgRef.current) {
// in editor
const editorImageEl = activeImage.getElement();
const editorImageEl = imageElements[currentImageIndex];
if (!editorImageEl) {
return;
}
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
const {
top: editorImgTop,
@@ -396,13 +266,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
};
const setupZoomOut = () => {
if (
imgRef.current &&
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
)
) {
if (imgRef.current) {
// in lightbox
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
const {
@@ -421,9 +285,9 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
};
// in editor
const editorImageEl = activeImage.getElement();
const editorImageEl = imageElements[currentImageIndex];
let to;
if (editorImageEl?.isConnected) {
if (editorImageEl) {
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
const {
top: editorImgTop,
@@ -496,31 +360,33 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
};
if (!activePos) {
return null;
}
const prev = () => {
if (
status.lightbox === LightboxStatus.OPENED &&
(status.image === ImageStatus.MIN_ZOOM ||
status.image === ImageStatus.ERROR)
) {
if (status.lightbox === LightboxStatus.OPENED) {
if (!activePos) {
return;
}
const prevIndex = currentImageIndex - 1;
if (prevIndex < 0) {
return;
}
onUpdate(images[prevIndex]);
onUpdate(imageNodes[prevIndex].pos);
}
};
const next = () => {
if (
status.lightbox === LightboxStatus.OPENED &&
(status.image === ImageStatus.MIN_ZOOM ||
status.image === ImageStatus.ERROR)
) {
const nextIndex = currentImageIndex + 1;
if (nextIndex >= images.length) {
if (status.lightbox === LightboxStatus.OPENED) {
if (!activePos) {
return;
}
onUpdate(images[nextIndex]);
const nextIndex = currentImageIndex + 1;
if (nextIndex >= imageNodes.length) {
return;
}
onUpdate(imageNodes[nextIndex].pos);
}
};
@@ -536,63 +402,12 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
};
const svgDataURLToBlob = (dataURL: string) => {
// Match the SVG data URL format
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
if (!match) {
return;
const download = () => {
if (currentImageNode && status.lightbox === LightboxStatus.OPENED) {
void downloadImageNode(currentImageNode);
}
const encodedSVGData = match[1];
const decodedSVGData = decodeURIComponent(encodedSVGData);
// Convert string to Uint8Array
const uint8 = new Uint8Array(decodedSVGData.length);
for (let i = 0; i < decodedSVGData.length; ++i) {
uint8[i] = decodedSVGData.charCodeAt(i);
}
// Create and return the Blob
return new Blob([uint8], { type: "image/svg+xml" });
};
const downloadImage = async (src: string, saveAs: string) => {
let imageBlob;
if (isInternalUrl(src)) {
const image = await fetch(src);
imageBlob = await image.blob();
} else {
// Assuming it's a mermaid svg
imageBlob = svgDataURLToBlob(src);
}
if (!imageBlob) {
toast.error(t("Unable to download image"));
return;
}
const imageURL = URL.createObjectURL(imageBlob);
const name = saveAs || "image";
const extension = imageBlob.type.split(/\/|\+/g)[1];
// create a temporary link node and click it with our image data
const link = document.createElement("a");
link.href = imageURL;
link.download = `${name}.${extension}`;
document.body.appendChild(link);
link.click();
// cleanup
document.body.removeChild(link);
URL.revokeObjectURL(imageURL);
};
const download = useCallback(() => {
if (activeImage && status.lightbox === LightboxStatus.OPENED) {
void downloadImage(activeImage.getSrc(), activeImage.getAlt());
}
}, [activeImage, status.lightbox]);
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
ev.preventDefault();
switch (ev.key) {
@@ -624,8 +439,6 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
if (animation.current?.fadeIn) {
animation.current = {
...(animation.current ?? {}),
zoomIn: undefined,
fadeIn: undefined,
startTime: undefined,
};
setStatus({
@@ -640,8 +453,12 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
};
if (!currentImageNode) {
return null;
}
return (
<Dialog.Root open={true}>
<Dialog.Root open={!!activePos}>
<Dialog.Portal>
<StyledOverlay
ref={overlayRef}
@@ -649,7 +466,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
onAnimationStart={handleFadeStart}
onAnimationEnd={handleFadeEnd}
/>
<StyledContent onKeyDown={handleKeyDown} ref={contentRef}>
<StyledContent onKeyDown={handleKeyDown}>
<VisuallyHidden.Root>
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
<Dialog.Description>
@@ -657,64 +474,9 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
</Dialog.Description>
</VisuallyHidden.Root>
<Actions animation={animation.current}>
<Tooltip content={t("Zoom in")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={
status.image === ImageStatus.MAX_ZOOM ||
status.image === ImageStatus.ERROR
}
onClick={() => {
if (zoomPanPinchRef.current) {
zoomPanPinchRef.current.zoomIn();
}
}}
aria-label={t("Zoom in")}
size={32}
icon={<ZoomInIcon />}
borderOnHover
neutral
/>
</Tooltip>
<Tooltip content={t("Zoom out")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
)
}
onClick={() => {
if (zoomPanPinchRef.current) {
zoomPanPinchRef.current.zoomOut();
}
}}
aria-label={t("Zoom out")}
size={32}
icon={<ZoomOutIcon />}
borderOnHover
neutral
/>
</Tooltip>
<Separator />
<Tooltip content={t("Copy link")} placement="bottom">
<CopyToClipboard text={imgRef.current?.src ?? ""}>
<ActionButton
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
aria-label={t("Copy link")}
size={32}
icon={<LinkIcon />}
borderOnHover
neutral
/>
</CopyToClipboard>
</Tooltip>
<Tooltip content={t("Download")} placement="bottom">
<ActionButton
<Button
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
onClick={download}
aria-label={t("Download")}
size={32}
@@ -723,10 +485,9 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
neutral
/>
</Tooltip>
<Separator />
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
<ActionButton
<Button
tabIndex={-1}
onClick={close}
aria-label={t("Close")}
@@ -738,86 +499,48 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
</Tooltip>
</Dialog.Close>
</Actions>
{currentImageIndex > 0 &&
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
) && (
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
<BackIcon size={32} />
</NavButton>
</Nav>
)}
<ZoomablePannablePinchable
panningDisabled={
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
)
{currentImageIndex > 0 && (
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
<BackIcon size={32} />
</NavButton>
</Nav>
)}
<Image
ref={imgRef}
src={sanitizeUrl(currentImageNode.attrs.src) ?? ""}
alt={currentImageNode.attrs.alt ?? ""}
onLoading={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADING,
})
}
disabled={status.image === ImageStatus.ERROR}
ref={zoomPanPinchRef}
>
<Image
ref={imgRef}
src={activeImage.getSrc()}
alt={activeImage.getAlt()}
onLoading={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADING,
})
}
onLoad={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADED,
})
}
onError={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.ERROR,
})
}
onSwipeRight={prev}
onSwipeLeft={next}
onSwipeUp={close}
onSwipeDown={close}
status={status}
animation={animation.current}
onMinZoom={() => {
setStatus({
lightbox: status.lightbox,
image: ImageStatus.MIN_ZOOM,
});
}}
onZoom={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.ZOOMED,
})
}
onMaxZoom={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.MAX_ZOOM,
})
}
/>
</ZoomablePannablePinchable>
{currentImageIndex < images.length - 1 &&
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
) && (
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={next} size={32} aria-label={t("Next")}>
<NextIcon size={32} />
</NavButton>
</Nav>
)}
onLoad={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADED,
})
}
onError={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.ERROR,
})
}
onSwipeRight={prev}
onSwipeLeft={next}
onSwipeUpOrDown={close}
status={status}
animation={animation.current}
/>
{currentImageIndex < imageNodes.length - 1 && (
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={next} size={32} aria-label={t("Next")}>
<NextIcon size={32} />
</NavButton>
</Nav>
)}
</StyledContent>
</Dialog.Portal>
</Dialog.Root>
@@ -832,13 +555,9 @@ type ImageProps = {
onError: () => void;
onSwipeRight: () => void;
onSwipeLeft: () => void;
onSwipeUp: () => void;
onSwipeDown: () => void;
onSwipeUpOrDown: () => void;
status: Status;
animation: Animation | null;
onMinZoom: () => void;
onZoom: () => void;
onMaxZoom: () => void;
};
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
@@ -850,43 +569,59 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
onError,
onSwipeRight,
onSwipeLeft,
onSwipeUp,
onSwipeDown,
onSwipeUpOrDown,
status,
animation,
onMinZoom,
onZoom,
onMaxZoom,
}: ImageProps,
ref
) {
const { t } = useTranslation();
const touchXStart = useRef<number>();
const touchXEnd = useRef<number>();
const touchYStart = useRef<number>();
const touchYEnd = useRef<number>();
const swipeHandlers = useSwipe({
onSwipeRight,
onSwipeLeft,
onSwipeUp,
onSwipeDown,
});
const handleTouchStart = (e: React.TouchEvent<HTMLImageElement>) => {
touchXStart.current = e.changedTouches[0].screenX;
touchYStart.current = e.changedTouches[0].screenY;
};
const { isImagePanning } = useContext(ZoomPanPinchContext);
const handleTouchMove = (e: React.TouchEvent<HTMLImageElement>) => {
touchXEnd.current = e.changedTouches[0].screenX;
touchYEnd.current = e.changedTouches[0].screenY;
const dx = touchXEnd.current - (touchXStart.current ?? 0);
const dy = touchYEnd.current - (touchYStart.current ?? 0);
useTransformEffect(({ state, instance }) => {
const minScale = instance.props.minScale ?? 1;
const maxScale = instance.props.maxScale ?? 8;
const { scale } = state;
if (scale === minScale && status.image === ImageStatus.ZOOMED) {
onMinZoom();
} else if (scale === maxScale && status.image === ImageStatus.ZOOMED) {
onMaxZoom();
} else if (
scale > minScale &&
scale < maxScale &&
status.image !== ImageStatus.ZOOMED
) {
onZoom();
const swipeRight = dx > 0 && Math.abs(dy) < Math.abs(dx);
if (swipeRight) {
return onSwipeRight();
}
});
const swipeLeft = dx < 0 && Math.abs(dy) < Math.abs(dx);
if (swipeLeft) {
return onSwipeLeft();
}
const swipeDown = dy > 0 && Math.abs(dy) > Math.abs(dx);
const swipeUp = dy < 0 && Math.abs(dy) > Math.abs(dx);
if (swipeUp || swipeDown) {
return onSwipeUpOrDown();
}
};
const handleTouchEnd = () => {
touchXStart.current = undefined;
touchXEnd.current = undefined;
touchYStart.current = undefined;
touchYEnd.current = undefined;
};
const handleTouchCancel = () => {
touchXStart.current = undefined;
touchXEnd.current = undefined;
touchYStart.current = undefined;
touchYEnd.current = undefined;
};
const [hidden, setHidden] = useState(
status.image === null || status.image === ImageStatus.LOADING
@@ -905,7 +640,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
}, [status.image]);
return status.image === ImageStatus.ERROR ? (
<StyledError animation={animation} {...swipeHandlers}>
<StyledError animation={animation}>
<CrossIcon size={16} /> {t("Image failed to load")}
</StyledError>
) : (
@@ -918,19 +653,16 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
alt={alt}
animation={animation}
onAnimationStart={() => setHidden(false)}
{...swipeHandlers}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchCancel}
onError={onError}
onLoad={onLoad}
$hidden={hidden}
$zoomedIn={
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
}
$zoomedOut={status.image === ImageStatus.MIN_ZOOM}
$panning={isImagePanning}
/>
<Caption>
{status.image === ImageStatus.MIN_ZOOM &&
{status.image === ImageStatus.LOADED &&
status.lightbox === LightboxStatus.OPENED ? (
<Fade>{alt}</Fade>
) : null}
@@ -986,25 +718,12 @@ const StyledOverlay = styled(Dialog.Overlay)<{
const StyledImg = styled.img<{
$hidden: boolean;
$zoomedIn: boolean;
$zoomedOut: boolean;
$panning: boolean;
animation: Animation | null;
}>`
visibility: ${(props) => (props.$hidden ? "hidden" : "visible")};
pointer-events: auto !important;
max-width: 100%;
min-height: 0;
object-fit: contain;
cursor: ${(props) =>
props.$panning
? "grab"
: props.$zoomedOut
? "zoom-in"
: props.$zoomedIn
? "zoom-out"
: "default"};
${(props) =>
props.animation?.zoomIn
? css`
@@ -1016,12 +735,7 @@ const StyledImg = styled.img<{
animation: ${props.animation.zoomOut.apply()}
${props.animation.zoomOut.duration}ms;
`
: props.animation?.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
: ""}
`;
const StyledContent = styled(Dialog.Content)`
@@ -1032,10 +746,7 @@ const StyledContent = styled(Dialog.Content)`
justify-content: center;
align-items: center;
outline: none;
`;
const ActionButton = styled(Button)`
background: transparent;
padding: 56px;
`;
const Actions = styled.div<{
@@ -1046,12 +757,7 @@ const Actions = styled.div<{
right: 0;
margin: 16px 12px;
display: flex;
align-items: center;
gap: 8px;
z-index: ${depths.modal};
background: ${(props) => transparentize(0.2, props.theme.background)};
backdrop-filter: blur(4px);
border-radius: 6px;
gap: 4px;
${(props) =>
props.animation === null
@@ -1079,7 +785,6 @@ const Nav = styled.div<{
position: absolute;
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
transition: opacity 500ms ease-in-out;
z-index: ${depths.modal};
${(props) => props.$hidden && "opacity: 0;"}
${(props) =>
props.animation === null
@@ -1099,7 +804,7 @@ const Nav = styled.div<{
: ""}
`;
const StyledError = styled(ImageError)<{
const StyledError = styled(Error)<{
animation: Animation | null;
}>`
${(props) =>
+16 -11
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import { ActionV2Variant, ActionV2WithChildren } from "~/types";
import { ActionContext, ActionV2Variant, ActionV2WithChildren } from "~/types";
import { toMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
@@ -11,7 +11,9 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
type Props = {
/** Root action with children representing the menu items */
action?: ActionV2WithChildren;
action: ActionV2WithChildren;
/** Action context to use - new context will be created if not provided */
context?: ActionContext;
/** Trigger for the menu */
children: React.ReactNode;
/** ARIA label for the menu */
@@ -23,22 +25,25 @@ type Props = {
};
export const ContextMenu = observer(
({ action, children, ariaLabel, onOpen, onClose }: Props) => {
({ action, children, ariaLabel, context, onOpen, onClose }: Props) => {
const isMobile = useMobile();
const contentRef = React.useRef<React.ElementRef<typeof MenuContent>>(null);
const actionContext = useActionContext({
isMenu: true,
});
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
return [];
}
return ((action?.children as ActionV2Variant[]) ?? []).map(
(childAction) => actionV2ToMenuItem(childAction, actionContext)
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action?.children, actionContext]);
}, [open, action.children, actionContext]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
@@ -68,14 +73,14 @@ export const ContextMenu = observer(
[]
);
if (isMobile || !action || menuItems.length === 0) {
if (isMobile) {
return <>{children}</>;
}
const content = toMenuItems(menuItems);
return (
<MenuProvider variant="context">
<MenuProvider variant={"context"}>
<Menu onOpenChange={handleOpenChange}>
<MenuTrigger aria-label={ariaLabel}>{children}</MenuTrigger>
<MenuContent
+11 -4
View File
@@ -14,6 +14,7 @@ import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import {
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
MenuItem,
@@ -26,6 +27,8 @@ import { useComputed } from "~/hooks/useComputed";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
/** Action context to use - new context will be created if not provided */
context?: ActionContext;
/** Trigger for the menu */
children: React.ReactNode;
/** Alignment w.r.t trigger - defaults to start */
@@ -46,6 +49,7 @@ export const DropdownMenu = observer(
(
{
action,
context,
children,
align = "start",
ariaLabel,
@@ -60,9 +64,12 @@ export const DropdownMenu = observer(
const isMobile = useMobile();
const contentRef =
React.useRef<React.ElementRef<typeof MenuContent>>(null);
const actionContext = useActionContext({
isMenu: true,
});
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
@@ -119,7 +126,7 @@ export const DropdownMenu = observer(
const content = toMenuItems(menuItems);
return (
<MenuProvider variant="dropdown">
<MenuProvider variant={"dropdown"}>
<Menu open={open} onOpenChange={handleOpenChange}>
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
@@ -6,17 +6,13 @@ import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, hover, truncateMultiline } from "@shared/styles";
import Notification from "~/models/Notification";
import CommentEditor from "~/scenes/Document/components/CommentEditor";
import useStores from "~/hooks/useStores";
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
import Flex from "../Flex";
import Text from "../Text";
import Time from "../Time";
import { UnreadBadge } from "../UnreadBadge";
import lazyWithRetry from "~/utils/lazyWithRetry";
const CommentEditor = lazyWithRetry(
() => import("~/scenes/Document/components/CommentEditor")
);
type Props = {
notification: Notification;
@@ -6,6 +6,7 @@ import styled from "styled-components";
import { s, hover } from "@shared/styles";
import Notification from "~/models/Notification";
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import NotificationMenu from "~/menus/NotificationMenu";
import Desktop from "~/utils/Desktop";
@@ -31,6 +32,7 @@ function Notifications(
{ onRequestClose }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const context = useActionContext();
const { notifications } = useStores();
const { t } = useTranslation();
const isEmpty = notifications.active.length === 0;
@@ -65,10 +67,7 @@ function Notifications(
<Flex gap={8}>
{notifications.approximateUnreadCount > 0 && (
<Tooltip content={t("Mark all as read")}>
<Button
action={markNotificationsAsRead}
aria-label={t("Mark all as read")}
>
<Button action={markNotificationsAsRead} context={context}>
<MarkAsReadIcon />
</Button>
</Tooltip>
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
Popover,
@@ -7,9 +7,7 @@ import {
PopoverContent,
} from "~/components/primitives/Popover";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Notifications = lazyWithRetry(() => import("./Notifications"));
import Notifications from "./Notifications";
type Props = {
children?: React.ReactNode;
@@ -18,18 +16,18 @@ type Props = {
const NotificationsPopover: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const { notifications } = useStores();
const [open, setOpen] = useState(false);
const scrollableRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = React.useState(false);
const scrollableRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
React.useEffect(() => {
void notifications.fetchPage({ archived: false });
}, [notifications]);
const handleRequestClose = useCallback(() => {
const handleRequestClose = React.useCallback(() => {
setOpen(false);
}, []);
const handleAutoFocus = useCallback((event: Event) => {
const handleAutoFocus = React.useCallback((event: Event) => {
// Prevent focus from moving to the popover content
event.preventDefault();
@@ -50,12 +48,10 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
onOpenAutoFocus={handleAutoFocus}
shrink
>
<Suspense fallback={null}>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
</Suspense>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
</PopoverContent>
</Popover>
);
@@ -63,7 +63,6 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
name="avatarUrl"
render={({ field }) => (
<ImageInput
alt={t("OAuth client icon")}
onSuccess={(url) => field.onChange(url)}
onError={(err) => setError("avatarUrl", { message: err })}
model={{
+66
View File
@@ -0,0 +1,66 @@
import "../stores";
import { render } from "@testing-library/react";
import { TFunction } from "i18next";
import { Provider } from "mobx-react";
import { getI18n } from "react-i18next";
import { Pagination } from "@shared/constants";
import PaginatedList from "./PaginatedList";
describe("PaginatedList", () => {
const i18n = getI18n();
const authStore = {};
const props = {
i18n,
tReady: true,
t: ((key: string) => key) as TFunction,
} as any;
it("with no items renders nothing", () => {
const result = render(
<Provider auth={authStore}>
<PaginatedList items={[]} renderItem={render} {...props} />
</Provider>
);
expect(result.container.innerHTML).toEqual("");
});
it("with no items renders empty prop", async () => {
const result = render(
<Provider auth={authStore}>
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>{" "}
</Provider>
);
await expect(
result.findAllByText("Sorry, no results")
).resolves.toHaveLength(1);
});
it("calls fetch with options + pagination on mount", () => {
const fetch = jest.fn();
const options = {
id: "one",
};
render(
<Provider auth={authStore}>
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>{" "}
</Provider>
);
expect(fetch).toHaveBeenCalledWith({
...options,
limit: Pagination.defaultLimit,
offset: 0,
});
});
});
-1
View File
@@ -255,7 +255,6 @@ const PaginatedList = <T extends PaginatedItem>({
<React.Fragment>
{heading}
<ArrowKeyNavigation
role={rest.role}
aria-label={rest["aria-label"]}
onEscape={onEscape}
className={className}
-26
View File
@@ -1,26 +0,0 @@
import { EyeIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useTextStats } from "~/hooks/useTextStats";
import type Document from "~/models/Document";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = useMemo(
() => ProsemirrorHelper.toMarkdown(document),
[document]
);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
export default ReadingTime;
-4
View File
@@ -168,7 +168,6 @@ function SearchPopover({ shareId, className }: Props) {
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverAnchor>
<StyledInputSearch
role="combobox"
aria-controls="search-results"
aria-expanded={open}
aria-haspopup="listbox"
@@ -177,8 +176,6 @@ function SearchPopover({ shareId, className }: Props) {
onFocus={handleSearchInputFocus}
onKeyDown={handleKeyDown}
className={className}
label={t("Search")}
labelHidden
/>
</PopoverAnchor>
<PopoverContent
@@ -197,7 +194,6 @@ function SearchPopover({ shareId, className }: Props) {
}}
>
<PaginatedList<SearchResult>
role="listbox"
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
items={cachedSearchResults}
fetch={performSearch}
@@ -71,19 +71,6 @@ function InnerPublicAccess({ collection, share }: Props) {
[share]
);
const handleShowTOCChanged = useCallback(
async (checked: boolean) => {
try {
await share?.save({
showTOC: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = useCallback(
async (checked: boolean) => {
try {
@@ -217,31 +204,6 @@ function InnerPublicAccess({ collection, share }: Props) {
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
<ShareLinkInput
type="text"
ref={inputRef}
@@ -77,19 +77,6 @@ function PublicAccess({ document, share, sharedParent }: Props) {
[share]
);
const handleShowTOCChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showTOC: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = React.useCallback(
async (checked: boolean) => {
try {
@@ -254,31 +241,6 @@ function PublicAccess({ document, share, sharedParent }: Props) {
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
</>
)}
@@ -25,11 +25,6 @@ export const AppearanceAction = observer(() => {
onClick={() =>
ui.setTheme(resolvedTheme === "light" ? Theme.Dark : Theme.Light)
}
aria-label={
resolvedTheme === "light"
? t("Switch to dark")
: t("Switch to light")
}
neutral
borderOnHover
/>
@@ -6,6 +6,7 @@ import { Inner } from "~/components/Button";
import ButtonSmall from "~/components/ButtonSmall";
import Fade from "~/components/Fade";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import useActionContext from "~/hooks/useActionContext";
import { Action, Permission } from "~/types";
export function PermissionAction({
@@ -20,6 +21,7 @@ export function PermissionAction({
onChange: (permission: CollectionPermission | DocumentPermission) => void;
}) {
const { t } = useTranslation();
const context = useActionContext();
return (
<Fade timing="150ms" key="invite">
@@ -29,7 +31,9 @@ export function PermissionAction({
onChange={onChange}
value={permission}
/>
<ButtonSmall action={action}>{t("Add")}</ButtonSmall>
<ButtonSmall action={action} context={context}>
{t("Add")}
</ButtonSmall>
</Flex>
</Fade>
);
-5
View File
@@ -81,11 +81,6 @@ function AppSidebar() {
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed
? t("Expand sidebar")
: t("Collapse sidebar")
}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
+9 -5
View File
@@ -7,6 +7,7 @@ import { depths, s } from "@shared/styles";
import ErrorBoundary from "~/components/ErrorBoundary";
import Flex from "~/components/Flex";
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { sidebarAppearDuration } from "~/styles/animations";
@@ -19,6 +20,7 @@ function Right({ children, border, className }: Props) {
const theme = useTheme();
const { ui } = useStores();
const [isResizing, setResizing] = React.useState(false);
const isMobile = useMobile();
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -98,11 +100,13 @@ function Right({ children, border, className }: Props) {
<Sidebar {...animationProps} $border={border} className={className}>
<Position style={style} column>
<ErrorBoundary>{children}</ErrorBoundary>
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={handleReset}
dir="right"
/>
{!isMobile && (
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={handleReset}
dir="right"
/>
)}
</Position>
</Sidebar>
);
-3
View File
@@ -52,9 +52,6 @@ function SettingsSidebar() {
>
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
<ToggleButton
aria-label={
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
}
position="bottom"
image={<SidebarIcon />}
onClick={() => {
+1 -11
View File
@@ -22,7 +22,6 @@ import { SharedCollectionLink } from "./components/SharedCollectionLink";
import { SharedDocumentLink } from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useEffect } from "react";
type Props = {
share: Share;
@@ -38,10 +37,6 @@ function SharedSidebar({ share }: Props) {
const rootNode = share.tree;
const shareId = share.urlId || share.id;
useEffect(() => {
ui.tocVisible = share.showTOC;
}, []);
if (!rootNode?.children.length) {
return null;
}
@@ -101,9 +96,6 @@ const ToggleSidebar = () => {
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
@@ -146,8 +138,7 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
${({ $hoverTransition }) =>
$hoverTransition &&
`
@media (hover: hover) {
&:${hover} {
&: ${hover} {
${StyledSearchPopover} {
width: 85%;
}
@@ -155,7 +146,6 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
${ToggleWrapper} {
opacity: 1;
transform: translateX(0);
}
}
}
`}
+5 -9
View File
@@ -7,6 +7,7 @@ import { depths, s } from "@shared/styles";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMenuContext from "~/hooks/useMenuContext";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
@@ -20,7 +21,6 @@ import { TooltipProvider } from "../TooltipContext";
import ResizeBorder from "./components/ResizeBorder";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useTranslation } from "react-i18next";
const ANIMATION_MS = 250;
@@ -35,15 +35,15 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
ref: React.RefObject<HTMLDivElement>
) {
const [isCollapsing, setCollapsing] = React.useState(false);
const { t } = useTranslation();
const theme = useTheme();
const { ui } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
const user = useCurrentUser({ rejectOnEmpty: false });
const isMobile = useMobile();
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed;
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -237,7 +237,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
position="bottom"
image={
<Avatar
alt={t("Avatar of {{ name }}", { name: user.name })}
alt={user.name}
model={user}
size={24}
style={{ marginLeft: 4 }}
@@ -245,11 +245,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
}
>
<NotificationsPopover>
<SidebarButton
position="bottom"
image={<NotificationIcon />}
aria-label={t("Notifications")}
/>
<SidebarButton position="bottom" image={<NotificationIcon />} />
</NotificationsPopover>
</SidebarButton>
</AccountMenu>
@@ -25,8 +25,6 @@ import DropToImport from "./DropToImport";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import { ActionContextProvider } from "~/hooks/useActionContext";
type Props = {
collection: Collection;
@@ -111,12 +109,8 @@ const CollectionLink: React.FC<Props> = ({
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
);
const contextMenuAction = useCollectionMenuAction({
collectionId: collection.id,
});
return (
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<>
<Relative ref={mergeRefs([parentRef, dropRef])}>
<DropToImport collectionId={collection.id}>
<SidebarLink
@@ -128,7 +122,6 @@ const CollectionLink: React.FC<Props> = ({
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
@@ -157,7 +150,6 @@ const CollectionLink: React.FC<Props> = ({
{can.createDocument && (
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
@@ -196,7 +188,7 @@ const CollectionLink: React.FC<Props> = ({
}
/>
)}
</ActionContextProvider>
</>
);
};
@@ -18,14 +18,10 @@ import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarAction from "./SidebarAction";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import Text from "@shared/components/Text";
import usePolicy from "~/hooks/usePolicy";
function Collections() {
const { documents, auth, collections } = useStores();
const { documents, collections } = useStores();
const { t } = useTranslation();
const can = usePolicy(auth.team?.id);
const orderedCollections = collections.allActive;
const params = useMemo(
@@ -61,7 +57,7 @@ function Collections() {
<PaginatedList<Collection>
options={params}
aria-label={t("Collections")}
items={orderedCollections}
items={collections.allActive}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
@@ -72,20 +68,6 @@ function Collections() {
/>
) : undefined
}
empty={
// No need for empty state if we're displaying the createCollection action
can.createCollection ? null : (
<SidebarLink
label={
<Text type="tertiary" size="small" italic>
{t("No collections")}
</Text>
}
onClick={() => {}}
depth={1.5}
/>
)
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item, index) => (
<DraggableCollectionLink
@@ -35,8 +35,6 @@ import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import UserMembership from "~/models/UserMembership";
import GroupMembership from "~/models/GroupMembership";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
type Props = {
node: NavigationNode;
@@ -318,14 +316,8 @@ function InnerDocumentLink(
]
);
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
return (
<ActionContextProvider
value={{
activeDocumentId: node.id,
}}
>
<>
<Relative ref={parentRef}>
<Draggable
key={node.id}
@@ -342,7 +334,6 @@ function InnerDocumentLink(
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
to={toPath}
icon={iconElement}
label={
@@ -373,6 +364,7 @@ function InnerDocumentLink(
{can.createChildDocument && (
<Tooltip content={t("New doc")}>
<NudeButton
type={undefined}
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
@@ -434,7 +426,7 @@ function InnerDocumentLink(
/>
))}
</Folder>
</ActionContextProvider>
</>
);
}
@@ -1,4 +1,3 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import invariant from "invariant";
import { observer } from "mobx-react";
import { useCallback } from "react";
@@ -62,12 +61,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
$isDragActive={isDragActive}
tabIndex={-1}
>
<VisuallyHidden>
<label>
{t("Import files")}
<input {...getInputProps()} />
</label>
</VisuallyHidden>
<input {...getInputProps()} />
{isImporting && <LoadingIndicator />}
{children}
</DropzoneContainer>
+4 -6
View File
@@ -1,7 +1,7 @@
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import styled, { keyframes } from "styled-components";
import { extraArea, s } from "@shared/styles";
import { s } from "@shared/styles";
import usePersistedState from "~/hooks/usePersistedState";
import { undraggableOnDesktop } from "~/styles";
@@ -71,18 +71,17 @@ const Button = styled.button`
font-size: 13px;
font-weight: 600;
user-select: none;
color: ${s("sidebarText")};
position: relative;
color: ${s("textTertiary")};
letter-spacing: 0.03em;
margin: 0;
padding: 4px 2px 4px 12px;
height: 22px;
border: 0;
background: none;
border-radius: 4px;
-webkit-appearance: none;
transition: all 100ms ease;
${undraggableOnDesktop()}
${extraArea(4)}
&:not(:disabled):hover,
&:not(:disabled):active {
@@ -103,8 +102,7 @@ const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
const H3 = styled.h3`
margin: 0;
&:hover,
&:focus-within {
&:hover {
${Disclosure} {
opacity: 1;
}
@@ -12,7 +12,7 @@ type Props = {
function SidebarAction({ action, ...rest }: Props) {
const context = useActionContext({
isMenu: false,
isContextMenu: false,
isCommandBar: false,
activeCollectionId: undefined,
activeDocumentId: undefined,
@@ -3,7 +3,7 @@ import * as React from "react";
import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { s, truncateMultiline } from "@shared/styles";
import { isMobile } from "@shared/utils/browser";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
@@ -11,10 +11,6 @@ import useClickIntent from "~/hooks/useClickIntent";
import { undraggableOnDesktop } from "~/styles";
import Disclosure from "./Disclosure";
import NavLink, { Props as NavLinkProps } from "./NavLink";
import { ActionV2WithChildren } from "~/types";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useTranslation } from "react-i18next";
import useBoolean from "~/hooks/useBoolean";
type Props = Omit<NavLinkProps, "to"> & {
to?: LocationDescriptor;
@@ -36,7 +32,6 @@ type Props = Omit<NavLinkProps, "to"> & {
isDraft?: boolean;
depth?: number;
scrollIntoViewIfNeeded?: boolean;
contextAction?: ActionV2WithChildren;
};
const activeDropStyle = {
@@ -67,29 +62,19 @@ function SidebarLink(
onDisclosureClick,
disabled,
unreadBadge,
contextAction,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent);
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + 12}px`,
paddingRight: unreadBadge ? "32px" : undefined,
}),
[depth]
);
const unreadStyle = React.useMemo(
() => ({
right: -12,
}),
[]
);
const activeStyle = React.useMemo(
() => ({
color: theme.text,
@@ -99,58 +84,41 @@ function SidebarLink(
[theme.text, theme.sidebarActiveBackground, style]
);
const hoverStyle = React.useMemo(
() => ({
color: theme.text,
...style,
}),
[theme.text, style]
);
const [openContextMenu, setOpen, setClosed] = useBoolean(false);
return (
<>
<ContextMenu
action={contextAction}
ariaLabel={t("Link options")}
onOpen={setOpen}
onClose={setClosed}
<Link
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
// @ts-expect-error exact does not exist on div
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
{...rest}
>
<Link
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={openContextMenu ? hoverStyle : active ? activeStyle : style}
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
// @ts-expect-error exact does not exist on div
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
{...rest}
>
<Content>
{expanded !== undefined && (
<Disclosure
expanded={expanded}
onMouseDown={onDisclosureClick}
onClick={preventDefault}
root={depth === 0}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</Link>
</ContextMenu>
<Content>
{expanded !== undefined && (
<Disclosure
expanded={expanded}
onMouseDown={onDisclosureClick}
onClick={preventDefault}
root={depth === 0}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{unreadBadge && <UnreadBadge />}
</Content>
</Link>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</>
);
@@ -305,6 +273,7 @@ const Label = styled.div`
position: relative;
width: 100%;
line-height: 24px;
${truncateMultiline(3)}
* {
unicode-bidi: plaintext;
+83 -190
View File
@@ -28,173 +28,11 @@ import SidebarContext, {
starredSidebarContext,
} from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { type ConnectDragSource } from "react-dnd";
type Props = {
star: Star;
};
type StarredDocumentLinkProps = {
star: Star;
documentId: string;
expanded: boolean;
sidebarContext: SidebarContextType;
isDragging: boolean;
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
handlePrefetch: () => void;
icon: React.ReactNode;
label: React.ReactNode;
menuOpen: boolean;
handleMenuOpen: () => void;
handleMenuClose: () => void;
draggableRef: ConnectDragSource;
cursor: React.ReactNode;
};
type StarredCollectionLinkProps = {
star: Star;
collection: any;
expanded: boolean;
sidebarContext: SidebarContextType;
isDragging: boolean;
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
draggableRef: ConnectDragSource;
cursor: React.ReactNode;
displayChildDocuments: boolean;
reorderStarProps: any;
};
function StarredDocumentLink({
star,
documentId,
expanded,
sidebarContext,
isDragging,
handleDisclosureClick,
handlePrefetch,
icon,
label,
menuOpen,
handleMenuOpen,
handleMenuClose,
draggableRef,
cursor,
}: StarredDocumentLinkProps) {
const { collections, documents } = useStores();
const document = documents.get(documentId);
if (!document) {
return null;
}
const documentCollection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const childDocuments = documentCollection
? documentCollection.getChildrenForDocument(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
const displayChildDocuments = expanded && !isDragging;
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
}}
>
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
<SidebarLink
depth={0}
to={{
pathname: document.url,
state: { sidebarContext },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
icon={icon}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</Draggable>
<SidebarContext.Provider value={sidebarContext}>
<Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={documentCollection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{cursor}
</Relative>
</SidebarContext.Provider>
</ActionContextProvider>
);
}
function StarredCollectionLink({
star,
collection,
sidebarContext,
isDragging,
handleDisclosureClick,
draggableRef,
cursor,
displayChildDocuments,
reorderStarProps,
}: StarredCollectionLinkProps) {
const { documents } = useStores();
return (
<SidebarContext.Provider value={sidebarContext}>
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
<CollectionLink
collection={collection}
expanded={isDragging ? undefined : displayChildDocuments}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={reorderStarProps.isDragging}
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
</SidebarContext.Provider>
);
}
function StarredLink({ star }: Props) {
const theme = useTheme();
const { ui, collections, documents } = useStores();
@@ -285,40 +123,95 @@ function StarredLink({ star }: Props) {
);
if (documentId) {
const document = documents.get(documentId);
if (!document) {
return null;
}
const documentCollection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const childDocuments = documentCollection
? documentCollection.getChildrenForDocument(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
return (
<StarredDocumentLink
star={star}
documentId={documentId}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
handlePrefetch={handlePrefetch}
icon={icon}
label={label}
menuOpen={menuOpen}
handleMenuOpen={handleMenuOpen}
handleMenuClose={handleMenuClose}
draggableRef={draggableRef}
cursor={cursor}
/>
<>
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
<SidebarLink
depth={0}
to={{
pathname: document.url,
state: { sidebarContext },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
icon={icon}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</Draggable>
<SidebarContext.Provider value={sidebarContext}>
<Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={documentCollection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{cursor}
</Relative>
</SidebarContext.Provider>
</>
);
}
if (collection) {
return (
<StarredCollectionLink
star={star}
collection={collection}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
draggableRef={draggableRef}
cursor={cursor}
displayChildDocuments={displayChildDocuments}
reorderStarProps={reorderStarProps}
/>
<SidebarContext.Provider value={sidebarContext}>
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
<CollectionLink
collection={collection}
expanded={isDragging ? undefined : displayChildDocuments}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={reorderStarProps.isDragging}
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
</SidebarContext.Provider>
);
}
@@ -22,11 +22,7 @@ export function useSidebarLabelAndIcon(
return {
label: document.titleWithDefault,
icon: document.icon ? (
<Icon
value={document.icon}
initial={document.initial}
color={document.color ?? undefined}
/>
<Icon value={document.icon} color={document.color ?? undefined} />
) : (
icon
),
+34 -36
View File
@@ -10,7 +10,7 @@ import {
unstarCollection,
} from "~/actions/definitions/collections";
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
import { ActionContextProvider } from "~/hooks/useActionContext";
import useActionContext from "~/hooks/useActionContext";
import NudeButton from "./NudeButton";
type Props = {
@@ -27,6 +27,10 @@ type Props = {
function Star({ size, document, collection, color, ...rest }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const context = useActionContext({
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
});
const target = document || collection;
@@ -35,43 +39,37 @@ function Star({ size, document, collection, color, ...rest }: Props) {
}
return (
<ActionContextProvider
value={{
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
<NudeButton
context={context}
hideOnActionDisabled
tooltip={{
content: target.isStarred ? t("Unstar document") : t("Star document"),
delay: 500,
}}
action={
collection
? collection.isStarred
? unstarCollection
: starCollection
: document
? document.isStarred
? unstarDocument
: starDocument
: undefined
}
size={size}
{...rest}
>
<NudeButton
hideOnActionDisabled
tooltip={{
content: target.isStarred ? t("Unstar document") : t("Star document"),
delay: 500,
}}
action={
collection
? collection.isStarred
? unstarCollection
: starCollection
: document
? document.isStarred
? unstarDocument
: starDocument
: undefined
}
size={size}
{...rest}
>
{target.isStarred ? (
<AnimatedStar size={size} color={theme.yellow} />
) : (
<AnimatedStar
size={size}
color={color ?? theme.textTertiary}
as={UnstarredIcon}
/>
)}
</NudeButton>
</ActionContextProvider>
{target.isStarred ? (
<AnimatedStar size={size} color={theme.yellow} />
) : (
<AnimatedStar
size={size}
color={color ?? theme.textTertiary}
as={UnstarredIcon}
/>
)}
</NudeButton>
);
}
+2
View File
@@ -5,6 +5,7 @@ import GlobalStyles from "@shared/styles/globals";
import { TeamPreference, UserPreference } from "@shared/types";
import useBuildTheme from "~/hooks/useBuildTheme";
import useStores from "~/hooks/useStores";
import { TooltipStyles } from "./Tooltip";
type Props = {
children?: React.ReactNode;
@@ -29,6 +30,7 @@ const Theme: React.FC = ({ children }: Props) => {
return (
<ThemeProvider theme={theme}>
<>
<TooltipStyles />
<GlobalStyles
useCursorPointer={auth.user?.getPreference(
UserPreference.UseCursorPointer
+5 -1
View File
@@ -1,7 +1,7 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { transparentize } from "polished";
import * as React from "react";
import styled, { keyframes } from "styled-components";
import styled, { createGlobalStyle, keyframes } from "styled-components";
import { s } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { useTooltipContext } from "./TooltipContext";
@@ -285,4 +285,8 @@ const StyledContent = styled(TooltipPrimitive.Content)`
}
`;
export const TooltipStyles = createGlobalStyle`
/* Legacy styles for backward compatibility - can be removed after migration */
`;
export default Tooltip;
+6 -6
View File
@@ -18,8 +18,6 @@ Drawer.displayName = "Drawer";
/** Drawer's trigger. */
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerHandle = DrawerPrimitive.Handle;
/** Drawer's content - renders the overlay and the actual content. */
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
@@ -58,9 +56,11 @@ const DrawerTitle = React.forwardRef<
const { hidden, children, ...rest } = props;
const title = (
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
{children}
</Text>
<TitleWrapper justify="center">
<Text size="medium" weight="bold">
{children}
</Text>
</TitleWrapper>
);
return (
@@ -100,4 +100,4 @@ const TitleWrapper = styled(Flex)`
padding: 8px 0;
`;
export { Drawer, DrawerTrigger, DrawerHandle, DrawerContent, DrawerTitle };
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
+1 -4
View File
@@ -3,6 +3,7 @@ import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { Props as ButtonProps } from "~/components/Button";
import Separator from "~/components/ContextMenu/Separator";
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
import {
SelectItemIndicator,
@@ -98,10 +99,6 @@ const InputSelectSeparator = React.forwardRef<
));
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
const Separator = styled.hr`
margin: 6px 0;
`;
/** Styled components. */
const StyledContent = styled(InputSelectPrimitive.Content)`
z-index: ${depths.menu};
+1 -1
View File
@@ -104,7 +104,7 @@ const MenuContent = React.forwardRef<
return (
<Portal>
<Content ref={ref} {...offsetProp} {...rest} collisionPadding={6} asChild>
<Content ref={ref} {...rest} {...offsetProp} collisionPadding={6} asChild>
<Components.MenuContent {...contentProps} hiddenScrollbars>
{children}
</Components.MenuContent>
+1 -1
View File
@@ -90,7 +90,7 @@ type StyledContentProps = {
const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
z-index: ${depths.modal};
max-height: min(85vh, var(--radix-popover-content-available-height));
max-height: var(--radix-popover-content-available-height);
transform-origin: var(--radix-popover-content-transform-origin);
background: ${s("menuBackground")};
+1 -20
View File
@@ -9,7 +9,6 @@ import { fadeAndScaleIn } from "~/styles/animations";
type BaseMenuItemProps = {
disabled?: boolean;
$active?: boolean;
$dangerous?: boolean;
};
@@ -45,24 +44,6 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
outline: 0; // Disable default outline on Firefox
}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.$dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
`}
${(props) =>
!props.disabled &&
`
@@ -77,7 +58,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
+1 -2
View File
@@ -119,6 +119,5 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 6px;
gap: 8px;
`;
+3 -14
View File
@@ -347,7 +347,6 @@ export default function FindAndReplace({
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.prevSearchMatch()}
aria-label={t("Previous match")}
>
<CaretUpIcon />
</ButtonLarge>
@@ -356,7 +355,6 @@ export default function FindAndReplace({
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.nextSearchMatch()}
aria-label={t("Next match")}
>
<CaretDownIcon />
</ButtonLarge>
@@ -392,10 +390,7 @@ export default function FindAndReplace({
shortcut={`${altDisplay}+${metaDisplay}+c`}
placement="bottom"
>
<ButtonSmall
onClick={handleCaseSensitive}
aria-label={t("Match case")}
>
<ButtonSmall onClick={handleCaseSensitive}>
<CaseSensitiveIcon
color={caseSensitive ? theme.accent : theme.textSecondary}
/>
@@ -406,10 +401,7 @@ export default function FindAndReplace({
shortcut={`${altDisplay}+${metaDisplay}+r`}
placement="bottom"
>
<ButtonSmall
onClick={handleRegex}
aria-label={t("Enable regex")}
>
<ButtonSmall onClick={handleRegex}>
<RegexIcon
color={regexEnabled ? theme.accent : theme.textSecondary}
/>
@@ -424,10 +416,7 @@ export default function FindAndReplace({
shortcut={`${altDisplay}+${metaDisplay}+f`}
placement="bottom"
>
<ButtonLarge
onClick={handleMore}
aria-label={t("Replace options")}
>
<ButtonLarge onClick={handleMore}>
<ReplaceIcon color={theme.textSecondary} />
</ButtonLarge>
</Tooltip>
+23 -59
View File
@@ -1,5 +1,5 @@
import { NodeSelection } from "prosemirror-state";
import { selectedRect } from "prosemirror-tables";
import { CellSelection, selectedRect } from "prosemirror-tables";
import * as React from "react";
import { Portal as ReactPortal } from "react-portal";
import styled, { css } from "styled-components";
@@ -15,12 +15,8 @@ import useMobile from "~/hooks/useMobile";
import useWindowSize from "~/hooks/useWindowSize";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
import { ColumnSelection } from "@shared/editor/selection/ColumnSelection";
import { RowSelection } from "@shared/editor/selection/RowSelection";
import { isTableSelected } from "@shared/editor/queries/table";
type Props = {
align?: "start" | "end" | "center";
active?: boolean;
children: React.ReactNode;
width?: number;
@@ -39,16 +35,18 @@ const defaultPosition = {
function usePosition({
menuRef,
active,
align = "center",
}: {
menuRef: React.RefObject<HTMLDivElement>;
active?: boolean;
align?: Props["align"];
}) {
const { view } = useEditor();
const { selection } = view.state;
const menuWidth = menuRef.current?.offsetWidth ?? 0;
const menuHeight = 36;
const menuWidth = menuRef.current?.offsetWidth;
const menuHeight = menuRef.current?.offsetHeight;
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
return defaultPosition;
}
// based on the start and end of the selection calculate the position at
// the center top
@@ -70,7 +68,7 @@ function usePosition({
right: Math.max(fromPos.right, toPos.right),
};
const offsetParent = menuRef.current?.offsetParent
const offsetParent = menuRef.current.offsetParent
? menuRef.current.offsetParent.getBoundingClientRect()
: ({
width: window.innerWidth,
@@ -95,23 +93,19 @@ function usePosition({
if (position !== null) {
const element = view.nodeDOM(position);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top + menuHeight;
selectionBounds.left = bounds.right;
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.right = bounds.right;
}
}
if (!active || !menuRef.current || !menuHeight) {
return defaultPosition;
}
// tables are an oddity, and need their own positioning logic
const isColSelection =
selection instanceof ColumnSelection && selection.isColSelection();
selection instanceof CellSelection && selection.isColSelection();
const isRowSelection =
selection instanceof RowSelection && selection.isRowSelection();
selection instanceof CellSelection && selection.isRowSelection();
if (isTableSelected(view.state)) {
if (isColSelection && isRowSelection) {
const rect = selectedRect(view.state);
const table = view.domAtPos(rect.tableStart);
const bounds = (table.node as HTMLElement).getBoundingClientRect();
@@ -166,8 +160,6 @@ function usePosition({
top: Math.round(top - menuHeight - offsetParent.top),
offset: 0,
visible: true,
blockSelection: false,
maxWidth: width,
};
}
}
@@ -188,11 +180,7 @@ function usePosition({
),
Math.max(
Math.max(offsetParent.x, margin),
align === "center"
? centerOfSelection - menuWidth / 2
: align === "start"
? selectionBounds.left
: selectionBounds.right
centerOfSelection - menuWidth / 2
)
);
const top = Math.max(
@@ -212,12 +200,8 @@ function usePosition({
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
blockSelection: !!(
codeBlock ||
isColSelection ||
isRowSelection ||
noticeBlock
),
blockSelection:
codeBlock || isColSelection || isRowSelection || noticeBlock,
visible: true,
};
}
@@ -232,7 +216,6 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
let position = usePosition({
menuRef,
active: props.active,
align: props.align,
});
if (isSelectingText) {
@@ -294,7 +277,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
left: `${position.left}px`,
}}
>
<Background align={props.align}>{props.children}</Background>
{props.children}
</Wrapper>
</Portal>
);
@@ -319,7 +302,7 @@ const arrow = (props: WrapperProps) =>
border-radius: 3px;
z-index: -1;
position: absolute;
bottom: -3px;
bottom: -2px;
left: calc(50% - ${props.$offset || 0}px);
pointer-events: none;
}
@@ -352,41 +335,22 @@ const MobileWrapper = styled.div`
}
`;
const Background = styled.div<{ align: Props["align"] }>`
position: relative;
background-color: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 4px;
height: 36px;
${(props) =>
props.align === "start" &&
`
position: absolute;
left: 0;
bottom: 0;
`}
${(props) =>
props.align === "end" &&
`
position: absolute;
right: 0;
bottom: 0;
`}
`;
const Wrapper = styled.div<WrapperProps>`
will-change: opacity, transform;
padding: 6px;
position: absolute;
z-index: ${depths.editorToolbar};
opacity: 0;
background-color: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 4px;
transform: scale(0.95);
transition:
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition-delay: 150ms;
line-height: 0;
height: 36px;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
+1 -2
View File
@@ -282,8 +282,7 @@ const LinkEditor: React.FC<Props> = ({
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 6px;
gap: 8px;
`;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
+5 -13
View File
@@ -7,7 +7,6 @@ import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { extraArea } from "@shared/styles";
import Input, { NativeInput, Outline } from "~/components/Input";
import { useEditor } from "./EditorContext";
import { useTranslation } from "react-i18next";
type Dimension = {
width: string;
@@ -21,7 +20,6 @@ export function MediaDimension() {
width: { min: number; max: number };
height: { min: number; max: number };
}>();
const { t } = useTranslation();
const { view, commands } = useEditor();
const { state } = view;
const { selection } = state;
@@ -33,8 +31,8 @@ export function MediaDimension() {
height = node.attrs.height as number;
const [localDimension, setLocalDimension] = useState<Dimension>(() => ({
width: width ? String(width) : "",
height: height ? String(height) : "",
width: String(width),
height: String(height),
changed: "none",
}));
const [error, setError] = useState<{ width: boolean; height: boolean }>({
@@ -59,8 +57,8 @@ export function MediaDimension() {
const reset = useCallback(() => {
setLocalDimension({
width: width ? String(width) : "",
height: height ? String(height) : "",
width: String(width),
height: String(height),
changed: "none",
});
setError({ width: false, height: false });
@@ -207,9 +205,6 @@ export function MediaDimension() {
return (
<StyledFlex ref={ref} align="center">
<StyledInput
label={t("Image width")}
labelHidden
placeholder={t("Width")}
value={localDimension.width}
onChange={handleChange("width")}
onBlur={handleBlur}
@@ -217,12 +212,9 @@ export function MediaDimension() {
$error={error.width}
/>
<Text size="xsmall" type="tertiary">
×
x
</Text>
<StyledInput
label={t("Image height")}
labelHidden
placeholder={t("Height")}
value={localDimension.height}
onChange={handleChange("height")}
onBlur={handleBlur}
+6 -5
View File
@@ -5,6 +5,7 @@ import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { toast } from "sonner";
import { v4 } from "uuid";
import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
@@ -91,7 +92,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: UserSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: v4(),
type: MentionType.User,
modelId: user.id,
actorId,
@@ -123,7 +124,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: DocumentsSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: v4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
@@ -151,7 +152,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: CollectionsSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: v4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
@@ -171,9 +172,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
priority: -1,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: v4(),
type: MentionType.Document,
modelId: crypto.randomUUID(),
modelId: v4(),
actorId,
label: search,
},
+4 -3
View File
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { EmailIcon, LinkIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { v4 } from "uuid";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
@@ -81,7 +82,7 @@ function useItems({
mentionType = integration
? determineMentionType({ url, integration })
: MentionType.URL;
: undefined;
}
return [
@@ -96,11 +97,11 @@ function useItems({
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: crypto.randomUUID(),
id: v4(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: crypto.randomUUID(),
modelId: v4(),
actorId: user?.id,
},
appendSpace: true,
+12 -16
View File
@@ -1,5 +1,6 @@
import some from "lodash/some";
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
@@ -7,11 +8,7 @@ import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
getColumnIndex,
getRowIndex,
isTableSelected,
} from "@shared/editor/queries/table";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
@@ -26,6 +23,7 @@ import getImageMenuItems from "../menus/image";
import getNoticeMenuItems from "../menus/notice";
import getReadOnlyMenuItems from "../menus/readOnly";
import getTableMenuItems from "../menus/table";
import getTableCellMenuItems from "../menus/tableCell";
import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
import { useEditor } from "./EditorContext";
@@ -180,13 +178,15 @@ export default function SelectionToolbar(props: Props) {
const { state } = view;
const { selection } = state;
if (isDragging) {
if ((readOnly && !canComment) || isDragging) {
return null;
}
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
const isCellSelection = selection instanceof CellSelection;
const link = getMarkRange(selection.$from, state.schema.marks.link);
const isImageSelection =
selection instanceof NodeSelection && selection.node.type.name === "image";
@@ -199,19 +199,17 @@ export default function SelectionToolbar(props: Props) {
const isNoticeSelection = isInNotice(state);
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
if (isCodeSelection && selection.empty) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelected(state)) {
items = readOnly ? [] : getTableMenuItems(state, dictionary);
} else if (isTableSelection) {
items = getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) {
items = readOnly
? []
: getTableColMenuItems(state, colIndex, rtl, dictionary);
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
} else if (rowIndex !== undefined) {
items = readOnly ? [] : getTableRowMenuItems(state, rowIndex, dictionary);
items = getTableRowMenuItems(state, rowIndex, dictionary);
} else if (isCellSelection) {
items = getTableCellMenuItems(state, dictionary);
} else if (isImageSelection) {
items = readOnly ? [] : getImageMenuItems(state, dictionary);
} else if (isAttachmentSelection) {
@@ -222,7 +220,6 @@ export default function SelectionToolbar(props: Props) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, dictionary);
align = "end";
} else {
items = getFormattingMenuItems(state, isTemplate, dictionary);
}
@@ -254,7 +251,6 @@ export default function SelectionToolbar(props: Props) {
return (
<FloatingToolbar
align={align}
active={isActive}
ref={menuRef}
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
+2 -4
View File
@@ -14,13 +14,13 @@ import { MenuItem } from "@shared/editor/types";
import { depths, s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import Header from "~/components/ContextMenu/Header";
import { Portal } from "~/components/Portal";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
import Input from "./Input";
import { MenuHeader } from "~/components/primitives/components/Menu";
type TopAnchor = {
top: number;
@@ -647,9 +647,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== previousHeading && (
<MenuHeader key={currentHeading}>
{currentHeading}
</MenuHeader>
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
onPointerMove={handlePointerMove}
+9 -18
View File
@@ -2,12 +2,8 @@ import { transparentize } from "polished";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import MenuItem from "~/components/ContextMenu/MenuItem";
import { usePortalContext } from "~/components/Portal";
import {
MenuButton,
MenuIconWrapper,
MenuLabel,
} from "~/components/primitives/components/Menu";
export type Props = {
/** Whether the item is selected */
@@ -57,22 +53,17 @@ function SuggestionsMenuItem({
);
return (
<MenuButton
<MenuItem
ref={ref}
disabled={disabled}
onClick={onClick}
active={selected}
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
$active={selected}
icon={icon}
>
<MenuIconWrapper>{icon}</MenuIconWrapper>
<MenuLabel>
{title}
{subtitle && (
<Subtitle $active={selected}>&middot; {subtitle}</Subtitle>
)}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuLabel>
</MenuButton>
{title}
{subtitle && <Subtitle $active={selected}>&middot; {subtitle}</Subtitle>}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuItem>
);
}
+1 -4
View File
@@ -31,9 +31,6 @@ export default styled.button.attrs((props) => ({
&:hover {
opacity: 1;
// extraArea overlaps slightly, this ensures the currently hovered button is on top
z-index: 1;
}
${(props) =>
@@ -47,7 +44,7 @@ export default styled.button.attrs((props) => ({
cursor: default;
}
${extraArea(5)}
${extraArea(4)}
${(props) =>
props.active &&
+51 -73
View File
@@ -1,9 +1,12 @@
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import { useMenuState } from "reakit";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Toolbar from "@radix-ui/react-toolbar";
import { MenuItem } from "@shared/editor/types";
import { s } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { TooltipProvider } from "~/components/TooltipContext";
import { MenuItem as TMenuItem } from "~/types";
import { useEditor } from "./EditorContext";
@@ -11,12 +14,6 @@ import { MediaDimension } from "./MediaDimension";
import ToolbarButton from "./ToolbarButton";
import ToolbarSeparator from "./ToolbarSeparator";
import Tooltip from "./Tooltip";
import { toMenuItems } from "~/components/Menu/transformer";
import { MenuContent } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { Menu, MenuTrigger } from "~/components/primitives/Menu";
import { useTranslation } from "react-i18next";
import EventBoundary from "@shared/components/EventBoundary";
type Props = {
items: MenuItem[];
@@ -26,8 +23,8 @@ type Props = {
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const menu = useMenuState();
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item } = props;
const { state } = view;
@@ -63,30 +60,20 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
: [];
}, [item.children, commands, state]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
ev.stopImmediatePropagation();
}, []);
return (
<EventBoundary>
<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}
>
{toMenuItems(items)}
</MenuContent>
</Menu>
</MenuProvider>
</EventBoundary>
<>
<MenuButton {...menu}>
{(buttonProps) => (
<ToolbarButton {...buttonProps} hovering={menu.visible}>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</MenuButton>
<ContextMenu aria-label={item.label} {...menu}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
@@ -107,47 +94,39 @@ function ToolbarMenu(props: Props) {
return (
<TooltipProvider>
<Toolbar.Root asChild>
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || (!item.skipIcon && !item.icon)) {
return null;
}
const isActive = item.active ? item.active(state) : false;
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || (!item.skipIcon && !item.icon)) {
return null;
}
const isActive = item.active ? item.active(state) : false;
return (
<Tooltip
key={index}
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{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}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</Toolbar.Button>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
</Toolbar.Root>
return (
<Tooltip
key={index}
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown active={isActive && !item.label} item={item} />
) : (
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
</TooltipProvider>
);
}
@@ -157,7 +136,6 @@ const FlexibleWrapper = styled.div`
overflow: hidden;
display: flex;
gap: 6px;
padding: 6px;
${breakpoint("mobile", "tablet")`
justify-content: space-evenly;
-6
View File
@@ -291,12 +291,6 @@ export default class FindAndReplaceExtension extends Extension {
const from = type === "inline" ? pos + i : pos;
const to = from + (type === "inline" ? m[0].length : node.nodeSize);
// Prevent wrap around matches when the regex matches at the end of the deburred
// string and continues matching at the start of the original string
if (i + this.searchTerm.length > text.length) {
continue;
}
// Check if already exists in results, possible due to duplicated
// search string on L257
if (this.results.some((r) => r.from === from && r.to === to)) {
+13 -17
View File
@@ -8,6 +8,7 @@ import {
TextSelection,
} from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { v4 } from "uuid";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import { codeLanguages } from "@shared/editor/lib/code";
import isMarkdown from "@shared/editor/lib/isMarkdown";
@@ -143,7 +144,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Document,
modelId: document.id,
label: document.titleWithDefault,
id: crypto.randomUUID(),
id: v4(),
})
)
);
@@ -188,7 +189,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: crypto.randomUUID(),
id: v4(),
})
)
);
@@ -458,7 +459,6 @@ export default class PasteHandler extends Extension {
const { view, schema } = this.editor;
const { state } = view;
const { from } = state.selection;
let tr = state.tr;
const links: string[] = [];
let allLinks = true;
@@ -480,26 +480,22 @@ export default class PasteHandler extends Extension {
return false;
});
const showPasteMenu = allLinks && links.length;
if (!allLinks || !links.length) {
return;
}
// it's possible that the links can be converted to mentions
if (showPasteMenu) {
const placeholderId = links[0];
const to = from + listNode.nodeSize;
const placeholderId = links[0];
const to = from + listNode.nodeSize;
tr = state.tr.replaceSelectionWith(listNode).setMeta(this.key, {
const transaction = state.tr
.replaceSelectionWith(listNode)
.setMeta(this.key, {
add: { from, to, id: placeholderId },
});
} else {
// Paste as simple list
tr = tr.replaceSelectionWith(listNode, this.shiftKey);
}
view.dispatch(tr);
view.dispatch(transaction);
if (showPasteMenu) {
this.showPasteMenu(links);
}
this.showPasteMenu(links);
}
private placeholderId = () =>
+8 -27
View File
@@ -54,12 +54,6 @@ import EditorContext from "./components/EditorContext";
import { NodeViewRenderer } from "./components/NodeViewRenderer";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
import isNull from "lodash/isNull";
import { map } from "lodash";
import {
LightboxImage,
LightboxImageFactory,
} from "@shared/editor/lib/Lightbox";
import Lightbox from "~/components/Lightbox";
export type Props = {
@@ -152,8 +146,8 @@ type State = {
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
/** Image that's being currently viewed in Lightbox */
activeLightboxImage: LightboxImage | null;
/** Position of image in doc that's being currently viewed in Lightbox */
activeLightboxImgPos: number | null;
};
/**
@@ -183,7 +177,7 @@ export class Editor extends React.PureComponent<
isRTL: false,
isEditorFocused: false,
selectionToolbarOpen: false,
activeLightboxImage: null,
activeLightboxImgPos: null,
};
isInitialized = false;
@@ -504,7 +498,6 @@ export class Editor extends React.PureComponent<
// Tell third-party libraries and screen-readers that this is an input
view.dom.setAttribute("role", "textbox");
view.dom.setAttribute("aria-label", "Editor content");
return view;
}
@@ -646,16 +639,6 @@ export class Editor extends React.PureComponent<
*/
public getImages = () => ProsemirrorHelper.getImages(this.view.state.doc);
public getLightboxImages = (): LightboxImage[] => {
const lightboxNodes = ProsemirrorHelper.getLightboxNodes(
this.view.state.doc
);
return map(lightboxNodes, (node) =>
LightboxImageFactory.createLightboxImage(this.view, node.pos)
);
};
/**
* Return the tasks/checkmarks in the current editor.
*
@@ -733,10 +716,10 @@ export class Editor extends React.PureComponent<
dispatch(tr);
};
public updateActiveLightboxImage = (activeImage: LightboxImage | null) => {
public updateActiveLightbox = (pos: number | null) => {
this.setState((state) => ({
...state,
activeLightboxImage: activeImage,
activeLightboxImgPos: pos,
}));
};
@@ -859,12 +842,10 @@ export class Editor extends React.PureComponent<
)}
</Observer>
</Flex>
{!isNull(this.state.activeLightboxImage) && (
{this.state.activeLightboxImgPos && (
<Lightbox
images={this.getLightboxImages()}
activeImage={this.state.activeLightboxImage}
onUpdate={this.updateActiveLightboxImage}
onClose={() => this.view.focus()}
onUpdate={this.updateActiveLightbox}
activePos={this.state.activeLightboxImgPos}
/>
)}
</EditorContext.Provider>
+5 -27
View File
@@ -17,8 +17,6 @@ import {
IndentIcon,
CopyIcon,
Heading3Icon,
TableMergeCellsIcon,
TableSplitCellsIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import styled from "styled-components";
@@ -36,11 +34,6 @@ import {
isMobile as isMobileDevice,
isTouchDevice,
} from "@shared/utils/browser";
import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { CellSelection } from "prosemirror-tables";
export default function formattingMenuItems(
state: EditorState,
@@ -53,7 +46,6 @@ export default function formattingMenuItems(
const isEmpty = state.selection.empty;
const isMobile = isMobileDevice();
const isTouch = isTouchDevice();
const isTableCell = state.selection instanceof CellSelection;
const highlight = getMarksBetween(
state.selection.from,
@@ -174,25 +166,11 @@ export default function formattingMenuItems(
icon: <BlockQuoteIcon />,
active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 },
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "separator",
},
{
name: "mergeCells",
tooltip: dictionary.mergeCells,
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
tooltip: dictionary.splitCell,
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
visible: !isCodeBlock && (!isMobile || isEmpty),
},
{
name: "separator",
visible: !isCodeBlock,
},
{
name: "checkbox_list",
@@ -201,7 +179,7 @@ export default function formattingMenuItems(
icon: <TodoListIcon />,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
visible: !isCodeBlock && (!isMobile || isEmpty),
},
{
name: "bullet_list",
@@ -209,7 +187,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+8`,
icon: <BulletedListIcon />,
active: isNodeActive(schema.nodes.bullet_list),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
visible: !isCodeBlock && (!isMobile || isEmpty),
},
{
name: "ordered_list",
@@ -217,7 +195,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+9`,
icon: <OrderedListIcon />,
active: isNodeActive(schema.nodes.ordered_list),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
visible: !isCodeBlock && (!isMobile || isEmpty),
},
{
name: "outdentList",
+36
View File
@@ -0,0 +1,36 @@
import { TableSplitCellsIcon, TableMergeCellsIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function tableCellMenuItems(
state: EditorState,
dictionary: Dictionary
): MenuItem[] {
const { selection } = state;
// Only show menu items if we have a CellSelection
if (!(selection instanceof CellSelection)) {
return [];
}
return [
{
name: "mergeCells",
label: dictionary.mergeCells,
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: dictionary.splitCell,
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
];
}
+13 -25
View File
@@ -5,15 +5,15 @@ import {
AlignCenterIcon,
InsertLeftIcon,
InsertRightIcon,
ArrowIcon,
MoreIcon,
TableHeaderColumnIcon,
TableMergeCellsIcon,
TableSplitCellsIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import { CellSelection } from "prosemirror-tables";
import styled from "styled-components";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
isMergedCellSelection,
@@ -21,7 +21,6 @@ import {
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
export default function tableColMenuItems(
state: EditorState,
@@ -35,8 +34,6 @@ export default function tableColMenuItems(
return [];
}
const tableMap = selectedRect(state);
return [
{
name: "setColumnAttr",
@@ -78,13 +75,13 @@ export default function tableColMenuItems(
name: "sortTable",
tooltip: dictionary.sortAsc,
attrs: { index, direction: "asc" },
icon: <AlphabeticalSortIcon />,
icon: <SortAscIcon />,
},
{
name: "sortTable",
tooltip: dictionary.sortDesc,
attrs: { index, direction: "desc" },
icon: <AlphabeticalReverseSortIcon />,
icon: <SortDescIcon />,
},
{
name: "separator",
@@ -110,23 +107,6 @@ export default function tableColMenuItems(
icon: <InsertRightIcon />,
attrs: { index },
},
{
name: "moveTableColumn",
label: dictionary.moveColumnLeft,
icon: <ArrowLeftIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableColumn",
label: dictionary.moveColumnRight,
icon: <ArrowRightIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.width - 1,
},
{
name: "separator",
},
{
name: "mergeCells",
label: dictionary.mergeCells,
@@ -152,3 +132,11 @@ export default function tableColMenuItems(
},
];
}
const SortAscIcon = styled(ArrowIcon)`
transform: rotate(-90deg);
`;
const SortDescIcon = styled(ArrowIcon)`
transform: rotate(90deg);
`;
+1 -22
View File
@@ -8,14 +8,13 @@ import {
TableMergeCellsIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import { CellSelection } from "prosemirror-tables";
import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
export default function tableRowMenuItems(
state: EditorState,
@@ -23,13 +22,10 @@ export default function tableRowMenuItems(
dictionary: Dictionary
): MenuItem[] {
const { selection } = state;
if (!(selection instanceof CellSelection)) {
return [];
}
const tableMap = selectedRect(state);
return [
{
icon: <MoreIcon />,
@@ -52,23 +48,6 @@ export default function tableRowMenuItems(
icon: <InsertBelowIcon />,
attrs: { index },
},
{
name: "moveTableRow",
label: dictionary.moveRowUp,
icon: <ArrowUpIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableRow",
label: dictionary.moveRowDown,
icon: <ArrowDownIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.height - 1,
},
{
name: "separator",
},
{
name: "mergeCells",
label: dictionary.mergeCells,
+33
View File
@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import useStores from "~/hooks/useStores";
import { ActionContext } from "~/types";
/**
* Hook to get the current action context, an object that is passed to all
* action definitions.
*
* @param overrides Overides of the default action context.
* @returns The current action context.
*/
export default function useActionContext(
overrides?: Partial<ActionContext>
): ActionContext {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
return {
isContextMenu: false,
isCommandBar: false,
isButton: false,
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId,
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
...overrides,
location,
stores,
t,
};
}
-102
View File
@@ -1,102 +0,0 @@
import { observer } from "mobx-react";
import React, { createContext, useContext, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import useStores from "~/hooks/useStores";
import { ActionContext as ActionContextType } from "~/types";
export const ActionContext = createContext<ActionContextType | undefined>(
undefined
);
type ActionContextProviderProps = {
children: ReactNode;
value?: Partial<ActionContextType>;
};
/**
* Provider that allows overriding the action context at different levels
* of the React component tree.
*
* @example
* ```tsx
* // Override context for a command bar
* <ActionContextProvider value={{ isCommandBar: true }}>
* <CommandBar />
* </ActionContextProvider>
*
* // Nested overrides
* <ActionContextProvider value={{ activeCollectionId: "collection-1" }}>
* <CollectionView />
* <ActionContextProvider value={{ activeDocumentId: "doc-1" }}>
* <DocumentView />
* </ActionContextProvider>
* </ActionContextProvider>
* ```
*/
export const ActionContextProvider = observer(function ActionContextProvider_({
children,
value = {},
}: ActionContextProviderProps) {
const parentContext = useContext(ActionContext);
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
// Create the base context if we don't have a parent context
const baseContext: ActionContextType = parentContext ?? {
isMenu: false,
isCommandBar: false,
isButton: false,
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
location,
stores,
t,
};
// Merge the parent context with the provided overrides
const contextValue: ActionContextType = {
...baseContext,
...value,
};
return (
<ActionContext.Provider value={contextValue}>
{children}
</ActionContext.Provider>
);
});
/**
* Hook to get the current action context, an object that is passed to all
* action definitions.
*
* This hook respects the ActionContextProvider hierarchy, merging values from:
* 1. Default system context (stores, location, translation)
* 2. Parent ActionContextProvider values (if any)
* 3. Local overrides parameter (highest priority)
*
* @param overrides Optional overrides of the action context. These will be
* merged with any provider context and take highest priority.
* @returns The current action context with all overrides applied.
*/
export default function useActionContext(
overrides?: Partial<ActionContextType>
): ActionContextType {
const contextValue = useContext(ActionContext);
// If we have a context value from a provider, use it as the base
if (contextValue) {
return {
...contextValue,
...overrides,
};
}
throw new Error(
"useActionContext must be used within an ActionContextProvider"
);
}
+1 -4
View File
@@ -9,7 +9,6 @@ import { CustomTheme } from "@shared/types";
import type { Theme } from "~/stores/UiStore";
import useMediaQuery from "~/hooks/useMediaQuery";
import useStores from "./useStores";
import useQuery from "./useQuery";
/**
* Builds a theme based on the current user's preferences, the current device
@@ -24,11 +23,9 @@ export default function useBuildTheme(
overrideTheme?: Theme
) {
const { ui } = useStores();
const params = useQuery();
const isMobile = useMediaQuery(`(max-width: ${breakpoints.tablet}px)`);
const isPrinting = useMediaQuery("print");
const queryTheme = (params.get("theme") as Theme) || undefined;
const resolvedTheme = overrideTheme ?? queryTheme ?? ui.resolvedTheme;
const resolvedTheme = overrideTheme ?? ui.resolvedTheme;
const theme = useMemo(
() =>
-72
View File
@@ -1,72 +0,0 @@
import { useMemo } from "react";
import { useMenuAction } from "./useMenuAction";
import { ActionV2Separator, createActionV2 } from "~/actions";
import {
deleteCollection,
editCollection,
editCollectionPermissions,
starCollection,
unstarCollection,
searchInCollection,
createTemplate,
archiveCollection,
restoreCollection,
subscribeCollection,
unsubscribeCollection,
createDocument,
exportCollection,
importDocument,
sortCollection,
} from "~/actions/definitions/collections";
import { ActiveCollectionSection } from "~/actions/sections";
import { InputIcon } from "outline-icons";
import usePolicy from "./usePolicy";
import useStores from "./useStores";
import { useTranslation } from "react-i18next";
type Props = {
/** Collection ID for which the actions are generated */
collectionId: string;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
};
export function useCollectionMenuAction({ collectionId, onRename }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const collection = collections.get(collectionId);
const can = usePolicy(collection);
const actions = useMemo(
() => [
restoreCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
ActionV2Separator,
createDocument,
importDocument,
ActionV2Separator,
createActionV2({
name: `${t("Rename")}`,
section: ActiveCollectionSection,
icon: <InputIcon />,
visible: !!can.update && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
}),
editCollection,
editCollectionPermissions,
createTemplate,
sortCollection,
exportCollection,
archiveCollection,
searchInCollection,
ActionV2Separator,
deleteCollection,
],
[t, can.createDocument, can.update, onRename]
);
return useMenuAction(actions);
}

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