Compare commits

..

2 Commits

Author SHA1 Message Date
Tom Moor b8cb10248e lint 2026-03-05 20:28:10 -05:00
Tom Moor 762a0c78c7 perf: Introduce max task timeout on queue 2026-03-05 20:23:53 -05:00
362 changed files with 3885 additions and 11784 deletions
+3 -18
View File
@@ -1,21 +1,5 @@
NODE_ENV=production
# –––––––––––––––––––––––––––––––––––––––––
# ––––––––––– FILE-BASED SECRETS ––––––––
# –––––––––––––––––––––––––––––––––––––––––
#
# Any environment variable can be loaded from a file by appending _FILE to the
# variable name and setting the value to the path of the file. This is useful
# for Docker secrets and other file-based secret management systems.
#
# For example, instead of:
# SECRET_KEY=your_secret_key
# You can use:
# SECRET_KEY_FILE=/run/secrets/outline_secret_key
#
# The file contents will be trimmed of leading/trailing whitespace. If both the
# variable and the _FILE variant are set, the direct variable takes precedence.
# This URL should point to the fully qualified, publicly accessible, URL. If using a
# proxy this will be the proxy's URL.
URL=
@@ -145,8 +129,9 @@ FORCE_HTTPS=true
# –––––––––– AUTHENTICATION ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Third party signin credentials, at least ONE OF these is required for a
# working installation or you'll have no sign-in options.
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# Discord, or Microsoft is required for a working installation or you'll
# have no sign-in options.
# Slack sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
+13 -13
View File
@@ -24,17 +24,17 @@ jobs:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- name: Use Node.js 24.x
- name: Use Node.js 22.x
uses: actions/setup-node@v5
with:
node-version: 24.x
node-version: 22.x
cache: "yarn"
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn install --immutable
@@ -48,13 +48,13 @@ jobs:
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 24.x
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- run: yarn lint --quiet
types:
@@ -66,13 +66,13 @@ jobs:
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 24.x
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- run: yarn tsc
changes:
@@ -114,13 +114,13 @@ jobs:
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 24.x
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- run: yarn test:${{ matrix.test-group }}
test-server:
@@ -152,13 +152,13 @@ jobs:
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 24.x
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
@@ -175,13 +175,13 @@ jobs:
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 24.x
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
-43
View File
@@ -1,43 +0,0 @@
name: Docker Build Check
on:
push:
paths:
- "Dockerfile"
- "Dockerfile.base"
pull_request:
paths:
- "Dockerfile"
- "Dockerfile.base"
env:
BASE_IMAGE_NAME: outline-base
jobs:
build:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build base image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
tags: ${{ env.BASE_IMAGE_NAME }}:latest
push: false
- name: Build main image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}:latest
+15 -15
View File
@@ -17,11 +17,11 @@ jobs:
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
@@ -30,14 +30,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
@@ -51,7 +51,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
@@ -61,7 +61,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -96,11 +96,11 @@ jobs:
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
@@ -109,14 +109,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
@@ -130,7 +130,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
@@ -140,7 +140,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -182,17 +182,17 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
-94
View File
@@ -1,94 +0,0 @@
name: Update Node.js LTS
on:
schedule:
# Run every Monday at 9:00 UTC
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-node:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Check for Node.js LTS update
id: check
run: |
# Get current Node version from Dockerfile
CURRENT_VERSION=$(grep -oP 'FROM node:\K[0-9]+\.[0-9]+\.[0-9]+' Dockerfile.base)
echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
echo "Current Node.js version: $CURRENT_VERSION"
# Fetch the latest LTS release (any major version) from nodejs.org
LATEST_VERSION=$(curl -s https://nodejs.org/dist/index.json | \
jq -r '[.[] | select(.lts != false)][0].version' | \
sed 's/^v//')
if ! [[ "$LATEST_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Failed to fetch a valid LTS version (got '$LATEST_VERSION')"
exit 1
fi
echo "latest=$LATEST_VERSION" >> "$GITHUB_OUTPUT"
echo "Latest Node.js LTS version: $LATEST_VERSION"
if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
echo "updated=false" >> "$GITHUB_OUTPUT"
echo "Already up to date."
else
echo "updated=true" >> "$GITHUB_OUTPUT"
echo "Update available: $CURRENT_VERSION -> $LATEST_VERSION"
fi
- name: Update Node.js version references
if: steps.check.outputs.updated == 'true'
env:
CURRENT: ${{ steps.check.outputs.current }}
LATEST: ${{ steps.check.outputs.latest }}
run: |
CURRENT_MAJOR=$(echo "$CURRENT" | cut -d. -f1)
LATEST_MAJOR=$(echo "$LATEST" | cut -d. -f1)
# Update Dockerfiles
sed -i "s/node:${CURRENT}-slim/node:${LATEST}-slim/g" Dockerfile
sed -i "s/node:${CURRENT} /node:${LATEST} /g" Dockerfile.base
# Update references that depend on major version
if [ "$CURRENT_MAJOR" != "$LATEST_MAJOR" ]; then
# .nvmrc
echo "$LATEST_MAJOR" > .nvmrc
# CI workflow: step name, node-version, and cache keys
sed -i "s/Use Node.js ${CURRENT_MAJOR}.x/Use Node.js ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
sed -i "s/node-version: ${CURRENT_MAJOR}.x/node-version: ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
# Update cache keys: replace node-modules-[optional old version] with new version
sed -i -E "s/node-modules-([0-9]+\.x-)?/node-modules-${LATEST_MAJOR}.x-/g" .github/workflows/ci.yml
# package.json engines field: append new major version
sed -i "s/\"node\": \"\(.*\)\"/\"node\": \"\1 || ${LATEST_MAJOR}\"/" package.json
fi
echo "Updated Node.js from $CURRENT to $LATEST"
- name: Create pull request
if: steps.check.outputs.updated == 'true'
uses: peter-evans/create-pull-request@v7
with:
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
title: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
body: |
Automated update of Node.js in Docker images.
- **Previous version:** ${{ steps.check.outputs.current }}
- **New version:** ${{ steps.check.outputs.latest }}
[Release notes](https://nodejs.org/en/blog/release/v${{ steps.check.outputs.latest }})
branch: automated/update-node-lts
delete-branch: true
labels: dependencies
+1 -1
View File
@@ -1 +1 @@
24
22
-3
View File
@@ -1,6 +1,3 @@
nodeLinker: node-modules
npmMinimalAgeGate: 86400
npmPreapprovedPackages:
- outline-icons
+1 -1
View File
@@ -70,7 +70,7 @@ yarn install
### Exports
- Exported members must appear at the top of the file.
- Always use named exports for new components & classes.
- Prefer named exports for components & classes.
- Document ALL public/exported functions with JSDoc.
## React Usage
+1 -1
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:24.14.1-slim AS runner
FROM node:22.21.0-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:24.14.1 AS deps
FROM node:22.21.0 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.6.1
Licensed Work: Outline 1.5.0
The Licensed Work is (c) 2026 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2030-03-18
Change Date: 2030-02-15
Change License: Apache License, Version 2.0
+2 -2
View File
@@ -33,9 +33,9 @@ There is a short guide for [setting up a development environment](https://docs.g
## Contributing
Outline is built and maintained by a small team your help finding and fixing bugs is appreciated, though AI assisted PR's from new contributors are discouraged and unlikely to be merged.
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted.
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
+3 -63
View File
@@ -32,8 +32,6 @@ import {
CaseSensitiveIcon,
RestoreIcon,
EditIcon,
EmbedIcon,
OpenIcon,
} from "outline-icons";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
@@ -75,7 +73,6 @@ import {
searchPath,
documentPath,
urlify,
desktopify,
trashPath,
documentEditPath,
} from "~/utils/routeHelpers";
@@ -89,8 +86,6 @@ import type {
} from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import env from "~/env";
import { isMac, isWindows } from "@shared/utils/browser";
import isCloudHosted from "~/utils/isCloudHosted";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
const Insights = lazyWithRetry(
@@ -340,15 +335,8 @@ export const createNewDocument = createActionWithChildren({
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return (
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
});
@@ -577,10 +565,7 @@ export const shareDocument = createAction({
section: ActiveDocumentSection,
icon: <PadlockIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
return false;
}
const can = stores.policies.abilities(activeDocumentId);
const can = stores.policies.abilities(activeDocumentId!);
return can.manageUsers || can.share;
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -959,49 +944,6 @@ export const printDocument = createAction({
},
});
export const openDocumentInDesktop = createAction({
name: ({ t }) => t("Open in desktop app"),
analyticsName: "Open in desktop",
section: ActiveDocumentSection,
icon: <OpenIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
isCloudHosted && (isMac || isWindows) && !!document && !document.isDeleted
);
},
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
window.location.href = desktopify(documentPath(document));
}
},
});
export const presentDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Present") : t("Present document")),
analyticsName: "Present document",
section: ActiveDocumentSection,
icon: <EmbedIcon />,
shortcut: ["Meta+Alt+p"],
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
}
stores.ui.setPresentingDocument(document);
},
});
export const importDocument = createAction({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
@@ -1545,13 +1487,11 @@ export const rootDocumentActions = [
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
presentDocument,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
shareDocument,
];
-3
View File
@@ -210,7 +210,6 @@ export function actionToKBar(
const name = resolve<string>(action.name, context);
const icon = resolve<React.ReactElement>(action.icon, context);
const section = resolve<string>(action.section, context);
const subtitle = resolve<string>(action.description, context);
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
@@ -230,7 +229,6 @@ export function actionToKBar(
section,
keywords: action.keywords,
shortcut: action.shortcut,
subtitle,
icon,
priority,
perform: () => performAction(action, context),
@@ -256,7 +254,6 @@ export function actionToKBar(
keywords: action.keywords,
shortcut: action.shortcut,
icon,
subtitle,
priority,
},
...children.map((child) => ({
+1 -4
View File
@@ -15,9 +15,6 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const SearchResultsSection = ({ t }: ActionContext) =>
t("Search results");
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
@@ -61,7 +58,7 @@ export const ShareSection = ({ t }: ActionContext) => t("Share");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recently viewed");
t("Recent searches");
RecentSearchesSection.priority = -0.1;
+14
View File
@@ -0,0 +1,14 @@
export default function Arrow() {
return (
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z" />
<path d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z" />
</svg>
);
}
+6 -8
View File
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route } from "react-router-dom";
import { Switch, Route, Redirect } from "react-router-dom";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
@@ -57,17 +57,15 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
history.push(newDocumentPath(activeCollectionId));
};
React.useEffect(() => {
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
history.replace(postLoginPath);
}
}, [spendPostLoginPath]);
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
+1 -11
View File
@@ -55,15 +55,6 @@ function Breadcrumb(
});
}
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.currentTarget.querySelector('[data-state="open"]')) {
event.preventDefault();
}
},
[]
);
const toBreadcrumb = React.useCallback(
(action: TopLevelAction, index: number) => {
if (action.type === "menu") {
@@ -77,7 +68,6 @@ function Breadcrumb(
{item.icon}
<Item
to={item.to}
onClick={handleClick}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
@@ -86,7 +76,7 @@ function Breadcrumb(
</>
);
},
[actionContext, handleClick, highlightFirstItem]
[actionContext, highlightFirstItem]
);
return (
-7
View File
@@ -3,8 +3,6 @@ import { DisclosureIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import type { HapticInput } from "web-haptics";
import { useWebHaptics } from "web-haptics/react";
import { s } from "@shared/styles";
import type { Props as ActionButtonProps } from "~/components/ActionButton";
import ActionButton from "~/components/ActionButton";
@@ -154,8 +152,6 @@ export type Props<T> = ActionButtonProps & {
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
/** Haptic feedback to trigger on click. Pass a preset name or custom pattern. */
haptic?: HapticInput;
borderOnHover?: boolean;
hideIcon?: boolean;
href?: string;
@@ -180,13 +176,11 @@ const Button = <T extends React.ElementType = "button">(
hideIcon,
fullwidth,
danger,
haptic,
...rest
} = props;
const hasText = !!children || value !== undefined;
const ic = hideIcon ? undefined : (action?.icon ?? icon);
const hasIcon = ic !== undefined;
const { trigger } = useWebHaptics();
return (
<RealButton
@@ -197,7 +191,6 @@ const Button = <T extends React.ElementType = "button">(
$danger={danger}
$fullwidth={fullwidth}
$borderOnHover={borderOnHover}
onClickCapture={haptic ? () => void trigger(haptic) : undefined}
{...rest}
>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
+40 -91
View File
@@ -6,8 +6,8 @@ import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission, TeamPreference } from "@shared/types";
import type { Option } from "~/components/InputSelect";
import type { CollectionPermission } from "@shared/types";
import { TeamPreference } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
@@ -15,7 +15,6 @@ import type Collection from "~/models/Collection";
import Button from "~/components/Button";
import { Collapsible } from "~/components/Collapsible";
import Input from "~/components/Input";
import { InputSelect } from "~/components/InputSelect";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
@@ -35,7 +34,6 @@ export interface FormData {
sharing: boolean;
permission: CollectionPermission | undefined;
commenting?: boolean | null;
templateManagement: CollectionPermission;
}
const useIconColor = (collection?: Collection) => {
@@ -70,22 +68,6 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const templateManagementOptions = useMemo<Option[]>(
() => [
{
type: "item",
label: t("Managers"),
value: CollectionPermission.Admin,
},
{
type: "item",
label: t("Members"),
value: CollectionPermission.ReadWrite,
},
],
[t]
);
const iconColor = useIconColor(collection);
const fallbackIcon = (
<Icon
@@ -111,8 +93,6 @@ export const CollectionForm = observer(function CollectionForm_({
sharing: collection?.sharing ?? true,
permission: collection?.permission,
commenting: collection?.commenting ?? true,
templateManagement:
collection?.templateManagement ?? CollectionPermission.Admin,
color: iconColor,
},
});
@@ -155,71 +135,6 @@ export const CollectionForm = observer(function CollectionForm_({
const initial = values.name.charAt(0).toUpperCase();
const options = (
<>
<Controller
control={control}
name="templateManagement"
render={({ field }) => (
<>
<InputSelect
value={field.value}
onChange={(value: string) => {
field.onChange(value as CollectionPermission);
}}
options={templateManagementOptions}
label={t("Manage templates")}
/>
<Text
type="secondary"
size="small"
as="p"
style={{ paddingTop: 4 }}
>
{t(
"Choose who can create and edit templates in this collection."
)}
</Text>
</>
)}
/>
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
</>
);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text as="p">
@@ -275,10 +190,44 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{collection ? (
options
) : (
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
{(team.sharing || team.getPreference(TeamPreference.Commenting)) && (
<Collapsible label={t("Advanced options")}>
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t(
"Allow commenting on documents within this collection."
)}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
</Collapsible>
)}
<HStack justify="flex-end">
@@ -4,7 +4,6 @@ import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Highlight from "~/components/Highlight";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
@@ -16,14 +15,6 @@ type Props = {
currentRootActionId: string | null | undefined;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function CommandBarItem(
{ action, active, currentRootActionId }: Props,
ref: React.RefObject<HTMLDivElement>
@@ -65,16 +56,6 @@ function CommandBarItem(
))}
{action.name}
{action.children?.length ? "…" : ""}
{action.subtitle && (
<Text type="secondary" ellipsis>
&nbsp;&nbsp;
<Highlight
text={action.subtitle}
highlight={SEARCH_RESULT_REGEX}
processResult={replaceResultMarks}
/>
</Text>
)}
</Content>
{action.shortcut?.length ? (
<Shortcut>
@@ -1,94 +0,0 @@
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import CommandBarResults from "./CommandBarResults";
import SharedSearchActions from "./SharedSearchActions";
/**
* A simplified command bar for public shares that only provides search.
*/
function SharedCommandBar() {
const { t } = useTranslation();
return (
<>
<SharedSearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchInput defaultPlaceholder={`${t("Search")}`} />
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
</>
);
}
type Props = {
children?: React.ReactNode;
};
const KBarPortal: React.FC = ({ children }: Props) => {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
if (!showing) {
return null;
}
return <Portal>{children}</Portal>;
};
const Positioner = styled(KBarPositioner)`
z-index: ${depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
position: relative;
padding: 16px 12px;
margin: 0 8px;
width: calc(100% - 16px);
outline: none;
border: none;
background: ${s("menuBackground")};
color: ${s("text")};
&:not(:last-child) {
border-bottom: 1px solid ${s("inputBorder")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
opacity: 1;
}
`;
const Animator = styled(KBarAnimator)`
max-width: 600px;
max-height: 75vh;
width: 90vw;
background: ${s("menuBackground")};
color: ${s("text")};
border-radius: 8px;
overflow: hidden;
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
transition: max-width 0.2s ease-in-out;
${breakpoint("desktopLarge")`
max-width: 740px;
`};
@media print {
display: none;
}
`;
export default observer(SharedCommandBar);
@@ -1,187 +0,0 @@
import { useKBar } from "kbar";
import escapeRegExp from "lodash/escapeRegExp";
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import useShare from "@shared/hooks/useShare";
import { Minute } from "@shared/utils/time";
import { createAction } from "~/actions";
import {
RecentSearchesSection,
SearchResultsSection,
} from "~/actions/sections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
import type Document from "~/models/Document";
import history from "~/utils/history";
import { sharedModelPath } from "~/utils/routeHelpers";
import type { SearchResult } from "~/types";
interface CacheEntry {
timestamp: number;
results: SearchResult[];
}
const cacheTTL = Minute.ms * 5;
const maxRecentDocs = 5;
/**
* Strip server-generated `<b>` highlight tags from context and re-apply them
* using the current search query. This prevents stale highlights when the
* displayed results are from a previous (in-flight) query.
*
* @param context the server-generated context string with `<b>` tags.
* @param query the current search query to highlight.
* @returns the context string with highlights matching the current query.
*/
function rehighlightContext(
context: string | undefined,
query: string
): string | undefined {
if (!context) {
return context;
}
const plain = context.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
const trimmed = query.trim();
if (!trimmed) {
return plain;
}
const terms = trimmed.split(/\s+/).filter(Boolean);
const patterns = [escapeRegExp(trimmed)];
if (terms.length > 1) {
patterns.push(...terms.map((t) => `\\b${escapeRegExp(t)}\\b`));
}
const regex = new RegExp(patterns.join("|"), "gi");
return plain.replace(regex, "<b>$&</b>");
}
/**
* Registers search result actions in the command bar scoped to a public share.
*/
function SharedSearchActions() {
const { documents } = useStores();
const { shareId } = useShare();
const searchCache = React.useRef<Map<string, CacheEntry>>(new Map());
const [results, setResults] = React.useState<SearchResult[]>([]);
const recentDocsRef = React.useRef<Document[]>([]);
const [recentDocs, setRecentDocs] = React.useState<Document[]>([]);
const { searchQuery } = useKBar((state) => ({
searchQuery: state.searchQuery,
}));
const searchQueryRef = React.useRef(searchQuery);
searchQueryRef.current = searchQuery;
React.useEffect(() => {
if (!searchQuery || !shareId) {
setResults([]);
return;
}
const now = Date.now();
const cachedEntry = searchCache.current.get(searchQuery);
const isExpired = cachedEntry
? now - cachedEntry.timestamp > cacheTTL
: true;
if (cachedEntry && !isExpired) {
setResults(cachedEntry.results);
return;
}
const currentQuery = searchQuery;
void documents.search({ query: searchQuery, shareId }).then((res) => {
searchCache.current.set(currentQuery, { timestamp: now, results: res });
if (searchQueryRef.current === currentQuery) {
setResults(res);
}
});
}, [documents, searchQuery, shareId]);
const addRecentDoc = React.useCallback((doc: Document) => {
const prev = recentDocsRef.current;
const filtered = prev.filter((d) => d.id !== doc.id);
const next = [doc, ...filtered].slice(0, maxRecentDocs);
recentDocsRef.current = next;
setRecentDocs(next);
}, []);
const documentIcon = React.useCallback(
(doc: Document) =>
doc.icon ? (
<Icon
value={doc.icon}
initial={doc.initial}
color={doc.color ?? undefined}
/>
) : (
<DocumentIcon />
),
[]
);
const actions = React.useMemo(
() =>
results.map((result) =>
createAction({
id: `shared-search-${result.document.id}`,
name: result.document.titleWithDefault,
description: rehighlightContext(result.context, searchQuery),
keywords: searchQuery,
analyticsName: "Open shared search result",
section: SearchResultsSection,
icon: documentIcon(result.document),
perform: () => {
if (shareId) {
const currentQuery = searchQueryRef.current;
addRecentDoc(result.document);
history.push({
pathname: sharedModelPath(shareId, result.document.url),
search: currentQuery
? `?q=${encodeURIComponent(currentQuery)}`
: undefined,
});
}
},
})
),
[results, shareId, searchQuery, addRecentDoc, documentIcon]
);
const recentDocActions = React.useMemo(
() =>
recentDocs.map((doc) =>
createAction({
id: `shared-recent-doc-${doc.id}`,
name: doc.titleWithDefault,
analyticsName: "Open recent shared document",
section: RecentSearchesSection,
icon: documentIcon(doc),
perform: () => {
if (shareId) {
history.push(sharedModelPath(shareId, doc.url));
}
},
})
),
[recentDocs, shareId, documentIcon]
);
useCommandBarActions(searchQuery ? actions : recentDocActions, [
searchQuery
? actions.map((a) => a.id).join("")
: recentDocActions.map((a) => a.id).join(""),
searchQuery,
]);
return null;
}
export default observer(SharedSearchActions);
+1 -8
View File
@@ -128,14 +128,7 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
React.useEffect(() => {
if (contentRef.current && value !== contentRef.current.textContent) {
if (document.activeElement === contentRef.current) {
// Don't reset content while the user is actively editing. Update
// lastValue so that the next input or blur event will push the
// current DOM text back to the model via onChange.
lastValue.current = value;
} else {
setInnerValue(value);
}
setInnerValue(value);
}
}, [value, contentRef]);
+11
View File
@@ -0,0 +1,11 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Divider = styled.hr`
border: 0;
border-bottom: 1px solid ${s("divider")};
margin: 0;
padding: 0;
`;
export default Divider;
+12 -85
View File
@@ -5,14 +5,9 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -73,9 +68,7 @@ function DocumentBreadcrumb(
to: archivePath(),
}),
createInternalLinkAction({
name: collection ? (
<CollectionName collection={collection} />
) : undefined,
name: collection?.name,
section: ActiveDocumentSection,
icon: collection ? (
<CollectionIcon collection={collection} expanded />
@@ -97,14 +90,17 @@ function DocumentBreadcrumb(
...path.map((node) => {
const title = node.title || t("Untitled");
return createInternalLinkAction({
name: (
<DocumentName
documentId={node.id}
collection={collection}
icon={node.icon}
color={node.color}
title={title}
/>
name: node.icon ? (
<>
<StyledIcon
value={node.icon}
color={node.color}
initial={node.title.charAt(0).toUpperCase()}
/>{" "}
{title}
</>
) : (
title
),
section: ActiveDocumentSection,
to: {
@@ -173,75 +169,6 @@ function DocumentBreadcrumb(
);
}
/** Renders a collection name wrapped in a context menu. */
const CollectionName = observer(function CollectionName_({
collection,
}: {
collection: Collection;
}) {
const { t } = useTranslation();
const menuAction = useCollectionMenuAction({
collectionId: collection.id,
});
return (
<ActionContextProvider value={{ activeModels: [collection] }}>
<ContextMenu action={menuAction} ariaLabel={t("Collection options")}>
<span>{collection.name}</span>
</ContextMenu>
</ActionContextProvider>
);
});
/** Renders a document name wrapped in a context menu. */
const DocumentName = observer(function DocumentName_({
documentId,
collection,
icon,
color,
title,
}: {
documentId: string;
collection: Collection | undefined;
icon: string | undefined;
color: string | undefined;
title: string;
}) {
const { t } = useTranslation();
const { documents } = useStores();
const doc = documents.get(documentId);
const menuAction = useDocumentMenuAction({ documentId });
const content = icon ? (
<>
<StyledIcon
value={icon}
color={color}
initial={title.charAt(0).toUpperCase()}
/>{" "}
{title}
</>
) : (
title
);
if (!doc) {
return <>{content}</>;
}
return (
<ActionContextProvider
value={{
activeModels: [doc, ...(collection ? [collection] : [])],
}}
>
<ContextMenu action={menuAction} ariaLabel={t("Document options")}>
<span>{content}</span>
</ContextMenu>
</ActionContextProvider>
);
});
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
@@ -12,6 +12,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
@@ -41,28 +42,6 @@ type Props = {
showDocuments?: boolean;
};
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
function DocumentExplorer({
onSubmit,
onSelect,
@@ -88,6 +67,8 @@ function DocumentExplorer({
return node || null;
}
);
const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
@@ -110,6 +91,9 @@ function DocumentExplorer({
);
const listRef = React.useRef<List<NavigationNode[]>>(null);
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const searchIndex = React.useMemo(
() =>
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
@@ -160,8 +144,7 @@ function DocumentExplorer({
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue]);
}, [defaultValue, selectedNode, nodes]);
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
@@ -169,9 +152,17 @@ function DocumentExplorer({
const normalizedBaseDepth =
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
const scrollNodeIntoView = React.useCallback((node: number) => {
listRef.current?.scrollToItem(node, "smart");
}, []);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
if (itemRefs[node] && itemRefs[node].current) {
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
behavior: "auto",
block: "center",
});
}
},
[itemRefs]
);
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(ev.target.value);
@@ -179,16 +170,16 @@ function DocumentExplorer({
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
const preserveScrollOffset = (itemCount: number) => {
const calculateInitialScrollOffset = (itemCount: number) => {
if (listRef.current) {
const { height, itemSize } = listRef.current.props;
const { scrollOffset } = listRef.current.state as {
scrollOffset: number;
};
const itemsHeight = itemCount * itemSize;
const offset = itemsHeight < Number(height) ? 0 : scrollOffset;
setTimeout(() => listRef.current?.scrollTo(offset), 0);
return itemsHeight < Number(height) ? 0 : scrollOffset;
}
return 0;
};
const collapse = (node: number) => {
@@ -199,7 +190,8 @@ function DocumentExplorer({
// remove children
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
preserveScrollOffset(newNodes.length);
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
};
const expand = (node: number) => {
@@ -208,7 +200,8 @@ function DocumentExplorer({
// add children
const newNodes = nodes.slice();
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
preserveScrollOffset(newNodes.length);
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
};
React.useEffect(() => {
@@ -232,8 +225,7 @@ function DocumentExplorer({
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 ||
(showDocuments !== false && nodes[node].type === "collection");
nodes[node].children.length > 0 || showDocuments !== false;
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -395,6 +387,25 @@ function DocumentExplorer({
}
};
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
return (
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
<ListSearch
@@ -414,12 +425,14 @@ function DocumentExplorer({
<Flex role="listbox" column>
<List
ref={listRef}
key={nodes.length}
width={width}
height={height}
itemData={nodes}
itemCount={nodes.length}
itemSize={isMobile ? 48 : 32}
innerElementType={innerElementType}
initialScrollOffset={initialScrollOffset}
itemKey={(index, results) => results[index].id}
>
{ListItem}
@@ -40,8 +40,10 @@ function DocumentExplorerNode(
ref: React.RefObject<HTMLSpanElement>
) {
const { t } = useTranslation();
const DISCLOSURE = 24;
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
const OFFSET = 12;
const DISCLOSURE = 20;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
return (
<Node
@@ -78,7 +80,7 @@ const Title = styled(Text)`
const StyledDisclosure = styled(Disclosure)`
position: relative;
left: auto;
margin: 2px 0;
margin-top: 2px;
`;
const Spacer = styled(Flex)<{ width: number }>`
@@ -1,6 +1,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "./DocumentExplorerNode";
@@ -31,8 +32,22 @@ function DocumentExplorerSearchResult({
}: Props) {
const { t } = useTranslation();
const ref = React.useCallback(
(node: HTMLSpanElement | null) => {
if (active && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
behavior: "auto",
block: "nearest",
});
}
},
[active]
);
return (
<SearchResult
ref={ref}
selected={selected}
active={active}
onClick={onClick}
@@ -3,7 +3,6 @@ import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import type { NavigationNode } from "@shared/types";
import { descendants, flattenTree } from "@shared/utils/tree";
import type Document from "~/models/Document";
import Button from "~/components/Button";
import Text from "~/components/Text";
@@ -24,23 +23,13 @@ function DocumentMove({ document }: Props) {
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(() => {
// Collect the IDs of the document itself and all of its descendants so they
// can be excluded from the move targets (moving to self or a descendant
// would create a cycle; moving to the exact same location is a no-op).
const allNodes = collectionTrees.flatMap(flattenTree);
const sourceNode = allNodes.find((node) => node.id === document.id);
const excludedIds = new Set<string>([document.id]);
if (sourceNode) {
descendants(sourceNode).forEach((n) => excludedIds.add(n.id));
}
// Recursively filter out the document itself and its descendants.
// The document's current parent is intentionally kept so that siblings
// remain visible as valid move targets.
// Recursively filter out the document itself and its existing parent doc, if any.
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
...node,
children: node.children
?.filter((c) => !excludedIds.has(c.id))
?.filter(
(c) => c.id !== document.id && c.id !== document.parentDocumentId
)
.map(filterSourceDocument),
});
@@ -54,7 +43,7 @@ function DocumentMove({ document }: Props) {
);
return nodes;
}, [policies, collectionTrees, document.id]);
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
const move = async () => {
if (!selectedPath) {
-1
View File
@@ -88,7 +88,6 @@ function Header(
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
haptic="light"
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
+6 -4
View File
@@ -43,9 +43,9 @@ export const Info = styled(StyledText).attrs(() => ({
white-space: nowrap;
`;
export const Description = styled(StyledText)<{ $margin?: string }>`
export const Description = styled(StyledText)`
${sharedVars}
margin-top: ${(props) => props.$margin ?? "0.5em"};
margin-top: 0.5em;
line-height: var(--line-height);
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
overflow: hidden;
@@ -64,6 +64,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
width: fit-content;
border-radius: 2em;
padding: 1px 8px 1px 20px;
margin-right: 0.5em;
margin-top: 0.5em;
position: relative;
flex-shrink: 0;
@@ -73,8 +75,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: ${(props) =>
props.color || props.theme.backgroundSecondary};
@@ -17,7 +17,6 @@ import HoverPreviewGroup from "./HoverPreviewGroup";
import HoverPreviewIssue from "./HoverPreviewIssue";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
import HoverPreviewProject from "./HoverPreviewProject";
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
const DELAY_CLOSE = 500;
@@ -193,18 +192,6 @@ const HoverPreviewDesktop = observer(
createdAt={data.createdAt}
state={data.state}
/>
) : data.type === UnfurlResourceType.Project ? (
<HoverPreviewProject
ref={cardRef}
url={data.url}
name={data.name}
color={data.color}
lead={data.lead}
labels={data.labels}
description={data.description}
state={data.state}
targetDate={data.targetDate}
/>
) : (
<HoverPreviewLink
ref={cardRef}
@@ -75,7 +75,7 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
</Description>
)}
<Flex wrap gap={6} style={{ marginTop: 8 }}>
<Flex wrap>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
@@ -1,148 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { Backticks } from "@shared/components/Backticks";
import Squircle from "@shared/components/Squircle";
import Editor from "~/components/Editor";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
import {
Preview,
Title,
Card,
CardContent,
Label,
Description,
} from "./Components";
import { richExtensions } from "@shared/editor/nodes";
type Props = Pick<
UnfurlResponse[UnfurlResourceType.Project],
| "url"
| "name"
| "color"
| "lead"
| "labels"
| "state"
| "targetDate"
| "description"
>;
const HoverPreviewProject = React.forwardRef(function HoverPreviewProject_(
{ url, name, color, lead, labels, state, description, targetDate }: Props,
ref: React.Ref<HTMLDivElement>
) {
const { t } = useTranslation();
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
<Card fadeOut={false}>
<CardContent>
<Flex gap={4} column>
<Title>
<StyledSquircle color={color} size={16} />
<span>
<Backticks content={name} />
</span>
</Title>
{description && (
<Description as="div" $margin="0">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Text
type="tertiary"
size="small"
style={{ textTransform: "capitalize" }}
>
{state.name}
</Text>
{(lead || targetDate) && (
<>
<Divider />
{lead && (
<MetadataRow>
<MetadataLabel>{t("Lead")}</MetadataLabel>
<Flex align="center" gap={6}>
<Avatar src={lead.avatarUrl} size={AvatarSize.Toast} />
<Text size="small">{lead.name}</Text>
</Flex>
</MetadataRow>
)}
{targetDate && (
<MetadataRow>
<MetadataLabel>{t("Target date")}</MetadataLabel>
<Text size="small">
<Time dateTime={targetDate} addSuffix />
</Text>
</MetadataRow>
)}
</>
)}
{labels.length > 0 && (
<>
<Divider />
<MetadataRow>
<MetadataLabel>{t("Labels")}</MetadataLabel>
<Flex wrap gap={6}>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
</Label>
))}
</Flex>
</MetadataRow>
</>
)}
</Flex>
</CardContent>
</Card>
</Flex>
</Preview>
);
});
const StyledSquircle = styled(Squircle)`
flex-shrink: 0;
margin-top: 4px;
`;
const Divider = styled.div`
height: 1px;
background: ${s("divider")};
margin: 4px 0;
`;
const MetadataRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 28px;
`;
const MetadataLabel = styled(Text).attrs({
type: "tertiary",
size: "small",
})`
flex-shrink: 0;
min-width: 80px;
`;
export default HoverPreviewProject;
+17 -22
View File
@@ -9,44 +9,39 @@ export interface LazyComponent<T extends React.ComponentType<any>> {
interface LazyLoadOptions {
retries?: number;
interval?: number;
/** If provided, picks this named export from the module instead of `default`. */
exportName?: string;
}
/**
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
* Supports both default and named exports.
*
* @param factory A function that returns a promise of a module.
* @param options Optional configuration for retry behavior and export name.
* @returns An object containing the lazy Component and a preload function.
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
* @param options Optional configuration for retry behavior
* @returns An object containing the lazy Component and a preload function
*
* @example
* ```typescript
* // Default export
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
*
* // Named export
* const MyComponent = createLazyComponent(() => import('./MyComponent'), {
* exportName: 'MyComponent',
* });
* function App() {
* return (
* <Suspense fallback={<div>Loading...</div>}>
* <MyComponent.Component />
* </Suspense>
* );
* }
*
* // Preload when needed:
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<Record<string, T>>,
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
const { retries, interval, exportName } = options;
const wrappedFactory = exportName
? () =>
factory().then((m) => ({
default: m[exportName],
}))
: (factory as () => Promise<{ default: T }>);
const { retries, interval } = options;
return {
Component: lazyWithRetry(wrappedFactory, retries, interval),
preload: wrappedFactory,
Component: lazyWithRetry(factory, retries, interval),
preload: factory,
};
}
-32
View File
@@ -1,32 +0,0 @@
import { observer } from "mobx-react";
import { Suspense } from "react";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const PresentationMode = lazyWithRetry(
() => import("~/scenes/Document/components/PresentationMode")
);
function Presentation() {
const { ui } = useStores();
if (!ui.presentationData) {
return null;
}
return (
<Suspense fallback={null}>
<PresentationMode
title={ui.presentationData.title}
icon={ui.presentationData.icon}
iconColor={ui.presentationData.color}
data={ui.presentationData.data}
onClose={() => {
ui.setPresentingDocument(null);
}}
/>
</Suspense>
);
}
export default observer(Presentation);
+1 -4
View File
@@ -1,5 +1,4 @@
import { useKBar } from "kbar";
import { observer } from "mobx-react";
import { useEffect, useRef } from "react";
import { Minute } from "@shared/utils/time";
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
@@ -15,7 +14,7 @@ interface CacheEntry {
// Cache configuration
const cacheTTL = Minute.ms * 5;
function SearchActions() {
export default function SearchActions() {
const { searches, documents } = useStores();
// Cache structure: Map of search queries to timestamp of last search
@@ -59,5 +58,3 @@ function SearchActions() {
return null;
}
export default observer(SearchActions);
+167
View File
@@ -0,0 +1,167 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s, hover, ellipsis } from "@shared/styles";
import type Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { sharedModelPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
highlight: string;
context: string | undefined;
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
shareId?: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(
props: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { document, highlight, context, shareId, ...rest } = props;
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
useFocusEffect(focused, itemRef);
return (
<DocumentLink
ref={itemRef}
dir={document.dir}
to={{
pathname: shareId
? sharedModelPath(shareId, document.url)
: document.url,
search: highlight ? `?q=${encodeURIComponent(highlight)}` : undefined,
state: {
title: document.titleWithDefault,
},
}}
{...rest}
{...rovingTabIndex}
onClick={(ev) => {
if (rest.onClick) {
rest.onClick(ev);
}
rovingTabIndex.onClick(ev);
}}
>
<Content>
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
</Heading>
{
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
}
</Content>
</DocumentLink>
);
}
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
`;
const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$menuOpen?: boolean;
}>`
display: flex;
align-items: center;
padding: 6px 12px;
max-height: 50vh;
cursor: var(--pointer);
&:not(:last-child) {
margin-bottom: 4px;
}
&:focus-visible {
outline: none;
}
${breakpoint("tablet")`
width: auto;
`};
&:${hover},
&:active,
&:focus,
&:focus-within {
background: ${s("listItemHoverBackground")};
}
${(props) =>
props.$menuOpen &&
css`
background: ${s("listItemHoverBackground")};
`}
`;
const Heading = styled.h4<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 22px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
white-space: nowrap;
color: ${s("text")};
`;
const Title = styled(Highlight)`
max-width: 90%;
${ellipsis()}
${Mark} {
padding: 0;
}
`;
const ResultContext = styled(Highlight)`
display: block;
color: ${s("textTertiary")};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0;
${ellipsis()}
${Mark} {
padding: 0;
}
`;
export default observer(React.forwardRef(DocumentListItem));
+289
View File
@@ -0,0 +1,289 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Empty from "~/components/Empty";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Placeholder from "~/components/List/Placeholder";
import PaginatedList from "~/components/PaginatedList";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "~/components/primitives/Popover";
import { id as bodyContentId } from "~/components/SkipNavContent";
import useKeyDown from "~/hooks/useKeyDown";
import useStores from "~/hooks/useStores";
import { preventDefault } from "~/utils/events";
import type { SearchResult } from "~/types";
import SearchListItem from "./SearchListItem";
interface Props extends React.HTMLAttributes<HTMLInputElement> {
shareId: string;
className?: string;
}
function SearchPopover({ shareId, className }: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const focusRef = React.useRef<HTMLElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [searchResults, setSearchResults] = React.useState<
SearchResult[] | undefined
>();
// Cache search results by query string to avoid redundant API calls
const cacheRef = React.useRef(new Map<string, SearchResult[]>());
const queryRef = React.useRef(query);
queryRef.current = query;
// When the query changes, restore cached results (including empty) or keep
// previous results visible until new results arrive to avoid layout shift
React.useEffect(() => {
if (!query) {
setSearchResults(undefined);
return;
}
const cached = cacheRef.current.get(query);
if (cached !== undefined) {
setSearchResults(cached);
if (cached.length) {
setOpen(true);
}
}
}, [query]);
const performSearch = React.useCallback(
async ({
query: searchQuery,
offset = 0,
...options
}: Record<string, any>) => {
if (!searchQuery?.length) {
return undefined;
}
// Return cached results for first-page lookups
if (offset === 0 && cacheRef.current.has(searchQuery)) {
return cacheRef.current.get(searchQuery)!;
}
// Force offset to 0 for new queries — PaginatedList's reset() sets
// offset via setState but fetchResults still uses the stale value
// from its closure
if (!cacheRef.current.has(searchQuery)) {
offset = 0;
}
const response = await documents.search({
query: searchQuery,
shareId,
offset,
...options,
});
// Build complete result set in cache: replace for new queries, append
// for pagination of an existing query
const existing = cacheRef.current.get(searchQuery);
cacheRef.current.set(
searchQuery,
existing ? [...existing, ...response] : response
);
// Only update state if this query is still current to prevent stale
// results from overwriting newer results after a race condition
if (queryRef.current === searchQuery) {
setSearchResults(cacheRef.current.get(searchQuery)!);
setOpen(true);
}
return response;
},
[documents, shareId]
);
const debouncedSetQuery = React.useMemo(
() =>
debounce((value: string) => {
setQuery(value);
setOpen(!!value);
}, 250),
[]
);
const handleSearchInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetQuery(event.target.value.trim());
},
[debouncedSetQuery]
);
React.useEffect(() => () => debouncedSetQuery.cancel(), [debouncedSetQuery]);
const handleEscapeList = React.useCallback(
() => searchInputRef.current?.focus(),
[]
);
const handleSearchInputFocus = React.useCallback(() => {
focusRef.current = searchInputRef.current;
}, []);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "Enter") {
if (searchResults) {
setOpen(true);
}
return;
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
if (ev.currentTarget.value.length) {
const atEnd =
ev.currentTarget.value.length === ev.currentTarget.selectionStart;
if (atEnd) {
setOpen(true);
}
if (open || atEnd) {
ev.preventDefault();
firstSearchItem.current?.focus();
}
}
return;
}
if (ev.key === "ArrowUp") {
if (open) {
setOpen(false);
if (!ev.shiftKey) {
ev.preventDefault();
}
}
if (ev.currentTarget.value && ev.currentTarget.selectionEnd === 0) {
ev.currentTarget.selectionStart = 0;
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
ev.preventDefault();
}
return;
}
if (ev.key === "Escape" && open) {
setOpen(false);
ev.preventDefault();
}
},
[open, searchResults]
);
const handleSearchItemClick = React.useCallback(() => {
setOpen(false);
setQuery("");
if (searchInputRef.current) {
searchInputRef.current.value = "";
focusRef.current = document.getElementById(bodyContentId);
}
}, []);
useKeyDown("/", (ev) => {
if (
searchInputRef.current &&
searchInputRef.current !== document.activeElement
) {
searchInputRef.current.focus();
ev.preventDefault();
}
});
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverAnchor>
<StyledInputSearch
role="combobox"
aria-controls="search-results"
aria-expanded={open}
aria-haspopup="listbox"
ref={searchInputRef}
onChange={handleSearchInputChange}
onFocus={handleSearchInputFocus}
onKeyDown={handleKeyDown}
className={className}
label={t("Search")}
labelHidden
/>
</PopoverAnchor>
<PopoverContent
id="search-results"
aria-label={t("Results")}
side="bottom"
align="start"
shrink
onEscapeKeyDown={handleEscapeList}
onOpenAutoFocus={preventDefault}
onInteractOutside={(event) => {
const target = event.target as Element | null;
if (target === searchInputRef.current) {
event.preventDefault();
}
}}
>
<PaginatedList<SearchResult>
role="listbox"
options={{
query,
snippetMinWords: 10,
snippetMaxWords: 11,
limit: 10,
}}
items={searchResults}
fetch={performSearch}
onEscape={handleEscapeList}
empty={
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
}
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item, index) => (
<SearchListItem
key={item.document.id}
shareId={shareId}
ref={index === 0 ? firstSearchItem : undefined}
document={item.document}
context={item.context}
highlight={query}
onClick={handleSearchItemClick}
/>
)}
/>
</PopoverContent>
</Popover>
);
}
const NoResults = styled(Empty)`
padding: 0 12px;
margin: 6px 0;
`;
const PlaceholderList = styled(Placeholder)`
padding: 6px 12px;
`;
const StyledInputSearch = styled(InputSearch)`
${Outline} {
border-radius: 16px;
}
`;
export default observer(SearchPopover);
@@ -16,6 +16,7 @@ import Scrollable from "~/components/Scrollable";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMaxHeight from "~/hooks/useMaxHeight";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
@@ -37,12 +38,10 @@ type Props = {
invitedInSession: string[];
/** Whether the popover is visible. */
visible: boolean;
/** Whether the share data is currently loading. */
loading: boolean;
};
export const AccessControlList = observer(
({ collection, share, invitedInSession, visible, loading }: Props) => {
({ collection, share, invitedInSession, visible }: Props) => {
const { memberships, groupMemberships } = useStores();
const team = useCurrentTeam();
const can = usePolicy(collection);
@@ -50,13 +49,35 @@ export const AccessControlList = observer(
const theme = useTheme();
const collectionId = collection.id;
const { request: fetchMemberships, loading: membershipLoading } =
useRequest(
React.useCallback(
() => memberships.fetchAll({ id: collectionId }),
[memberships, collectionId]
)
);
const { request: fetchGroupMemberships, loading: groupMembershipLoading } =
useRequest(
React.useCallback(
() => groupMemberships.fetchAll({ collectionId }),
[groupMemberships, collectionId]
)
);
const groupMembershipsInCollection =
groupMemberships.inCollection(collectionId);
const membershipsInCollection = memberships.inCollection(collectionId);
const hasMemberships =
groupMembershipsInCollection.length > 0 ||
membershipsInCollection.length > 0;
const showLoading = !hasMemberships && loading;
const showLoading =
!hasMemberships && (membershipLoading || groupMembershipLoading);
React.useEffect(() => {
void fetchMemberships();
void fetchGroupMemberships();
}, [fetchMemberships, fetchGroupMemberships]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessRef = React.useRef<HTMLDivElement | null>(null);
@@ -18,7 +18,6 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useShareDataLoader from "~/hooks/useShareDataLoader";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { collectionPath, urlify } from "~/utils/routeHelpers";
@@ -36,22 +35,11 @@ type Props = {
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
/** Whether the share data is currently loading, managed externally. */
loading?: boolean;
};
function SharePopover({
collection,
visible,
onRequestClose,
loading: externalLoading,
}: Props) {
function SharePopover({ collection, visible, onRequestClose }: Props) {
const team = useCurrentTeam();
const { groupMemberships, users, groups, memberships, shares } = useStores();
const { preload, loading: internalLoading } = useShareDataLoader({
collection,
});
const loading = externalLoading ?? internalLoading;
const { t } = useTranslation();
const can = usePolicy(collection);
const [query, setQuery] = React.useState("");
@@ -106,12 +94,10 @@ function SharePopover({
React.useEffect(() => {
if (visible) {
if (externalLoading === undefined) {
preload();
}
void collection.share();
setHasRendered(true);
}
}, [visible, externalLoading, preload]);
}, [collection, visible]);
React.useEffect(() => {
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
@@ -382,7 +368,6 @@ function SharePopover({
share={share}
invitedInSession={invitedInSession}
visible={visible}
loading={loading}
/>
</div>
</Wrapper>
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { Pagination } from "@shared/constants";
import { s } from "@shared/styles";
import { CollectionPermission, IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
@@ -42,8 +43,6 @@ type Props = {
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
/** Whether the share data is currently loading. */
loading: boolean;
};
export const AccessControlList = observer(
@@ -54,14 +53,13 @@ export const AccessControlList = observer(
sharedParent,
onRequestClose,
visible,
loading,
}: Props) => {
const { t } = useTranslation();
const theme = useTheme();
const collection = document.collection;
const usersInCollection = useUsersInCollection(collection);
const user = useCurrentUser();
const { groupMemberships } = useStores();
const { userMemberships, groupMemberships } = useStores();
const collectionSharingDisabled = document.collection?.sharing === false;
const team = useCurrentTeam();
const can = usePolicy(document);
@@ -77,10 +75,36 @@ export const AccessControlList = observer(
margin: 24,
});
const { loading: userMembershipLoading, request: fetchUserMemberships } =
useRequest(
React.useCallback(
() =>
userMemberships.fetchDocumentMemberships({
id: documentId,
limit: Pagination.defaultLimit,
}),
[userMemberships, documentId]
)
);
const { loading: groupMembershipLoading, request: fetchGroupMemberships } =
useRequest(
React.useCallback(
() => groupMemberships.fetchAll({ documentId }),
[groupMemberships, documentId]
)
);
const hasMemberships =
groupMemberships.inDocument(documentId)?.length > 0 ||
document.members.length > 0;
const showLoading = !hasMemberships && loading;
const showLoading =
!hasMemberships && (groupMembershipLoading || userMembershipLoading);
React.useEffect(() => {
void fetchUserMemberships();
void fetchGroupMemberships();
}, [fetchUserMemberships, fetchGroupMemberships]);
React.useEffect(() => {
calcMaxHeight();
@@ -18,7 +18,6 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useShareDataLoader from "~/hooks/useShareDataLoader";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { documentPath, urlify } from "~/utils/routeHelpers";
@@ -36,16 +35,9 @@ type Props = {
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
/** Whether the share data is currently loading, managed externally. */
loading?: boolean;
};
function SharePopover({
document,
onRequestClose,
visible,
loading: externalLoading,
}: Props) {
function SharePopover({ document, onRequestClose, visible }: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const can = usePolicy(document);
@@ -54,10 +46,6 @@ function SharePopover({
const sharedParent = shares.getByDocumentParents(document);
const [hasRendered, setHasRendered] = React.useState(visible);
const { users, userMemberships, groups, groupMemberships } = useStores();
const { preload, loading: internalLoading } = useShareDataLoader({
document,
});
const loading = externalLoading ?? internalLoading;
const [query, setQuery] = React.useState("");
const [picker, showPicker, hidePicker] = useBoolean();
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
@@ -91,14 +79,13 @@ function SharePopover({
}
);
// Fetch sharefocus the link button when the popover is opened
React.useEffect(() => {
if (visible) {
if (externalLoading === undefined) {
preload();
}
void document.share();
setHasRendered(true);
}
}, [visible, externalLoading, preload]);
}, [document, hidePicker, visible]);
// Hide the picker when the popover is closed
React.useEffect(() => {
@@ -390,7 +377,6 @@ function SharePopover({
share={share}
sharedParent={sharedParent}
visible={visible}
loading={loading}
onRequestClose={onRequestClose}
/>
</div>
@@ -14,7 +14,6 @@ import type User from "~/models/User";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import type { IAvatar } from "~/components/Avatar";
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
import ButtonLink from "~/components/ButtonLink";
import Empty from "~/components/Empty";
import Placeholder from "~/components/List/Placeholder";
import Scrollable from "~/components/Scrollable";
@@ -22,7 +21,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useMaxHeight from "~/hooks/useMaxHeight";
import useStores from "~/hooks/useStores";
import useThrottledCallback from "~/hooks/useThrottledCallback";
import { GroupMembersPopover } from "./GroupMembersPopover";
import { InviteIcon, ListItem } from "./ListItem";
type Suggestion = IAvatar & {
@@ -150,18 +148,9 @@ export const Suggestions = observer(
if (suggestion instanceof Group) {
return {
title: suggestion.name,
subtitle: (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
<span onClick={(ev) => ev.stopPropagation()}>
<GroupMembersPopover group={suggestion}>
<StyledButtonLink>
{t("{{ count }} member", {
count: suggestion.memberCount,
})}
</StyledButtonLink>
</GroupMembersPopover>
</span>
),
subtitle: t("{{ count }} member", {
count: suggestion.memberCount,
}),
image: <GroupAvatar group={suggestion} />,
};
}
@@ -279,13 +268,6 @@ const Separator = styled.div`
margin: 12px 0;
`;
const StyledButtonLink = styled(ButtonLink)`
color: ${s("textTertiary")};
&:hover {
text-decoration: underline;
}
`;
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
+2 -3
View File
@@ -31,7 +31,7 @@ function SettingsSidebar() {
const groupedConfig = groupBy(
configs.filter((item) =>
item.group === t("Integrations") && item.pluginId
item.group === "Integrations" && item.pluginId
? integrations.findByService(item.pluginId)
: true
),
@@ -76,8 +76,7 @@ function SettingsSidebar() {
to={item.path}
onClickIntent={item.preload}
active={
item.path.startsWith(settingsPath("templates")) ||
item.path.startsWith(settingsPath("groups"))
item.path.startsWith(settingsPath("templates"))
? location.pathname.startsWith(item.path)
: undefined
}
+12 -42
View File
@@ -1,15 +1,10 @@
import { useKBar } from "kbar";
import { observer } from "mobx-react";
import { SearchIcon } from "outline-icons";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { metaDisplay } from "@shared/utils/keyboard";
import type Share from "~/models/Share";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import SearchPopover from "~/components/SearchPopover";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -22,6 +17,8 @@ import Section from "./components/Section";
import { SharedCollectionLink } from "./components/SharedCollectionLink";
import { SharedDocumentLink } from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import { useEffect } from "react";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
type Props = {
share: Share;
@@ -32,7 +29,6 @@ function SharedSidebar({ share }: Props) {
const user = useCurrentUser({ rejectOnEmpty: false });
const { ui, documents, collections } = useStores();
const { t } = useTranslation();
const { query } = useKBar();
const teamAvailable = !!team?.name;
const rootNode = share.tree;
@@ -42,10 +38,6 @@ function SharedSidebar({ share }: Props) {
? ProsemirrorHelper.isEmptyData(collection?.data)
: false;
const handleOpenSearch = useCallback(() => {
query.toggle();
}, [query]);
useEffect(() => {
ui.tocVisible = share.showTOC;
}, []);
@@ -72,11 +64,9 @@ function SharedSidebar({ share }: Props) {
)}
<ScrollContainer topShadow flex>
<TopSection>
<SearchButton onClick={handleOpenSearch}>
<SearchIcon size={20} />
<SearchLabel>{t("Search")}</SearchLabel>
<Shortcut>{metaDisplay}K</Shortcut>
</SearchButton>
<SearchWrapper>
<StyledSearchPopover shareId={shareId} />
</SearchWrapper>
</TopSection>
<Section>
{share.collectionId ? (
@@ -112,34 +102,14 @@ const TopSection = styled(Flex)`
flex-shrink: 0;
`;
const SearchButton = styled.button`
display: flex;
align-items: center;
gap: 8px;
const SearchWrapper = styled.div`
width: 100%;
padding: 6px 12px;
`;
const StyledSearchPopover = styled(SearchPopover)`
width: 100%;
transition: width 100ms ease-out;
margin: 8px 0;
border: 1px solid ${s("inputBorder")};
border-radius: 16px;
background: ${s("background")};
color: ${s("textTertiary")};
cursor: var(--pointer);
font-size: 14px;
&:hover {
border-color: ${s("inputBorderFocused")};
color: ${s("textSecondary")};
}
`;
const SearchLabel = styled.span`
flex-grow: 1;
text-align: left;
`;
const Shortcut = styled.span`
flex-shrink: 0;
font-size: 13px;
`;
export default observer(SharedSidebar);
+1 -8
View File
@@ -1,6 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useWebHaptics } from "web-haptics/react";
import { useLocation } from "react-router-dom";
import styled, { css, useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -54,7 +53,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
const collapsed = ui.sidebarIsClosed && canCollapse;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const { trigger } = useWebHaptics();
const [offset, setOffset] = React.useState(0);
const [isHovering, setHovering] = React.useState(false);
@@ -226,11 +224,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
[width]
);
const handleCloseSidebar = () => {
trigger("light");
ui.toggleMobileSidebar();
};
return (
<TooltipProvider>
<Container
@@ -282,7 +275,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={handleCloseSidebar} />}
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
</TooltipProvider>
);
});
@@ -265,30 +265,27 @@ function InnerDocumentLink(
};
});
const insertDraftChild = !!(
activeDocument?.isDraft &&
activeDocument?.isActive &&
activeDocument?.parentDocumentId === node.id
);
const nodeChildren = React.useMemo(() => {
const insertDraftDocument =
activeDocument?.isDraft &&
activeDocument?.isActive &&
activeDocument?.parentDocumentId === node.id;
// Only subscribe to asNavigationNode when this node is the parent of an
// active draft. This avoids every DocumentLink observer re-rendering on
// every title keystroke.
const draftNavNode = insertDraftChild
? activeDocument?.asNavigationNode
: undefined;
const nodeChildren = React.useMemo(
() =>
collection && draftNavNode
? sortNavigationNodes(
[draftNavNode, ...node.children],
collection.sort,
false
)
: node.children,
[draftNavNode, collection, node]
);
return collection && insertDraftDocument
? sortNavigationNodes(
[activeDocument?.asNavigationNode, ...node.children],
collection.sort,
false
)
: node.children;
}, [
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.parentDocumentId,
activeDocument?.asNavigationNode,
collection,
node,
]);
const doc = documents.get(node.id);
const title = doc?.title || node.title || t("Untitled");
@@ -152,7 +152,7 @@ function SidebarLink(
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
style={active ? activeStyle : style}
style={style}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
onClick={handleClick}
onActiveClick={handleDisclosureClick}
@@ -7,32 +7,38 @@ export default function useCollectionDocuments(
collection: Collection | undefined,
activeDocument: Document | undefined
) {
const insertDraftDocument = !!(
activeDocument &&
activeDocument.isActive &&
activeDocument.isDraft &&
activeDocument.collectionId === collection?.id &&
!activeDocument.parentDocumentId
const insertDraftDocument = useMemo(
() =>
activeDocument &&
activeDocument.isActive &&
activeDocument.isDraft &&
activeDocument.collectionId === collection?.id &&
!activeDocument.parentDocumentId,
[
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.collectionId,
activeDocument?.parentDocumentId,
collection?.id,
]
);
// Only subscribe to asNavigationNode when we actually need to insert a draft
// into the sorted list. This avoids every CollectionLinkChildren observer
// re-rendering on every title keystroke.
const draftNavNode = insertDraftDocument
? activeDocument?.asNavigationNode
: undefined;
return useMemo(() => {
if (!collection?.sortedDocuments) {
return undefined;
}
return draftNavNode
return insertDraftDocument && activeDocument
? sortNavigationNodes(
[draftNavNode, ...collection.sortedDocuments],
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
collection.sort,
false
)
: collection.sortedDocuments;
}, [draftNavNode, collection?.sortedDocuments, collection?.sort]);
}, [
insertDraftDocument,
activeDocument?.asNavigationNode,
collection?.sortedDocuments,
collection?.sort,
]);
}
+30
View File
@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { toast } from "sonner";
import { TemplateForm } from "./TemplateForm";
import type Template from "~/models/Template";
type Props = {
template: Template;
onSubmit: () => void;
};
export const TemplateEdit = observer(function TemplateEdit_({
template,
onSubmit,
}: Props) {
const handleSubmit = useCallback(async () => {
try {
await template?.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
+36
View File
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import Template from "~/models/Template";
import useStores from "~/hooks/useStores";
import { TemplateForm } from "./TemplateForm";
type Props = {
collectionId?: string | null;
onSubmit?: () => void;
};
export const TemplateNew = observer(function TemplateNew_({
collectionId,
onSubmit,
}: Props) {
const { templates } = useStores();
const [template] = useState(
new Template({ title: "", collectionId }, templates)
);
const handleSubmit = useCallback(async () => {
try {
await template.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
const Label = ({ icon, value }: { icon: React.ReactNode; value: string }) => (
<Flex align="center" gap={4}>
<IconWrapper>{icon}</IconWrapper>
{value}
</Flex>
);
const IconWrapper = styled.span`
display: flex;
justify-content: center;
align-items: center;
height: 24px;
width: 24px;
overflow: hidden;
flex-shrink: 0;
`;
export default Label;
+1 -18
View File
@@ -1,28 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Toaster, useSonner } from "sonner";
import { Toaster } from "sonner";
import styled, { useTheme } from "styled-components";
import { useWebHaptics } from "web-haptics/react";
import useStores from "~/hooks/useStores";
function Toasts() {
const { ui } = useStores();
const theme = useTheme();
const { toasts } = useSonner();
const { trigger } = useWebHaptics();
const prevCountRef = React.useRef(toasts.length);
React.useEffect(() => {
if (toasts.length > prevCountRef.current) {
const latest = toasts[toasts.length - 1];
if (latest.type === "error") {
void trigger("error");
} else if (latest.type === "success") {
void trigger("success");
}
}
prevCountRef.current = toasts.length;
}, [toasts, trigger]);
return (
<StyledToaster
+2 -2
View File
@@ -2,7 +2,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 { s, depths } from "@shared/styles";
import { s } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { useTooltipContext } from "./TooltipContext";
@@ -267,7 +267,7 @@ const StyledContent = styled(TooltipPrimitive.Content)`
white-space: normal;
outline: 0;
padding: 5px 9px;
z-index: ${depths.tooltip};
z-index: 9999;
max-width: calc(100vw - 10px);
/* Animation */
+3 -112
View File
@@ -1,126 +1,17 @@
import { DocumentIcon, ShapesIcon } from "outline-icons";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import type { MenuItem } from "@shared/editor/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useCallback } from "react";
import useDictionary from "~/hooks/useDictionary";
import useStores from "~/hooks/useStores";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
/**
* Hook that returns a template menu item with children for inserting template
* content into the editor, or undefined if no templates are available.
*/
function useTemplateMenuItem(): MenuItem | undefined {
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const { documents, templates: templatesStore } = useStores();
const editor = useEditor();
const documentId = editor.props.id;
const document = documentId ? documents.get(documentId) : undefined;
const collectionId = document?.collectionId;
return useMemo(() => {
if (!user) {
return undefined;
}
const allTemplates = templatesStore.orderedData.filter(
(template) => template.isActive
);
const hasTemplates = allTemplates.some(
(template) =>
template.isWorkspaceTemplate || template.collectionId === collectionId
);
if (!hasTemplates) {
return undefined;
}
const toMenuItem = (template: (typeof allTemplates)[0]): MenuItem => ({
name: "noop",
title: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
user
),
icon: template.icon ? (
<Icon
value={template.icon}
initial={template.initial}
color={template.color ?? undefined}
/>
) : (
<DocumentIcon />
),
keywords: template.titleWithDefault,
onClick: () => {
const data = cloneDeep(template.data);
ProsemirrorHelper.replaceTemplateVariables(data, user);
editor.insertContent(data);
},
});
const children = (): MenuItem[] => {
const collectionTemplates = allTemplates.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === collectionId
);
const workspaceTemplates = allTemplates.filter(
(tmpl) => tmpl.isWorkspaceTemplate
);
const items: MenuItem[] = collectionTemplates.map(toMenuItem);
if (collectionTemplates.length && workspaceTemplates.length) {
items.push({ name: "separator" });
}
if (workspaceTemplates.length) {
for (const template of workspaceTemplates) {
items.push(toMenuItem(template));
}
}
return items;
};
return {
name: "noop",
title: t("Templates"),
icon: <ShapesIcon />,
keywords: "template",
children,
} satisfies MenuItem;
}, [user, templatesStore.orderedData, collectionId, editor, t]);
}
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
Required<Pick<SuggestionsMenuProps, "embeds">>;
function BlockMenu(props: Props) {
const dictionary = useDictionary();
const { elementRef } = useEditor();
const templateMenuItem = useTemplateMenuItem();
const items = useMemo(() => {
const baseItems = getMenuItems(dictionary, elementRef);
if (!templateMenuItem) {
return baseItems;
}
return [...baseItems, { name: "separator" } as MenuItem, templateMenuItem];
}, [dictionary, elementRef, templateMenuItem]);
const renderMenuItem = useCallback(
(item, _index, options) => (
@@ -141,9 +32,9 @@ function BlockMenu(props: Props) {
filterable
trigger="/"
renderMenuItem={renderMenuItem}
items={items}
items={getMenuItems(dictionary, elementRef)}
/>
);
}
export default observer(BlockMenu);
export default BlockMenu;
+1 -24
View File
@@ -1,12 +1,7 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import {
DocumentIcon,
PlusIcon,
NewDocumentIcon,
CollectionIcon,
} from "outline-icons";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -232,24 +227,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
label: search,
},
} as MentionItem,
{
name: "link",
icon: <NewDocumentIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a nested doc"),
visible: !!search && !isEmail(search) && !!documentId,
priority: -2,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: uuidv4(),
actorId,
label: search,
nested: true,
},
} as MentionItem,
])
: [];
+5 -8
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import type { EmbedDescriptor } from "@shared/editor/embeds";
import type { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import { isInternalUrl, isUrl } from "@shared/utils/urls";
import { isUrl } from "@shared/utils/urls";
import type Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -67,7 +67,6 @@ function useItems({
const singleUrl =
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
const isInternal = singleUrl ? isInternalUrl(singleUrl) : false;
const matchedEmbed = singleUrl
? getMatchingEmbed(embeds, singleUrl)?.embed
: null;
@@ -75,7 +74,7 @@ function useItems({
// Check embeddability for single URL
useEffect(() => {
if (!singleUrl || !embed || isInternal) {
if (!singleUrl || !embed) {
setEmbedCheck({ loading: false });
return;
}
@@ -102,7 +101,7 @@ function useItems({
return () => {
cancelled = true;
};
}, [singleUrl, embed, isInternal]);
}, [singleUrl, embed]);
// single item is pasted.
if (typeof pastedText === "string") {
@@ -144,10 +143,8 @@ function useItems({
name: "embed",
title: t("Embed"),
subtitle:
embedCheck.embeddable === false || isInternal
? t("Not supported")
: undefined,
disabled: isInternal || embedCheck.loading || !embedCheck.embeddable,
embedCheck.embeddable === false ? t("Not supported") : undefined,
disabled: embedCheck.loading || !embedCheck.embeddable,
icon: embed?.icon,
keywords: embed?.keywords,
},
+19 -30
View File
@@ -125,11 +125,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
React.useEffect(() => {
if (props.isActive) {
// Save the selection position when the menu opens and as the user types.
// On mobile, the editor may lose focus/selection when tapping on menu
// items, so we restore it. The position must stay current as the search
// text grows, otherwise the deletion range calculated in handleClearSearch
// will be wrong.
// Save the selection position when the menu opens. On mobile, the editor
// may lose focus/selection when tapping on menu items, so we restore it.
requestAnimationFrame(() => {
const { from, to } = view.state.selection;
selectionRef.current = { from, to };
@@ -138,7 +135,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
selectionRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isActive, props.search]);
}, [props.isActive]);
React.useEffect(() => {
setSubmenu(null);
@@ -213,9 +210,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
if (item.name === "noop") {
if ("onClick" in item) {
item.onClick?.();
}
// Do nothing
} else if (command) {
command(attrs);
} else {
@@ -245,13 +240,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
...item,
name: "mention",
});
void editorProps.onCreateLink?.(
{
title: item.attrs.label,
id: item.attrs.modelId,
},
!!item.attrs.nested
);
void editorProps.onCreateLink?.({
title: item.attrs.label,
id: item.attrs.modelId,
});
return;
case "image":
return triggerFilePick(
@@ -734,16 +726,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
capture: true,
});
};
}, [
close,
filtered,
handleClickItem,
insertItem,
openSubmenu,
props,
selectedIndex,
submenu,
]);
}, [close, filtered, handleClickItem, insertItem, openSubmenu, props, selectedIndex, submenu]);
const { isActive, uploadFile } = props;
const items = filtered;
@@ -760,7 +743,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const fileInput = uploadFile && (
<VisuallyHidden.Root>
<label>
<Trans>Upload file</Trans>
<Trans>Import document</Trans>
<input
type="file"
ref={inputRef}
@@ -956,7 +939,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
onCloseAutoFocus={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (submenuContentRef.current?.contains(e.target as Node)) {
if (
submenuContentRef.current?.contains(
e.target as Node
)
) {
e.preventDefault();
}
}}
@@ -980,16 +967,18 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
) : (
<List>{renderItems()}</List>
)}
{fileInput}
</BouncyPopoverContent>
</Popover>
{fileInput}
{submenu && itemRefs.current.get(submenu.index) && (
<Popover open modal={false}>
<PopoverAnchor
virtualRef={{
current: {
getBoundingClientRect: () =>
itemRefs.current.get(submenu.index)!.getBoundingClientRect(),
itemRefs.current
.get(submenu.index)!
.getBoundingClientRect(),
},
}}
/>
@@ -12,10 +12,6 @@ export default class ClipboardTextSerializer extends Extension {
return "clipboardTextSerializer";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const mdSerializer = this.editor.extensions.serializer();
+48 -188
View File
@@ -14,8 +14,6 @@ import { ancestors } from "@shared/editor/utils";
import FindAndReplace from "../components/FindAndReplace";
const pluginKey = new PluginKey("find-and-replace");
const supportsHighlightAPI =
typeof CSS !== "undefined" && CSS.highlights !== undefined;
export default class FindAndReplaceExtension extends Extension {
public get name() {
@@ -24,34 +22,13 @@ export default class FindAndReplaceExtension extends Extension {
public get defaultOptions() {
return {
resultClassName: "find-result",
resultCurrentClassName: "current-result",
caseSensitive: false,
regexEnabled: false,
};
}
keys(): Record<string, Command> {
return {
Escape: (state, dispatch) => {
if (!this.searchTerm) {
return false;
}
const params = new URLSearchParams(window.location.search);
if (params.has("q")) {
params.delete("q");
const search = params.toString();
window.history.replaceState(
window.history.state,
"",
window.location.pathname + (search ? `?${search}` : "")
);
}
return this.clear()(state, dispatch);
},
};
}
public commands() {
return {
/**
@@ -105,6 +82,20 @@ export default class FindAndReplaceExtension extends Extension {
};
}
private get decorations() {
return this.results.map((deco, index) => {
const decorationType =
deco.type === "node" ? Decoration.node : Decoration.inline;
return decorationType(deco.from, deco.to, {
class:
this.options.resultClassName +
(this.currentResultIndex === index
? ` ${this.options.resultCurrentClassName}`
: ""),
});
});
}
public replace(replace: string): Command {
return (state, dispatch) => {
// Redo the search to ensure we have the latest results, the document may
@@ -218,25 +209,14 @@ export default class FindAndReplaceExtension extends Extension {
}
private scrollToCurrentMatch() {
if (supportsHighlightAPI) {
if (this.currentHighlightRange) {
const node = this.currentHighlightRange.startContainer;
const element = node instanceof HTMLElement ? node : node.parentElement;
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
}
} else {
const element = window.document.querySelector(".current-result");
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
const element = window.document.querySelector(
`.${this.options.resultCurrentClassName}`
);
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
}
@@ -404,83 +384,13 @@ export default class FindAndReplaceExtension extends Extension {
});
}
/**
* Build ProseMirror decorations from search results (fallback for browsers
* without CSS Custom Highlight API support).
*/
private get decorations() {
return this.results.map((deco, index) => {
const decorationType =
deco.type === "node" ? Decoration.node : Decoration.inline;
return decorationType(deco.from, deco.to, {
class:
"find-result" +
(this.currentResultIndex === index ? " current-result" : ""),
});
});
}
/**
* Create a DecorationSet from the current search results.
*/
private createDecorationSet(doc: Node) {
private createDeco(doc: Node) {
this.search(doc);
return this.decorations.length
return this.decorations
? DecorationSet.create(doc, this.decorations)
: DecorationSet.empty;
}
/**
* Update CSS Custom Highlight API highlights based on current search results.
*/
private updateHighlights() {
const view = this.editor?.view;
if (!view || !this.results.length || !this.searchTerm) {
CSS.highlights.delete("search-results");
CSS.highlights.delete("search-results-current");
this.currentHighlightRange = undefined;
return;
}
const allRanges: StaticRange[] = [];
const currentRanges: StaticRange[] = [];
this.currentHighlightRange = undefined;
for (let i = 0; i < this.results.length; i++) {
const result = this.results[i];
try {
const from = view.domAtPos(result.from);
const to = view.domAtPos(result.to);
const range = new StaticRange({
startContainer: from.node,
startOffset: from.offset,
endContainer: to.node,
endOffset: to.offset,
});
allRanges.push(range);
if (i === this.currentResultIndex) {
currentRanges.push(range);
this.currentHighlightRange = range;
}
} catch {
// Position may not be in the visible DOM (e.g. inside folded toggle)
}
}
CSS.highlights.set("search-results", new Highlight(...allRanges));
if (currentRanges.length) {
CSS.highlights.set(
"search-results-current",
new Highlight(...currentRanges)
);
} else {
CSS.highlights.delete("search-results-current");
}
}
private currentHighlightRange?: StaticRange;
get allowInReadOnly() {
return true;
}
@@ -490,85 +400,35 @@ export default class FindAndReplaceExtension extends Extension {
}
get plugins() {
if (supportsHighlightAPI) {
return [this.highlightAPIPlugin];
}
return [this.decorationPlugin];
}
return [
new Plugin({
key: pluginKey,
state: {
init: () => DecorationSet.empty,
apply: (tr, decorationSet) => {
const action = tr.getMeta(pluginKey);
/** Plugin using the CSS Custom Highlight API (no DOM modifications). */
private get highlightAPIPlugin() {
return new Plugin({
key: pluginKey,
state: {
init: () => 0,
apply: (tr, generation) => {
const action = tr.getMeta(pluginKey);
if (action) {
if (action.open) {
this.open = true;
if (action) {
if (action.open) {
this.open = true;
}
return this.createDeco(tr.doc);
}
this.search(tr.doc);
return generation + 1;
}
if (tr.docChanged && this.searchTerm) {
this.search(tr.doc);
return generation + 1;
}
return generation;
},
},
view: () => {
let lastGeneration = 0;
return {
update: (view) => {
const generation = pluginKey.getState(view.state) as number;
if (generation !== lastGeneration) {
lastGeneration = generation;
this.updateHighlights();
if (tr.docChanged) {
return decorationSet.map(tr.mapping, tr.doc);
}
return decorationSet;
},
destroy: () => {
CSS.highlights?.delete("search-results");
CSS.highlights?.delete("search-results-current");
},
props: {
decorations(state) {
return this.getState(state);
},
};
},
});
}
/** Fallback plugin using ProseMirror decorations. */
private get decorationPlugin() {
return new Plugin({
key: pluginKey,
state: {
init: () => DecorationSet.empty,
apply: (tr, decorationSet) => {
const action = tr.getMeta(pluginKey);
if (action) {
if (action.open) {
this.open = true;
}
return this.createDecorationSet(tr.doc);
}
if (tr.docChanged) {
return decorationSet.map(tr.mapping, tr.doc);
}
return decorationSet;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
}),
];
}
public widget = ({ readOnly }: WidgetProps) => (
-4
View File
@@ -33,10 +33,6 @@ export default class HoverPreviews extends Extension {
return "hover-previews";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const isHoverTarget = (target: Element | null, view: EditorView) =>
target instanceof HTMLElement &&
-4
View File
@@ -25,10 +25,6 @@ export default class Multiplayer extends Extension {
return "multiplayer";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const { user, provider, document: doc } = this.options;
const type = doc.get("default", Y.XmlFragment);
+1 -1
View File
@@ -19,7 +19,7 @@ export default class Suggestion extends Extension {
super(options);
this.openRegex = new RegExp(
`(?:^|\\s|\\(|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${escapeRegExp(
`(?:^|\\s|\\()${escapeRegExp(
this.options.trigger
)}(${`[\\p{L}\/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
+15 -56
View File
@@ -133,10 +133,7 @@ export type Props = {
/** Callback when file upload progress changes */
onFileUploadProgress?: (id: string, fractionComplete: number) => void;
/** Callback when a link is created, should return url to created document */
onCreateLink?: (
params: Properties<Document>,
nested?: boolean
) => Promise<string>;
onCreateLink?: (params: Properties<Document>) => Promise<string>;
/** Callback when user clicks on any link in the document */
onClickLink: (
href: string,
@@ -253,25 +250,17 @@ export class Editor extends React.PureComponent<
this.view.updateState(newState);
}
// When transitioning from readOnly to editable, reinitialize to create
// editing extensions, keymaps, input rules, and commands that were skipped.
if (prevProps.readOnly && !this.props.readOnly) {
const docJSON = this.view.state.doc.toJSON();
this.view.destroy();
this.init();
const newState = this.createState(docJSON);
this.view.updateState(newState);
} else if (!prevProps.readOnly && this.props.readOnly) {
// pass readOnly changes through to underlying editor instance
// pass readOnly changes through to underlying editor instance
if (prevProps.readOnly !== this.props.readOnly) {
this.view.update({
...this.view.props,
editable: () => false,
editable: () => !this.props.readOnly,
});
// NodeView will not automatically render when editable changes so we must trigger an update
// manually, see: https://discuss.prosemirror.net/t/re-render-custom-nodeview-when-view-editable-changes/6441
Array.from(this.renderers).forEach((view) =>
view.setProp("isEditable", false)
view.setProp("isEditable", !this.props.readOnly)
);
}
@@ -312,24 +301,15 @@ export class Editor extends React.PureComponent<
this.nodes = this.createNodes();
this.marks = this.createMarks();
this.schema = this.createSchema();
this.widgets = this.createWidgets();
this.plugins = this.createPlugins();
this.rulePlugins = this.createRulePlugins();
this.keymaps = this.createKeymaps();
this.serializer = this.createSerializer();
this.parser = this.createParser();
this.pasteParser = this.createPasteParser();
this.inputRules = this.createInputRules();
this.nodeViews = this.createNodeViews();
this.widgets = this.createWidgets();
if (this.props.readOnly) {
this.keymaps = [];
this.inputRules = [];
this.pasteParser = this.parser;
} else {
this.keymaps = this.createKeymaps();
this.inputRules = this.createInputRules();
this.pasteParser = this.createPasteParser();
}
this.view = this.createView();
this.commands = this.createCommands();
}
@@ -431,20 +411,12 @@ export class Editor extends React.PureComponent<
private createState(value?: string | ProsemirrorData | ProsemirrorNode) {
const doc = this.createDocument(value || this.props.defaultValue);
if (this.props.readOnly) {
return EditorState.create({
schema: this.schema,
doc,
plugins: [...this.plugins, anchorPlugin()],
});
}
return EditorState.create({
schema: this.schema,
doc,
plugins: [
...this.plugins,
...this.keymaps,
...this.plugins,
anchorPlugin(),
dropCursor({
color: this.props.theme.cursor,
@@ -648,25 +620,12 @@ export class Editor extends React.PureComponent<
window?.getSelection()?.removeAllRanges();
};
/**
* Insert content into the editor, replacing the block at the current selection.
*
* @param content The prosemirror data to insert.
*/
public insertContent = (content: ProsemirrorData) => {
const doc = ProsemirrorNode.fromJSON(this.schema, content);
const { $from } = this.view.state.selection;
const start = $from.before($from.depth);
const end = $from.after($from.depth);
this.view.dispatch(this.view.state.tr.replaceWith(start, end, doc.content));
};
/**
* Insert files at the current selection.
*
* @param event The source event.
* @param files The files to insert.
* @returns True if the files were inserted.
* =
* @param event The source event
* @param files The files to insert
* @returns True if the files were inserted
*/
public insertFiles = (
event: React.ChangeEvent<HTMLInputElement>,
@@ -944,7 +903,7 @@ const EditorContainer = styled(Styles)<{
a#comment-${props.focusedCommentId}
~ span.component-image
div.image-wrapper {
outline: ${props.theme.commentedImageOutlineDark} solid 2px;
outline: ${props.theme.commentMarkBackground} solid 2px;
}
`}
+1 -11
View File
@@ -1,4 +1,4 @@
import { TrashIcon, DownloadIcon, ReplaceIcon, PDFIcon } from "outline-icons";
import { TrashIcon, DownloadIcon, ReplaceIcon } from "outline-icons";
import type { EditorState } from "prosemirror-state";
import type { MenuItem } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
@@ -17,9 +17,6 @@ export default function attachmentMenuItems(
const isAttachmentWithPreview = isNodeActive(schema.nodes.attachment, {
preview: true,
});
const isPdfAttachment = isNodeActive(schema.nodes.attachment, {
contentType: "application/pdf",
});
return [
{
@@ -32,13 +29,6 @@ export default function attachmentMenuItems(
tooltip: dictionary.deleteAttachment,
icon: <TrashIcon />,
},
{
name: "toggleAttachmentPreview",
tooltip: dictionary.previewAttachment,
icon: <PDFIcon />,
active: isAttachmentWithPreview,
visible: isPdfAttachment(state),
},
{
name: "separator",
},
+6 -7
View File
@@ -126,7 +126,6 @@ export default function blockMenuItems(
accept: "application/pdf",
width: 300,
height: 424,
preview: true,
},
},
{
@@ -165,12 +164,6 @@ export default function blockMenuItems(
icon: <MathIcon />,
keywords: "math katex latex",
},
{
name: "container_toggle",
title: dictionary.toggleBlock,
icon: <CollapseIcon />,
keywords: "toggle collapsible collapse fold",
},
{
name: "hr",
title: dictionary.hr,
@@ -250,6 +243,12 @@ export default function blockMenuItems(
icon: <Img src="/images/diagrams.png" alt="Diagrams.net Diagram" />,
keywords: "diagram flowchart draw.io",
},
{
name: "container_toggle",
title: dictionary.toggleBlock,
icon: <CollapseIcon />,
keywords: "toggle collapsible collapse fold",
},
];
// Filter out diagrams.net in desktop app
-2
View File
@@ -14,7 +14,6 @@ import {
import { isMermaid } from "@shared/editor/lib/isCode";
import type { MenuItem } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "@shared/utils/keyboard";
export default function codeMenuItems(
state: EditorState,
@@ -68,7 +67,6 @@ export default function codeMenuItems(
name: "edit_mermaid",
icon: <EditIcon />,
tooltip: dictionary.editDiagram,
shortcut: `${metaDisplay} Enter`,
visible: isMermaid(node) && !isEditingMermaid && !readOnly,
},
{
-1
View File
@@ -44,7 +44,6 @@ export default function useDictionary() {
deleteRow: t("Delete"),
deleteTable: t("Delete table"),
deleteAttachment: t("Delete file"),
previewAttachment: t("Show preview"),
dimensions: `${t("Width")} × ${t("Height")}`,
download: t("Download"),
downloadAttachment: t("Download file"),
-4
View File
@@ -24,10 +24,8 @@ import {
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
downloadDocument,
copyDocument,
presentDocument,
printDocument,
searchInDocument,
deleteDocument,
@@ -108,8 +106,6 @@ export function useDocumentMenuAction({
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
presentDocument,
downloadDocument,
copyDocument,
printDocument,
+22 -48
View File
@@ -1,11 +1,11 @@
import * as React from "react";
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import type Group from "~/models/Group";
import {
DeleteGroupDialog,
EditGroupDialog,
ViewGroupMembersDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -16,35 +16,27 @@ import {
} from "~/actions";
import { GroupSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import { settingsPath } from "~/utils/routeHelpers";
interface Options {
/** Whether to hide the "Members" navigation action. */
hideMembers?: boolean;
}
/**
* Hook that constructs the action menu for group management operations.
*
*
* @param targetGroup - the group to build actions for, or null to skip.
* @param options - optional configuration for the menu.
* @returns action with children for use in menus, or undefined if group is null.
*/
export function useGroupMenuActions(
targetGroup: Group | null,
options?: Options
) {
export function useGroupMenuActions(targetGroup: Group | null) {
const { t } = useTranslation();
const { dialogs } = useStores();
const history = useHistory();
const can = usePolicy(targetGroup ?? ({} as Group));
const navigateToMembers = React.useCallback(() => {
const openMembersDialog = React.useCallback(() => {
if (!targetGroup) {
return;
}
history.push(settingsPath("groups", targetGroup.id, "members"));
}, [targetGroup, history]);
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={targetGroup} />,
});
}, [t, targetGroup, dialogs]);
const openEditDialog = React.useCallback(() => {
if (!targetGroup) {
@@ -53,10 +45,7 @@ export function useGroupMenuActions(
dialogs.openModal({
title: t("Edit group"),
content: (
<EditGroupDialog
group={targetGroup}
onSubmit={dialogs.closeAllModals}
/>
<EditGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, targetGroup, dialogs]);
@@ -68,10 +57,7 @@ export function useGroupMenuActions(
dialogs.openModal({
title: t("Delete group"),
content: (
<DeleteGroupDialog
group={targetGroup}
onSubmit={dialogs.closeAllModals}
/>
<DeleteGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, targetGroup, dialogs]);
@@ -81,30 +67,26 @@ export function useGroupMenuActions(
!targetGroup
? []
: [
...(options?.hideMembers
? []
: [
createAction({
name: t("Members"),
icon: <GroupIcon />,
section: GroupSection,
visible: can.read,
perform: navigateToMembers,
}),
ActionSeparator,
]),
createAction({
name: `${t("Members")}`,
icon: <GroupIcon />,
section: GroupSection,
visible: !!(targetGroup && can.read),
perform: openMembersDialog,
}),
ActionSeparator,
createAction({
name: `${t("Edit")}`,
icon: <EditIcon />,
section: GroupSection,
visible: can.update,
visible: !!(targetGroup && can.update),
perform: openEditDialog,
}),
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: GroupSection,
visible: can.delete,
visible: !!(targetGroup && can.delete),
dangerous: true,
perform: openDeleteDialog,
}),
@@ -116,13 +98,6 @@ export function useGroupMenuActions(
disabled: true,
url: "",
}),
createExternalLinkAction({
name: `External ID: ${targetGroup.externalGroup?.externalId ?? ""}`,
section: GroupSection,
visible: !!targetGroup.externalGroup?.externalId,
disabled: true,
url: "",
}),
],
[
t,
@@ -130,8 +105,7 @@ export function useGroupMenuActions(
can.read,
can.update,
can.delete,
options?.hideMembers,
navigateToMembers,
openMembersDialog,
openEditDialog,
openDeleteDialog,
]
-1
View File
@@ -39,7 +39,6 @@ export const useLocaleTime = ({
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
de_DE: "d. MMMM yyyy 'um' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
-79
View File
@@ -1,79 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Pagination } from "@shared/constants";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import useStores from "./useStores";
type Params =
| { document: Document; collection?: undefined }
| { collection: Collection; document?: undefined };
/**
* Hook to preload all data needed by the share popover. Returns a `preload`
* function that can be called on hover so the popover renders instantly.
*
* @param params - the document or collection to load share data for.
* @returns preload function, loading state, and reset function.
*/
export default function useShareDataLoader(params: Params) {
const { userMemberships, groupMemberships, memberships } = useStores();
const [loading, setLoading] = useState(false);
const requestedRef = useRef(false);
const requestCountRef = useRef(0);
const entityId = params.document?.id ?? params.collection?.id;
// Reset when the entity changes so preload fires for the new target.
useEffect(() => {
requestedRef.current = false;
setLoading(false);
}, [entityId]);
const preload = useCallback(() => {
if (requestedRef.current) {
return;
}
requestedRef.current = true;
setLoading(true);
const thisRequest = ++requestCountRef.current;
const promises: Promise<unknown>[] = [];
if (params.document) {
const doc = params.document;
promises.push(
doc.share(),
userMemberships.fetchDocumentMemberships({
id: doc.id,
limit: Pagination.defaultLimit,
}),
groupMemberships.fetchAll({ documentId: doc.id })
);
} else {
const col = params.collection;
promises.push(
col.share(),
memberships.fetchAll({ id: col.id }),
groupMemberships.fetchAll({ collectionId: col.id })
);
}
void Promise.all(promises).finally(() => {
if (requestCountRef.current === thisRequest) {
setLoading(false);
}
});
}, [
params.document,
params.collection,
userMemberships,
groupMemberships,
memberships,
]);
const reset = useCallback(() => {
requestedRef.current = false;
}, []);
return { preload, loading, reset };
}
+30
View File
@@ -0,0 +1,30 @@
import { useLayoutEffect, useState } from "react";
/**
* Hook to get the current viewport height, accounting for mobile virtual keyboards.
* Uses the VisualViewport API when available, falling back to window.innerHeight.
*
* @returns The current viewport height in pixels
*/
export default function useViewportHeight(): number | void {
// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility
// Note: No support in Firefox at time of writing, however this mainly exists
// for virtual keyboards on mobile devices, so that's okay.
const [height, setHeight] = useState<number>(
() => window.visualViewport?.height || window.innerHeight
);
useLayoutEffect(() => {
const handleResize = () => {
setHeight(() => window.visualViewport?.height || window.innerHeight);
};
window.visualViewport?.addEventListener("resize", handleResize);
return () => {
window.visualViewport?.removeEventListener("resize", handleResize);
};
}, []);
return height;
}
-2
View File
@@ -11,7 +11,6 @@ import { Router } from "react-router-dom";
import stores from "~/stores";
import Analytics from "~/components/Analytics";
import Dialogs from "~/components/Dialogs";
import Presentation from "~/components/Presentation";
import ErrorBoundary from "~/components/ErrorBoundary";
import PageTheme from "~/components/PageTheme";
import ScrollToTop from "~/components/ScrollToTop";
@@ -73,7 +72,6 @@ if (element) {
</ScrollToTop>
<Toasts />
<Dialogs />
<Presentation />
<Desktop />
</PageScroll>
</LazyMotion>
+2 -4
View File
@@ -8,13 +8,11 @@ import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
type Props = {
group: Group;
/** Whether to hide the "Members" navigation action. */
hideMembers?: boolean;
};
function GroupMenu({ group, hideMembers }: Props) {
function GroupMenu({ group }: Props) {
const { t } = useTranslation();
const rootAction = useGroupMenuActions(group, { hideMembers });
const rootAction = useGroupMenuActions(group);
return (
<DropdownMenu
+7
View File
@@ -0,0 +1,7 @@
import type { MenuSeparator } from "~/types";
export default function separator(): MenuSeparator {
return {
type: "separator",
};
}
-9
View File
@@ -1,5 +1,4 @@
import { computed, observable } from "mobx";
import type { AuthenticationProviderSettings } from "@shared/types";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterDelete } from "./decorators/Lifecycle";
@@ -14,10 +13,6 @@ class AuthenticationProvider extends Model {
providerId: string;
groupSyncSupported: boolean;
groupSyncUsesClaim: boolean;
@observable
isConnected: boolean;
@@ -25,10 +20,6 @@ class AuthenticationProvider extends Model {
@observable
isEnabled: boolean;
@Field
@observable
settings: AuthenticationProviderSettings | undefined;
@computed
get isActive() {
return this.isEnabled && this.isConnected;
-5
View File
@@ -67,11 +67,6 @@ export default class Collection extends ParanoidModel {
direction: "asc" | "desc";
};
/** The minimum permission level required to manage templates in this collection. */
@Field
@observable
templateManagement: CollectionPermission;
/**
* Whether commenting is enabled for the collection.
*/
-27
View File
@@ -5,22 +5,6 @@ import Field from "./decorators/Field";
import { GroupPermission } from "@shared/types";
import type { Searchable } from "./interfaces/Searchable";
/**
* Information about a group that is managed by an external provider.
*/
interface ExternalGroupInfo {
/** The unique identifier of the external group record in Outline. */
id: string;
/** The unique identifier of the group in the external provider. */
externalId: string;
/** The name of the external provider (e.g. google, slack, azure). */
provider: string;
/** The display name of the group in the external provider. */
displayName: string;
/** The date and time the group was last synced from the external provider. */
lastSyncedAt: string | null;
}
class Group extends Model implements Searchable {
static modelName = "Group";
@@ -42,17 +26,6 @@ class Group extends Model implements Searchable {
@observable
disableMentions: boolean;
@observable
externalGroup: ExternalGroupInfo | undefined;
/**
* Whether this group's membership is managed by an external authentication provider.
*/
@computed
get isExternallyManaged(): boolean {
return !!this.externalGroup;
}
/**
* Returns the users that are members of this group.
*/
-4
View File
@@ -64,10 +64,6 @@ class Team extends Model {
@observable
defaultUserRole: UserRole;
@Field
@observable
guidanceMCP: string | null;
@Field
@observable
preferences: TeamPreferences | null;
+4 -12
View File
@@ -1,15 +1,12 @@
import { Switch } from "react-router-dom";
import Error404 from "~/scenes/Errors/Error404";
import { createLazyComponent as lazy } from "~/components/LazyLoad";
import Route from "~/components/ProfiledRoute";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import lazy from "~/utils/lazyWithRetry";
import { settingsPath } from "~/utils/routeHelpers";
import { observer } from "mobx-react";
const Application = lazy(() => import("~/scenes/Settings/Application"));
const GroupMembers = lazy(() => import("~/scenes/Settings/GroupMembers"), {
exportName: "GroupMembersScene",
});
const Template = lazy(() => import("~/scenes/Settings/Template"));
const TemplateNew = lazy(() => import("~/scenes/Settings/TemplateNew"));
@@ -27,25 +24,20 @@ function SettingsRoutes() {
/>
))}
{/* TODO: Refactor these exceptions into config? */}
<Route
exact
path={settingsPath("groups", ":id", "members")}
component={GroupMembers.Component}
/>
<Route
exact
path={settingsPath("applications", ":id")}
component={Application.Component}
component={Application}
/>
<Route
exact
path={settingsPath("templates", "new")}
component={TemplateNew.Component}
component={TemplateNew}
/>
<Route
exact
path={settingsPath("templates", ":id")}
component={Template.Component}
component={Template}
/>
<Route component={Error404} />
</Switch>
+2 -9
View File
@@ -66,12 +66,7 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
shortcut="e"
placement="bottom"
>
<Button
icon={<EditIcon />}
onClick={goToEdit}
haptic="light"
neutral
>
<Button icon={<EditIcon />} onClick={goToEdit} neutral>
{t("Edit")}
</Button>
</Tooltip>
@@ -80,9 +75,7 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
{isEditing && user?.separateEditMode && (
<Action>
<RegisterKeyDown trigger="Escape" handler={goBack} />
<Button onClick={goBack} haptic="medium">
{t("Done editing")}
</Button>
<Button onClick={goBack}>{t("Done editing")}</Button>
</Action>
)}
{can.createDocument && (
@@ -61,7 +61,7 @@ function Overview({ collection, readOnly }: Props) {
() => ({
padding: "0 32px",
margin: "0 -32px",
paddingBottom: `calc(30vh - ${childOffsetHeight}px)`,
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
}),
[childOffsetHeight]
);
@@ -11,7 +11,6 @@ import {
} from "~/components/primitives/Popover";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMobile from "~/hooks/useMobile";
import useShareDataLoader from "~/hooks/useShareDataLoader";
import useStores from "~/hooks/useStores";
import { preventDefault } from "~/utils/events";
import lazyWithRetry from "~/utils/lazyWithRetry";
@@ -34,23 +33,14 @@ function ShareButton({ collection }: Props) {
const share = shares.getByCollectionId(collection.id);
const isPubliclyShared =
team.sharing !== false && collection?.sharing !== false && share?.published;
const { preload, loading, reset } = useShareDataLoader({ collection });
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
if (isOpen) {
preload();
} else {
reset();
}
},
[preload, reset]
);
const closePopover = useCallback(() => {
handleOpenChange(false);
}, [handleOpenChange]);
setOpen(false);
}, []);
const handleMouseEnter = useCallback(() => {
void collection.share();
}, [collection]);
if (isMobile) {
return null;
@@ -63,9 +53,9 @@ function ShareButton({ collection }: Props) {
);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<Button icon={icon} neutral onMouseEnter={preload}>
<Button icon={icon} neutral onMouseEnter={handleMouseEnter}>
{t("Share")}
</Button>
</PopoverTrigger>
@@ -82,7 +72,6 @@ function ShareButton({ collection }: Props) {
collection={collection}
onRequestClose={closePopover}
visible={open}
loading={loading}
/>
</Suspense>
</PopoverContent>
@@ -103,11 +103,6 @@ function CommentForm({
useOnClickOutside(formRef, reset);
React.useEffect(() => {
window.addEventListener("beforeunload", reset);
return () => window.removeEventListener("beforeunload", reset);
}, [reset]);
const handleCreateComment = action(async (event: React.FormEvent) => {
event.preventDefault();
@@ -259,13 +254,11 @@ function CommentForm({
const handleMounted = React.useCallback(
(ref) => {
if (autoFocus && ref && !hasFocusedOnMount.current) {
if (!draft) {
ref.focusAtStart();
}
ref.focusAtStart();
hasFocusedOnMount.current = true;
}
},
[autoFocus, draft]
[autoFocus]
);
const presence = animatePresence
@@ -16,7 +16,6 @@ import { useDocumentContext } from "~/components/DocumentContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import type { Properties } from "~/types";
import Logger from "~/utils/Logger";
@@ -89,7 +88,6 @@ function DataLoader({ match, children }: Props) {
const isEditing = isEditRoute || !user?.separateEditMode;
const can = usePolicy(document);
const location = useLocation<LocationState>();
const query = useQuery();
const missingPolicy = !can || Object.keys(can).length === 0;
useDocumentSidebar();
@@ -207,13 +205,6 @@ function DataLoader({ match, children }: Props) {
revisionId,
]);
// Auto-enter presentation mode when ?present=true query param is set
React.useEffect(() => {
if (document && query.has("present") && !ui.presentationData) {
ui.setPresentingDocument(document);
}
}, [document, query, ui]);
if (error) {
return error instanceof OfflineError ? (
<ErrorOffline />
+7 -9
View File
@@ -669,11 +669,9 @@ const Main = styled.div<MainProps>`
@media print {
display: block;
max-width: ${({ fullWidth }: MainProps) =>
fullWidth
? `100%`
: `calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})`
};
max-width: calc(
${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter}
);
}
`;
@@ -722,10 +720,10 @@ const EditorContainer = styled.div<EditorContainerProps>`
// Decides the editor column position & span
grid-column: ${({
docFullWidth,
showContents,
tocPosition,
}: EditorContainerProps) =>
docFullWidth,
showContents,
tocPosition,
}: EditorContainerProps) =>
docFullWidth
? showContents
? tocPosition === TOCPosition.Left
+2 -2
View File
@@ -93,7 +93,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
}, [ref]);
React.useEffect(() => {
if (focusedComment && focusedComment.documentId === document.id) {
if (focusedComment) {
const viewingResolved = params.get("resolved") === "";
if (
(focusedComment.isResolved && !viewingResolved) ||
@@ -172,7 +172,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
() => ({
padding: "0 32px",
margin: "0 -32px",
paddingBottom: `calc(30vh - ${childOffsetHeight}px)`,
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
}),
[childOffsetHeight]
);
@@ -158,7 +158,6 @@ function DocumentHeader({
pathname: documentEditPath(document),
state: { sidebarContext },
}}
haptic="light"
neutral
>
{isMobile ? null : t("Edit")}
@@ -284,7 +283,6 @@ function DocumentHeader({
onClick={handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
haptic="medium"
hideIcon
>
{isDraft ? t("Save draft") : t("Done editing")}
@@ -1,516 +0,0 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { ShrinkIcon, GrowIcon, CloseIcon } from "outline-icons";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import { richExtensions } from "@shared/editor/nodes";
import { canUseElementFullscreen } from "@shared/utils/browser";
import { s, depths, hover } from "@shared/styles";
import cloneDeep from "lodash/cloneDeep";
import type { ProsemirrorData } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { colorPalette } from "@shared/utils/collections";
import Editor from "~/components/Editor";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import Flex from "~/components/Flex";
import Tooltip from "~/components/Tooltip";
import useIdle from "~/hooks/useIdle";
import useKeyDown from "~/hooks/useKeyDown";
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
/** Activity events that reset the idle timer — excludes keyboard to stay idle during navigation. */
const idleEvents = [
"click",
"mousemove",
"mousedown",
"touchstart",
"touchmove",
];
type Slide =
| {
type: "title";
title: string;
icon?: string | null;
iconColor?: string | null;
}
| { type: "content"; content: ProsemirrorData[] }
| { type: "instructions" };
interface Props {
/** The document title. */
title: string;
/** The document icon. */
icon?: string | null;
/** The document icon color. */
iconColor?: string | null;
/** The prosemirror data for the document. */
data: ProsemirrorData;
/** Callback when presentation mode is closed. */
onClose: () => void;
}
/**
* Returns true if the given content nodes contain no meaningful text or elements.
*
* @param nodes the prosemirror content nodes.
* @returns true when every node is an empty paragraph.
*/
function isContentEmpty(nodes: ProsemirrorData[]): boolean {
return nodes.every(
(node) =>
node.type === "paragraph" && (!node.content || node.content.length === 0)
);
}
/**
* Splits a ProseMirror document into slides based on heading and divider nodes.
* A dedicated title slide is prepended. Each h1/h2 heading or horizontal rule
* starts a new content slide. Divider nodes are consumed as separators and not
* rendered on slides.
*
* @param data the prosemirror document data.
* @param title the document title.
* @param icon the document icon.
* @param iconColor the document icon color.
* @returns an array of slides.
*/
function splitIntoSlides(
data: ProsemirrorData,
title: string,
icon?: string | null,
iconColor?: string | null
): Slide[] {
const content = data.content ?? [];
const slides: Slide[] = [{ type: "title", title, icon, iconColor }];
let currentNodes: ProsemirrorData[] = [];
for (const node of content) {
const isDivider = node.type === "horizontal_rule" || node.type === "hr";
const isHeadingBreak =
node.type === "heading" &&
node.attrs &&
typeof node.attrs.level === "number" &&
node.attrs.level <= 2;
if (isDivider) {
if (currentNodes.length > 0) {
slides.push({ type: "content", content: currentNodes });
currentNodes = [];
}
continue;
}
if (isHeadingBreak && currentNodes.length > 0) {
slides.push({ type: "content", content: currentNodes });
currentNodes = [];
}
currentNodes.push(node);
}
if (currentNodes.length > 0) {
slides.push({ type: "content", content: currentNodes });
}
return slides;
}
/**
* Full-screen presentation mode that splits a document into slides by headings
* and dividers, and allows navigating through them with keyboard controls.
*/
function PresentationMode({ title, icon, iconColor, data, onClose }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const [currentSlide, setCurrentSlide] = React.useState(0);
const containerRef = React.useRef<HTMLDivElement>(null);
const slideContentRef = React.useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = React.useState(false);
const supportsFullscreen = React.useMemo(() => canUseElementFullscreen(), []);
const isIdle = useIdle(3000, idleEvents);
const strippedData = React.useMemo(
() =>
ProsemirrorHelper.removeMarks(cloneDeep(data), [
"comment",
]) as ProsemirrorData,
[data]
);
const slides = React.useMemo(() => {
const result = splitIntoSlides(strippedData, title, icon, iconColor);
const contentSlides = result.filter((s) => s.type === "content");
const hasContent =
contentSlides.length > 0 &&
contentSlides.some(
(s) => s.type === "content" && !isContentEmpty(s.content)
);
if (!hasContent) {
return [result[0], { type: "instructions" as const }];
}
return result;
}, [strippedData, title, icon, iconColor]);
const totalSlides = slides.length;
const goNext = React.useCallback(() => {
setCurrentSlide((prev) => Math.min(prev + 1, totalSlides - 1));
}, [totalSlides]);
const goPrev = React.useCallback(() => {
setCurrentSlide((prev) => Math.max(prev - 1, 0));
}, []);
const goFirst = React.useCallback(() => {
setCurrentSlide(0);
}, []);
const goLast = React.useCallback(() => {
setCurrentSlide(totalSlides - 1);
}, [totalSlides]);
const toggleFullscreen = React.useCallback(() => {
if (!supportsFullscreen) {
return;
}
const el = containerRef.current;
if (!el) {
return;
}
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {
// ignore
});
} else {
el.requestFullscreen().catch(() => {
// ignore
});
}
}, [supportsFullscreen]);
useKeyDown("Escape", onClose);
useKeyDown("ArrowRight", goNext);
useKeyDown("ArrowDown", goNext);
useKeyDown("PageDown", goNext);
useKeyDown("ArrowLeft", goPrev);
useKeyDown("ArrowUp", goPrev);
useKeyDown("PageUp", goPrev);
useKeyDown("Home", goFirst);
useKeyDown("End", goLast);
useKeyDown(" ", goNext);
useKeyDown("f", toggleFullscreen);
// Prevent body scrolling while presentation is open
React.useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, []);
// Track fullscreen state changes
React.useEffect(() => {
if (!supportsFullscreen) {
return;
}
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {
// ignore
});
}
};
}, [supportsFullscreen]);
// Measure natural size once per slide, then apply scale directly to the DOM
// to avoid React re-render loops during window resize.
const naturalSize = React.useRef({ width: 0, height: 0 });
React.useEffect(() => {
const el = slideContentRef.current;
const container = containerRef.current;
if (!el || !container) {
return;
}
const applyScale = () => {
const { width, height } = naturalSize.current;
if (width === 0 || height === 0) {
el.style.transform = "scale(1)";
return;
}
const availableWidth = container.clientWidth - 160;
const availableHeight = container.clientHeight - 160;
const scaleX = availableWidth / width;
const scaleY = availableHeight / height;
const newScale = Math.min(scaleX, scaleY, 1.5);
el.style.transform = `scale(${Math.max(newScale, 0.5)})`;
};
// Measure natural size with scale removed, then apply
el.style.transform = "none";
requestAnimationFrame(() => {
naturalSize.current = {
width: el.scrollWidth,
height: el.scrollHeight,
};
applyScale();
window.addEventListener("resize", applyScale);
});
return () => {
window.removeEventListener("resize", applyScale);
};
}, [currentSlide]);
const slide = slides[currentSlide];
const slideData: ProsemirrorData | undefined = React.useMemo(
() =>
slide.type === "content"
? { type: "doc", content: slide.content }
: undefined,
[slide]
);
const extensions = React.useMemo(() => richExtensions, []);
return createPortal(
<Container ref={containerRef} $background={theme.background} $idle={isIdle}>
<TopBar $idle={isIdle}>
<Flex align="center" gap={12}>
<Tooltip content={t("Previous slide")} delay={500}>
<Button onClick={goPrev} disabled={currentSlide === 0}>
<ArrowLeftIcon />
</Button>
</Tooltip>
<SlideCounter>
{currentSlide + 1} / {totalSlides}
</SlideCounter>
<Tooltip content={t("Next slide")} delay={500}>
<Button
onClick={goNext}
disabled={currentSlide === totalSlides - 1}
>
<ArrowRightIcon color="currentColor" />
</Button>
</Tooltip>
</Flex>
<RightButtons>
{supportsFullscreen && (
<Tooltip content={t("Toggle fullscreen")} delay={500}>
<Button onClick={toggleFullscreen}>
{isFullscreen ? (
<ShrinkIcon color="currentColor" />
) : (
<GrowIcon color="currentColor" />
)}
</Button>
</Tooltip>
)}
<Tooltip content={t("Close")} delay={500}>
<Button onClick={onClose}>
<CloseIcon />
</Button>
</Tooltip>
</RightButtons>
</TopBar>
<SlideArea onClick={goNext}>
<SlideContent ref={slideContentRef}>
{slide.type === "title" ? (
<TitleSlide>
{slide.icon && (
<TitleIcon>
<Icon
value={slide.icon}
color={slide.iconColor ?? colorPalette[0]}
size={64}
initial={slide.title[0]}
/>
</TitleIcon>
)}
<TitleText>{slide.title}</TitleText>
</TitleSlide>
) : slide.type === "instructions" ? (
<InstructionSlide>
<InstructionHeading>
{t("Create your presentation")}
</InstructionHeading>
<InstructionBody>
{t(
"Add content to your document, then use headings or dividers to separate it into slides."
)}{" "}
<a
href="https://docs.getoutline.com/s/guide/doc/present-mode-yMGzaY7A9L"
target="_blank"
>
{t("Learn more")}
</a>
.
</InstructionBody>
</InstructionSlide>
) : slideData ? (
<Editor
key={currentSlide}
defaultValue={slideData}
extensions={extensions}
readOnly
grow={false}
placeholder=""
/>
) : null}
</SlideContent>
</SlideArea>
</Container>,
document.body
);
}
const Container = styled.div<{ $background: string; $idle: boolean }>`
position: fixed;
inset: 0;
z-index: ${depths.presentation};
background: ${(props) => props.$background};
display: flex;
flex-direction: column;
user-select: none;
cursor: ${(props) => (props.$idle ? "none" : "default")};
* {
cursor: inherit;
}
`;
const SlideArea = styled.div`
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 80px;
`;
const SlideContent = styled.div`
max-width: 960px;
width: 100%;
transform-origin: center center;
.ProseMirror {
padding: 0;
font-size: 1.4em;
}
h1 {
font-size: 2.4em;
}
h2 {
font-size: 1.8em;
}
h3 {
font-size: 1.4em;
}
`;
const TitleSlide = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 24px;
min-height: 200px;
`;
const TitleIcon = styled.div`
flex-shrink: 0;
`;
const TitleText = styled.h1`
font-size: 3em;
font-weight: 600;
line-height: 1.25;
margin: 0;
color: ${s("text")};
`;
const TopBar = styled.div<{ $idle: boolean }>`
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1;
opacity: ${(props) => (props.$idle ? 0 : 1)};
pointer-events: ${(props) => (props.$idle ? "none" : "auto")};
transition: opacity 300ms ease;
`;
const SlideCounter = styled(Text)`
font-variant-numeric: tabular-nums;
color: ${s("textTertiary")};
font-size: 14px;
min-width: 60px;
text-align: center;
`;
const RightButtons = styled(Flex).attrs({ align: "center", gap: 16 })`
position: absolute;
right: 16px;
`;
const Button = styled(NudeButton).attrs({ size: 32 })`
&:not(:disabled) {
color: ${s("textTertiary")};
&:${hover},
&:active {
color: ${s("text")};
}
}
&:disabled {
color: ${s("textTertiary")};
opacity: 0.5;
}
`;
const InstructionSlide = styled(TitleSlide)`
gap: 16px;
max-width: 560px;
margin: 0 auto;
`;
const InstructionHeading = styled.h2`
font-size: 2em;
font-weight: 600;
margin: 0;
color: ${s("text")};
`;
const InstructionBody = styled.p`
font-size: 1.2em;
line-height: 1.6;
margin: 0;
color: ${s("textSecondary")};
`;
export default PresentationMode;
@@ -30,7 +30,7 @@ function References({ document }: Props) {
useEffect(() => {
if (!isShare) {
void documents.fetchRelationships(document.id);
void documents.fetchBacklinks(document.id);
}
}, [isShare, documents, document.id]);
+8 -19
View File
@@ -10,7 +10,6 @@ import {
PopoverContent,
} from "~/components/primitives/Popover";
import useMobile from "~/hooks/useMobile";
import useShareDataLoader from "~/hooks/useShareDataLoader";
import useStores from "~/hooks/useStores";
import { preventDefault } from "~/utils/events";
import lazyWithRetry from "~/utils/lazyWithRetry";
@@ -32,23 +31,14 @@ function ShareButton({ document }: Props) {
const share = shares.getByDocumentId(document.id);
const sharedParent = shares.getByDocumentParents(document);
const domain = share?.domain || sharedParent?.domain;
const { preload, loading, reset } = useShareDataLoader({ document });
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
if (isOpen) {
preload();
} else {
reset();
}
},
[preload, reset]
);
const closePopover = useCallback(() => {
handleOpenChange(false);
}, [handleOpenChange]);
setOpen(false);
}, []);
const handleMouseEnter = useCallback(() => {
void document.share();
}, [document]);
if (isMobile) {
return null;
@@ -57,9 +47,9 @@ function ShareButton({ document }: Props) {
const icon = document.isPubliclyShared ? <GlobeIcon /> : undefined;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<Button icon={icon} neutral onMouseEnter={preload}>
<Button icon={icon} neutral onMouseEnter={handleMouseEnter}>
{t("Share")} {domain && <>&middot; {domain}</>}
</Button>
</PopoverTrigger>
@@ -76,7 +66,6 @@ function ShareButton({ document }: Props) {
document={document}
onRequestClose={closePopover}
visible={open}
loading={loading}
/>
</Suspense>
</PopoverContent>
-9
View File
@@ -108,15 +108,6 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) {
),
label: t("Go to link"),
},
{
shortcut: (
<>
<Key symbol>{metaDisplay}</Key> + <Key symbol>{altDisplay}</Key>{" "}
+ <Key>p</Key>
</>
),
label: t("Present document"),
},
{
shortcut: (
<>
@@ -89,16 +89,12 @@ function AuthenticationProvider(props: Props) {
// Populate hidden form fields with authentication data
if (formRef.current) {
const createInputs = (obj: Record<string, unknown>, prefix = "") => {
const createInputs = (obj: any, prefix = "") => {
Object.entries(obj).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
const fieldName = prefix ? `${prefix}[${key}]` : key;
if (typeof value === "object" && !Array.isArray(value)) {
createInputs(value as Record<string, unknown>, fieldName);
if (value && typeof value === "object" && !Array.isArray(value)) {
createInputs(value, fieldName);
} else {
// Create hidden input for primitive values
const input = document.createElement("input");
+46 -229
View File
@@ -6,8 +6,6 @@ import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import { InputSelect } from "~/components/InputSelect";
import type AuthenticationProvider from "~/models/AuthenticationProvider";
import PluginIcon from "~/components/PluginIcon";
import Scene from "~/components/Scene";
@@ -23,7 +21,6 @@ import { settingsPath } from "~/utils/routeHelpers";
import DomainManagement from "./components/DomainManagement";
import Button from "~/components/Button";
import { ConnectedIcon } from "~/components/Icons/ConnectedIcon";
import { client } from "~/utils/ApiClient";
import { useTheme } from "styled-components";
import { VStack } from "~/components/primitives/VStack";
@@ -100,54 +97,6 @@ function Authentication() {
window.location.href = `/auth/${name}?host=${window.location.host}`;
}, []);
const handleToggleGroupSync = React.useCallback(
(provider: AuthenticationProvider, checked: boolean) => {
if (checked) {
void (async () => {
try {
await provider.save({
settings: {
...provider.settings,
groupSyncEnabled: true,
},
});
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
})();
} else {
dialogs.openModal({
title: t("Disable group sync"),
content: (
<DisableGroupSyncDialog
provider={provider}
onSubmit={dialogs.closeAllModals}
/>
),
});
}
},
[t, dialogs]
);
const handleGroupClaimChange = React.useCallback(
async (provider: AuthenticationProvider, groupClaim: string) => {
try {
await provider.save({
settings: {
...provider.settings,
groupClaim,
},
});
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
},
[t]
);
const showSuccessMessage = React.useMemo(
() => () => toast.success(t("Settings saved")),
[t]
@@ -166,107 +115,58 @@ function Authentication() {
<Heading as="h2">{t("Sign In")}</Heading>
{authenticationProviders.orderedData.map((provider) => (
<React.Fragment key={provider.name}>
<SettingRow
label={
<Flex gap={8} align="center">
<PluginIcon id={provider.name} /> {provider.displayName}
</Flex>
}
name={provider.name}
description={
provider.isConnected
? t("Allow members to sign-in with {{ authProvider }}", {
authProvider: provider.displayName,
})
: t("Connect {{ authProvider }} to allow members to sign-in", {
authProvider: provider.displayName,
})
}
border={!(provider.isActive && provider.groupSyncSupported)}
>
<Flex align="center" gap={12}>
{provider.isConnected ? (
<VStack align="start">
<Button
icon={
provider.isEnabled ? (
<ConnectedIcon />
) : (
<ConnectedIcon color={theme.textSecondary} />
)
}
onClick={() =>
!provider.isEnabled
? handleToggleProvider(provider, true)
: handleRemoveProvider(provider)
}
neutral
>
{provider.isEnabled ? t("Connected") : t("Disabled")}
</Button>
<Text type="tertiary" size="small">
{provider.providerId}
</Text>
</VStack>
) : (
<SettingRow
key={provider.name}
label={
<Flex gap={8} align="center">
<PluginIcon id={provider.name} /> {provider.displayName}
</Flex>
}
name={provider.name}
description={
provider.isConnected
? t("Allow members to sign-in with {{ authProvider }}", {
authProvider: provider.displayName,
})
: t("Connect {{ authProvider }} to allow members to sign-in", {
authProvider: provider.displayName,
})
}
>
<Flex align="center" gap={12}>
{provider.isConnected ? (
<VStack align="start">
<Button
onClick={() => handleConnectProvider(provider.name)}
icon={
provider.isEnabled ? (
<ConnectedIcon />
) : (
<ConnectedIcon color={theme.textSecondary} />
)
}
onClick={() =>
!provider.isEnabled
? handleToggleProvider(provider, true)
: handleRemoveProvider(provider)
}
neutral
>
{t("Connect")}
{provider.isEnabled ? t("Connected") : t("Disabled")}
</Button>
)}
</Flex>
</SettingRow>
{provider.isActive && provider.groupSyncSupported && (
<SettingRow
label={t("Group sync")}
name={`groupSync-${provider.name}`}
description={t(
"Sync group memberships from {{ authProvider }} on each sign-in",
{ authProvider: provider.displayName }
)}
border={
!(
provider.settings?.groupSyncEnabled &&
provider.groupSyncUsesClaim
)
}
>
<Switch
id={`groupSync-${provider.name}`}
checked={provider.settings?.groupSyncEnabled ?? false}
onChange={(checked) => handleToggleGroupSync(provider, checked)}
/>
</SettingRow>
)}
{provider.isActive &&
provider.groupSyncSupported &&
provider.groupSyncUsesClaim &&
provider.settings?.groupSyncEnabled && (
<SettingRow
label={t("Group claim")}
name={`groupClaim-${provider.name}`}
description={t(
"The claim in the provider response that contains group names (e.g. groups, roles)"
)}
border={false}
<Text type="tertiary" size="small">
{provider.providerId}
</Text>
</VStack>
) : (
<Button
onClick={() => handleConnectProvider(provider.name)}
neutral
>
<Input
id={`groupClaim-${provider.name}`}
defaultValue={provider.settings?.groupClaim ?? "groups"}
placeholder="groups"
onBlur={(ev: React.FocusEvent<HTMLInputElement>) => {
const value = ev.target.value.trim();
if (value !== (provider.settings?.groupClaim ?? "")) {
void handleGroupClaimChange(provider, value);
}
}}
/>
</SettingRow>
{t("Connect")}
</Button>
)}
</React.Fragment>
</Flex>
</SettingRow>
))}
<SettingRow
label={
@@ -319,87 +219,4 @@ function Authentication() {
);
}
const DisableGroupSyncDialog = observer(function DisableGroupSyncDialog({
provider,
onSubmit,
}: {
provider: AuthenticationProvider;
onSubmit: () => void;
}) {
const { t } = useTranslation();
const [action, setAction] = React.useState("keep");
const [isSaving, setIsSaving] = React.useState(false);
const options = React.useMemo(
() => [
{
type: "item" as const,
label: t("Keep synced groups"),
description: t("Groups will remain but no longer update"),
value: "keep",
},
{
type: "item" as const,
label: t("Delete synced groups"),
description: t("Remove all groups created by sync"),
value: "delete",
},
],
[t]
);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await provider.save({
settings: {
...provider.settings,
groupSyncEnabled: false,
},
});
if (action === "delete") {
await client.post("/groups.deleteAll", {
authenticationProviderId: provider.id,
});
}
toast.success(t("Settings saved"));
onSubmit();
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[provider, action, onSubmit, t]
);
return (
<form onSubmit={handleSubmit}>
<Flex gap={12} column>
<Text type="secondary">
{t(
"Group memberships will no longer be synced from {{ authProvider }} when members sign in.",
{ authProvider: provider.displayName }
)}
</Text>
<InputSelect
label={t("Existing groups")}
options={options}
value={action}
onChange={setAction}
/>
<Flex justify="flex-end">
<Button type="submit" disabled={isSaving} danger>
{isSaving ? `${t("Disabling")}` : t("Disable")}
</Button>
</Flex>
</Flex>
</form>
);
});
export default observer(Authentication);
-39
View File
@@ -4,7 +4,6 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { TeamPreference } from "@shared/types";
import { TeamValidation } from "@shared/validations";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
@@ -31,18 +30,6 @@ function Features() {
[team, t]
);
const handleGuidanceMCPChange = React.useCallback(
async (ev: React.ChangeEvent<HTMLTextAreaElement>) => {
team.guidanceMCP = ev.target.value || null;
},
[team]
);
const handleGuidanceMCPBlur = React.useCallback(async () => {
await team.save();
toast.success(t("Settings saved"));
}, [team, t]);
const handleCopied = React.useCallback(() => {
toast.success(t("Copied to clipboard"));
}, [t]);
@@ -59,7 +46,6 @@ function Features() {
<SettingRow
name={TeamPreference.MCP}
label={t("MCP server")}
border={!team.getPreference(TeamPreference.MCP)}
description={
<>
<Text type="secondary" as="p">
@@ -111,31 +97,6 @@ function Features() {
/>
</SettingRow>
{team.getPreference(TeamPreference.MCP) && (
<SettingRow
name="guidanceMCP"
label={t("Additional guidance")}
description={
<>
<div style={{ marginBottom: 8 }}>
{t(
"You can use these optional instructions to tell MCP clients how to use your knowledge base."
)}
</div>
<Input
id="guidanceMCP"
type="textarea"
rows={6}
value={team.guidanceMCP ?? ""}
maxLength={TeamValidation.maxGuidanceMCPLength}
onChange={handleGuidanceMCPChange}
onBlur={handleGuidanceMCPBlur}
/>
</>
}
/>
)}
<SettingRow
name="answers"
label={t("AI answers")}
-284
View File
@@ -1,284 +0,0 @@
import type { ColumnSort } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { GroupIcon, HiddenIcon, PlusIcon } from "outline-icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { useHistory, useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { toast } from "sonner";
import type User from "~/models/User";
import { Action } from "~/components/Actions";
import Breadcrumb from "~/components/Breadcrumb";
import Button from "~/components/Button";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import LoadingIndicator from "~/components/LoadingIndicator";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import Error404 from "~/scenes/Errors/Error404";
import { createInternalLinkAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import type { FetchPageParams, PaginatedResponse } from "~/stores/base/Store";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import GroupMenu from "~/menus/GroupMenu";
import { AddPeopleToGroupDialog } from "./components/GroupDialogs";
import GroupPermissionFilter from "./components/GroupPermissionFilter";
import { GroupMembersTable } from "./components/GroupMembersTable";
import { StickyFilters } from "./components/StickyFilters";
import { settingsPath } from "~/utils/routeHelpers";
/**
* Settings page that lists members of a specific group.
*/
function GroupMembers() {
const { id } = useParams<{ id: string }>();
const { groups } = useStores();
const group = groups.get(id);
const { request, error } = useRequest(() => groups.fetch(id));
useEffect(() => {
if (!group) {
void request();
}
}, [group, request]);
if (error) {
return <Error404 />;
}
if (!group) {
return <LoadingIndicator />;
}
return <GroupMembersPage groupId={group.id} />;
}
const GroupMembersPage = observer(function GroupMembersPage({
groupId,
}: {
groupId: string;
}) {
const { t } = useTranslation();
const theme = useTheme();
const { dialogs, groups, users, groupUsers } = useStores();
const group = groups.get(groupId)!;
const can = usePolicy(group);
const history = useHistory();
const location = useLocation();
const params = useQuery();
const [query, setQuery] = useState("");
const reqParams = useMemo(
() => ({
id: group.id,
query: params.get("query") || undefined,
permission: params.get("permission") || undefined,
sort: params.get("sort") || "name",
direction: (params.get("direction") || "asc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params, group.id]
);
const sort: ColumnSort = useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
const fetchMembers = useCallback(
async (fetchParams: FetchPageParams): Promise<PaginatedResponse<User>> => {
const response = await groupUsers.fetchPage(fetchParams);
const result = response.map((gu) => gu.user) as PaginatedResponse<User>;
result[PAGINATION_SYMBOL] = response[PAGINATION_SYMBOL];
return result;
},
[groupUsers]
);
const filteredUsers = useMemo(() => {
let result = users.inGroup(group.id, reqParams.query);
if (reqParams.permission) {
const memberIds = new Set(
groupUsers.orderedData
.filter(
(gu) =>
gu.groupId === group.id && gu.permission === reqParams.permission
)
.map((gu) => gu.userId)
);
result = result.filter((user) => memberIds.has(user.id));
}
return result;
}, [
users,
groupUsers.orderedData,
group.id,
reqParams.query,
reqParams.permission,
]);
const { data, error, loading, next } = useTableRequest({
data: filteredUsers,
sort,
reqFn: fetchMembers,
reqParams,
});
const updateParams = useCallback(
(name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const updateQuery = useCallback(
(value: string) => updateParams("query", value),
[updateParams]
);
const handlePermissionFilter = useCallback(
(permission: string | null | undefined) =>
updateParams("permission", permission ?? ""),
[updateParams]
);
const handleSearch = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setQuery(value);
},
[]
);
const handleAddPeople = useCallback(() => {
dialogs.openModal({
title: t(`Add people to {{groupName}}`, {
groupName: group.name,
}),
content: <AddPeopleToGroupDialog group={group} />,
});
}, [t, group, dialogs]);
useEffect(() => {
if (error) {
toast.error(t("Could not load group members"));
}
}, [t, error]);
useEffect(() => {
const timeout = setTimeout(() => updateQuery(query), 250);
return () => clearTimeout(timeout);
}, [query, updateQuery]);
const breadcrumbActions = useMemo(
() => [
createInternalLinkAction({
name: t("Groups"),
section: NavigationSection,
icon: <GroupIcon />,
to: settingsPath("groups"),
}),
],
[t]
);
return (
<Scene
title={group.name}
left={<Breadcrumb actions={breadcrumbActions} />}
actions={
<>
{can.update && (
<Action>
<Button
type="button"
onClick={handleAddPeople}
disabled={group.isExternallyManaged}
icon={<PlusIcon />}
>
{`${t("Add people")}`}
</Button>
</Action>
)}
<Action>
<GroupMenu group={group} hideMembers />
</Action>
</>
}
wide
>
<Heading>
{group.name}
{group.disableMentions && (
<>
&nbsp;
<Tooltip content={t("This group is hidden")}>
<HiddenIcon size={32} color={theme.textSecondary} />
</Tooltip>
</>
)}
</Heading>
<Text as="p" type="secondary">
{group.externalGroup && (
<>
{t("Synced to {{ provider }}", {
provider: group.externalGroup.displayName,
})}
{group.description && <> &middot; </>}
</>
)}
{group.description || (!group.externalGroup && t("No description"))}
</Text>
<StickyFilters>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeGroupPermissionFilter
activeKey={reqParams.permission ?? ""}
onSelect={handlePermissionFilter}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<GroupMembersTable
group={group}
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</ConditionalFade>
</Scene>
);
});
const LargeGroupPermissionFilter = styled(GroupPermissionFilter)`
height: 32px;
`;
export const GroupMembersScene = observer(GroupMembers);

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