Compare commits

..

1 Commits

Author SHA1 Message Date
tommoor 9a94b74827 chore: Compressed inefficient images automatically 2025-09-21 20:06:10 +00:00
360 changed files with 5226 additions and 10369 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Close unsigned PRs
uses: actions/github-script@v8
uses: actions/github-script@v6
with:
script: |
const now = new Date();
@@ -40,7 +40,7 @@ jobs:
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout Branch
uses: actions/checkout@v5
uses: actions/checkout@v2
- name: Compress Images
id: calibre
uses: calibreapp/image-actions@main
@@ -48,7 +48,6 @@ jobs:
githubToken: ${{ secrets.GITHUB_TOKEN }}
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
compressOnly: ${{ github.event_name != 'pull_request' }}
minPctChange: "10"
- name: Create Pull Request
# If it's not a Pull Request then commit any changes as a new PR.
if: |
+13 -13
View File
@@ -25,9 +25,9 @@ jobs:
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
@@ -38,8 +38,8 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
@@ -50,8 +50,8 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
@@ -65,7 +65,7 @@ jobs:
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
@@ -92,8 +92,8 @@ jobs:
matrix:
test-group: [app, shared]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
@@ -124,8 +124,8 @@ jobs:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
@@ -141,8 +141,8 @@ jobs:
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubicloud-standard-8-arm
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -93,7 +93,7 @@ jobs:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
+30
View File
@@ -0,0 +1,30 @@
name: Lint
on:
pull_request:
branches: [main]
jobs:
run-linters:
if: startsWith(github.actor, 'codegen-sh')
name: Run linters
runs-on: ubuntu-latest
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Applied automatic fixes"
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v5
with:
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
+4 -2
View File
@@ -20,7 +20,8 @@
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
@@ -47,7 +48,8 @@
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom",
+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'),
}
+13 -12
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:22.21.0-slim AS runner
FROM node:22-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
@@ -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
@@ -29,13 +23,20 @@ COPY --from=base $APP_PATH/package.json ./package.json
# Install wget to healthcheck the server
RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
&& 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" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
VOLUME /var/lib/outline/data
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:22.21.0 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 1.0.1
Licensed Work: Outline 0.87.4
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-10-29
Change Date: 2029-09-18
Change License: Apache License, Version 2.0
+7 -7
View File
@@ -5,13 +5,6 @@
{
"files": ["**/*.{jsx,tsx}"],
"rules": {
"no-restricted-globals": [
"error",
{
"name": "crypto",
"message": "Do not use, does not work in environments without SSL."
}
],
"no-restricted-imports": [
"error",
{
@@ -20,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."
}
]
}
]
+1 -134
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"),
@@ -147,129 +137,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",
-16
View File
@@ -176,21 +176,6 @@ export const toggleDebugLogging = createAction({
},
});
export const toggleDebugSafeArea = createAction({
name: () => "Toggle menu safe area debugging",
icon: <ToolsIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: ({ stores }) => {
stores.ui.toggleDebugSafeArea();
toast.message(
stores.ui.debugSafeArea
? "Menu safe area debugging enabled"
: "Menu safe area debugging disabled"
);
},
});
export const toggleFeatureFlag = createAction({
name: "Toggle feature flag",
icon: <BeakerIcon />,
@@ -224,7 +209,6 @@ export const developer = createAction({
children: [
copyId,
toggleDebugLogging,
toggleDebugSafeArea,
toggleFeatureFlag,
createToast,
createTestUsers,
+8 -19
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"),
@@ -599,15 +593,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 +612,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"));
}
},
@@ -861,7 +849,7 @@ export const importDocument = createActionV2({
}
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
return !!stores.policies.abilities(activeCollectionId).update;
}
return false;
@@ -874,6 +862,7 @@ export const importDocument = createActionV2({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
try {
-1
View File
@@ -22,7 +22,6 @@ export const inviteUser = createAction({
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Invite to workspace"),
width: "500px",
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
});
},
+1 -1
View File
@@ -1,8 +1,8 @@
import { LocationDescriptor } from "history";
import { v4 as uuidv4 } from "uuid";
import flattenDeep from "lodash/flattenDeep";
import { toast } from "sonner";
import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import {
Action,
ActionContext,
+6 -5
View File
@@ -13,6 +13,7 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
@@ -29,7 +30,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 +37,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;
@@ -131,7 +130,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 -12
View File
@@ -2,7 +2,6 @@ import * as React from "react";
import styled from "styled-components";
import useBoolean from "~/hooks/useBoolean";
import Initials from "./Initials";
import Tooltip from "../Tooltip";
export enum AvatarSize {
Small = 16,
@@ -23,7 +22,6 @@ export interface IAvatar {
avatarUrl: string | null;
color?: string;
initial?: string;
name?: string;
id?: string;
}
@@ -44,8 +42,6 @@ type Props = {
className?: string;
/** Optional style */
style?: React.CSSProperties;
/** Whether to show a tooltip */
showTooltip?: boolean;
};
function Avatar(props: Props) {
@@ -54,13 +50,12 @@ function Avatar(props: Props) {
style,
variant = AvatarVariant.Round,
className,
showTooltip,
...rest
} = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
const content = (
return (
<Relative
style={style}
$variant={variant}
@@ -78,12 +73,6 @@ function Avatar(props: Props) {
)}
</Relative>
);
return showTooltip ? (
<Tooltip content={props.alt || model?.name || ""}>{content}</Tooltip>
) : (
content
);
}
Avatar.defaultProps = {
-1
View File
@@ -26,7 +26,6 @@ export function GroupAvatar({
return (
<Squircle color={color ?? theme.text} size={size} className={className}>
<GroupIcon
data-fixed-color
color={backgroundColor ?? theme.background}
size={size * 0.75}
/>
+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;
+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);
@@ -1,8 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
type Positions = {
/** Sub-menu x */
@@ -24,7 +21,7 @@ type Positions = {
* allow moving cursor to lower parts of sub-menu without the sub-menu
* disappearing.
*/
export const MouseSafeArea = observer(function MouseSafeArea_(props: {
export default function MouseSafeArea(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const {
@@ -33,32 +30,15 @@ export const MouseSafeArea = observer(function MouseSafeArea_(props: {
height: h = 0,
width: w = 0,
} = props.parentRef.current?.getBoundingClientRect() || {};
const { ui } = useStores();
const [mouseX, mouseY] = useMousePosition();
const [isVisible, setIsVisible] = React.useState(true);
const positions = { x, y, h, w, mouseX, mouseY };
const distance = Math.abs(mouseX - x);
const prevDistance = usePrevious(distance) ?? distance;
// Hide the safe area if the mouse is moving _away_ from the menu
React.useEffect(() => {
if (distance > prevDistance) {
setIsVisible(false);
} else if (distance < prevDistance) {
setIsVisible(true);
}
}, [distance, prevDistance]);
if (!isVisible) {
return null;
}
return (
<div
style={{
position: "absolute",
top: 0,
backgroundColor: ui.debugSafeArea ? "rgba(255,0,0,0.2)" : undefined,
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
right: getRight(positions),
left: getLeft(positions),
height: h,
@@ -67,26 +47,24 @@ export const MouseSafeArea = observer(function MouseSafeArea_(props: {
}}
/>
);
});
const buffer = 10;
}
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX + buffer, buffer) + "px";
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w) + buffer, buffer) + "px" : undefined;
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? Math.max(mouseX - (x + w - buffer), buffer) + "px"
: Math.max(x - mouseX + buffer, buffer) + "px";
? 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% ${
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
(100 * (mouseY - y)) / h + 5
}%, 100% ${(100 * (mouseY - y)) / h - buffer}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - buffer}%, 0% ${
}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
@@ -0,0 +1,27 @@
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof MenuButton> & {
className?: string;
};
export default function OverflowMenuButton({ className, ...rest }: Props) {
const { t } = useTranslation();
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton
className={className}
aria-label={t("More options")}
{...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({
isMenu: 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 -9
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}
@@ -32,13 +29,11 @@ function Dialogs() {
}}
title={modal.title}
style={modal.style}
width={modal.width}
height={modal.height}
>
{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
+1 -1
View File
@@ -94,7 +94,7 @@ function DocumentListItem(
currentContext: locationSidebarContext,
});
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
const contextMenuAction = useDocumentMenuAction({ document });
return (
<ActionContextProvider
-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 ? (
+3 -12
View File
@@ -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,22 +74,18 @@ function EditableTitle(
return;
}
setIsSubmitting(true);
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
setIsEditing(false);
} catch (error) {
setValue(value);
setIsEditing(true);
setValue(originalValue);
toast.error(error.message);
throw error;
} finally {
setIsSubmitting(false);
setIsEditing(false);
}
},
[originalValue, value, onCancel, onSubmit, isSubmitting]
[originalValue, value, onCancel, onSubmit]
);
const handleKeyDown = React.useCallback(
+20 -53
View File
@@ -25,7 +25,6 @@ function ExportDialog({ collection, onSubmit }: Props) {
);
const [includeAttachments, setIncludeAttachments] =
React.useState<boolean>(true);
const [includePrivate, setIncludePrivate] = React.useState<boolean>(true);
const user = useCurrentUser();
const { collections } = useStores();
const { t } = useTranslation();
@@ -45,13 +44,6 @@ function ExportDialog({ collection, onSubmit }: Props) {
[]
);
const handleIncludePrivateChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setIncludePrivate(ev.target.checked);
},
[]
);
const handleSubmit = async () => {
if (collection) {
await collection.export(format, includeAttachments);
@@ -67,7 +59,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
},
});
} else {
await collections.export({ format, includeAttachments, includePrivate });
await collections.export(format, includeAttachments);
toast.success(t("Export started"));
}
onSubmit();
@@ -131,62 +123,37 @@ function ExportDialog({ collection, onSubmit }: Props) {
<Text as="p" size="small" weight="bold">
{item.title}
</Text>
<Text size="small" type="secondary">
{item.description}
</Text>
<Text size="small">{item.description}</Text>
</div>
</Option>
))}
</Flex>
<HR />
<Flex gap={12} column>
<Option>
<input
type="checkbox"
name="includeAttachments"
checked={includeAttachments}
onChange={handleIncludeAttachmentsChange}
/>
<div>
<Text as="p" size="small" weight="bold">
{t("Include attachments")}
</Text>
<Text size="small" type="secondary">
{t("Including uploaded images and files in the exported data")}.
</Text>{" "}
</div>
</Option>
<Option>
<input
type="checkbox"
name="includePrivate"
checked={includePrivate}
onChange={handleIncludePrivateChange}
/>
<div>
<Text as="p" size="small" weight="bold">
{t("Include private collections")}
</Text>
</div>
</Option>
</Flex>
<hr />
<Option>
<input
type="checkbox"
name="includeAttachments"
checked={includeAttachments}
onChange={handleIncludeAttachmentsChange}
/>
<div>
<Text as="p" size="small" weight="bold">
{t("Include attachments")}
</Text>
<Text size="small">
{t("Including uploaded images and files in the exported data")}.
</Text>{" "}
</div>
</Option>
</ConfirmationDialog>
);
}
const HR = styled.hr`
margin: 16px 0;
`;
const Option = styled.label`
display: flex;
align-items: start;
align-items: center;
gap: 16px;
input {
margin-top: 4px;
}
p {
margin: 0;
}
-10
View File
@@ -5,7 +5,6 @@ import styled from "styled-components";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { s } from "@shared/styles";
type Props = {
/** The users to display */
@@ -22,8 +21,6 @@ type Props = {
model: User;
}
>;
/** Whether to show tooltips on hover, defaults to true */
showTooltip?: boolean;
};
function Facepile({
@@ -32,7 +29,6 @@ function Facepile({
size = AvatarSize.Large,
limit = 8,
renderAvatar = Avatar,
showTooltip = true,
...rest
}: Props) {
const { t } = useTranslation();
@@ -55,7 +51,6 @@ function Facepile({
<Component
key={model.id}
{...{
showTooltip,
model,
size,
style: {
@@ -106,11 +101,6 @@ const Avatars = styled(Flex)`
align-items: center;
flex-direction: row-reverse;
cursor: var(--pointer);
*:hover {
clip-path: none !important;
box-shadow: 0 0 0 2px ${s("background")};
}
`;
export default observer(Facepile);
+3 -11
View File
@@ -13,7 +13,6 @@ import useStores from "~/hooks/useStores";
import LoadingIndicator from "../LoadingIndicator";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
import HoverPreviewGroup from "./HoverPreviewGroup";
import HoverPreviewIssue from "./HoverPreviewIssue";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
@@ -133,13 +132,6 @@ const HoverPreviewDesktop = observer(
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Group ? (
<HoverPreviewGroup
ref={cardRef}
name={data.name}
memberCount={data.memberCount}
users={data.users}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
@@ -303,10 +295,10 @@ const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
&:before {
border: 8px solid transparent;
${({ direction }) =>
${({ direction, theme }) =>
direction === Direction.UP
? `border-bottom-color: rgba(0, 0, 0, 0.1)`
: `border-top-color: rgba(0, 0, 0, 0.1)`};
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
${({ direction }) =>
direction === Direction.UP ? "right: -1px" : "left: -1px"};
}
@@ -1,59 +0,0 @@
import * as React from "react";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import User from "~/models/User";
import Facepile from "~/components/Facepile";
import Flex from "~/components/Flex";
import {
Preview,
Title,
Info,
Card,
CardContent,
Description,
} from "./Components";
import ErrorBoundary from "../ErrorBoundary";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
{ name, memberCount, users }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
<Preview as="div">
<Card fadeOut={false} ref={ref}>
<CardContent>
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
<Flex column gap={2} align="start">
<Title>{name}</Title>
<Info>
{memberCount === 1 ? "1 member" : `${memberCount} members`}
</Info>
{users.length > 0 && (
<Description>
<Facepile
users={users.map(
(member) =>
({
id: member.id,
name: member.name,
avatarUrl: member.avatarUrl,
color: member.color,
initial: member.name ? member.name[0] : "?",
}) as User
)}
overflow={Math.max(0, memberCount - users.length)}
limit={MAX_AVATAR_DISPLAY}
/>
</Description>
)}
</Flex>
</ErrorBoundary>
</CardContent>
</Card>
</Preview>
);
});
export default HoverPreviewGroup;
@@ -7,7 +7,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
{ avatarUrl, name, lastActive, color }: Props,
{ avatarUrl, name, lastActive, color, email }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
@@ -25,6 +25,7 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
/>
<Flex column gap={2} justify="center">
<Title>{name}</Title>
{email && <Info>{email}</Info>}
<Info>{lastActive}</Info>
</Flex>
</Flex>
+15 -4
View File
@@ -1,10 +1,12 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { QuestionMarkIcon } from "outline-icons";
import { transparentize } from "polished";
import * as React from "react";
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";
@@ -217,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;
@@ -341,9 +343,9 @@ function Option({
{option.description && (
<>
&nbsp;
<Text type="tertiary" size="small" ellipsis>
<Description type="tertiary" size="small" ellipsis>
{option.description}
</Text>
</Description>
</>
)}
</OptionContainer>
@@ -359,6 +361,15 @@ const OptionContainer = styled(Flex)`
min-height: 24px;
`;
const Description = styled(Text)`
@media (hover: hover) {
&:hover,
&:focus {
color: ${(props) => transparentize(0.5, props.theme.accentText)};
}
}
`;
const IconWrapper = styled.span`
display: flex;
justify-content: center;
+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>
);
});
+118 -461
View File
@@ -1,21 +1,12 @@
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,
HTMLAttributes,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
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,
@@ -23,13 +14,12 @@ import {
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";
@@ -39,17 +29,6 @@ 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";
import { mergeRefs } from "react-merge-refs";
export enum LightboxStatus {
READY_TO_OPEN,
@@ -64,9 +43,6 @@ export enum ImageStatus {
LOADING,
ERROR,
LOADED,
MIN_ZOOM,
MAX_ZOOM,
ZOOMED,
}
type Status = {
lightbox: LightboxStatus | null;
@@ -84,152 +60,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;
onClose?: () => void;
};
const ZoomablePannablePinchable = forwardRef<
ReactZoomPanPinchRef,
ZoomablePannablePinchableProps
>(({ children, panningDisabled, disabled, onClose }, ref) => {
const { isPanning, ...panningHandlers } = usePanning();
const wrapperRef = useRef<ReactZoomPanPinchRef>(null);
const scale = wrapperRef.current?.instance.transformState.scale ?? 1;
const wrapperProps = useMemo(
() =>
({
onClick: (event) => {
if (scale > 1) {
return;
}
if (event.defaultPrevented) {
return;
}
if (
["IMG", "INPUT", "BUTTON", "A"].includes(
(event.target as Element).tagName
)
) {
return;
}
onClose?.();
},
}) satisfies HTMLAttributes<HTMLDivElement>,
[onClose, scale]
);
return (
<ZoomPanPinchContext.Provider value={{ isImagePanning: isPanning }}>
<TransformWrapper
ref={mergeRefs([ref, wrapperRef])}
disabled={disabled}
doubleClick={{ disabled: true }}
minScale={1}
maxScale={8}
panning={{
disabled: panningDisabled,
}}
{...panningHandlers}
>
<TransformComponent
wrapperStyle={{
width: "100%",
height: "100%",
cursor: isPanning ? "grabbing" : scale > 1 ? "grab" : "zoom-out",
}}
contentStyle={{
width: "100%",
height: "100%",
padding: "56px",
justifyContent: "center",
alignItems: "center",
}}
wrapperProps={wrapperProps}
>
{children}
</TransformComponent>
</TransformWrapper>
</ZoomPanPinchContext.Provider>
);
});
function usePanning() {
const [isPanning, setPanning] = useState(false);
const dragged = useRef(false);
const onPanningStart: ComponentProps<
typeof TransformWrapper
>["onPanningStart"] = (ref) => {
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) => {
setPanning(ref.instance.isPanning);
if (dragged.current) {
dragged.current = false;
} else if (event.target instanceof HTMLImageElement) {
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(() => {
@@ -238,21 +108,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) {
@@ -275,18 +139,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();
@@ -304,15 +156,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();
@@ -336,10 +179,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,
@@ -426,13 +270,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 {
@@ -451,7 +289,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
};
// in editor
const editorImageEl = activeImage.getElement();
const editorImageEl = imageElements[currentImageIndex];
let to;
if (editorImageEl?.isConnected) {
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
@@ -526,31 +364,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);
}
};
@@ -566,63 +406,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) {
@@ -670,8 +459,14 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
};
if (!currentImageNode) {
return null;
}
const src = sanitizeUrl(currentImageNode.attrs.src) ?? "";
return (
<Dialog.Root open={true}>
<Dialog.Root open={!!activePos}>
<Dialog.Portal>
<StyledOverlay
ref={overlayRef}
@@ -679,7 +474,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>
@@ -687,52 +482,10 @@ 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
<Button
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
aria-label={t("Copy link")}
size={32}
icon={<LinkIcon />}
@@ -742,9 +495,8 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
</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}
@@ -756,7 +508,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
<Separator />
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
<ActionButton
<Button
tabIndex={-1}
onClick={close}
aria-label={t("Close")}
@@ -768,87 +520,49 @@ 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={src}
alt={currentImageNode.attrs.alt ?? ""}
onLoading={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADING,
})
}
disabled={status.image === ImageStatus.ERROR}
ref={zoomPanPinchRef}
onClose={close}
>
<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}
onSwipeUp={close}
onSwipeDown={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>
@@ -867,9 +581,6 @@ type ImageProps = {
onSwipeDown: () => void;
status: Status;
animation: Animation | null;
onMinZoom: () => void;
onZoom: () => void;
onMaxZoom: () => void;
};
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
@@ -885,9 +596,6 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
onSwipeDown,
status,
animation,
onMinZoom,
onZoom,
onMaxZoom,
}: ImageProps,
ref
) {
@@ -900,25 +608,6 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
onSwipeDown,
});
const { isImagePanning } = useContext(ZoomPanPinchContext);
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 [hidden, setHidden] = useState(
status.image === null || status.image === ImageStatus.LOADING
);
@@ -953,15 +642,9 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
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}
@@ -1017,25 +700,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
? "grabbing"
: props.$zoomedOut
? "zoom-in"
: props.$zoomedIn
? "zoom-out"
: "default"};
${(props) =>
props.animation?.zoomIn
? css`
@@ -1047,12 +717,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)`
@@ -1063,10 +728,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<{
@@ -1079,10 +741,6 @@ const Actions = styled.div<{
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;
${(props) =>
props.animation === null
@@ -1110,7 +768,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
@@ -1130,7 +787,7 @@ const Nav = styled.div<{
: ""}
`;
const StyledError = styled(ImageError)<{
const StyledError = styled(Error)<{
animation: Animation | null;
}>`
${(props) =>
+5 -5
View File
@@ -11,7 +11,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
type Props = {
/** Root action with children representing the menu items */
action?: ActionV2WithChildren;
action: ActionV2WithChildren;
/** Trigger for the menu */
children: React.ReactNode;
/** ARIA label for the menu */
@@ -35,10 +35,10 @@ export const ContextMenu = observer(
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,7 +68,7 @@ export const ContextMenu = observer(
[]
);
if (isMobile || !action || menuItems.length === 0) {
if (isMobile) {
return <>{children}</>;
}
+1 -7
View File
@@ -11,12 +11,9 @@ import {
} from "~/components/primitives/Menu";
import * as Components from "~/components/primitives/components/Menu";
import { MenuItem } from "~/types";
import { MouseSafeArea } from "~/components/MouseSafeArea";
import { createRef } from "react";
export function toMenuItems(items: MenuItem[]) {
const filteredItems = filterMenuItems(items);
const parentRef = createRef<HTMLDivElement>();
if (!filteredItems.length) {
return null;
@@ -91,10 +88,7 @@ export function toMenuItems(items: MenuItem[]) {
icon={icon}
disabled={item.disabled}
/>
<SubMenuContent ref={parentRef}>
<MouseSafeArea parentRef={parentRef} />
{submenuItems}
</SubMenuContent>
<SubMenuContent>{submenuItems}</SubMenuContent>
</SubMenu>
);
}
+14 -21
View File
@@ -22,8 +22,6 @@ type Props = {
isOpen: boolean;
title?: React.ReactNode;
style?: React.CSSProperties;
width?: number | string;
height?: number | string;
onRequestClose: () => void;
};
@@ -32,8 +30,6 @@ const Modal: React.FC<Props> = ({
isOpen,
title = "Untitled",
style,
width,
height,
onRequestClose,
}: Props) => {
const wasOpen = usePrevious(isOpen);
@@ -61,7 +57,7 @@ const Modal: React.FC<Props> = ({
>
{isMobile ? (
<Mobile>
<MobileContent>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && (
<Text size="xlarge" weight="bold">
@@ -70,7 +66,7 @@ const Modal: React.FC<Props> = ({
)}
<ErrorBoundary>{children}</ErrorBoundary>
</Centered>
</MobileContent>
</Content>
<Close onClick={onRequestClose}>
<CloseIcon size={32} />
</Close>
@@ -80,7 +76,7 @@ const Modal: React.FC<Props> = ({
</Back>
</Mobile>
) : (
<Wrapper $width={width} $height={height}>
<Small>
<Centered
onClick={(ev) => ev.stopPropagation()}
// maxHeight needed for proper overflow behavior in Safari
@@ -88,9 +84,9 @@ const Modal: React.FC<Props> = ({
column
reverse
>
<DesktopContent style={style} shadow>
<SmallContent style={style} shadow>
<ErrorBoundary component="div">{children}</ErrorBoundary>
</DesktopContent>
</SmallContent>
<Header>
{title && <Text size="large">{title}</Text>}
<NudeButton onClick={onRequestClose}>
@@ -98,7 +94,7 @@ const Modal: React.FC<Props> = ({
</NudeButton>
</Header>
</Centered>
</Wrapper>
</Small>
)}
</StyledContent>
</Dialog.Portal>
@@ -146,7 +142,7 @@ const Mobile = styled.div`
outline: none;
`;
const MobileContent = styled(Scrollable)`
const Content = styled(Scrollable)`
width: 100%;
padding: 8vh 12px;
@@ -155,10 +151,6 @@ const MobileContent = styled(Scrollable)`
`};
`;
const DesktopContent = styled(Scrollable)`
padding: 8px 24px 24px;
`;
const Centered = styled(Flex)`
width: 640px;
max-width: 100%;
@@ -215,17 +207,14 @@ const Header = styled(Flex)`
padding: 24px 24px 12px;
`;
const Wrapper = styled.div<{
$width?: number | string;
$height?: number | string;
}>`
const Small = styled.div`
animation: ${fadeAndScaleIn} 250ms ease;
margin: 25vh auto auto auto;
width: 75vw;
min-width: 350px;
max-width: ${(props) => props.$width || "450px"};
max-height: ${(props) => props.$height || "70vh"};
max-width: 450px;
max-height: 65vh;
z-index: ${depths.modal};
display: flex;
justify-content: center;
@@ -248,4 +237,8 @@ const Wrapper = styled.div<{
}
`;
const SmallContent = styled(Scrollable)`
padding: 8px 24px 24px;
`;
export default observer(Modal);
@@ -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;
@@ -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>
);
-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;
@@ -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}
/>
}
/>
</>
)}
+5 -10
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,16 +37,12 @@ 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;
}
return (
<StyledSidebar $hoverTransition={!teamAvailable} canResize={false}>
<StyledSidebar $hoverTransition={!teamAvailable}>
{teamAvailable && (
<SidebarButton
title={team.name}
@@ -57,7 +52,9 @@ function SharedSidebar({ share }: Props) {
onClick={() =>
history.push(user ? homePath() : sharedModelPath(shareId))
}
/>
>
<ToggleSidebar />
</SidebarButton>
)}
<ScrollContainer topShadow flex>
<TopSection>
@@ -144,8 +141,7 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
${({ $hoverTransition }) =>
$hoverTransition &&
`
@media (hover: hover) {
&:${hover} {
&: ${hover} {
${StyledSearchPopover} {
width: 85%;
}
@@ -153,7 +149,6 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
${ToggleWrapper} {
opacity: 1;
transform: translateX(0);
}
}
}
`}
+10 -12
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";
@@ -25,15 +26,13 @@ import { useTranslation } from "react-i18next";
const ANIMATION_MS = 250;
type Props = {
hidden?: boolean;
/** Whether the sidebar can be resized and collapsed, defaults to true. */
canResize?: boolean;
className?: string;
children: React.ReactNode;
hidden?: boolean;
className?: string;
};
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{ children, hidden = false, canResize = true, className }: Props,
{ children, hidden = false, className }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const [isCollapsing, setCollapsing] = React.useState(false);
@@ -42,10 +41,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
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 && canResize;
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -256,12 +256,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
</SidebarButton>
</AccountMenu>
)}
{canResize && (
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
)}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
</TooltipProvider>
@@ -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;
@@ -86,43 +84,33 @@ const CollectionLink: React.FC<Props> = ({
editableTitleRef.current?.setIsEditing(true);
}, [editableTitleRef]);
const newChildTitleRef = React.useRef<RefHandle>(null);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const handleNewDoc = React.useCallback(
async (input) => {
try {
newChildTitleRef.current?.setIsEditing(false);
const newDocument = await documents.create(
{
collectionId: collection.id,
title: input,
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument);
const newDocument = await documents.create(
{
collectionId: collection.id,
title: input,
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument);
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
} catch (_err) {
newChildTitleRef.current?.setIsEditing(true);
}
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[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
@@ -134,7 +122,6 @@ const CollectionLink: React.FC<Props> = ({
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
@@ -198,12 +185,11 @@ const CollectionLink: React.FC<Props> = ({
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
)}
</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
@@ -22,7 +22,7 @@ function Disclosure({ onClick, root, expanded, ...rest }: Props) {
aria-label={expanded ? t("Collapse") : t("Expand")}
{...rest}
>
<StyledCollapsedIcon $expanded={expanded} size={20} />
<StyledCollapsedIcon expanded={expanded} size={20} />
</Button>
);
}
@@ -52,13 +52,13 @@ const Button = styled(NudeButton)<{ $root?: boolean }>`
`;
const StyledCollapsedIcon = styled(CollapsedIcon)<{
$expanded?: boolean;
expanded?: boolean;
}>`
transition:
opacity 100ms ease,
transform 100ms ease,
fill 50ms !important;
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
${(props) => !props.expanded && "transform: rotate(-90deg);"};
`;
// Enables identifying this component within styled components
@@ -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;
@@ -281,36 +279,30 @@ function InnerDocumentLink(
[setExpanded, setCollapsed, hasChildren, expanded]
);
const newChildTitleRef = React.useRef<RefHandle>(null);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const handleNewDoc = React.useCallback(
async (input) => {
try {
newChildTitleRef.current?.setIsEditing(false);
const newDocument = await documents.create(
{
collectionId: collection?.id,
parentDocumentId: node.id,
fullWidth:
doc?.fullWidth ??
user.getPreference(UserPreference.FullWidthDocuments),
title: input,
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument, node.id);
const newDocument = await documents.create(
{
collectionId: collection?.id,
parentDocumentId: node.id,
fullWidth:
doc?.fullWidth ??
user.getPreference(UserPreference.FullWidthDocuments),
title: input,
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument, node.id);
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
} catch (_err) {
newChildTitleRef.current?.setIsEditing(true);
}
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[
documents,
@@ -324,70 +316,8 @@ function InnerDocumentLink(
]
);
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
const labelElement = React.useMemo(
() => (
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
ref={editableTitleRef}
/>
),
[title, handleTitleChange, isEditing, setIsEditing, canUpdate]
);
const menuElement = React.useMemo(
() =>
document && !isMoving && !isEditing && !isDraggingAnyDocument ? (
<Fade>
{can.createChildDocument && (
<Tooltip content={t("New doc")}>
<NudeButton
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
setExpanded();
}}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
<DocumentMenu
document={document}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined,
[
document,
isMoving,
isEditing,
isDraggingAnyDocument,
can.createChildDocument,
t,
setIsAddingNewChild,
setExpanded,
handleRename,
handleMenuOpen,
handleMenuClose,
]
);
return (
<ActionContextProvider
value={{
activeDocumentId: node.id,
}}
>
<>
<Relative ref={parentRef}>
<Draggable
key={node.id}
@@ -404,10 +334,19 @@ function InnerDocumentLink(
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
to={toPath}
icon={iconElement}
label={labelElement}
label={
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
ref={editableTitleRef}
/>
}
isActive={isActiveCheck}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
@@ -416,7 +355,35 @@ function InnerDocumentLink(
scrollIntoViewIfNeeded={sidebarContext === "collections"}
isDraft={isDraft}
ref={ref}
menu={menuElement}
menu={
document &&
!isMoving &&
!isEditing &&
!isDraggingAnyDocument ? (
<Fade>
{can.createChildDocument && (
<Tooltip content={t("New doc")}>
<NudeButton
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
setExpanded();
}}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
<DocumentMenu
document={document}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</DropToImport>
</div>
@@ -438,7 +405,6 @@ function InnerDocumentLink(
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
@@ -459,7 +425,7 @@ function InnerDocumentLink(
/>
))}
</Folder>
</ActionContextProvider>
</>
);
}
@@ -8,32 +8,19 @@ import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { RequestResponse } from "~/hooks/usePaginatedRequest";
import GroupMembership from "~/models/GroupMembership";
import { t } from "i18next";
import { toast } from "sonner";
type Props = {
/** The group to render */
group: Group;
/** The response from the group memberships request */
response: RequestResponse<GroupMembership>;
};
const GroupLink: React.FC<Props> = ({ group, response }) => {
const GroupLink: React.FC<Props> = ({ group }) => {
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = groupSidebarContext(group.id);
const { loading, next, end, error } = response;
const [expanded, setExpanded] = React.useState(
locationSidebarContext === sidebarContext
);
React.useEffect(() => {
if (error) {
toast.error(t("Could not load shared documents"));
}
}, [error, t]);
const handleDisclosureClick = React.useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
@@ -63,14 +50,6 @@ const GroupLink: React.FC<Props> = ({ group, response }) => {
depth={1}
/>
))}
{!end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={loading}
depth={0}
/>
)}
</Folder>
</SidebarContext.Provider>
</Relative>
+3 -3
View File
@@ -41,7 +41,7 @@ export const Header: React.FC<Props> = ({ id, title, children }: Props) => {
<H3>
<Button onClick={handleClick} disabled={!id}>
{title}
{id && <Disclosure $expanded={expanded} size={20} />}
{id && <Disclosure expanded={expanded} size={20} />}
</Button>
</H3>
{expanded && (firstRender ? children : <Fade>{children}</Fade>)}
@@ -91,12 +91,12 @@ const Button = styled.button`
}
`;
const Disclosure = styled(CollapsedIcon)<{ $expanded?: boolean }>`
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
transition:
opacity 100ms ease,
transform 100ms ease,
fill 50ms !important;
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
opacity: 0;
`;
@@ -30,9 +30,7 @@ function SharedWithMe() {
const history = useHistory();
const locationSidebarContext = useLocationSidebarContext();
const gmResponse = usePaginatedRequest<GroupMembership>(
groupMemberships.fetchAll
);
usePaginatedRequest<GroupMembership>(groupMemberships.fetchAll);
const { loading, next, end, error, page } =
usePaginatedRequest<UserMembership>(userMemberships.fetchPage, {
@@ -110,7 +108,7 @@ function SharedWithMe() {
<Flex column>
<Header id="shared" title={t("Shared with me")}>
{user.groupsWithDocumentMemberships.map((group) => (
<GroupLink key={group.id} group={group} response={gmResponse} />
<GroupLink key={group.id} group={group} />
))}
<Relative>
{reorderProps.isDragging && (
@@ -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>}
</>
);
+83 -191
View File
@@ -28,174 +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);
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 });
if (!document) {
return null;
}
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();
@@ -286,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>
);
}
@@ -7,28 +7,18 @@ export default function useCollectionDocuments(
collection: Collection | undefined,
activeDocument: Document | undefined
) {
const insertDraftDocument = useMemo(
() =>
activeDocument &&
activeDocument.isActive &&
activeDocument.isDraft &&
activeDocument.collectionId === collection?.id &&
!activeDocument.parentDocumentId,
[
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.collectionId,
activeDocument?.parentDocumentId,
collection?.id,
]
);
return useMemo(() => {
if (!collection?.sortedDocuments) {
return undefined;
}
return insertDraftDocument && activeDocument
const insertDraftDocument =
activeDocument?.isActive &&
activeDocument?.isDraft &&
activeDocument?.collectionId === collection.id &&
!activeDocument?.parentDocumentId;
return insertDraftDocument
? sortNavigationNodes(
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
collection.sort,
@@ -36,9 +26,14 @@ export default function useCollectionDocuments(
)
: collection.sortedDocuments;
}, [
insertDraftDocument,
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.collectionId,
activeDocument?.parentDocumentId,
activeDocument?.asNavigationNode,
collection,
collection?.sortedDocuments,
collection?.id,
collection?.sort,
]);
}
@@ -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
),
+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;
+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>
@@ -9,8 +9,6 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Button, { Inner } from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import { transparentize } from "polished";
export const SelectItem = forwardRef<
HTMLDivElement,
@@ -116,10 +114,6 @@ const ItemContainer = styled(Flex)`
color: ${s("accentText")};
fill: ${s("accentText")};
}
${Text} {
color: ${(props) => transparentize(0.5, props.theme.accentText)};
}
}
}
+15 -30
View File
@@ -9,7 +9,6 @@ import { fadeAndScaleIn } from "~/styles/animations";
type BaseMenuItemProps = {
disabled?: boolean;
$active?: boolean;
$dangerous?: boolean;
};
@@ -46,38 +45,24 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
}
${(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:not([data-fixed-color]) {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
`}
${(props) =>
!props.disabled &&
`
&: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:not([data-fixed-color]) {
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
color: ${props.theme.accentText};
fill: ${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};
}
}
}
`}
+9 -15
View File
@@ -1,4 +1,3 @@
import { useCallback } from "react";
import useDictionary from "~/hooks/useDictionary";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
@@ -14,25 +13,20 @@ function BlockMenu(props: Props) {
const dictionary = useDictionary();
const { elementRef } = useEditor();
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
/>
),
[]
);
return (
<SuggestionsMenu
{...props}
filterable
trigger="/"
renderMenuItem={renderMenuItem}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
/>
)}
items={getMenuItems(dictionary, elementRef)}
/>
);
@@ -3,57 +3,71 @@ import { Node } from "prosemirror-model";
import { Selection, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
import Flex from "~/components/Flex";
import Tooltip from "~/components/Tooltip";
import Input from "~/editor/components/Input";
import { Dictionary } from "~/hooks/useDictionary";
import useEmbeds from "~/hooks/useEmbeds";
import ToolbarButton from "./ToolbarButton";
type Props = {
node: Node;
view: EditorView;
dictionary: Dictionary;
autoFocus?: boolean;
};
export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
const url = (node.attrs.href ?? node.attrs.src) as string;
export function EmbedLinkEditor({ node, view, dictionary }: Props) {
const { t } = useTranslation();
const embeds = useEmbeds();
const url = node.attrs.href as string;
const [localUrl, setLocalUrl] = useState(url);
const moveSelectionToEnd = useCallback(() => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(
state.tr.doc.resolve(state.selection.from),
1,
true
);
const selection = nextSelection ?? TextSelection.create(state.tr.doc, 0);
dispatch(state.tr.setSelection(selection));
view.focus();
}, [view]);
const openLink = useCallback(() => {
const openEmbed = useCallback(() => {
window.open(url, "_blank");
}, [url]);
const remove = useCallback(() => {
const removeEmbed = useCallback(() => {
const { state, dispatch } = view;
dispatch(state.tr.deleteSelection());
}, [view]);
const update = useCallback(() => {
const { state } = view;
const hrefType = node.type.name === "image" ? "src" : "href";
const tr = state.tr.setNodeMarkup(state.selection.from, undefined, {
...node.attrs,
[hrefType]: localUrl,
});
const updateEmbed = useCallback(() => {
const matchingEmbed = getMatchingEmbed(embeds, localUrl);
if (!matchingEmbed) {
toast.error(t("Sorry, invalid embed link"));
return;
}
const { state, dispatch } = view;
dispatch(
state.tr.setNodeMarkup(state.selection.from, undefined, {
...node.attrs,
href: localUrl,
})
);
view.dispatch(tr);
moveSelectionToEnd();
}, [localUrl, node, view, moveSelectionToEnd]);
}, [t, localUrl, embeds, node, view, moveSelectionToEnd]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -64,7 +78,7 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
switch (event.key) {
case "Enter": {
event.preventDefault();
update();
updateEmbed();
return;
}
@@ -75,13 +89,12 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
}
}
},
[update, moveSelectionToEnd]
[updateEmbed, moveSelectionToEnd]
);
return (
<Wrapper>
<Input
autoFocus={autoFocus}
value={localUrl}
placeholder={dictionary.pasteLink}
onChange={(e) => setLocalUrl(e.target.value)}
@@ -89,19 +102,13 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
readOnly={!view.editable}
/>
<Tooltip content={dictionary.openLink}>
<ToolbarButton onClick={openLink} disabled={!localUrl}>
<ToolbarButton onClick={openEmbed} disabled={!localUrl}>
<OpenIcon />
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip
content={
node.type.name === "embed"
? dictionary.deleteEmbed
: dictionary.deleteImage
}
>
<ToolbarButton onClick={remove}>
<Tooltip content={dictionary.deleteEmbed}>
<ToolbarButton onClick={removeEmbed}>
<TrashIcon />
</ToolbarButton>
</Tooltip>
@@ -112,7 +119,5 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 6px;
min-width: 350px;
gap: 8px;
`;
+9 -14
View File
@@ -1,5 +1,5 @@
import capitalize from "lodash/capitalize";
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { search as emojiSearch } from "@shared/utils/emoji";
import EmojiMenuItem from "./EmojiMenuItem";
@@ -45,23 +45,18 @@ const EmojiMenu = (props: Props) => {
[search]
);
const renderMenuItem = useCallback(
(item, _index, options) => (
<EmojiMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.description}
emoji={item.emoji}
/>
),
[]
);
return (
<SuggestionsMenu
{...props}
filterable={false}
renderMenuItem={renderMenuItem}
renderMenuItem={(item, _index, options) => (
<EmojiMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.description}
emoji={item.emoji}
/>
)}
items={items}
/>
);
+24 -73
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,26 +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, setMenuWidth] = React.useState(0);
const menuHeight = 36;
const menuWidth = menuRef.current?.offsetWidth;
const menuHeight = menuRef.current?.offsetHeight;
// Measure the menu width after DOM updates to ensure accurate positioning
React.useLayoutEffect(() => {
if (menuRef.current) {
const width = menuRef.current.offsetWidth;
if (width !== menuWidth) {
setMenuWidth(width);
}
}
});
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
return defaultPosition;
}
// based on the start and end of the selection calculate the position at
// the center top
@@ -80,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,
@@ -105,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();
@@ -176,8 +160,6 @@ function usePosition({
top: Math.round(top - menuHeight - offsetParent.top),
offset: 0,
visible: true,
blockSelection: false,
maxWidth: "100%",
};
}
}
@@ -198,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(
@@ -222,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,
};
}
@@ -242,7 +216,6 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
let position = usePosition({
menuRef,
active: props.active,
align: props.align,
});
if (isSelectingText) {
@@ -298,13 +271,13 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
ref={menuRef}
$offset={position.offset}
style={{
minWidth: props.width,
width: props.width,
maxWidth: `${position.maxWidth}px`,
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
<Background align={props.align}>{props.children}</Background>
{props.children}
</Wrapper>
</Portal>
);
@@ -319,7 +292,7 @@ type WrapperProps = {
const arrow = (props: WrapperProps) =>
props.arrow
? css`
&::after {
&::before {
content: "";
display: block;
width: 24px;
@@ -327,14 +300,11 @@ const arrow = (props: WrapperProps) =>
transform: translateX(-50%) rotate(45deg);
background: ${s("menuBackground")};
border-radius: 3px;
z-index: 0;
z-index: -1;
position: absolute;
bottom: -2px;
left: calc(50% - ${props.$offset || 0}px);
pointer-events: none;
// clip to show only the bottom right corner
clip-path: polygon(100% 50%, 100% 100%, 50% 100%);
}
`
: "";
@@ -365,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 }>`
+34 -98
View File
@@ -1,23 +1,22 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
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";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Flex from "~/components/Flex";
import {
DocumentsSection,
UserSection,
CollectionsSection,
GroupSection,
} from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
@@ -26,7 +25,6 @@ import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import { runInAction } from "mobx";
interface MentionItem extends MenuItem {
attrs: {
@@ -47,7 +45,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = useState(false);
const [items, setItems] = useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users, collections, groups } = useStores();
const { auth, documents, users, collections } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
@@ -55,17 +53,11 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const { loading, request } = useRequest(
useCallback(async () => {
const res = await client.post("/suggestions.mention", {
query: search,
limit: maxResultsInSection,
});
const res = await client.post("/suggestions.mention", { query: search });
runInAction(() => {
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
res.data.groups.map(groups.add);
});
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
}, [search, documents, users, collections])
);
@@ -100,7 +92,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: UserSection,
appendSpace: true,
attrs: {
id: uuidv4(),
id: v4(),
type: MentionType.User,
modelId: user.id,
actorId,
@@ -108,32 +100,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
},
}) as MentionItem
)
.concat(
groups
.findByQuery(search, { maxResults: maxResultsInSection })
.map((group) => ({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24, marginRight: 4 }}
>
<GroupAvatar group={group} size={AvatarSize.Small} />
</Flex>
),
title: group.name,
section: GroupSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Group,
modelId: group.id,
actorId,
label: group.name,
},
}))
)
.concat(
documents
.findByQuery(search, { maxResults: maxResultsInSection })
@@ -158,7 +124,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: DocumentsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
id: v4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
@@ -186,7 +152,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: CollectionsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
id: v4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
@@ -206,9 +172,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
priority: -1,
appendSpace: true,
attrs: {
id: uuidv4(),
id: v4(),
type: MentionType.Document,
modelId: uuidv4(),
modelId: v4(),
actorId,
label: search,
},
@@ -218,17 +184,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
setItems(items);
setLoaded(true);
}
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
groups,
collections,
]);
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
const handleSelect = useCallback(
async (item: MentionItem) => {
@@ -241,57 +197,29 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
if (!documentId) {
return;
}
if (item.attrs.type === MentionType.User) {
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
userId: item.attrs.modelId,
});
if (!res.data.length) {
const user = users.get(item.attrs.modelId);
toast.message(
t(
"{{ userName }} won't be notified, as they do not have access to this document",
{
userName: item.attrs.label,
}
),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
duration: 10000,
}
);
}
} else if (item.attrs.type === MentionType.Group) {
const group = groups.get(item.attrs.modelId);
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
userId: item.attrs.modelId,
});
if (!res.data.length) {
const user = users.get(item.attrs.modelId);
toast.message(
t(
`Members of "{{ groupName }}" that have access to this document will be notified`,
"{{ userName }} won't be notified, as they do not have access to this document",
{
groupName: item.attrs.label,
userName: item.attrs.label,
}
),
{
icon: group ? <GroupAvatar group={group} /> : undefined,
icon: <Avatar model={user} size={AvatarSize.Toast} />,
duration: 10000,
}
);
}
},
[t, users, documentId, groups]
);
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
subtitle={item.subtitle}
title={item.title}
icon={item.icon}
/>
),
[]
[t, users, documentId]
);
// Prevent showing the menu until we have data otherwise it will be positioned
@@ -307,7 +235,15 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
filterable={false}
search={search}
onSelect={handleSelect}
renderMenuItem={renderMenuItem}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
subtitle={item.subtitle}
title={item.title}
icon={item.icon}
/>
)}
items={items}
/>
);
+13 -18
View File
@@ -1,8 +1,8 @@
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { EmailIcon, LinkIcon } from "outline-icons";
import React, { useCallback } from "react";
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";
@@ -27,18 +27,6 @@ type Props = Omit<
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const items = useItems({ pastedText, embeds });
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.title}
icon={item.icon}
/>
),
[]
);
if (!items) {
props.onClose();
return null;
@@ -49,7 +37,14 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
{...props}
trigger=""
filterable={false}
renderMenuItem={renderMenuItem}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.title}
icon={item.icon}
/>
)}
items={items}
/>
);
@@ -87,7 +82,7 @@ function useItems({
mentionType = integration
? determineMentionType({ url, integration })
: MentionType.URL;
: undefined;
}
return [
@@ -102,11 +97,11 @@ function useItems({
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: uuidv4(),
id: v4(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: uuidv4(),
modelId: v4(),
actorId: user?.id,
},
appendSpace: true,
+88 -60
View File
@@ -1,20 +1,20 @@
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
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";
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";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import getAttachmentMenuItems from "../menus/attachment";
import getCodeMenuItems from "../menus/code";
import getDividerMenuItems from "../menus/divider";
@@ -23,36 +23,75 @@ 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";
import { MediaLinkEditor } from "./MediaLinkEditor";
import { EmbedLinkEditor } from "./EmbedLinkEditor";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
type Props = {
/** Whether the text direction is right-to-left */
rtl: boolean;
/** Whether the current document is a template */
isTemplate: boolean;
/** Whether the toolbar is currently active/visible */
isActive: boolean;
/** The current selection */
selection?: Selection;
/** Whether the editor is in read-only mode */
readOnly?: boolean;
/** Whether the user has permission to add comments */
canComment?: boolean;
/** Whether the user has permission to update the document */
canUpdate?: boolean;
/** Callback function when a link is clicked */
onOpen: () => void;
onClose: () => void;
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
};
function useIsActive(state: EditorState) {
const { selection, doc } = state;
if (isMarkActive(state.schema.marks.link)(state)) {
return true;
}
if (
(isNodeActive(state.schema.nodes.code_block)(state) ||
isNodeActive(state.schema.nodes.code_fence)(state)) &&
selection.from > 0
) {
return true;
}
if (isInNotice(state) && selection.from > 0) {
return true;
}
if (!selection || selection.empty) {
return false;
}
if (selection instanceof NodeSelection && selection.node.type.name === "hr") {
return true;
}
if (
selection instanceof NodeSelection &&
["image", "attachment", "embed"].includes(selection.node.type.name)
) {
return true;
}
if (selection instanceof NodeSelection) {
return false;
}
const selectionText = doc.cut(selection.from, selection.to).textContent;
if (selection instanceof TextSelection && !selectionText) {
return false;
}
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
return some(nodes, (n) => n.content.size);
}
function useIsDragging() {
const [isDragging, setDragging, setNotDragging] = useBoolean();
useEventListener("dragstart", setDragging);
@@ -61,19 +100,25 @@ function useIsDragging() {
return isDragging;
}
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
export default function SelectionToolbar(props: Props) {
const { onClose, readOnly, onOpen } = props;
const { view, commands } = useEditor();
const dictionary = useDictionary();
const menuRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useMobile();
const isActive = props.isActive || isMobile;
const isActive = useIsActive(view.state) || isMobile;
const isDragging = useIsDragging();
const [isEditingImgUrl, setIsEditingImgUrl] = React.useState(false);
const previousIsActive = usePrevious(isActive);
React.useEffect(() => {
setIsEditingImgUrl(false);
}, [isActive]);
// Trigger callbacks when the toolbar is opened or closed
if (previousIsActive && !isActive) {
onClose();
}
if (!previousIsActive && isActive) {
onOpen();
}
}, [isActive, onClose, onOpen, previousIsActive]);
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
@@ -96,8 +141,6 @@ export function SelectionToolbar(props: Props) {
return;
}
setIsEditingImgUrl(false);
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
@@ -109,7 +152,7 @@ export function SelectionToolbar(props: Props) {
return () => {
window.removeEventListener("mouseup", handleClickOutside);
};
}, [isActive, readOnly, view]);
}, [isActive, previousIsActive, readOnly, view]);
const handleOnSelectLink = ({
href,
@@ -131,17 +174,19 @@ export function SelectionToolbar(props: Props) {
);
};
if (isDragging) {
return null;
}
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
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";
@@ -154,33 +199,27 @@ export 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 = getTableMenuItems(state, readOnly, dictionary);
} else if (isTableSelection) {
items = getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) {
items = getTableColMenuItems(state, readOnly, dictionary, {
index: colIndex,
rtl,
});
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
} else if (rowIndex !== undefined) {
items = getTableRowMenuItems(state, readOnly, dictionary, {
index: rowIndex,
});
items = getTableRowMenuItems(state, rowIndex, dictionary);
} else if (isCellSelection) {
items = getTableCellMenuItems(state, dictionary);
} else if (isImageSelection) {
items = getImageMenuItems(state, readOnly, dictionary);
items = readOnly ? [] : getImageMenuItems(state, dictionary);
} else if (isAttachmentSelection) {
items = getAttachmentMenuItems(state, readOnly, dictionary);
items = readOnly ? [] : getAttachmentMenuItems(state, dictionary);
} else if (isDividerSelection) {
items = getDividerMenuItems(state, readOnly, dictionary);
items = getDividerMenuItems(state, dictionary);
} else if (readOnly) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, dictionary);
align = "end";
} else {
items = getFormattingMenuItems(state, isTemplate, dictionary);
}
@@ -210,12 +249,8 @@ export function SelectionToolbar(props: Props) {
const showLinkToolbar =
link && link.from === selection.from && link.to === selection.to;
const isEditingMedia =
isEmbedSelection || (isImageSelection && isEditingImgUrl);
return (
<FloatingToolbar
align={align}
active={isActive}
ref={menuRef}
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
@@ -231,22 +266,15 @@ export function SelectionToolbar(props: Props) {
onClickLink={props.onClickLink}
onSelectLink={handleOnSelectLink}
/>
) : isEditingMedia ? (
<MediaLinkEditor
) : isEmbedSelection ? (
<EmbedLinkEditor
key={`embed-${selection.from}`}
node={selection.node}
node={(selection as NodeSelection).node}
view={view}
dictionary={dictionary}
autoFocus={isEditingImgUrl}
/>
) : (
<ToolbarMenu
items={items}
{...rest}
handlers={{
editImageUrl: () => setIsEditingImgUrl(true),
}}
/>
<ToolbarMenu items={items} {...rest} />
)}
</FloatingToolbar>
);
+3 -9
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;
@@ -641,19 +641,13 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
};
const handleOnClick = () => {
handleClickItem(item);
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== previousHeading && (
<MenuHeader key={currentHeading}>
{currentHeading}
</MenuHeader>
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
onPointerMove={handlePointerMove}
@@ -661,7 +655,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: handleOnClick,
onClick: () => handleClickItem(item),
})}
</ListItem>
</React.Fragment>
+10 -19
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>
);
}
@@ -92,4 +83,4 @@ const Shortcut = styled.span<{ $active?: boolean }>`
text-align: right;
`;
export default React.memo(SuggestionsMenuItem);
export default SuggestionsMenuItem;
+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 &&
+62 -93
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,29 +14,18 @@ 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[];
handlers?: Record<string, (...args: any[]) => void>;
};
/*
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: {
active: boolean;
item: MenuItem;
handlers?: Record<string, Function>;
}) {
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const menu = useMenuState();
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item, handlers } = props;
const { item } = props;
const { state } = view;
const items: TMenuItem[] = useMemo(() => {
@@ -42,19 +34,11 @@ function ToolbarDropdown(props: {
return;
}
if (commands[menuItem.name]) {
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (handlers && handlers[menuItem.name]) {
handlers[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
}
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
};
return item.children
@@ -76,30 +60,24 @@ function ToolbarDropdown(props: {
: [];
}, [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}
<>
<MenuButton {...menu}>
{(buttonProps) => (
<ToolbarButton
{...buttonProps}
hovering={menu.visible}
aria-label={item.tooltip}
>
{toMenuItems(items)}
</MenuContent>
</Menu>
</MenuProvider>
</EventBoundary>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</MenuButton>
<ContextMenu aria-label={item.label} {...menu}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
@@ -120,48 +98,40 @@ 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
handlers={props.handlers}
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}
aria-label={item.label ? undefined : item.tooltip}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
</TooltipProvider>
);
}
@@ -171,7 +141,6 @@ const FlexibleWrapper = styled.div`
overflow: hidden;
display: flex;
gap: 6px;
padding: 6px;
${breakpoint("mobile", "tablet")`
justify-content: space-evenly;
@@ -1,5 +1,6 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import { isList } from "@shared/editor/queries/isList";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
/**
@@ -18,32 +19,33 @@ export default class ClipboardTextSerializer extends Extension {
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice) => {
// Check if the only node is a code block
const isSingleCodeBlock =
slice.content.childCount === 1 &&
(slice.content.firstChild?.type.name === "code_block" ||
slice.content.firstChild?.type.name === "code_fence");
clipboardTextSerializer: (slice, view) => {
const isMultiline = slice.content.childCount > 1;
// Check if the only mark is a code mark
const marks = new Set<string>();
slice.content.descendants((node) => {
node.marks.forEach((mark) => marks.add(mark.type.name));
});
const hasOnlyCodeMark =
marks.size === 1 && marks.has("code_inline");
// This is a cheap way to determine if the content is "complex",
// aka it has multiple marks or formatting. In which case we'll use
// markdown formatting
const hasMultipleListItems = slice.content.content
.filter((node) => node.content.content.length > 1)
.some((node) => isList(node, view.state.schema));
const hasMultipleBlockTypes =
[
...new Set(
slice.content.content
.filter((node) => node.content.content.length > 1)
.map((node) => node.type.name)
),
].length > 1;
const copyAsMarkdown =
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
// Use plain text serializer only for code-only content
const usePlainText = isSingleCodeBlock || hasOnlyCodeMark;
return usePlainText
? slice.content.content
.map((node) => ProsemirrorHelper.toPlainText(node))
.join("")
: mdSerializer.serialize(slice.content, {
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
softBreak: true,
});
})
: slice.content.content
.map((node) => ProsemirrorHelper.toPlainText(node))
.join("");
},
},
}),
+3 -3
View File
@@ -1,5 +1,4 @@
import { action, observable } from "mobx";
import { v4 as uuidv4 } from "uuid";
import { toggleMark } from "prosemirror-commands";
import { Node, Slice } from "prosemirror-model";
import {
@@ -9,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";
@@ -144,7 +144,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Document,
modelId: document.id,
label: document.titleWithDefault,
id: uuidv4(),
id: v4(),
})
)
);
@@ -189,7 +189,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: uuidv4(),
id: v4(),
})
)
);
-116
View File
@@ -1,116 +0,0 @@
import some from "lodash/some";
import { action, observable } from "mobx";
import {
EditorState,
NodeSelection,
Selection,
Plugin,
TextSelection,
} from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { SelectionToolbar } from "../components/SelectionToolbar";
export default class SelectionToolbarExtension extends Extension {
get name() {
return "selection-toolbar";
}
get allowInReadOnly() {
return true;
}
get plugins(): Plugin[] {
return [
new Plugin({
view: () => ({
update: this.handleUpdate,
}),
}),
];
}
@observable
state: Selection | boolean = false;
private handleUpdate = action((view: EditorView) => {
const { state } = view;
this.state = this.calculateState(state);
});
private calculateState(state: EditorState): Selection | boolean {
const { selection, doc, schema } = state;
if (isMarkActive(schema.marks.link)(state)) {
return selection;
}
if (
(isNodeActive(schema.nodes.code_block)(state) ||
isNodeActive(schema.nodes.code_fence)(state)) &&
selection.from > 0
) {
return selection;
}
if (isInNotice(state) && selection.from > 0) {
return selection;
}
if (!selection || selection.empty) {
return false;
}
if (
selection instanceof NodeSelection &&
selection.node.type.name === "hr"
) {
return selection;
}
if (
selection instanceof NodeSelection &&
["image", "attachment", "embed"].includes(selection.node.type.name)
) {
return selection;
}
if (selection instanceof NodeSelection) {
return false;
}
const selectionText = doc.cut(selection.from, selection.to).textContent;
if (selection instanceof TextSelection && !selectionText) {
return false;
}
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
if (some(nodes, (n) => n.content.size)) {
return selection;
}
return false;
}
widget = (props: WidgetProps) => {
const editorProps = this.editor.props;
return (
<SelectionToolbar
{...props}
isActive={!!this.state}
selection={this.state ? (this.state as Selection) : undefined}
canUpdate={editorProps.canUpdate}
canComment={editorProps.canComment}
isTemplate={editorProps.template === true}
onClickLink={editorProps.onClickLink}
/>
);
};
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
// Note that the suppression of pipe here prevents conflict with table creation rule.
const emdash = new InputRule(/(?:^|[^\|])(--\s)$/, "— ");
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
-2
View File
@@ -10,7 +10,6 @@ import Keys from "~/editor/extensions/Keys";
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SelectionToolbarExtension from "~/editor/extensions/SelectionToolbar";
import SmartText from "~/editor/extensions/SmartText";
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
@@ -25,7 +24,6 @@ export const withUIExtensions = (nodes: Nodes) => [
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
SelectionToolbarExtension,
// Order these default key handlers last
PreventTab,
Keys,
+58 -81
View File
@@ -52,16 +52,9 @@ import Logger from "~/utils/Logger";
import ComponentView from "./components/ComponentView";
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 { isArray, map } from "lodash";
import {
LightboxImage,
LightboxImageFactory,
} from "@shared/editor/lib/Lightbox";
import Lightbox from "~/components/Lightbox";
import { anchorPlugin } from "@shared/editor/plugins/anchorPlugin";
export type Props = {
/** An optional identifier for the editor context. It is used to persist local settings */
@@ -151,8 +144,10 @@ type State = {
isRTL: boolean;
/** If the editor is currently focused */
isEditorFocused: boolean;
/** Image that's being currently viewed in Lightbox */
activeLightboxImage: LightboxImage | null;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
/** Position of image in doc that's being currently viewed in Lightbox */
activeLightboxImgPos: number | null;
};
/**
@@ -181,7 +176,8 @@ export class Editor extends React.PureComponent<
state: State = {
isRTL: false,
isEditorFocused: false,
activeLightboxImage: null,
selectionToolbarOpen: false,
activeLightboxImgPos: null,
};
isInitialized = false;
@@ -268,12 +264,19 @@ export class Editor extends React.PureComponent<
this.calculateDir();
}
if (!this.isBlurred && !this.state.isEditorFocused) {
if (
!this.isBlurred &&
!this.state.isEditorFocused &&
!this.state.selectionToolbarOpen
) {
this.isBlurred = true;
this.props.onBlur?.();
}
if (this.isBlurred && this.state.isEditorFocused) {
if (
this.isBlurred &&
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
) {
this.isBlurred = false;
this.props.onFocus?.();
}
@@ -407,7 +410,6 @@ export class Editor extends React.PureComponent<
plugins: [
...this.keymaps,
...this.plugins,
anchorPlugin(),
dropCursor({
color: this.props.theme.cursor,
}),
@@ -638,16 +640,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.
*
@@ -670,36 +662,19 @@ export class Editor extends React.PureComponent<
public removeComment = (commentId: string) => {
const { state, dispatch } = this.view;
const tr = state.tr;
let markRemoved = false;
state.doc.descendants((node, pos) => {
if (markRemoved) {
return false;
if (!node.isInline) {
return;
}
const mark = node.marks.find(
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
);
if (mark) {
tr.removeMark(pos, pos + node.nodeSize, mark);
markRemoved = true;
return;
}
if (isArray(node.attrs?.marks)) {
const existingMarks = node.attrs.marks;
const updatedMarks = existingMarks.filter(
(mark: any) => mark.attrs.id !== commentId
);
const attrs = {
...node.attrs,
marks: updatedMarks,
};
tr.setNodeMarkup(pos, undefined, attrs);
markRemoved = true;
}
return;
});
dispatch(tr);
@@ -708,7 +683,7 @@ export class Editor extends React.PureComponent<
/**
* Update all marks related to a specific comment in the document.
*
* @param commentId The id of the comment to update
* @param commentId The id of the comment to remove
* @param attrs The attributes to update
*/
public updateComment = (
@@ -717,11 +692,10 @@ export class Editor extends React.PureComponent<
) => {
const { state, dispatch } = this.view;
const tr = state.tr;
let markUpdated = false;
state.doc.descendants((node, pos) => {
if (markUpdated) {
return false;
if (!node.isInline) {
return;
}
const mark = node.marks.find(
@@ -735,36 +709,18 @@ export class Editor extends React.PureComponent<
...mark.attrs,
...attrs,
});
tr.removeMark(from, to, mark).addMark(from, to, newMark);
markUpdated = true;
return;
}
if (isArray(node.attrs?.marks)) {
const existingMarks = node.attrs.marks;
const updatedMarks = existingMarks.map((mark: any) =>
mark.type === "comment" && mark.attrs.id === commentId
? { ...mark, attrs: { ...mark.attrs, ...attrs } }
: mark
);
const newAttrs = {
...node.attrs,
marks: updatedMarks,
};
tr.setNodeMarkup(pos, undefined, newAttrs);
markUpdated = true;
}
return;
});
dispatch(tr);
};
public updateActiveLightboxImage = (activeImage: LightboxImage | null) => {
public updateActiveLightbox = (pos: number | null) => {
this.setState((state) => ({
...state,
activeLightboxImage: activeImage,
activeLightboxImgPos: pos,
}));
};
@@ -819,6 +775,23 @@ export class Editor extends React.PureComponent<
return false;
};
private handleOpenSelectionToolbar = () => {
this.setState((state) => ({
...state,
selectionToolbarOpen: true,
}));
};
private handleCloseSelectionToolbar = () => {
if (!this.state.selectionToolbarOpen) {
return;
}
this.setState((state) => ({
...state,
selectionToolbarOpen: false,
}));
};
public render() {
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
this.props;
@@ -848,7 +821,18 @@ export class Editor extends React.PureComponent<
ref={this.elementRef}
lang=""
/>
{this.view && (
<SelectionToolbar
rtl={isRTL}
readOnly={readOnly}
canUpdate={this.props.canUpdate}
canComment={this.props.canComment}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionToolbar}
onClose={this.handleCloseSelectionToolbar}
onClickLink={this.props.onClickLink}
/>
)}
{this.widgets &&
Object.values(this.widgets).map((Widget, index) => (
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
@@ -859,12 +843,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>
@@ -880,15 +862,10 @@ const EditorContainer = styled(Styles)<{
${(props) =>
props.focusedCommentId &&
css`
span#comment-${props.focusedCommentId} {
#comment-${props.focusedCommentId} {
background: ${transparentize(0.5, props.theme.brand.marine)};
border-bottom: 2px solid ${props.theme.commentMarkBackground};
}
a#comment-${props.focusedCommentId}
~ span.component-image
div.image-wrapper {
outline: ${props.theme.commentMarkBackground} solid 2px;
}
`}
${(props) =>
-4
View File
@@ -5,12 +5,8 @@ import { Dictionary } from "~/hooks/useDictionary";
export default function attachmentMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
return [
{
name: "replaceAttachment",
-4
View File
@@ -6,12 +6,8 @@ import { Dictionary } from "~/hooks/useDictionary";
export default function dividerMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { schema } = state;
return [
+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",
+1 -25
View File
@@ -6,22 +6,16 @@ import {
AlignImageRightIcon,
AlignImageCenterIcon,
AlignFullWidthIcon,
CommentIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "@shared/utils/keyboard";
export default function imageMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { schema } = state;
const isLeftAligned = isNodeActive(schema.nodes.image, {
layoutClass: "left-50",
@@ -81,32 +75,14 @@ export default function imageMenuItems(
visible: !!fetch,
},
{
name: "replaceImage",
tooltip: dictionary.replaceImage,
icon: <ReplaceIcon />,
children: [
{
name: "replaceImage",
label: dictionary.uploadImage,
},
{
name: "editImageUrl",
label: dictionary.editImageUrl,
},
],
},
{
name: "deleteImage",
tooltip: dictionary.deleteImage,
icon: <TrashIcon />,
},
{
name: "separator",
},
{
name: "commentOnImage",
tooltip: dictionary.comment,
shortcut: `${metaDisplay}+⌥+M`,
icon: <CommentIcon />,
},
];
}
-4
View File
@@ -6,12 +6,8 @@ import { Dictionary } from "~/hooks/useDictionary";
export default function tableMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { schema } = state;
const isFullWidth = isNodeActive(schema.nodes.table, {
layout: TableLayout.fullWidth,
+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),
},
];
}
+3 -11
View File
@@ -25,18 +25,10 @@ import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
export default function tableColMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary,
options: {
index: number;
rtl: boolean;
}
index: number,
rtl: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { index, rtl } = options;
const { schema, selection } = state;
if (!(selection instanceof CellSelection)) {
+2 -10
View File
@@ -19,17 +19,9 @@ import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
export default function tableRowMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary,
options: {
index: number;
}
index: number,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { index } = options;
const { selection } = state;
if (!(selection instanceof CellSelection)) {
-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);
}
-2
View File
@@ -32,7 +32,6 @@ export default function useDictionary() {
comment: t("Comment"),
copy: t("Copy"),
createLink: t("Create link"),
editImageUrl: t("Edit image URL"),
createLinkError: t("Sorry, an error occurred creating the link"),
createNewDoc: t("Create a new doc"),
createNewChildDoc: t("Create a new child doc"),
@@ -109,7 +108,6 @@ export default function useDictionary() {
untitled: t("Untitled"),
none: t("None"),
deleteEmbed: t("Delete embed"),
uploadImage: t("Upload an image"),
}),
[t]
);
+6 -5
View File
@@ -43,8 +43,8 @@ import { useTemplateMenuActions } from "./useTemplateMenuActions";
import { useMenuAction } from "./useMenuAction";
type Props = {
/** Document ID for which the actions are generated */
documentId: string;
/** Document for which the actions are generated */
document: Document;
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
/** Invoked when the "Rename" menu item is clicked */
@@ -54,7 +54,7 @@ type Props = {
};
export function useDocumentMenuAction({
documentId,
document,
onFindAndReplace,
onRename,
onSelectTemplate,
@@ -62,10 +62,11 @@ export function useDocumentMenuAction({
const { t } = useTranslation();
const isMobile = useMobile();
const user = useCurrentUser();
const can = usePolicy(documentId);
const can = usePolicy(document);
const templateMenuActions = useTemplateMenuActions({
documentId,
document,
onSelectTemplate,
});

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