Compare commits

..

1 Commits

Author SHA1 Message Date
Saumya Pandey cf5e365c7a fix: indent the note 2022-02-22 00:17:04 +05:30
748 changed files with 12764 additions and 24976 deletions
+83 -109
View File
@@ -1,93 +1,51 @@
version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:14.19
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
resource_class: large
environment:
NODE_ENV: test
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
DATABASE_URL_TEST: postgres://postgres:password@localhost:5432/circle_test
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
URL: http://localhost:3000
SMTP_FROM_EMAIL: hello@example.com
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
<<: *defaults
working_directory: ~/outline
docker:
- image: circleci/node:14
- image: circleci/redis:latest
- image: circleci/postgres:9.6.5-alpine-ram
environment:
NODE_ENV: test
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
DATABASE_URL_TEST: postgres://root@localhost:5432/circle_test
DATABASE_URL: postgres://root@localhost:5432/circle_test
URL: http://localhost:3000
SMTP_FROM_EMAIL: hello@example.com
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --frozen-lockfile
command: yarn install --pure-lockfile
- save_cache:
key: dependency-cache-{{ checksum "package.json" }}
paths:
- ./node_modules
lint:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: lint
command: yarn lint
types:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: typescript
command: yarn tsc
test-app:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:app
test-server:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: lint
command: yarn lint
- run:
name: typescript
command: yarn tsc
- run:
name: test
command: yarn test:server
bundle-size:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
@@ -98,59 +56,59 @@ jobs:
- setup_remote_docker:
version: 20.10.6
- run:
name: Install Docker buildx
name: Build Docker image
command: docker build -t $IMAGE_NAME:latest .
- run:
name: Archive Docker image
command: docker save -o image.tar $IMAGE_NAME
- persist_to_workspace:
root: .
paths:
- ./image.tar
publish-latest:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
mkdir -p ~/.docker/cli-plugins
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
chmod a+x ~/.docker/cli-plugins/docker-buildx
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:latest
docker push $IMAGE_NAME:$IMAGE_TAG
publish-tag:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Enable Docker buildx
command: export DOCKER_CLI_EXPERIMENTAL=enabled
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Initialize Docker buildx
name: Publish Docker Image to Docker Hub
command: |
docker buildx install
docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx inspect --builder docker-multiarch --bootstrap
docker buildx use docker-multiarch
- run:
name: Build base image
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
- run:
name: Login to Docker Hub
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
- run:
name: Publish base Docker Image to Docker Hub
command: docker push $BASE_IMAGE_NAME:latest
- run:
name: Build and push Docker image
command: docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:$IMAGE_TAG
workflows:
version: 2
all:
build-and-test:
jobs:
- build
- lint:
requires:
- build
- test-server:
requires:
- build
- test-app:
requires:
- build
- types:
requires:
- build
- bundle-size:
requires:
- test-app
- test-server
- build:
filters:
tags:
ignore: /^v.*/
build-docker:
jobs:
- build-image:
@@ -159,3 +117,19 @@ workflows:
only: /^v.*/
branches:
ignore: /.*/
- publish-latest:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+$/
branches:
ignore: /.*/
- publish-tag:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+-.*$/
branches:
ignore: /.*/
+10 -17
View File
@@ -10,21 +10,11 @@ UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@localhost:5432/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5432/outline-test
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
DATABASE_URL=postgres://user:pass@localhost:5532/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://localhost:6379
# or alternatively, if you would like to provide addtional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
# Example: Use Redis Sentinel for high availability
# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"}
# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ==
REDIS_URL=redis://localhost:6479
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
@@ -46,7 +36,6 @@ COLLABORATION_URL=
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
AWS_S3_ACCELERATE_URL=
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
@@ -65,8 +54,8 @@ AWS_S3_ACL=private
#
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
# https://<URL>/auth/slack.callback
SLACK_CLIENT_ID=get_a_key_from_slack
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
# To configure Google auth, you'll need to create an OAuth Client ID at
# => https://console.cloud.google.com/apis/credentials
@@ -100,7 +89,7 @@ OIDC_USERNAME_CLAIM=preferred_username
OIDC_DISPLAY_NAME=OpenID
# Space separated auth scopes.
OIDC_SCOPES=openid profile email
OIDC_SCOPES="openid profile email"
# –––––––––––––––– OPTIONAL ––––––––––––––––
@@ -137,6 +126,10 @@ MAXIMUM_IMPORT_SIZE=5120000
# requests and this ends up being duplicative
DEBUG=http
# Comma separated list of domains to be allowed to signin to the wiki. If not
# set, all domains are allowed by default when using Google OAuth to signin
ALLOWED_DOMAINS=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
-2
View File
@@ -16,7 +16,6 @@
"plugin:prettier/recommended"
],
"plugins": [
"es",
"@typescript-eslint",
"eslint-plugin-import",
"eslint-plugin-node",
@@ -29,7 +28,6 @@
"curly": 2,
"no-mixed-operators": "off",
"no-useless-escape": "off",
"es/no-regexp-lookbehind-assertions": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
+22
View File
@@ -0,0 +1,22 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 120
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 14
# Issues with these labels will never be considered stale
exemptLabels:
- security
- pinned
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
Hey! The issue has been automatically marked as stale because it has not had
recent activity. It will be closed soon if no further activity occurs. Please
reply here if you wish for the issue to be kept open.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
@@ -1,56 +0,0 @@
# Image Actions will run in the following scenarios:
# - on Pull Requests containing images (not including forks)
# - on pushing of images to `main` (for forks)
# - on demand (https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/)
# - at 11 PM every Sunday in anything gets missed with any of the above scenarios
# For Pull Requests, the images are added to the PR.
# For other scenarios, a new PR will be opened if any images are compressed.
name: Compress images
on:
pull_request:
paths:
- "**.jpg"
- "**.jpeg"
- "**.png"
- "**.webp"
push:
branches:
- main
paths:
- "**.jpg"
- "**.jpeg"
- "**.png"
- "**.webp"
workflow_dispatch:
schedule:
- cron: "00 20 * * 0"
jobs:
build:
name: calibreapp/image-actions
runs-on: ubuntu-latest
# Only run on main repo on and PRs that match the main repo.
if: |
github.repository == 'outline/outline' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout Branch
uses: actions/checkout@v2
- name: Compress Images
id: calibre
uses: calibreapp/image-actions@main
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
compressOnly: ${{ github.event_name != 'pull_request' }}
- name: Create Pull Request
# If it's not a Pull Request then commit any changes as a new PR.
if: |
github.event_name != 'pull_request' &&
steps.calibre.outputs.markdown != ''
uses: peter-evans/create-pull-request@v3
with:
title: "chore: Auto Compress Images"
branch-suffix: timestamp
commit-message: "chore: Compressed inefficient images automatically"
body: ${{ steps.calibre.outputs.markdown }}
+3 -3
View File
@@ -42,7 +42,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -53,7 +53,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -67,4 +67,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v1
-29
View File
@@ -1,29 +0,0 @@
name: "Close Stale PRs"
on:
workflow_dispatch:
schedule:
- cron: "30 1 * * *"
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v5
with:
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
close-pr-message: "Automatically closed due to inactivity"
close-issue-message: "Automatically closed due to inactivity"
days-before-issue-stale: 120
days-before-pr-stale: 60
days-before-close: 5
operations-per-run: 60
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: "security,pinned"
- name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }}
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
+31 -8
View File
@@ -1,22 +1,45 @@
# syntax=docker/dockerfile:1.2
ARG APP_PATH=/opt/outline
FROM outlinewiki/outline-base as base
FROM node:16-alpine AS deps-common
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
# ---
FROM deps-common AS deps-dev
RUN yarn install --no-optional --frozen-lockfile && \
yarn cache clean
# ---
FROM deps-common AS deps-prod
RUN yarn install --production=true --frozen-lockfile && \
yarn cache clean
# ---
FROM node:16-alpine AS builder
ARG APP_PATH
WORKDIR $APP_PATH
COPY . .
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
ARG CDN_URL
RUN yarn build
# ---
FROM node:16.14.2-alpine3.15 AS runner
FROM node:16-alpine AS runner
ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV production
COPY --from=base $APP_PATH/build ./build
COPY --from=base $APP_PATH/server ./server
COPY --from=base $APP_PATH/public ./public
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base $APP_PATH/node_modules ./node_modules
COPY --from=base $APP_PATH/package.json ./package.json
COPY --from=builder $APP_PATH/build ./build
COPY --from=builder $APP_PATH/server ./server
COPY --from=builder $APP_PATH/public ./public
COPY --from=builder $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=deps-prod $APP_PATH/node_modules ./node_modules
COPY --from=builder $APP_PATH/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
-18
View File
@@ -1,18 +0,0 @@
ARG APP_PATH=/opt/outline
FROM node:16.14.2-alpine3.15 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
COPY . .
ARG CDN_URL
RUN yarn build
RUN rm -rf node_modules
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.64.0
Licensed Work: Outline 0.60.1
The Licensed Work is (c) 2020 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: 2026-05-23
Change Date: 2025-11-11
Change License: Apache License, Version 2.0
+1 -2
View File
@@ -1,2 +1 @@
window.matchMedia = (data) => data;
window.env = {};
window.matchMedia = data => data;
+7 -3
View File
@@ -43,6 +43,10 @@
"value": "true",
"required": true
},
"ALLOWED_DOMAINS": {
"description": "Comma separated list of domains to be allowed (optional). If not set, all domains are allowed by default when using Google OAuth to signin. Consider putting {your app name}.herokuapp.com and any domain you are binding on in this list.",
"required": false
},
"URL": {
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
"required": true
@@ -102,11 +106,11 @@
"value": "openid profile email",
"required": false
},
"SLACK_CLIENT_ID": {
"SLACK_KEY": {
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
"required": false
},
"SLACK_CLIENT_SECRET": {
"SLACK_SECRET": {
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
"required": false
},
@@ -205,4 +209,4 @@
"required": false
}
}
}
}
+3 -69
View File
@@ -1,13 +1,6 @@
import {
CollectionIcon,
EditIcon,
PlusIcon,
StarredIcon,
UnstarredIcon,
} from "outline-icons";
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionNew from "~/scenes/CollectionNew";
import DynamicCollectionIcon from "~/components/CollectionIcon";
@@ -15,10 +8,6 @@ import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
import history from "~/utils/history";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => {
return <DynamicCollectionIcon collection={collection} />;
};
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
section: CollectionSection,
@@ -31,7 +20,7 @@ export const openCollection = createAction({
// cache if the collection is renamed
id: collection.url,
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
icon: <DynamicCollectionIcon collection={collection} />,
section: CollectionSection,
perform: () => history.push(collection.url),
}));
@@ -79,59 +68,4 @@ export const editCollection = createAction({
},
});
export const starCollection = createAction({
name: ({ t }) => t("Star"),
section: CollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).star
);
},
perform: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
collection?.star();
},
});
export const unstarCollection = createAction({
name: ({ t }) => t("Unstar"),
section: CollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
collection?.unstar();
},
});
export const rootCollectionActions = [
openCollection,
createCollection,
starCollection,
unstarCollection,
];
export const rootCollectionActions = [openCollection, createCollection];
+13 -32
View File
@@ -10,15 +10,14 @@ import {
ShapesIcon,
ImportIcon,
PinIcon,
SearchIcon,
} from "outline-icons";
import * as React from "react";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import DocumentTemplatize from "~/scenes/DocumentTemplatize";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import history from "~/utils/history";
import { homePath, newDocumentPath, searchPath } from "~/utils/routeHelpers";
import { homePath, newDocumentPath } from "~/utils/routeHelpers";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -52,11 +51,8 @@ export const createDocument = createAction({
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ activeCollectionId, inStarredSection }) =>
activeCollectionId &&
history.push(newDocumentPath(activeCollectionId), {
starred: inStarredSection,
}),
perform: ({ activeCollectionId }) =>
activeCollectionId && history.push(newDocumentPath(activeCollectionId)),
});
export const starDocument = createAction({
@@ -154,11 +150,10 @@ export const duplicateDocument = createAction({
* Pin a document to a collection. Pinned documents will be displayed at the top
* of the collection for all collection members to see.
*/
export const pinDocumentToCollection = createAction({
export const pinDocument = createAction({
name: ({ t }) => t("Pin to collection"),
section: DocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (!activeDocumentId || !activeCollectionId) {
return false;
@@ -193,7 +188,6 @@ export const pinDocumentToHome = createAction({
name: ({ t }) => t("Pin to home"),
section: DocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, currentTeamId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
@@ -220,13 +214,6 @@ export const pinDocumentToHome = createAction({
},
});
export const pinDocument = createAction({
name: ({ t }) => t("Pin"),
section: DocumentSection,
icon: <PinIcon />,
children: [pinDocumentToCollection, pinDocumentToHome],
});
export const printDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
@@ -306,28 +293,22 @@ export const createTemplate = createAction({
if (!activeDocumentId) {
return;
}
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create template"),
isCentered: true,
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
content: (
<DocumentTemplatize
documentId={activeDocumentId}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const searchDocumentsForQuery = (searchQuery: string) =>
createAction({
id: "search",
section: DocumentSection,
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery)),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const rootDocumentActions = [
openDocument,
createDocument,
@@ -338,6 +319,6 @@ export const rootDocumentActions = [
unstarDocument,
duplicateDocument,
printDocument,
pinDocumentToCollection,
pinDocument,
pinDocumentToHome,
];
+17 -27
View File
@@ -10,26 +10,23 @@ import {
KeyboardIcon,
EmailIcon,
LogoutIcon,
ProfileIcon,
} from "outline-icons";
import * as React from "react";
import {
developersUrl,
changelogUrl,
feedbackUrl,
mailToUrl,
githubIssuesUrl,
} from "@shared/utils/urlHelpers";
import stores from "~/stores";
import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import { NavigationSection } from "~/actions/sections";
import history from "~/utils/history";
import {
organizationSettingsPath,
profileSettingsPath,
settingsPath,
homePath,
searchPath,
searchUrl,
draftsPath,
templatesPath,
archivePath,
@@ -45,13 +42,14 @@ export const navigateToHome = createAction({
visible: ({ location }) => location.pathname !== homePath(),
});
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
createAction({
section: RecentSearchesSection,
name: searchQuery.query,
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery.query)),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
section: NavigationSection,
shortcut: ["/"],
icon: <SearchIcon />,
perform: () => history.push(searchUrl()),
visible: ({ location }) => location.pathname !== searchUrl(),
});
export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"),
@@ -72,7 +70,6 @@ export const navigateToTemplates = createAction({
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
section: NavigationSection,
shortcut: ["g", "a"],
icon: <ArchiveIcon />,
perform: () => history.push(archivePath()),
visible: ({ location }) => location.pathname !== archivePath(),
@@ -90,18 +87,9 @@ export const navigateToSettings = createAction({
name: ({ t }) => t("Settings"),
section: NavigationSection,
shortcut: ["g", "s"],
icon: <SettingsIcon />,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(organizationSettingsPath()),
});
export const navigateToProfileSettings = createAction({
name: ({ t }) => t("Profile"),
section: NavigationSection,
iconInContextMenu: false,
icon: <ProfileIcon />,
perform: () => history.push(profileSettingsPath()),
icon: <SettingsIcon />,
perform: () => history.push(settingsPath()),
});
export const openAPIDocumentation = createAction({
@@ -117,7 +105,7 @@ export const openFeedbackUrl = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
perform: () => window.open(feedbackUrl()),
perform: () => window.open(mailToUrl()),
});
export const openBugReportUrl = createAction({
@@ -157,10 +145,12 @@ export const logout = createAction({
export const rootNavigationActions = [
navigateToHome,
navigateToSearch,
navigateToDrafts,
navigateToTemplates,
navigateToArchive,
navigateToTrash,
navigateToSettings,
openAPIDocumentation,
openFeedbackUrl,
openBugReportUrl,
+42 -24
View File
@@ -1,6 +1,6 @@
import { flattenDeep } from "lodash";
import * as React from "react";
import { Optional } from "utility-types";
import { $Diff } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import {
Action,
@@ -10,14 +10,17 @@ import {
MenuItemWithChildren,
} from "~/types";
function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
}
export function createAction(definition: Optional<Action, "id">): Action {
export function createAction(
definition: $Diff<
Action,
{
id?: string;
}
>
): Action {
return {
...definition,
id: uuidv4(),
...definition,
};
}
@@ -25,10 +28,18 @@ export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemButton | MenuItemWithChildren {
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const visible = action.visible ? action.visible(context) : true;
const title = resolve<string>(action.name, context);
const title = resolve<string>(action.name);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? React.cloneElement(resolvedIcon, {
@@ -37,17 +48,14 @@ export function actionToMenuItem(
: undefined;
if (resolvedChildren) {
const items = resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter(Boolean)
.filter((a) => a.visible);
return {
type: "submenu",
title,
icon,
items,
visible: visible && items.length > 0,
items: resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter((a) => !!a),
visible,
};
}
@@ -65,15 +73,23 @@ export function actionToKBar(
action: Action,
context: ActionContext
): CommandBarAction[] {
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
if (typeof action.visible === "function" && !action.visible(context)) {
return [];
}
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const resolvedSection = resolve<string>(action.section, context);
const resolvedName = resolve<string>(action.name, context);
const resolvedPlaceholder = resolve<string>(action.placeholder, context);
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const resolvedSection = resolve<string>(action.section);
const resolvedName = resolve<string>(action.name);
const resolvedPlaceholder = resolve<string>(action.placeholder);
const children = resolvedChildren
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
(a) => !!a
@@ -86,10 +102,12 @@ export function actionToKBar(
name: resolvedName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: action.keywords ?? "",
keywords: `${action.keywords}`,
shortcut: action.shortcut || [],
icon: resolvedIcon,
perform: action.perform ? () => action?.perform?.(context) : undefined,
perform: action.perform
? () => action.perform && action.perform(context)
: undefined,
},
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
].concat(children.map((child) => ({ ...child, parent: action.id })));
-3
View File
@@ -11,6 +11,3 @@ export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
-73
View File
@@ -1,73 +0,0 @@
import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { Action, ActionContext } from "~/types";
export type Props = {
/** Show the button in a disabled state */
disabled?: boolean;
/** Hide the button entirely if action is not applicable */
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Action;
/** Context of action, must be provided with action */
context?: ActionContext;
/** If tooltip props are provided the button will be wrapped in a tooltip */
tooltip?: Omit<TooltipProps, "children">;
};
/**
* Button that can be used to trigger an action definition.
*/
const ActionButton = React.forwardRef(
(
{
action,
context,
tooltip,
hideOnActionDisabled,
...rest
}: Props & React.HTMLAttributes<HTMLButtonElement>,
ref: React.Ref<HTMLButtonElement>
) => {
const disabled = rest.disabled;
if (!context || !action) {
return <button {...rest} ref={ref} />;
}
if (action?.visible && !action.visible(context) && hideOnActionDisabled) {
return null;
}
const label =
typeof action.name === "function" ? action.name(context) : action.name;
const button = (
<button
{...rest}
aria-label={label}
disabled={disabled}
ref={ref}
onClick={
action?.perform && context
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
action.perform?.(context);
}
: rest.onClick
}
>
{rest.children ?? label}
</button>
);
if (tooltip) {
return <Tooltip {...tooltip}>{button}</Tooltip>;
}
return button;
}
);
export default ActionButton;
+5 -1
View File
@@ -2,7 +2,11 @@
import * as React from "react";
import env from "~/env";
export default class Analytics extends React.Component {
type Props = {
children?: React.ReactNode;
};
export default class Analytics extends React.Component<Props> {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) {
return;
-51
View File
@@ -1,51 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import {
useCompositeState,
Composite,
CompositeStateReturn,
} from "reakit/Composite";
type Props = {
children: (composite: CompositeStateReturn) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
function ArrowKeyNavigation(
{ children, onEscape, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const composite = useCompositeState();
const handleKeyDown = React.useCallback(
(ev) => {
if (onEscape) {
if (ev.key === "Escape") {
onEscape(ev);
}
if (
ev.key === "ArrowUp" &&
composite.currentId === composite.items[0].id
) {
onEscape(ev);
}
}
},
[composite.currentId, composite.items, onEscape]
);
return (
<Composite
{...rest}
{...composite}
onKeyDown={handleKeyDown}
role="menu"
ref={ref}
>
{children(composite)}
</Composite>
);
}
export default observer(React.forwardRef(ArrowKeyNavigation));
+21 -1
View File
@@ -2,7 +2,9 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import { isCustomSubdomain } from "@shared/utils/domains";
import LoadingIndicator from "~/components/LoadingIndicator";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
@@ -13,7 +15,7 @@ type Props = {
const Authenticated = ({ children }: Props) => {
const { auth } = useStores();
const { i18n } = useTranslation();
const language = auth.user?.language;
const language = auth.user && auth.user.language;
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
@@ -23,11 +25,29 @@ const Authenticated = ({ children }: Props) => {
if (auth.authenticated) {
const { user, team } = auth;
const { hostname } = window.location;
if (!team || !user) {
return <LoadingIndicator />;
}
// If we're authenticated but viewing a domain that doesn't match the
// current team then kick the user to the teams correct domain.
if (team.domain) {
if (team.domain !== hostname) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
} else if (
env.SUBDOMAINS_ENABLED &&
team.subdomain &&
isCustomSubdomain(hostname) &&
!hostname.startsWith(`${team.subdomain}.`)
) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
return children;
}
+11 -15
View File
@@ -11,12 +11,11 @@ import Sidebar from "~/components/Sidebar";
import SettingsSidebar from "~/components/Sidebar/Settings";
import history from "~/utils/history";
import {
searchPath,
searchUrl,
matchDocumentSlug as slug,
newDocumentPath,
settingsPath,
} from "~/utils/routeHelpers";
import Fade from "./Fade";
import withStores from "./withStores";
const DocumentHistory = React.lazy(
@@ -34,7 +33,10 @@ const CommandBar = React.lazy(
)
);
type Props = WithTranslation & RootStore;
type Props = WithTranslation &
RootStore & {
children?: React.ReactNode;
};
@observer
class AuthenticatedLayout extends React.Component<Props> {
@@ -47,15 +49,11 @@ class AuthenticatedLayout extends React.Component<Props> {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
ev.stopPropagation();
history.push(searchPath());
history.push(searchUrl());
}
};
goToNewDocument = (event: KeyboardEvent) => {
if (event.metaKey || event.altKey) {
return;
}
goToNewDocument = () => {
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) {
return;
@@ -76,12 +74,10 @@ class AuthenticatedLayout extends React.Component<Props> {
}
const sidebar = showSidebar ? (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
) : undefined;
const rightRail = (
+3 -7
View File
@@ -11,7 +11,6 @@ type Props = {
icon?: React.ReactNode;
user?: User;
alt?: string;
showBorder?: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
className?: string;
};
@@ -30,13 +29,12 @@ class Avatar extends React.Component<Props> {
};
render() {
const { src, icon, showBorder, ...rest } = this.props;
const { src, icon, ...rest } = this.props;
return (
<AvatarWrapper>
<CircleImg
onError={this.handleError}
src={this.error ? placeholder : src}
$showBorder={showBorder}
{...rest}
/>
{icon && <IconWrapper>{icon}</IconWrapper>}
@@ -61,14 +59,12 @@ const IconWrapper = styled.div`
height: 20px;
`;
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
const CircleImg = styled.img<{ size: number }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
border: 2px solid ${(props) => props.theme.background};
flex-shrink: 0;
`;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 B

After

Width:  |  Height:  |  Size: 976 B

+1 -2
View File
@@ -1,7 +1,6 @@
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import env from "~/env";
import OutlineLogo from "./OutlineLogo";
@@ -39,7 +38,7 @@ const Link = styled.a`
}
${breakpoint("tablet")`
z-index: ${depths.sidebar + 1};
z-index: ${(props: any) => props.theme.depths.sidebar + 1};
position: fixed;
bottom: 0;
left: 0;
+2 -6
View File
@@ -9,15 +9,11 @@ import { MenuInternalLink } from "~/types";
type Props = {
items: MenuInternalLink[];
max?: number;
children?: React.ReactNode;
highlightFirstItem?: boolean;
};
function Breadcrumb({
items,
highlightFirstItem,
children,
max = 2,
}: React.PropsWithChildren<Props>) {
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
const totalItems = items.length;
const topLevelItems: MenuInternalLink[] = [...items];
let overflowItems;
+38
View File
@@ -0,0 +1,38 @@
import * as React from "react";
import styled from "styled-components";
import { bounceIn } from "~/styles/animations";
type Props = {
count: number;
};
const Bubble = ({ count }: Props) => {
if (!count) {
return null;
}
return <Count>{count}</Count>;
};
const Count = styled.div`
animation: ${bounceIn} 600ms;
transform-origin: center center;
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.slateDark};
display: inline-block;
font-feature-settings: "tnum";
font-weight: 600;
font-size: 9px;
white-space: nowrap;
vertical-align: baseline;
min-width: 16px;
min-height: 16px;
line-height: 16px;
border-radius: 8px;
text-align: center;
padding: 0 4px;
margin-left: 8px;
user-select: none;
`;
export default Bubble;
+4 -11
View File
@@ -41,8 +41,7 @@ const RealButton = styled.button<{
border: 0;
}
&:hover:not(:disabled),
&[aria-expanded="true"] {
&:hover:not(:disabled) {
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
}
@@ -77,8 +76,7 @@ const RealButton = styled.button<{
}
&:hover:not(:disabled),
&[aria-expanded="true"] {
&:hover:not(:disabled) {
background: ${
props.borderOnHover
? props.theme.buttonNeutralBackground
@@ -105,17 +103,12 @@ const RealButton = styled.button<{
background: ${props.theme.danger};
color: ${props.theme.white};
&:hover:not(:disabled),
&[aria-expanded="true"] {
&:hover:not(:disabled) {
background: ${darken(0.05, props.theme.danger)};
}
&:disabled {
background: ${lighten(0.05, props.theme.danger)};
}
&.focus-visible {
outline-color: ${darken(0.2, props.theme.danger)} !important;
background: none;
}
`};
`;
+2 -1
View File
@@ -3,9 +3,10 @@ import styled from "styled-components";
type Props = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
children: React.ReactNode;
};
const ButtonLink: React.FC<Props> = React.forwardRef(
const ButtonLink = React.forwardRef(
(props: Props, ref: React.Ref<HTMLButtonElement>) => {
return <Button {...props} ref={ref} />;
}
+4 -4
View File
@@ -3,17 +3,17 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {
children?: React.ReactNode;
withStickyHeader?: boolean;
};
const Container = styled.div<Props>`
const Container = styled.div<{ withStickyHeader?: boolean }>`
width: 100%;
max-width: 100vw;
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
${breakpoint("tablet")`
padding: ${(props: Props) =>
props.withStickyHeader ? "4px 44px 60px" : "60px 44px"};
padding: ${(props: any) => (props.withStickyHeader ? "4px 60px" : "60px")};
`};
`;
@@ -26,7 +26,7 @@ const Content = styled.div`
`};
`;
const CenteredContent: React.FC<Props> = ({ children, ...rest }) => {
const CenteredContent = ({ children, ...rest }: Props) => {
return (
<Container {...rest}>
<Content>{children}</Content>
+12 -9
View File
@@ -42,9 +42,8 @@ function Collaborators(props: Props) {
filter(
users.orderedData,
(user) =>
(presentIds.includes(user.id) ||
document.collaboratorIds.includes(user.id)) &&
!user.isSuspended
presentIds.includes(user.id) ||
document.collaboratorIds.includes(user.id)
),
(user) => presentIds.includes(user.id)
),
@@ -53,14 +52,18 @@ function Collaborators(props: Props) {
// load any users we don't yet have in memory
React.useEffect(() => {
const ids = uniq([...document.collaboratorIds, ...presentIds])
.filter((userId) => !users.get(userId))
.sort();
const userIdsToFetch = uniq([
...document.collaboratorIds,
...presentIds,
]).filter((userId) => !users.get(userId));
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
setRequestedUserIds(ids);
users.fetchPage({ ids, limit: 100 });
if (!isEqual(requestedUserIds, userIdsToFetch)) {
setRequestedUserIds(userIdsToFetch);
}
userIdsToFetch
.filter((userId) => requestedUserIds.includes(userId))
.forEach((userId) => users.fetch(userId));
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
const popover = usePopoverState({
+19 -23
View File
@@ -1,4 +1,3 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
@@ -10,7 +9,7 @@ import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import usePolicy from "~/hooks/usePolicy";
import useDebouncedCallback from "~/hooks/useDebouncedCallback";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
@@ -19,13 +18,13 @@ type Props = {
};
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { collections, policies } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection.id);
const can = policies.abilities(collection.id);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
@@ -49,25 +48,21 @@ function CollectionDescription({ collection }: Props) {
[isExpanded]
);
const handleSave = React.useMemo(
() =>
debounce(async (getValue) => {
try {
await collection.save({
description: getValue(),
});
setDirty(false);
} catch (err) {
showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
);
throw err;
}
}, 1000),
[collection, showToast, t]
);
const handleSave = useDebouncedCallback(async (getValue) => {
try {
await collection.save({
description: getValue(),
});
setDirty(false);
} catch (err) {
showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
);
throw err;
}
}, 1000);
const handleChange = React.useCallback(
(getValue) => {
@@ -111,6 +106,7 @@ function CollectionDescription({ collection }: Props) {
maxLength={1000}
embedsDisabled
readOnlyWriteCheckboxes
grow
/>
</React.Suspense>
) : (
+1 -4
View File
@@ -5,7 +5,6 @@ import * as React from "react";
import Collection from "~/models/Collection";
import { icons } from "~/components/IconPicker";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
type Props = {
collection: Collection;
@@ -37,9 +36,7 @@ function ResolvedCollectionIcon({
const Component = icons[collection.icon].component;
return <Component color={color} size={size} />;
} catch (error) {
Logger.warn("Failed to render custom icon", {
icon: collection.icon,
});
console.warn("Failed to render custom icon " + collection.icon);
}
}
+25 -56
View File
@@ -1,32 +1,25 @@
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import { QuestionMarkIcon } from "outline-icons";
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 } from "@shared/styles";
import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useSettingsActions from "~/hooks/useSettingsAction";
import useStores from "~/hooks/useStores";
import { CommandBarAction } from "~/types";
import { metaDisplay } from "~/utils/keyboard";
import Text from "./Text";
export const CommandBarOptions = {
animations: {
enterMs: 250,
exitMs: 200,
},
};
function CommandBar() {
const { t } = useTranslation();
const { ui } = useStores();
const settingsActions = useSettingsActions();
const commandBarActions = React.useMemo(
() => [...rootActions, settingsActions],
[settingsActions]
);
useCommandBarActions(commandBarActions);
useCommandBarActions(rootActions);
const { rootAction } = useKBar((state) => ({
rootAction: state.currentRootActionId
@@ -37,38 +30,24 @@ function CommandBar() {
}));
return (
<>
<SearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<CommandBarResults />
{ui.commandBarOpenedFromSidebar && (
<Hint size="small" type="tertiary">
<QuestionMarkIcon size={18} color="currentColor" />
{t(
"Open search from anywhere with the {{ shortcut }} shortcut",
{
shortcut: `${metaDisplay} + k`,
}
)}
</Hint>
)}
</Animator>
</Positioner>
</KBarPortal>
</>
<KBarPortal>
<Positioner>
<Animator>
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
);
}
const KBarPortal: React.FC = ({ children }) => {
function KBarPortal({ children }: { children: React.ReactNode }) {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
@@ -78,20 +57,10 @@ const KBarPortal: React.FC = ({ children }) => {
}
return <Portal>{children}</Portal>;
};
const Hint = styled(Text)`
display: flex;
align-items: center;
gap: 4px;
border-top: 1px solid ${(props) => props.theme.background};
margin: 1px 0 0;
padding: 6px 16px;
width: 100%;
`;
}
const Positioner = styled(KBarPositioner)`
z-index: ${depths.commandBar};
z-index: ${(props) => props.theme.depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
-61
View File
@@ -1,61 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = {
/** Callback when the dialog is submitted */
onSubmit: () => Promise<void> | void;
/** Text to display on the submit button */
submitText?: string;
/** Text to display while the form is saving */
savingText?: string;
/** If true, the submit button will be a dangerous red */
danger?: boolean;
};
const ConfirmationDialog: React.FC<Props> = ({
onSubmit,
children,
submitText,
savingText,
danger,
}) => {
const [isSaving, setIsSaving] = React.useState(false);
const { dialogs } = useStores();
const { showToast } = useToasts();
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await onSubmit();
dialogs.closeAllModals();
} catch (err) {
showToast(err.message, {
type: "error",
});
} finally {
setIsSaving(false);
}
},
[onSubmit, dialogs, showToast]
);
return (
<Flex column>
<form onSubmit={handleSubmit}>
<Text type="secondary">{children}</Text>
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
{isSaving ? savingText : submitText}
</Button>
</form>
</Flex>
);
};
export default observer(ConfirmationDialog);
+11 -82
View File
@@ -1,7 +1,6 @@
import isPrintableKeyEvent from "is-printable-key-event";
import * as React from "react";
import styled from "styled-components";
import useOnScreen from "~/hooks/useOnScreen";
type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
disabled?: boolean;
@@ -18,13 +17,6 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
value: string;
};
export type RefHandle = {
focus: () => void;
focusAtStart: () => void;
focusAtEnd: () => void;
getComputedDirection: () => string;
};
/**
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
@@ -48,36 +40,13 @@ const ContentEditable = React.forwardRef(
onClick,
...rest
}: Props,
ref: React.RefObject<RefHandle>
forwardedRef: React.RefObject<HTMLSpanElement>
) => {
const contentRef = React.useRef<HTMLSpanElement>(null);
const innerRef = React.useRef<HTMLSpanElement>(null);
const ref = forwardedRef || innerRef;
const [innerValue, setInnerValue] = React.useState<string>(value);
const lastValue = React.useRef("");
React.useImperativeHandle(ref, () => ({
focus: () => {
contentRef.current?.focus();
},
focusAtStart: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, true);
}
},
focusAtEnd: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, false);
}
},
getComputedDirection: () => {
if (contentRef.current) {
return window.getComputedStyle(contentRef.current).direction;
}
return "ltr";
},
}));
const wrappedEvent = (
callback:
| React.FocusEventHandler<HTMLSpanElement>
@@ -85,7 +54,7 @@ const ContentEditable = React.forwardRef(
| React.KeyboardEventHandler<HTMLSpanElement>
| undefined
) => (event: any) => {
const text = contentRef.current?.innerText || "";
const text = ref.current?.innerText || "";
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
event?.preventDefault();
@@ -100,44 +69,26 @@ const ContentEditable = React.forwardRef(
callback?.(event);
};
// This is to account for being within a React.Suspense boundary, in this
// case the component may be rendered with display: none. React 18 may solve
// this in the future by delaying useEffect hooks:
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
const isVisible = useOnScreen(contentRef);
React.useEffect(() => {
if (autoFocus && isVisible && !disabled && !readOnly) {
contentRef.current?.focus();
React.useLayoutEffect(() => {
if (autoFocus) {
ref.current?.focus();
}
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
}, [autoFocus, ref]);
React.useEffect(() => {
if (value !== contentRef.current?.innerText) {
if (value !== ref.current?.innerText) {
setInnerValue(value);
}
}, [value, contentRef]);
// Ensure only plain text can be pasted into title when pasting from another
// rich text editor
const handlePaste = React.useCallback(
(event: React.ClipboardEvent<HTMLSpanElement>) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
window.document.execCommand("insertText", false, text);
},
[]
);
}, [value, ref]);
return (
<div className={className} dir={dir} onClick={onClick}>
<Content
ref={contentRef}
ref={ref}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste}
data-placeholder={placeholder}
suppressContentEditableWarning
role="textbox"
@@ -151,29 +102,7 @@ const ContentEditable = React.forwardRef(
}
);
function placeCaret(element: HTMLElement, atStart: boolean) {
if (
typeof window.getSelection !== "undefined" &&
typeof document.createRange !== "undefined"
) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(atStart);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
const Content = styled.span`
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
color: ${(props) => props.theme.text};
-webkit-text-fill-color: ${(props) => props.theme.text};
outline: none;
resize: none;
cursor: text;
&:empty {
display: inline-block;
}
+16 -22
View File
@@ -3,10 +3,12 @@ import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { hover } from "~/styles";
import MenuIconWrapper from "../MenuIconWrapper";
type Props = {
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
onClick?: (arg0: React.SyntheticEvent) => void | Promise<void>;
children?: React.ReactNode;
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
@@ -19,7 +21,7 @@ type Props = {
icon?: React.ReactElement;
};
const MenuItem: React.FC<Props> = ({
const MenuItem = ({
onClick,
children,
selected,
@@ -28,7 +30,7 @@ const MenuItem: React.FC<Props> = ({
hide,
icon,
...rest
}) => {
}: Props) => {
const handleClick = React.useCallback(
(ev) => {
if (onClick) {
@@ -91,14 +93,11 @@ const Spacer = styled.svg`
flex-shrink: 0;
`;
type MenuAnchorProps = {
export const MenuAnchorCSS = css<{
level?: number;
disabled?: boolean;
dangerous?: boolean;
disclosure?: boolean;
};
export const MenuAnchorCSS = css<MenuAnchorProps>`
}>`
display: flex;
margin: 0;
border: 0;
@@ -115,7 +114,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
cursor: default;
user-select: none;
white-space: nowrap;
position: relative;
svg:not(:last-child) {
margin-right: 4px;
@@ -131,26 +129,22 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
? "pointer-events: none;"
: `
@media (hover: hover) {
&:hover,
&:focus,
&.focus-visible {
color: ${props.theme.white};
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
box-shadow: none;
cursor: pointer;
&:${hover},
&:focus,
&.focus-visible {
color: ${props.theme.white};
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
box-shadow: none;
cursor: pointer;
svg {
fill: ${props.theme.white};
}
svg {
fill: ${props.theme.white};
}
}
`};
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`};
`;
@@ -1,66 +0,0 @@
import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
type Positions = {
/* Sub-menu x */
x: number;
/* Sub-menu y */
y: number;
/* Sub-menu height */
h: number;
/* Sub-menu width */
w: number;
/* Mouse x */
mouseX: number;
/* Mouse y */
mouseY: number;
};
/**
* Component to cover the area between the mouse cursor and the sub-menu, to
* allow moving cursor to lower parts of sub-menu without the sub-menu
* disappearing.
*/
export default function MouseSafeArea(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const { x = 0, y = 0, height: h = 0, width: w = 0 } =
props.parentRef.current?.getBoundingClientRect() || {};
const [mouseX, mouseY] = useMousePosition();
const positions = { x, y, h, w, mouseX, mouseY };
return (
<div
style={{
position: "absolute",
top: 0,
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
right: getRight(positions),
left: getLeft(positions),
height: h,
width: getWidth(positions),
clipPath: getClipPath(positions),
}}
/>
);
}
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? Math.max(mouseX - (x + w), 10) + "px"
: Math.max(x - mouseX, 10) + "px";
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
mouseX > x
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
(100 * (mouseY - y)) / h + 5
}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
export default function Separator(rest: any) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
+24 -24
View File
@@ -21,7 +21,6 @@ import {
} from "~/types";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
import Separator from "./Separator";
import ContextMenu from ".";
@@ -54,13 +53,12 @@ const Submenu = React.forwardRef(
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
<MenuAnchor {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Submenu")}>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
@@ -69,27 +67,29 @@ const Submenu = React.forwardRef(
);
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator if the previous item was a separator
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as TMenuItem[])
.filter((item, index, arr) => {
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unnecessary separators
filtered = filtered.reduce((acc, item, index) => {
// trim separators from start / end
if (item.type === "separator" && index === 0) {
return acc;
}
if (item.type === "separator" && index === filtered.length - 1) {
return acc;
}
// trim double separators looking ahead / behind
const prev = filtered[index - 1];
if (prev && prev.type === "separator" && item.type === "separator") {
return acc;
}
// otherwise, continue
return [...acc, item];
}, []);
return filtered;
}
function Template({ items, actions, context, ...menu }: Props) {
+19 -67
View File
@@ -1,17 +1,10 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
import {
fadeIn,
fadeAndSlideUp,
@@ -41,70 +34,36 @@ type Props = {
visible?: boolean;
placement?: Placement;
animating?: boolean;
children: React.ReactNode;
unstable_disclosureRef?: React.RefObject<HTMLElement | null>;
onOpen?: () => void;
onClose?: () => void;
hide?: () => void;
};
const ContextMenu: React.FC<Props> = ({
export default function ContextMenu({
children,
onOpen,
onClose,
...rest
}) => {
}: Props) {
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
const backgroundRef = React.useRef<HTMLDivElement>(null);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
useUnmount(() => {
setIsMenuOpen(false);
});
React.useEffect(() => {
if (rest.visible && !previousVisible) {
if (onOpen) {
onOpen();
}
if (rest["aria-label"] !== t("Submenu")) {
setIsMenuOpen(true);
}
}
if (!rest.visible && previousVisible) {
if (onClose) {
onClose();
}
if (rest["aria-label"] !== t("Submenu")) {
setIsMenuOpen(false);
}
}
}, [
onOpen,
onClose,
previousVisible,
rest.visible,
ui.sidebarCollapsed,
setIsMenuOpen,
rest,
t,
]);
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (rest.visible && scrollElement) {
disableBodyScroll(scrollElement);
}
return () => {
scrollElement && enableBodyScroll(scrollElement);
};
}, [rest.visible]);
}, [onOpen, onClose, previousVisible, rest.visible]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
@@ -115,7 +74,7 @@ const ContextMenu: React.FC<Props> = ({
// trigger and the bottom of the window
return (
<>
<Menu hideOnClickOutside preventBodyScroll={false} {...rest}>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
@@ -131,7 +90,6 @@ const ContextMenu: React.FC<Props> = ({
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
@@ -153,9 +111,7 @@ const ContextMenu: React.FC<Props> = ({
)}
</>
);
};
export default ContextMenu;
}
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
@@ -165,7 +121,7 @@ export const Backdrop = styled.div`
right: 0;
bottom: 0;
background: ${(props) => props.theme.backdrop};
z-index: ${depths.menu - 1};
z-index: ${(props) => props.theme.depths.menu - 1};
${breakpoint("tablet")`
display: none;
@@ -174,12 +130,10 @@ export const Backdrop = styled.div`
export const Position = styled.div`
position: absolute;
z-index: ${depths.menu};
z-index: ${(props) => props.theme.depths.menu};
/*
* overrides make mobile-first coding style challenging
* so we explicitly define mobile breakpoint here
*/
// overrides make mobile-first coding style challenging
// so we explicitly define mobile breakpoint here
${breakpoint("mobile", "tablet")`
position: fixed !important;
transform: none !important;
@@ -190,13 +144,10 @@ export const Position = styled.div`
`};
`;
type BackgroundProps = {
export const Background = styled.div<{
topAnchor?: boolean;
rightAnchor?: boolean;
theme: DefaultTheme;
};
export const Background = styled(Scrollable)<BackgroundProps>`
}>`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
@@ -205,6 +156,8 @@ export const Background = styled(Scrollable)<BackgroundProps>`
padding: 6px 0;
min-width: 180px;
min-height: 44px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;
pointer-events: all;
font-weight: normal;
@@ -214,12 +167,11 @@ export const Background = styled(Scrollable)<BackgroundProps>`
}
${breakpoint("tablet")`
animation: ${(props: BackgroundProps) =>
animation: ${(props: any) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
transform-origin: ${(props: any) => (props.rightAnchor ? "75%" : "25%")} 0;
max-width: 276px;
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
background: ${(props: any) => props.theme.menuBackground};
box-shadow: ${(props: any) => props.theme.menuShadow};
`};
`;
+5 -5
View File
@@ -1,12 +1,11 @@
import copy from "copy-to-clipboard";
import * as React from "react";
import env from "~/env";
type Props = {
text: string;
children?: React.ReactElement;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
onCopy?: () => void;
onCopy: () => void;
};
class CopyToClipboard extends React.PureComponent<Props> {
@@ -15,11 +14,12 @@ class CopyToClipboard extends React.PureComponent<Props> {
const elem = React.Children.only(children);
copy(text, {
debug: env.ENVIRONMENT !== "production",
debug: process.env.NODE_ENV !== "production",
format: "text/plain",
});
onCopy?.();
if (onCopy) {
onCopy();
}
if (elem && elem.props && typeof elem.props.onClick === "function") {
elem.props.onClick(ev);
@@ -1,7 +1,6 @@
import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Optional } from "utility-types";
import CollectionIcon from "~/components/CollectionIcon";
import Flex from "~/components/Flex";
import InputSelect from "~/components/InputSelect";
@@ -9,9 +8,7 @@ import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
> & {
type DefaultCollectionInputSelectProps = {
onSelectCollection: (collection: string) => void;
defaultCollectionId: string | null;
};
@@ -19,7 +16,6 @@ type DefaultCollectionInputSelectProps = Optional<
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
...rest
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
@@ -92,11 +88,14 @@ const DefaultCollectionInputSelect = ({
return (
<InputSelect
value={defaultCollectionId ?? "home"}
label={t("Start view")}
options={options}
onChange={onSelectCollection}
ariaLabel={t("Default collection")}
note={t(
"This is the screen that team members will first see when they sign in."
)}
short
{...rest}
/>
);
};
-1
View File
@@ -22,7 +22,6 @@ function Dialogs() {
<Modal
key={id}
isOpen={modal.isOpen}
isCentered={modal.isCentered}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
>
+2 -5
View File
@@ -12,6 +12,7 @@ import { collectionUrl } from "~/utils/routeHelpers";
type Props = {
document: Document;
children?: React.ReactNode;
onlyText?: boolean;
};
@@ -48,11 +49,7 @@ function useCategory(document: Document): MenuInternalLink | null {
return null;
}
const DocumentBreadcrumb: React.FC<Props> = ({
document,
children,
onlyText,
}) => {
const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
+1 -1
View File
@@ -108,7 +108,7 @@ function DocumentCard(props: Props) {
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<PinButton onClick={handleUnpin}>
<CloseIcon color="currentColor" />
</PinButton>
</Tooltip>
-1
View File
@@ -72,7 +72,6 @@ function DocumentHistory() {
</Header>
<Scrollable topShadow>
<PaginatedEventList
aria-label={t("History")}
fetch={events.fetchPage}
events={items}
options={{
+28
View File
@@ -0,0 +1,28 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import * as React from "react";
import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
type Props = {
documents: Document[];
limit?: number;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
export default function DocumentList({ limit, documents, ...rest }: Props) {
const items = limit ? documents.splice(0, limit) : documents;
return (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.map((document) => (
<DocumentListItem key={document.id} document={document} {...rest} />
))}
</ArrowKeyNavigation>
);
}
+7 -24
View File
@@ -3,7 +3,6 @@ import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document";
@@ -13,13 +12,12 @@ import DocumentMeta from "~/components/DocumentMeta";
import EventBoundary from "~/components/EventBoundary";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -34,8 +32,7 @@ type Props = {
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
} & CompositeStateReturn;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
@@ -49,6 +46,7 @@ function DocumentListItem(
ref: React.RefObject<HTMLAnchorElement>
) {
const { t } = useTranslation();
const { policies } = useStores();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
@@ -63,22 +61,19 @@ function DocumentListItem(
showTemplate,
highlight,
context,
...rest
} = props;
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = usePolicy(currentTeam.id);
const canCollection = usePolicy(document.collectionId);
const can = policies.abilities(currentTeam.id);
const canCollection = policies.abilities(document.collectionId);
return (
<CompositeItem
as={DocumentLink}
<DocumentLink
ref={ref}
dir={document.dir}
role="menuitem"
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
@@ -87,7 +82,6 @@ function DocumentListItem(
title: document.titleWithDefault,
},
}}
{...rest}
>
<Content>
<Heading dir={document.dir}>
@@ -161,7 +155,7 @@ function DocumentListItem(
modal={false}
/>
</Actions>
</CompositeItem>
</DocumentLink>
);
}
@@ -178,13 +172,6 @@ const Actions = styled(EventBoundary)`
flex-shrink: 0;
flex-grow: 0;
${NudeButton} {
&:hover,
&[aria-expanded="true"] {
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
}
${breakpoint("tablet")`
display: flex;
`};
@@ -202,10 +189,6 @@ const DocumentLink = styled(Link)<{
max-height: 50vh;
width: calc(100vw - 8px);
&:focus-visible {
outline: none;
}
${breakpoint("tablet")`
width: auto;
`};
+11 -28
View File
@@ -35,10 +35,11 @@ type Props = {
showLastViewed?: boolean;
showParentDocuments?: boolean;
document: Document;
children?: React.ReactNode;
to?: string;
};
const DocumentMeta: React.FC<Props> = ({
function DocumentMeta({
showPublished,
showCollection,
showLastViewed,
@@ -47,7 +48,7 @@ const DocumentMeta: React.FC<Props> = ({
children,
to,
...rest
}) => {
}: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const user = useCurrentUser();
@@ -73,61 +74,42 @@ const DocumentMeta: React.FC<Props> = ({
const collection = collections.get(document.collectionId);
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
const userName = updatedBy.name;
let content;
if (deletedAt) {
content = (
<span>
{lastUpdatedByCurrentUser
? t("You deleted")
: t("{{ userName }} deleted", { userName })}{" "}
<Time dateTime={deletedAt} addSuffix />
{t("deleted")} <Time dateTime={deletedAt} addSuffix />
</span>
);
} else if (archivedAt) {
content = (
<span>
{lastUpdatedByCurrentUser
? t("You archived")
: t("{{ userName }} archived", { userName })}{" "}
<Time dateTime={archivedAt} addSuffix />
{t("archived")} <Time dateTime={archivedAt} addSuffix />
</span>
);
} else if (createdAt === updatedAt) {
content = (
<span>
{lastUpdatedByCurrentUser
? t("You created")
: t("{{ userName }} created", { userName })}{" "}
<Time dateTime={updatedAt} addSuffix />
{t("created")} <Time dateTime={updatedAt} addSuffix />
</span>
);
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
content = (
<span>
{lastUpdatedByCurrentUser
? t("You published")
: t("{{ userName }} published", { userName })}{" "}
<Time dateTime={publishedAt} addSuffix />
{t("published")} <Time dateTime={publishedAt} addSuffix />
</span>
);
} else if (isDraft) {
content = (
<span>
{lastUpdatedByCurrentUser
? t("You saved")
: t("{{ userName }} saved", { userName })}{" "}
<Time dateTime={updatedAt} addSuffix />
{t("saved")} <Time dateTime={updatedAt} addSuffix />
</span>
);
} else {
content = (
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
{lastUpdatedByCurrentUser
? t("You updated")
: t("{{ userName }} updated", { userName })}{" "}
<Time dateTime={updatedAt} addSuffix />
{t("updated")} <Time dateTime={updatedAt} addSuffix />
</Modified>
);
}
@@ -162,6 +144,7 @@ const DocumentMeta: React.FC<Props> = ({
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
@@ -189,6 +172,6 @@ const DocumentMeta: React.FC<Props> = ({
{children}
</Container>
);
};
}
export default observer(DocumentMeta);
+2 -2
View File
@@ -1,4 +1,4 @@
import { observer, useObserver } from "mobx-react";
import { useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
@@ -83,4 +83,4 @@ const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
}
`;
export default observer(DocumentMetaWithViews);
export default DocumentMetaWithViews;
+1 -2
View File
@@ -1,4 +1,3 @@
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, TFunction } from "react-i18next";
@@ -61,4 +60,4 @@ const Done = styled(DoneIcon)<{ $animated: boolean }>`
transform-origin: center center;
`;
export default observer(DocumentTasks);
export default DocumentTasks;
@@ -1,52 +0,0 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { documentUrl } from "~/utils/routeHelpers";
type Props = {
documentId: string;
};
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { showToast } = useToasts();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize();
if (template) {
history.push(documentUrl(template));
showToast(t("Template created, go ahead and customize it"), {
type: "info",
});
}
}, [document, showToast, history, t]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);
+2 -3
View File
@@ -4,7 +4,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
@@ -41,9 +40,8 @@ function DocumentViews({ document, isOpen }: Props) {
<>
{isOpen && (
<PaginatedList
aria-label={t("Viewers")}
items={users}
renderItem={(item: User) => {
renderItem={(item) => {
const view = documentViews.find((v) => v.user.id === item.id);
const isPresent = presentIds.includes(item.id);
const isEditing = editingIds.includes(item.id);
@@ -63,6 +61,7 @@ function DocumentViews({ document, isOpen }: Props) {
subtitle={subtitle}
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
border={false}
compact
small
/>
);
+22 -208
View File
@@ -1,32 +1,17 @@
import { formatDistanceToNow } from "date-fns";
import { deburr, sortBy } from "lodash";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import mergeRefs from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import embeds from "@shared/editor/embeds";
import { Heading } from "@shared/editor/lib/getHeadings";
import { supportedImageMimeTypes } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
import HoverPreview from "~/components/HoverPreview";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import { Props as EditorProps } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { uploadFile } from "~/utils/uploadFile";
import { isHash } from "~/utils/urls";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
const LazyLoadedEditor = React.lazy(
const SharedEditor = React.lazy(
() =>
import(
/* webpackChunkName: "shared-editor" */
@@ -36,95 +21,21 @@ const LazyLoadedEditor = React.lazy(
export type Props = Optional<
EditorProps,
| "placeholder"
| "defaultValue"
| "onClickLink"
| "embeds"
| "dictionary"
| "onShowToast"
| "extensions"
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "dictionary"
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
grow?: boolean;
onHeadingsChange?: (headings: Heading[]) => void;
onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => any;
};
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onHeadingsChange } = props;
const { documents } = useStores();
function Editor(props: Props, ref: React.Ref<any>) {
const { id, shareId } = props;
const { showToast } = useToasts();
const dictionary = useDictionary();
const [
activeLinkEvent,
setActiveLinkEvent,
] = React.useState<MouseEvent | null>(null);
const previousHeadings = React.useRef<Heading[] | null>(null);
const handleLinkActive = React.useCallback((event: MouseEvent) => {
setActiveLinkEvent(event);
return false;
}, []);
const handleLinkInactive = React.useCallback(() => {
setActiveLinkEvent(null);
}, []);
const handleSearchLink = React.useCallback(
async (term: string) => {
if (isInternalUrl(term)) {
// search for exact internal document
const slug = parseDocumentSlug(term);
if (!slug) {
return [];
}
try {
const document = await documents.fetch(slug);
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
addSuffix: true,
});
return [
{
title: document.title,
subtitle: `Updated ${time}`,
url: document.url,
},
];
} catch (error) {
// NotFoundError could not find document for slug
if (!(error instanceof NotFoundError)) {
throw error;
}
}
}
// default search for anything that doesn't look like a URL
const results = await documents.searchTitles(term);
return sortBy(
results.map((document: Document) => {
return {
title: document.title,
subtitle: <DocumentBreadcrumb document={document} onlyText />,
url: document.url,
};
}),
(document) =>
deburr(document.title)
.toLowerCase()
.startsWith(deburr(term).toLowerCase())
? -1
: 1
);
},
[documents]
);
const onUploadFile = React.useCallback(
const onUploadImage = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
documentId: id,
@@ -168,123 +79,26 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[shareId]
);
const focusAtEnd = React.useCallback(() => {
ref?.current?.focusAtEnd();
}, [ref]);
const handleDrop = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const files = getDataTransferFiles(event);
const view = ref?.current?.view;
if (!view) {
return;
}
// Insert all files as attachments if any of the files are not images.
const isAttachment = files.some(
(file) => !supportedImageMimeTypes.includes(file.type)
);
// Find a valid position at the end of the document
const pos = TextSelection.near(
view.state.doc.resolve(view.state.doc.nodeSize - 2)
).from;
insertFiles(view, event, pos, files, {
uploadFile: onUploadFile,
onFileUploadStart: props.onFileUploadStart,
onFileUploadStop: props.onFileUploadStop,
onShowToast: showToast,
dictionary,
isAttachment,
});
const onShowToast = React.useCallback(
(message: string) => {
showToast(message);
},
[
ref,
props.onFileUploadStart,
props.onFileUploadStop,
dictionary,
onUploadFile,
showToast,
]
);
// see: https://stackoverflow.com/a/50233827/192065
const handleDragOver = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
},
[]
);
// Calculate if headings have changed and trigger callback if so
const updateHeadings = React.useCallback(() => {
if (onHeadingsChange) {
const headings = ref?.current?.getHeadings();
if (
headings &&
headings.map((h) => h.level + h.title).join("") !==
previousHeadings.current?.map((h) => h.level + h.title).join("")
) {
previousHeadings.current = headings;
onHeadingsChange(headings);
}
}
}, [ref, onHeadingsChange]);
const handleChange = React.useCallback(
(event) => {
onChange?.(event);
updateHeadings();
},
[onChange, updateHeadings]
);
const handleRefChanged = React.useCallback(
(node: SharedEditor | null) => {
if (node && !previousHeadings.current) {
updateHeadings();
}
},
[updateHeadings]
[showToast]
);
return (
<ErrorBoundary reloadOnChunkMissing>
<>
<LazyLoadedEditor
ref={mergeRefs([ref, handleRefChanged])}
uploadFile={onUploadFile}
onShowToast={showToast}
embeds={embeds}
dictionary={dictionary}
{...props}
onHoverLink={handleLinkActive}
onClickLink={onClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.grow && !props.readOnly && (
<ClickablePadding
onClick={focusAtEnd}
onDrop={handleDrop}
onDragOver={handleDragOver}
grow
/>
)}
{activeLinkEvent && !shareId && (
<HoverPreview
node={activeLinkEvent.target as HTMLAnchorElement}
event={activeLinkEvent}
onClose={handleLinkInactive}
/>
)}
</>
<SharedEditor
ref={ref}
uploadImage={onUploadImage}
onShowToast={onShowToast}
embeds={embeds}
dictionary={dictionary}
{...props}
onClickLink={onClickLink}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
</ErrorBoundary>
);
}
-32
View File
@@ -1,32 +0,0 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
/* The emoji to render */
emoji: string;
/* The size of the emoji, 24px is default to match standard icons */
size?: number;
};
/**
* EmojiIcon is a component that renders an emoji in the size of a standard icon
* in a way that can be used wherever an Icon would be.
*/
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
return (
<Span $size={size} {...rest}>
{emoji}
</Span>
);
}
const Span = styled.span<{ $size: number }>`
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px;
text-indent: -0.15em;
font-size: 14px;
`;
+7 -4
View File
@@ -1,3 +1,4 @@
import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
@@ -9,10 +10,9 @@ import CenteredContent from "~/components/CenteredContent";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
import Logger from "~/utils/Logger";
import isCloudHosted from "~/utils/isCloudHosted";
type Props = WithTranslation & {
children: React.ReactNode;
reloadOnChunkMissing?: boolean;
};
@@ -26,6 +26,7 @@ class ErrorBoundary extends React.Component<Props> {
componentDidCatch(error: Error) {
this.error = error;
console.error(error);
if (
this.props.reloadOnChunkMissing &&
@@ -39,7 +40,9 @@ class ErrorBoundary extends React.Component<Props> {
return;
}
Logger.error("ErrorBoundary", error);
if (env.SENTRY_DSN) {
Sentry.captureException(error);
}
}
handleReload = () => {
@@ -59,7 +62,7 @@ class ErrorBoundary extends React.Component<Props> {
if (this.error) {
const error = this.error;
const isReported = !!env.SENTRY_DSN && isCloudHosted;
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
const isChunkError = this.error.message.match(/chunk/);
if (isChunkError) {
+3 -4
View File
@@ -1,10 +1,11 @@
import * as React from "react";
type Props = {
children: React.ReactNode;
className?: string;
};
const EventBoundary: React.FC<Props> = ({ children, className }) => {
export default function EventBoundary({ children, className }: Props) {
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
event.preventDefault();
event.stopPropagation();
@@ -15,6 +16,4 @@ const EventBoundary: React.FC<Props> = ({ children, className }) => {
{children}
</span>
);
};
export default EventBoundary;
}
+10 -54
View File
@@ -5,22 +5,17 @@ import {
PublishIcon,
MoveIcon,
CheckboxIcon,
UnpublishIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { CompositeStateReturn } from "reakit/Composite";
import styled, { css } from "styled-components";
import styled from "styled-components";
import Document from "~/models/Document";
import Event from "~/models/Event";
import Avatar from "~/components/Avatar";
import CompositeItem, {
Props as ItemProps,
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import { documentHistoryUrl } from "~/utils/routeHelpers";
@@ -28,25 +23,19 @@ type Props = {
document: Document;
event: Event;
latest?: boolean;
} & CompositeStateReturn;
};
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const EventListItem = ({ event, latest, document }: Props) => {
const { t } = useTranslation();
const { policies } = useStores();
const location = useLocation();
const can = usePolicy(document.id);
const can = policies.abilities(document.id);
const opts = {
userName: event.actor.name,
};
const isRevision = event.name === "revisions.create";
let meta, icon, to;
const ref = React.useRef<HTMLAnchorElement>(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
const handleTimeClick = React.useCallback(() => {
ref.current?.focus();
}, [ref]);
switch (event.name) {
case "revisions.create":
case "documents.latest_version": {
@@ -86,11 +75,6 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
meta = t("{{userName}} published", opts);
break;
case "documents.unpublish":
icon = <UnpublishIcon color="currentColor" size={16} />;
meta = t("{{userName}} unpublished", opts);
break;
case "documents.move":
icon = <MoveIcon color="currentColor" size={16} />;
meta = t("{{userName}} moved", opts);
@@ -106,26 +90,18 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const isActive = location.pathname === to;
if (document.isDeleted) {
to = undefined;
}
return (
<BaseItem
<ListItem
small
exact
to={to}
to={document.isDeleted ? undefined : to}
title={
<Time
dateTime={event.createdAt}
tooltipDelay={500}
format={{
en_US: "MMM do, h:mm a",
fr_FR: "'Le 'd MMMM 'à' H:mm",
}}
format="MMM do, h:mm a"
relative={false}
addSuffix
onClick={handleTimeClick}
/>
}
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
@@ -140,22 +116,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
ref={ref}
{...rest}
/>
);
};
const BaseItem = React.forwardRef(
({ to, ...rest }: ItemProps, ref?: React.Ref<HTMLAnchorElement>) => {
if (to) {
return <CompositeListItem to={to} ref={ref} {...rest} />;
}
return <ListItem ref={ref} {...rest} />;
}
);
const Subtitle = styled.span`
svg {
margin: -3px;
@@ -163,7 +127,7 @@ const Subtitle = styled.span`
}
`;
const ItemStyle = css`
const ListItem = styled(Item)`
border: 0;
position: relative;
margin: 8px;
@@ -209,12 +173,4 @@ const ItemStyle = css`
}
`;
const ListItem = styled(Item)`
${ItemStyle}
`;
const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
export default EventListItem;
+13 -12
View File
@@ -1,9 +1,18 @@
import { CSSProperties } from "react";
import styled from "styled-components";
type JustifyValues = CSSProperties["justifyContent"];
type JustifyValues =
| "center"
| "space-around"
| "space-between"
| "flex-start"
| "flex-end";
type AlignValues = CSSProperties["alignItems"];
type AlignValues =
| "stretch"
| "center"
| "baseline"
| "flex-start"
| "flex-end";
const Flex = styled.div<{
auto?: boolean;
@@ -11,19 +20,11 @@ const Flex = styled.div<{
align?: AlignValues;
justify?: JustifyValues;
shrink?: boolean;
reverse?: boolean;
gap?: number;
}>`
display: flex;
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
flex-direction: ${({ column, reverse }) =>
reverse
? column
? "column-reverse"
: "row-reverse"
: column
? "column"
: "row"};
flex-direction: ${({ column }) => (column ? "column" : "row")};
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
+1 -1
View File
@@ -19,7 +19,7 @@ type Props = RootStore & {
membership?: CollectionGroupMembership;
showFacepile?: boolean;
showAvatar?: boolean;
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
renderActions: (arg0: { openMembersModal: () => void }) => React.ReactNode;
};
@observer
+7 -6
View File
@@ -1,23 +1,24 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled from "styled-components";
import { depths } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import usePrevious from "~/hooks/usePrevious";
type Props = {
children?: React.ReactNode;
isOpen: boolean;
title?: string;
onRequestClose: () => void;
};
const Guide: React.FC<Props> = ({
const Guide = ({
children,
isOpen,
title = "Untitled",
onRequestClose,
...rest
}) => {
}: Props) => {
const dialog = useDialogState({
animated: 250,
});
@@ -72,7 +73,7 @@ const Backdrop = styled.div`
right: 0;
bottom: 0;
background-color: ${(props) => props.theme.backdrop} !important;
z-index: ${depths.modalOverlay};
z-index: ${(props) => props.theme.depths.modalOverlay};
transition: opacity 200ms ease-in-out;
opacity: 0;
@@ -87,7 +88,7 @@ const Scene = styled.div`
right: 0;
bottom: 0;
margin: 12px;
z-index: ${depths.modal};
z-index: ${(props) => props.theme.depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
@@ -111,4 +112,4 @@ const Content = styled(Scrollable)`
padding: 16px;
`;
export default Guide;
export default observer(Guide);
+12 -12
View File
@@ -5,11 +5,9 @@ import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { supportsPassiveListener } from "~/utils/browser";
@@ -30,17 +28,19 @@ function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
const passThrough = !actions && !breadcrumb && !title;
const [isScrolled, setScrolled] = React.useState(false);
const handleScroll = React.useMemo(
() => throttle(() => setScrolled(window.scrollY > 75), 50),
const handleScroll = React.useCallback(
throttle(() => setScrolled(window.scrollY > 75), 50),
[]
);
useEventListener(
"scroll",
handleScroll,
window,
supportsPassiveListener ? { passive: true } : { capture: false }
);
React.useEffect(() => {
window.addEventListener(
"scroll",
handleScroll,
supportsPassiveListener ? { passive: true } : false
);
return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
const handleClickTitle = React.useCallback(() => {
window.scrollTo({
@@ -100,7 +100,7 @@ const Actions = styled(Flex)`
const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
top: 0;
z-index: ${depths.header};
z-index: ${(props) => props.theme.depths.header};
position: sticky;
background: ${(props) => props.theme.background};
@@ -118,7 +118,7 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
min-height: 64px;
min-height: 56px;
justify-content: flex-start;
@supports (backdrop-filter: blur(20px)) {
+8
View File
@@ -5,6 +5,14 @@ const Heading = styled.h1<{ centered?: boolean }>`
align-items: center;
user-select: none;
${(props) => (props.centered ? "text-align: center;" : "")}
svg {
margin-top: 4px;
margin-left: -6px;
margin-right: 2px;
align-self: flex-start;
flex-shrink: 0;
}
`;
export default Heading;
+3 -4
View File
@@ -1,4 +1,3 @@
import { escapeRegExp } from "lodash";
import * as React from "react";
import replace from "string-replace-to-array";
import styled from "styled-components";
@@ -24,7 +23,7 @@ function Highlight({
regex = highlight;
} else {
regex = new RegExp(
escapeRegExp(highlight || ""),
(highlight || "").replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"),
caseSensitive ? "g" : "gi"
);
}
@@ -42,10 +41,10 @@ function Highlight({
);
}
export const Mark = styled.mark`
const Mark = styled.mark`
background: ${(props) => props.theme.searchHighlight};
border-radius: 2px;
padding: 0 2px;
padding: 0 4px;
`;
export default Highlight;
+4 -11
View File
@@ -2,11 +2,9 @@ import { transparentize } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { depths } from "@shared/styles";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isExternalUrl } from "@shared/utils/urls";
import { isInternalUrl } from "@shared/utils/urls";
import HoverPreviewDocument from "~/components/HoverPreviewDocument";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { fadeAndSlideDown } from "~/styles/animations";
@@ -125,13 +123,8 @@ function HoverPreviewInternal({ node, onClose }: Props) {
}
function HoverPreview({ node, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
}
// previews only work for internal doc links for now
if (isExternalUrl(node.href)) {
if (!isInternalUrl(node.href)) {
return null;
}
@@ -157,7 +150,7 @@ const Margin = styled.div`
const CardContent = styled.div`
overflow: hidden;
max-height: 20em;
max-height: 350px;
user-select: none;
`;
@@ -202,7 +195,7 @@ const Card = styled.div`
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
margin-top: 10px;
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
z-index: ${depths.hoverPreview};
z-index: ${(props) => props.theme.depths.hoverPreview};
display: flex;
max-height: 75%;
+6 -11
View File
@@ -38,13 +38,6 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
color: ${(props) => props.theme.placeholder};
}
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0px 1000px ${(props) => props.theme.background}
inset;
}
&::-webkit-search-cancel-button {
-webkit-appearance: none;
}
@@ -104,7 +97,7 @@ export const LabelText = styled.div`
display: inline-block;
`;
export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
export type Props = {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
value?: string;
label?: string;
@@ -115,7 +108,6 @@ export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
margin?: string | number;
icon?: React.ReactNode;
name?: string;
pattern?: string;
minLength?: number;
maxLength?: number;
autoFocus?: boolean;
@@ -127,7 +119,6 @@ export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
onChange?: (
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
innerRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
@@ -135,7 +126,7 @@ export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
@observer
class Input extends React.Component<Props> {
input = this.props.innerRef;
input = React.createRef<HTMLInputElement | HTMLTextAreaElement>();
@observable
focused = false;
@@ -156,6 +147,10 @@ class Input extends React.Component<Props> {
}
};
focus() {
this.input.current?.focus();
}
render() {
const {
type = "text",
+2 -8
View File
@@ -2,7 +2,7 @@ import { SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Input, { Props as InputProps } from "~/components/Input";
import Input, { Props as InputProps } from "./Input";
type Props = InputProps & {
placeholder?: string;
@@ -11,10 +11,7 @@ type Props = InputProps & {
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
};
function InputSearch(
props: Props,
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
) {
export default function InputSearch(props: Props) {
const { t } = useTranslation();
const theme = useTheme();
const [isFocused, setIsFocused] = React.useState(false);
@@ -42,10 +39,7 @@ function InputSearch(
onBlur={handleBlur}
margin={0}
labelHidden
innerRef={ref}
{...rest}
/>
);
}
export default React.forwardRef(InputSearch);
+5 -9
View File
@@ -7,8 +7,8 @@ import styled, { useTheme } from "styled-components";
import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown";
import { isModKey } from "~/utils/keyboard";
import { searchPath } from "~/utils/routeHelpers";
import Input, { Outline } from "./Input";
import { searchUrl } from "~/utils/routeHelpers";
import Input from "./Input";
type Props = {
source: string;
@@ -30,7 +30,7 @@ function InputSearchPage({
collectionId,
source,
}: Props) {
const inputRef = React.useRef<HTMLInputElement>(null);
const inputRef = React.useRef<Input>(null);
const theme = useTheme();
const history = useHistory();
const { t } = useTranslation();
@@ -51,7 +51,7 @@ function InputSearchPage({
if (ev.key === "Enter") {
ev.preventDefault();
history.push(
searchPath(ev.currentTarget.value, {
searchUrl(ev.currentTarget.value, {
collectionId,
ref: source,
})
@@ -67,7 +67,7 @@ function InputSearchPage({
return (
<InputMaxWidth
innerRef={inputRef}
ref={inputRef}
type="search"
placeholder={placeholder || `${t("Search")}`}
value={value}
@@ -89,10 +89,6 @@ function InputSearchPage({
const InputMaxWidth = styled(Input)`
max-width: 30vw;
${Outline} {
border-radius: 16px;
}
`;
export default observer(InputSearchPage);
+1 -5
View File
@@ -23,8 +23,6 @@ export type Option = {
};
export type Props = {
id?: string;
name?: string;
value?: string | null;
label?: string;
nude?: boolean;
@@ -56,7 +54,6 @@ const InputSelect = (props: Props) => {
disabled,
note,
icon,
...rest
} = props;
const select = useSelectState({
@@ -131,7 +128,7 @@ const InputSelect = (props: Props) => {
wrappedLabel
))}
<Select {...select} disabled={disabled} {...rest} ref={buttonRef}>
<Select {...select} disabled={disabled} ref={buttonRef}>
{(props) => (
<StyledButton
neutral
@@ -232,7 +229,6 @@ const StyledButton = styled(Button)<{ nude?: boolean }>`
margin-bottom: 16px;
display: block;
width: 100%;
cursor: default;
&:hover:not(:disabled) {
background: ${(props) => props.theme.buttonNeutralBackground};
+2 -1
View File
@@ -5,9 +5,10 @@ import Flex from "~/components/Flex";
type Props = {
label: React.ReactNode | string;
children?: React.ReactNode;
};
const Labeled: React.FC<Props> = ({ label, children, ...props }) => (
const Labeled = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
<Label>{label}</Label>
{children}
+2 -2
View File
@@ -10,7 +10,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { detectLanguage } from "~/utils/language";
function Icon({ className }: { className?: string }) {
function Icon(props: any) {
return (
<svg
width="32"
@@ -18,7 +18,7 @@ function Icon({ className }: { className?: string }) {
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
{...props}
>
<path
fill-rule="evenodd"
+8 -11
View File
@@ -1,24 +1,24 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import styled, { DefaultTheme } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "~/components/Flex";
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
import SkipNavContent from "~/components/SkipNavContent";
import SkipNavLink from "~/components/SkipNavLink";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
type Props = {
title?: string;
children?: React.ReactNode;
sidebar?: React.ReactNode;
rightRail?: React.ReactNode;
};
const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
function Layout({ title, children, sidebar, rightRail }: Props) {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed;
@@ -40,7 +40,7 @@ const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
{ui.progressBarVisible && <LoadingIndicatorBar />}
<Container auto>
<MenuProvider>{sidebar}</MenuProvider>
{sidebar}
<SkipNavContent />
<Content
@@ -64,7 +64,7 @@ const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
</Container>
</Container>
);
};
}
const Container = styled(Flex)`
background: ${(props) => props.theme.background};
@@ -74,14 +74,11 @@ const Container = styled(Flex)`
min-height: 100%;
`;
type ContentProps = {
const Content = styled(Flex)<{
$isResizing?: boolean;
$sidebarCollapsed?: boolean;
$hasSidebar?: boolean;
theme: DefaultTheme;
};
const Content = styled(Flex)<ContentProps>`
}>`
margin: 0;
transition: ${(props) =>
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
@@ -95,7 +92,7 @@ const Content = styled(Flex)<ContentProps>`
`}
${breakpoint("tablet")`
${(props: ContentProps) =>
${(props: any) =>
props.$hasSidebar &&
props.$sidebarCollapsed &&
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
-23
View File
@@ -1,23 +0,0 @@
import * as React from "react";
import { loadPolyfills } from "~/utils/polyfills";
/**
* Asyncronously load required polyfills. Should wrap the React tree.
*/
export const LazyPolyfill: React.FC = ({ children }) => {
const [isLoaded, setIsLoaded] = React.useState(false);
React.useEffect(() => {
loadPolyfills().then(() => {
setIsLoaded(true);
});
}, []);
if (!isLoaded) {
return null;
}
return <>{children}</>;
};
export default LazyPolyfill;
-17
View File
@@ -1,17 +0,0 @@
import * as React from "react";
import {
CompositeStateReturn,
CompositeItem as BaseCompositeItem,
} from "reakit/Composite";
import Item, { Props as ItemProps } from "./Item";
export type Props = ItemProps & CompositeStateReturn;
function CompositeItem(
{ to, ...rest }: Props,
ref?: React.Ref<HTMLAnchorElement>
) {
return <BaseCompositeItem as={Item} to={to} {...rest} ref={ref} />;
}
export default React.forwardRef(CompositeItem);
+7 -10
View File
@@ -3,13 +3,14 @@ import styled, { useTheme } from "styled-components";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
export type Props = {
type Props = {
image?: React.ReactNode;
to?: string;
exact?: boolean;
title: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
compact?: boolean;
border?: boolean;
small?: boolean;
};
@@ -49,7 +50,7 @@ const ListItem = (
<Wrapper
ref={ref}
$border={border}
$small={small}
$compact={compact}
activeStyle={{
background: theme.primary,
}}
@@ -63,17 +64,16 @@ const ListItem = (
}
return (
<Wrapper ref={ref} $border={border} $small={small} {...rest}>
<Wrapper $compact={compact} $border={border} {...rest}>
{content(false)}
</Wrapper>
);
};
const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
const Wrapper = styled.div<{ $compact?: boolean; $border?: boolean }>`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) =>
props.$border === false ? (props.$small ? "8px 0" : "16px 0") : 0};
margin: ${(props) => (props.$compact === false ? 0 : "8px 0")};
padding: ${(props) => (props.$compact === false ? "8px 0" : 0)};
border-bottom: 1px solid
${(props) =>
props.$border === false ? "transparent" : props.theme.divider};
@@ -81,8 +81,6 @@ const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
&:last-child {
border-bottom: 0;
}
cursor: ${({ to }) => (to ? "pointer" : "default")};
`;
const Image = styled(Flex)`
@@ -92,7 +90,6 @@ const Image = styled(Flex)`
user-select: none;
flex-shrink: 0;
align-self: center;
color: ${(props) => props.theme.text};
`;
const Heading = styled.p<{ $small?: boolean }>`
+5 -10
View File
@@ -3,24 +3,19 @@ import * as React from "react";
import styled from "styled-components";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import PlaceholderText, {
Props as PlaceholderTextProps,
} from "~/components/PlaceholderText";
import PlaceholderText from "~/components/PlaceholderText";
type Props = {
count?: number;
className?: string;
header?: PlaceholderTextProps;
body?: PlaceholderTextProps;
};
const ListPlaceHolder = ({ count, className, header, body }: Props) => {
const ListPlaceHolder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
<Item key={index} className={className} column auto>
<PlaceholderText {...header} header delay={0.2 * index} />
<PlaceholderText {...body} delay={0.2 * index} />
<Item key={index} column auto>
<PlaceholderText header delay={0.2 * index} />
<PlaceholderText delay={0.2 * index} />
</Item>
))}
</Fade>
@@ -1,6 +1,5 @@
import * as React from "react";
import styled, { keyframes } from "styled-components";
import { depths } from "@shared/styles";
const LoadingIndicatorBar = () => {
return (
@@ -18,7 +17,7 @@ const loadingFrame = keyframes`
const Container = styled.div`
position: fixed;
top: 0;
z-index: ${depths.loadingIndicatorBar};
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
width: 100%;
animation: ${loadingFrame} 4s ease-in-out infinite;
animation-delay: 250ms;
+15 -16
View File
@@ -2,7 +2,7 @@ import { format as formatDate, formatDistanceToNow } from "date-fns";
import * as React from "react";
import Tooltip from "~/components/Tooltip";
import useUserLocale from "~/hooks/useUserLocale";
import { dateLocale, locales } from "~/utils/i18n";
import { dateLocale } from "~/utils/i18n";
let callbacks: (() => void)[] = [];
@@ -22,14 +22,15 @@ function eachMinute(fn: () => void) {
type Props = {
dateTime: string;
children?: React.ReactNode;
tooltipDelay?: number;
addSuffix?: boolean;
shorten?: boolean;
relative?: boolean;
format?: Partial<Record<keyof typeof locales, string>>;
format?: string;
};
const LocaleTime: React.FC<Props> = ({
function LocaleTime({
addSuffix,
children,
dateTime,
@@ -37,14 +38,8 @@ const LocaleTime: React.FC<Props> = ({
format,
relative,
tooltipDelay,
}) => {
const userLocale: string = useUserLocale() || "";
const dateFormatLong = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong = dateFormatLong[userLocale] ?? "MMMM do, yyyy h:mm a";
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
}: Props) {
const userLocale = useUserLocale();
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
@@ -72,13 +67,17 @@ const LocaleTime: React.FC<Props> = ({
.replace("minute", "min");
}
const tooltipContent = formatDate(Date.parse(dateTime), formatLocaleLong, {
locale,
});
const tooltipContent = formatDate(
Date.parse(dateTime),
"MMMM do, yyyy h:mm a",
{
locale,
}
);
const content =
relative !== false
? relativeContent
: formatDate(Date.parse(dateTime), formatLocale, {
: formatDate(Date.parse(dateTime), format || "MMMM do, yyyy h:mm a", {
locale,
});
@@ -87,6 +86,6 @@ const LocaleTime: React.FC<Props> = ({
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
};
}
export default LocaleTime;
+43 -119
View File
@@ -4,39 +4,34 @@ import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled, { DefaultTheme } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useUnmount from "~/hooks/useUnmount";
import { fadeAndScaleIn } from "~/styles/animations";
let openModals = 0;
type Props = {
children?: React.ReactNode;
isOpen: boolean;
isCentered?: boolean;
title?: React.ReactNode;
onRequestClose: () => void;
};
const Modal: React.FC<Props> = ({
const Modal = ({
children,
isOpen,
isCentered,
title = "Untitled",
onRequestClose,
}) => {
}: Props) => {
const dialog = useDialogState({
animated: 250,
});
const [depth, setDepth] = React.useState(0);
const wasOpen = usePrevious(isOpen);
const isMobile = useMobile();
const { t } = useTranslation();
React.useEffect(() => {
@@ -64,66 +59,37 @@ const Modal: React.FC<Props> = ({
return (
<DialogBackdrop {...dialog}>
{(props) => (
<Backdrop $isCentered={isCentered} {...props}>
<Backdrop {...props}>
<Dialog
{...dialog}
aria-label={typeof title === "string" ? title : undefined}
preventBodyScroll
hideOnEsc
hideOnClickOutside={!!isCentered}
hideOnClickOutside={false}
hide={onRequestClose}
>
{(props) =>
isCentered && !isMobile ? (
<Small {...props}>
<Centered
onClick={(ev) => ev.stopPropagation()}
column
reverse
>
<SmallContent shadow>{children}</SmallContent>
<Header>
{title && (
<Text as="span" size="large">
{title}
</Text>
)}
<Text as="span" size="large">
<NudeButton onClick={onRequestClose}>
<CloseIcon color="currentColor" />
</NudeButton>
</Text>
</Header>
{(props) => (
<Scene
$nested={!!depth}
style={{
marginLeft: `${depth * 12}px`,
}}
{...props}
>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Centered>
</Small>
) : (
<Fullscreen
$nested={!!depth}
style={
isMobile
? undefined
: {
marginLeft: `${depth * 12}px`,
}
}
{...props}
>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Centered>
</Content>
<Close onClick={onRequestClose}>
<CloseIcon size={32} color="currentColor" />
</Close>
<Back onClick={onRequestClose}>
<BackIcon size={32} color="currentColor" />
<Text as="span">{t("Back")} </Text>
</Back>
</Fullscreen>
)
}
</Content>
<Back onClick={onRequestClose}>
<BackIcon size={32} color="currentColor" />
<Text>{t("Back")}</Text>
</Back>
<Close onClick={onRequestClose}>
<CloseIcon size={32} color="currentColor" />
</Close>
</Scene>
)}
</Dialog>
</Backdrop>
)}
@@ -131,17 +97,15 @@ const Modal: React.FC<Props> = ({
);
};
const Backdrop = styled(Flex)<{ $isCentered?: boolean }>`
const Backdrop = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: ${(props) =>
props.$isCentered
? props.theme.modalBackdrop
: transparentize(0.25, props.theme.background)} !important;
z-index: ${depths.modalOverlay};
transparentize(0.25, props.theme.background)} !important;
z-index: ${(props) => props.theme.depths.modalOverlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
@@ -150,12 +114,7 @@ const Backdrop = styled(Flex)<{ $isCentered?: boolean }>`
}
`;
type FullscreenProps = {
$nested: boolean;
theme: DefaultTheme;
};
const Fullscreen = styled.div<FullscreenProps>`
const Scene = styled.div<{ $nested: boolean }>`
animation: ${fadeAndScaleIn} 250ms ease;
position: absolute;
@@ -163,7 +122,7 @@ const Fullscreen = styled.div<FullscreenProps>`
left: 0;
right: 0;
bottom: 0;
z-index: ${depths.modal};
z-index: ${(props) => props.theme.depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
@@ -172,7 +131,7 @@ const Fullscreen = styled.div<FullscreenProps>`
outline: none;
${breakpoint("tablet")`
${(props: FullscreenProps) =>
${(props: any) =>
props.$nested &&
`
box-shadow: 0 -2px 10px ${props.theme.shadow};
@@ -184,10 +143,10 @@ const Fullscreen = styled.div<FullscreenProps>`
const Content = styled(Scrollable)`
width: 100%;
padding: 8vh 32px;
padding: 8vh 2rem 2rem;
${breakpoint("tablet")`
padding: 13vh 2rem 2rem;
padding-top: 13vh;
`};
`;
@@ -198,6 +157,13 @@ const Centered = styled(Flex)`
margin: 0 auto;
`;
const Text = styled.span`
font-size: 16px;
font-weight: 500;
padding-right: 12px;
user-select: none;
`;
const Close = styled(NudeButton)`
position: absolute;
display: block;
@@ -226,7 +192,6 @@ const Back = styled(NudeButton)`
left: 2rem;
opacity: 0.75;
color: ${(props) => props.theme.text};
font-weight: 500;
width: auto;
height: auto;
@@ -239,45 +204,4 @@ const Back = styled(NudeButton)`
`};
`;
const Header = styled(Flex)`
color: ${(props) => props.theme.textSecondary};
align-items: center;
justify-content: space-between;
font-weight: 600;
padding: 24px 24px 4px;
`;
const Small = styled.div`
animation: ${fadeAndScaleIn} 250ms ease;
margin: auto auto;
min-width: 350px;
max-width: 30vw;
z-index: ${depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
background: ${(props) => props.theme.modalBackground};
transition: ${(props) => props.theme.backgroundTransition};
box-shadow: ${(props) => props.theme.modalShadow};
border-radius: 8px;
outline: none;
${NudeButton} {
&:hover,
&[aria-expanded="true"] {
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
vertical-align: middle;
}
${Header} {
align-items: start;
}
`;
const SmallContent = styled(Scrollable)`
padding: 12px 24px 24px;
`;
export default observer(Modal);
+4 -13
View File
@@ -1,15 +1,8 @@
import * as React from "react";
import { match, NavLink, Route } from "react-router-dom";
import { NavLink, Route } from "react-router-dom";
type Props = React.ComponentProps<typeof NavLink> & {
children?: (
match:
| match<{
[x: string]: string | undefined;
}>
| boolean
| null
) => React.ReactNode;
children?: (match: any) => React.ReactNode;
exact?: boolean;
activeStyle?: React.CSSProperties;
to: string;
@@ -21,11 +14,9 @@ function NavLinkWithChildrenFunc(
) {
return (
<Route path={to} exact={exact}>
{({ match, location }) => (
{({ match }) => (
<NavLink {...rest} to={to} exact={exact} ref={ref}>
{children
? children(rest.isActive ? rest.isActive(match, location) : match)
: null}
{children ? children(match) : null}
</NavLink>
)}
</Route>
+2 -1
View File
@@ -4,11 +4,12 @@ import Flex from "./Flex";
import Text from "./Text";
type Props = {
children: React.ReactNode;
icon?: JSX.Element;
description?: JSX.Element;
};
const Notice: React.FC<Props> = ({ children, icon, description }) => {
const Notice = ({ children, icon, description }: Props) => {
return (
<Container>
<Flex as="span" gap={8}>
+6 -4
View File
@@ -1,7 +1,11 @@
import * as React from "react";
import Notice from "~/components/Notice";
const AlertNotice: React.FC = ({ children }) => {
export default function AlertNotice({
children,
}: {
children: React.ReactNode;
}) {
return (
<Notice>
<svg
@@ -24,6 +28,4 @@ const AlertNotice: React.FC = ({ children }) => {
{children}
</Notice>
);
};
export default AlertNotice;
}
+5 -11
View File
@@ -1,18 +1,12 @@
import styled from "styled-components";
import ActionButton, {
Props as ActionButtonProps,
} from "~/components/ActionButton";
type Props = ActionButtonProps & {
const Button = styled.button.attrs((props) => ({
type: "type" in props ? props.type : "button",
}))<{
width?: number;
height?: number;
size?: number;
type?: "button" | "submit" | "reset";
};
const StyledNudeButton = styled(ActionButton).attrs((props: Props) => ({
type: "type" in props ? props.type : "button",
}))<Props>`
}>`
width: ${(props) => props.width || props.size || 24}px;
height: ${(props) => props.height || props.size || 24}px;
background: none;
@@ -26,4 +20,4 @@ const StyledNudeButton = styled(ActionButton).attrs((props: Props) => ({
color: inherit;
`;
export default StyledNudeButton;
export default Button;
+1 -1
View File
@@ -16,7 +16,7 @@ const PageTitle = ({ title, favicon }: Props) => {
return (
<Helmet>
<title>
{team?.name ? `${title} - ${team.name}` : `${title} - Outline`}
{team && team.name ? `${title} - ${team.name}` : `${title} - Outline`}
</title>
{favicon ? (
<link rel="shortcut icon" href={favicon} />
+3 -18
View File
@@ -1,12 +1,11 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
import PaginatedList from "~/components/PaginatedList";
type Props = {
documents: Document[];
fetch: (options: any) => Promise<Document[] | undefined>;
fetch: (options: any) => Promise<void>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -23,37 +22,23 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
documents,
fetch,
options,
showParentDocuments,
showCollection,
showPublished,
showTemplate,
showDraft,
...rest
}: Props) {
const { t } = useTranslation();
return (
<PaginatedList
aria-label={t("Documents")}
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Document, _index, compositeProps) => (
renderItem={(item) => (
<DocumentListItem
key={item.id}
document={item}
showPin={!!options?.collectionId}
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
{...compositeProps}
{...rest}
/>
)}
{...rest}
/>
);
});
+10 -13
View File
@@ -8,7 +8,7 @@ import EventListItem from "./EventListItem";
type Props = {
events: Event[];
document: Document;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
fetch: (options: Record<string, any> | null | undefined) => Promise<void>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -29,19 +29,16 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event, index, compositeProps) => {
return (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...compositeProps}
/>
);
}}
renderItem={(item, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...rest}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
/>
);
});
+75 -105
View File
@@ -1,54 +1,42 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { CompositeStateReturn } from "reakit/Composite";
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DelayedMount from "~/components/DelayedMount";
import PlaceholderList from "~/components/List/Placeholder";
import withStores from "~/components/withStores";
import { dateToHeading } from "~/utils/dates";
export interface PaginatedItem {
id: string;
createdAt?: string;
updatedAt?: string;
}
type Props<T> = WithTranslation &
type Props = WithTranslation &
RootStore & {
fetch?: (
options: Record<string, any> | undefined
) => Promise<T[] | undefined> | undefined;
fetch?: (options: Record<string, any> | null | undefined) => Promise<any>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
loading?: React.ReactElement;
items?: T[];
renderItem: (
item: T,
index: number,
compositeProps: CompositeStateReturn
) => React.ReactNode;
items: any[];
renderItem: (arg0: any, index: number) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
@observer
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
class PaginatedList extends React.Component<Props> {
isInitiallyLoaded = this.props.items.length > 0;
@observable
isLoaded = false;
@observable
isFetchingMore = false;
@observable
isFetching = false;
@observable
fetchCounter = 0;
@observable
renderCount: number = DEFAULT_PAGINATION_LIMIT;
@@ -62,7 +50,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
this.fetchResults();
}
componentDidUpdate(prevProps: Props<T>) {
componentDidUpdate(prevProps: Props) {
if (
prevProps.fetch !== this.props.fetch ||
!isEqual(prevProps.options, this.props.options)
@@ -78,6 +66,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
this.renderCount = DEFAULT_PAGINATION_LIMIT;
this.isFetching = false;
this.isFetchingMore = false;
this.isLoaded = false;
};
fetchResults = async () => {
@@ -85,9 +74,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
return;
}
this.isFetching = true;
const counter = ++this.fetchCounter;
const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.fetch({
limit,
offset: this.offset,
@@ -101,12 +88,9 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
}
this.renderCount += limit;
// only the most recent fetch should end the loading state
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
}
this.isLoaded = true;
this.isFetching = false;
this.isFetchingMore = false;
};
@action
@@ -117,9 +101,9 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
}
// If there are already cached results that we haven't yet rendered because
// of lazy rendering then show another page.
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
const leftToRender = this.props.items.length - this.renderCount;
if (leftToRender > 0) {
if (leftToRender > 1) {
this.renderCount += DEFAULT_PAGINATION_LIMIT;
}
@@ -132,81 +116,67 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
};
render() {
const {
items = [],
heading,
auth,
empty = null,
renderHeading,
onEscape,
} = this.props;
const { items, heading, auth, empty, renderHeading } = this.props;
let previousHeading = "";
const showLoading =
this.isFetching &&
!this.isFetchingMore &&
(!items?.length || this.fetchCounter === 0);
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
const showEmpty = !items.length && !showLoading;
const showList =
(this.isLoaded || this.isInitiallyLoaded) && !showLoading && !showEmpty;
return (
<>
{showEmpty && empty}
{showList && (
<>
{heading}
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index);
if (showLoading) {
return (
this.props.loading || (
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
})}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
</>
)}
{showLoading && (
<DelayedMount>
<PlaceholderList count={5} />
</DelayedMount>
)
);
}
if (items?.length === 0) {
return empty;
}
return (
<>
{heading}
<ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
>
{(composite: CompositeStateReturn) => {
let previousHeading = "";
return items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index, composite);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
});
}}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
</>
);
+1 -1
View File
@@ -14,7 +14,7 @@ type Props = {
collection: Collection | null | undefined;
onSuccess?: () => void;
style?: React.CSSProperties;
ref?: (element: React.ElementRef<"div"> | null | undefined) => void;
ref?: (arg0: React.ElementRef<"div"> | null | undefined) => void;
};
@observer
+1 -1
View File
@@ -4,7 +4,7 @@ import { randomInteger } from "@shared/random";
import Flex from "~/components/Flex";
import { pulsate } from "~/styles/animations";
export type Props = {
type Props = {
header?: boolean;
height?: number;
minWidth?: number;
+10 -19
View File
@@ -1,50 +1,41 @@
import * as React from "react";
import { Dialog } from "reakit/Dialog";
import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
import { Popover as ReakitPopover } from "reakit/Popover";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { fadeAndScaleIn } from "~/styles/animations";
type Props = PopoverProps & {
type Props = {
children: React.ReactNode;
width?: number;
shrink?: boolean;
tabIndex?: number;
width?: number;
};
const Popover: React.FC<Props> = ({
children,
shrink,
width = 380,
...rest
}) => {
function Popover({ children, width = 380, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return (
<Dialog {...rest} modal>
<Contents $shrink={shrink}>{children}</Contents>
<Contents>{children}</Contents>
</Dialog>
);
}
return (
<ReakitPopover {...rest}>
<Contents $shrink={shrink} $width={width}>
{children}
</Contents>
<Contents $width={width}>{children}</Contents>
</ReakitPopover>
);
};
}
const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
const Contents = styled.div<{ $width?: number }>`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
background: ${(props) => props.theme.menuBackground};
border-radius: 6px;
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
padding: 12px 24px;
max-height: 50vh;
overflow-y: scroll;
box-shadow: ${(props) => props.theme.menuShadow};
@@ -52,7 +43,7 @@ const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
${breakpoint("mobile", "tablet")`
position: fixed;
z-index: ${depths.menu};
z-index: ${(props: any) => props.theme.depths.menu};
// 50 is a magic number that positions us nicely under the top bar
top: 50px;
+4 -3
View File
@@ -8,12 +8,13 @@ type Props = {
icon?: React.ReactNode;
title?: React.ReactNode;
textTitle?: string;
children: React.ReactNode;
breadcrumb?: React.ReactNode;
actions?: React.ReactNode;
centered?: boolean;
};
const Scene: React.FC<Props> = ({
function Scene({
title,
icon,
textTitle,
@@ -21,7 +22,7 @@ const Scene: React.FC<Props> = ({
breadcrumb,
children,
centered,
}) => {
}: Props) {
return (
<FillWidth>
<PageTitle title={textTitle || title} />
@@ -46,7 +47,7 @@ const Scene: React.FC<Props> = ({
)}
</FillWidth>
);
};
}
const FillWidth = styled.div`
width: 100%;
+2 -17
View File
@@ -3,17 +3,16 @@ import * as React from "react";
import styled from "styled-components";
import useWindowSize from "~/hooks/useWindowSize";
type Props = React.HTMLAttributes<HTMLDivElement> & {
type Props = {
shadow?: boolean;
topShadow?: boolean;
bottomShadow?: boolean;
hiddenScrollbars?: boolean;
flex?: boolean;
children: React.ReactNode;
};
function Scrollable(
{ shadow, topShadow, bottomShadow, hiddenScrollbars, flex, ...rest }: Props,
{ shadow, topShadow, bottomShadow, flex, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const fallbackRef = React.useRef<HTMLDivElement>();
@@ -50,13 +49,11 @@ function Scrollable(
React.useEffect(() => {
updateShadows();
}, [height, updateShadows]);
return (
<Wrapper
ref={ref || fallbackRef}
onScroll={updateShadows}
$flex={flex}
$hiddenScrollbars={hiddenScrollbars}
$topShadowVisible={topShadowVisible}
$bottomShadowVisible={bottomShadowVisible}
{...rest}
@@ -68,7 +65,6 @@ const Wrapper = styled.div<{
$flex?: boolean;
$topShadowVisible?: boolean;
$bottomShadowVisible?: boolean;
$hiddenScrollbars?: boolean;
}>`
display: ${(props) => (props.$flex ? "flex" : "block")};
flex-direction: column;
@@ -93,17 +89,6 @@ const Wrapper = styled.div<{
return "none";
}};
transition: all 100ms ease-in-out;
${(props) =>
props.$hiddenScrollbars &&
`
-ms-overflow-style: none;
overflow: -moz-scrollbars-none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`}
`;
export default observer(React.forwardRef(Scrollable));
-27
View File
@@ -1,27 +0,0 @@
import { useKBar } from "kbar";
import * as React from "react";
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
import { navigateToRecentSearchQuery } from "~/actions/definitions/navigation";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
export default function SearchActions() {
const { searches } = useStores();
React.useEffect(() => {
searches.fetchPage({});
}, [searches]);
const { searchQuery } = useKBar((state) => ({
searchQuery: state.searchQuery,
}));
useCommandBarActions(
searchQuery ? [searchDocumentsForQuery(searchQuery)] : []
);
useCommandBarActions(searches.recent.map(navigateToRecentSearchQuery));
return null;
}
-148
View File
@@ -1,148 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { hover } from "~/styles";
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;
return (
<CompositeItem
as={DocumentLink}
ref={ref}
dir={document.dir}
to={{
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
state: {
title: document.titleWithDefault,
},
}}
{...rest}
>
<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>
</CompositeItem>
);
}
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;
&:not(:last-child) {
margin-bottom: 4px;
}
&:focus-visible {
outline: none;
}
${breakpoint("tablet")`
width: auto;
`};
&:${hover},
&:active,
&:focus,
&:focus-within {
background: ${(props) => props.theme.listItemHoverBackground};
}
${(props) =>
props.$menuOpen &&
css`
background: ${(props) => props.theme.listItemHoverBackground};
`}
`;
const Heading = styled.h4<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 18px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
white-space: nowrap;
color: ${(props) => props.theme.text};
`;
const Title = styled(Highlight)`
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
${Mark} {
padding: 0;
}
`;
const ResultContext = styled(Highlight)`
display: block;
color: ${(props) => props.theme.textTertiary};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0.25em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
${Mark} {
padding: 0;
}
`;
export default observer(React.forwardRef(DocumentListItem));
-227
View File
@@ -1,227 +0,0 @@
import { debounce } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import { depths } from "@shared/styles";
import Empty from "~/components/Empty";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Placeholder from "~/components/List/Placeholder";
import PaginatedList, { PaginatedItem } from "~/components/PaginatedList";
import Popover from "~/components/Popover";
import { id as bodyContentId } from "~/components/SkipNavContent";
import useKeyDown from "~/hooks/useKeyDown";
import useStores from "~/hooks/useStores";
import { SearchResult } from "~/types";
import SearchListItem from "./SearchListItem";
type Props = { shareId: string };
function SearchPopover({ shareId }: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const focusRef = React.useRef<HTMLElement | null>(null);
const popover = usePopoverState({
placement: "bottom-start",
unstable_offset: [-24, 0],
modal: true,
});
const [query, setQuery] = React.useState("");
const searchResults = documents.searchResults(query);
const [cachedQuery, setCachedQuery] = React.useState(query);
const [cachedSearchResults, setCachedSearchResults] = React.useState<
PaginatedItem[] | undefined
>(searchResults);
React.useEffect(() => {
if (searchResults) {
setCachedQuery(query);
setCachedSearchResults(searchResults);
popover.show();
}
}, [searchResults, query, popover.show]);
const performSearch = React.useCallback(
async ({ query, ...options }) => {
if (query?.length > 0) {
return await documents.search(query, { shareId, ...options });
}
return undefined;
},
[documents, shareId]
);
const handleSearchInputChange = React.useMemo(
() =>
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setQuery(value.trim());
// covers edge case: user manually dismisses popover then
// quickly edits input resulting in no change in query
// the useEffect that normally shows the popover will miss it
if (value === cachedQuery) {
popover.show();
}
if (!value.length) {
popover.hide();
}
}, 300),
[popover, cachedQuery]
);
const searchInputRef = popover.unstable_referenceRef as React.RefObject<
HTMLInputElement
>;
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
const handleEscapeList = React.useCallback(
() => searchInputRef?.current?.focus(),
[searchInputRef]
);
const handleSearchInputFocus = React.useCallback(() => {
focusRef.current = searchInputRef.current;
}, []);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
if (searchResults) {
popover.show();
}
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
if (ev.currentTarget.value.length) {
if (
ev.currentTarget.value.length === ev.currentTarget.selectionStart
) {
popover.show();
}
firstSearchItem.current?.focus();
}
}
if (ev.key === "ArrowUp") {
if (popover.visible) {
popover.hide();
if (!ev.shiftKey) {
ev.preventDefault();
}
}
if (ev.currentTarget.value) {
if (ev.currentTarget.selectionEnd === 0) {
ev.currentTarget.selectionStart = 0;
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
ev.preventDefault();
}
}
}
if (ev.key === "Escape") {
if (popover.visible) {
popover.hide();
ev.preventDefault();
}
}
},
[popover, searchResults]
);
const handleSearchItemClick = React.useCallback(() => {
popover.hide();
if (searchInputRef.current) {
searchInputRef.current.value = "";
focusRef.current = document.getElementById(bodyContentId);
}
}, [popover.hide]);
useKeyDown("/", (ev) => {
if (
searchInputRef.current &&
searchInputRef.current !== document.activeElement
) {
searchInputRef.current.focus();
ev.preventDefault();
}
});
return (
<>
<PopoverDisclosure {...popover}>
{(props) => {
// props assumes the disclosure is a button, but we want a type-ahead
// so we take the aria props, and ref and ignore the event handlers
return (
<StyledInputSearch
aria-controls={props["aria-controls"]}
aria-expanded={props["aria-expanded"]}
aria-haspopup={props["aria-haspopup"]}
ref={props.ref}
onChange={handleSearchInputChange}
onFocus={handleSearchInputFocus}
onKeyDown={handleKeyDown}
/>
);
}}
</PopoverDisclosure>
<Popover
{...popover}
aria-label={t("Results")}
unstable_autoFocusOnShow={false}
unstable_finalFocusRef={focusRef}
style={{ zIndex: depths.sidebar + 1 }}
shrink
>
<PaginatedList
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
items={cachedSearchResults}
fetch={performSearch}
onEscape={handleEscapeList}
empty={
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
}
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item: SearchResult, index, compositeProps) => (
<SearchListItem
key={item.document.id}
shareId={shareId}
ref={index === 0 ? firstSearchItem : undefined}
document={item.document}
context={item.context}
highlight={cachedQuery}
onClick={handleSearchItemClick}
{...compositeProps}
/>
)}
/>
</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);
@@ -1,40 +1,46 @@
import { observer } from "mobx-react";
import { EditIcon, SearchIcon, ShapesIcon, HomeIcon } from "outline-icons";
import {
EditIcon,
SearchIcon,
ShapesIcon,
HomeIcon,
SettingsIcon,
} from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Bubble from "~/components/Bubble";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import { inviteUser } from "~/actions/definitions/users";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import AccountMenu from "~/menus/AccountMenu";
import {
homePath,
searchUrl,
draftsPath,
templatesPath,
searchPath,
settingsPath,
} from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import Section from "./components/Section";
import SidebarAction from "./components/SidebarAction";
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import TeamButton from "./components/TeamButton";
import TrashLink from "./components/TrashLink";
function AppSidebar() {
function MainSidebar() {
const { t } = useTranslation();
const { documents } = useStores();
const { policies, documents } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team.id);
const user = useCurrentUser();
React.useEffect(() => {
documents.fetchDrafts();
@@ -49,29 +55,24 @@ function AppSidebar() {
}),
[dndArea]
);
const can = policies.abilities(team.id);
return (
<Sidebar ref={handleSidebarRef}>
{dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}>
<OrganizationMenu>
{(props: SidebarButtonProps) => (
<SidebarButton
<AccountMenu>
{(props) => (
<TeamButton
{...props}
title={team.name}
image={
<StyledTeamLogo
src={team.avatarUrl}
width={32}
height={32}
alt={t("Logo")}
/>
}
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
showDisclosure
/>
)}
</OrganizationMenu>
<Scrollable flex shadow>
</AccountMenu>
<Scrollable flex topShadow>
<Section>
<SidebarLink
to={homePath()}
@@ -80,7 +81,12 @@ function AppSidebar() {
label={t("Home")}
/>
<SidebarLink
to={searchPath()}
to={{
pathname: searchUrl(),
state: {
fromMenu: true,
},
}}
icon={<SearchIcon color="currentColor" />}
label={t("Search")}
exact={false}
@@ -90,19 +96,15 @@ function AppSidebar() {
to={draftsPath()}
icon={<EditIcon color="currentColor" />}
label={
<Flex align="center" justify="space-between">
<Drafts align="center">
{t("Drafts")}
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts}
</Drafts>
</Flex>
<Bubble count={documents.totalDrafts} />
</Drafts>
}
/>
)}
</Section>
<Section>
<Starred />
</Section>
<Starred />
<Section auto>
<Collections />
</Section>
@@ -126,6 +128,12 @@ function AppSidebar() {
<TrashLink />
</>
)}
<SidebarLink
to={settingsPath()}
icon={<SettingsIcon color="currentColor" />}
exact={false}
label={t("Settings")}
/>
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
@@ -135,13 +143,8 @@ function AppSidebar() {
);
}
const StyledTeamLogo = styled(TeamLogo)`
margin-right: 4px;
background: white;
const Drafts = styled(Flex)`
height: 24px;
`;
const Drafts = styled(Text)`
margin: 0 4px;
`;
export default observer(AppSidebar);
export default observer(MainSidebar);
+139 -32
View File
@@ -1,59 +1,161 @@
import { groupBy } from "lodash";
import { observer } from "mobx-react";
import { BackIcon } from "outline-icons";
import {
NewDocumentIcon,
EmailIcon,
ProfileIcon,
PadlockIcon,
CodeIcon,
UserIcon,
GroupIcon,
LinkIcon,
TeamIcon,
ExpandedIcon,
BeakerIcon,
DownloadIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
import isCloudHosted from "~/utils/isCloudHosted";
import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import Sidebar from "./Sidebar";
import Header from "./components/Header";
import Section from "./components/Section";
import SidebarButton from "./components/SidebarButton";
import SidebarLink from "./components/SidebarLink";
import TeamButton from "./components/TeamButton";
import Version from "./components/Version";
const isHosted = env.DEPLOYMENT === "hosted";
function SettingsSidebar() {
const { t } = useTranslation();
const history = useHistory();
const configs = useAuthorizedSettingsConfig();
const groupedConfig = groupBy(configs, "group");
const team = useCurrentTeam();
const { policies } = useStores();
const can = policies.abilities(team.id);
const returnToApp = React.useCallback(() => {
const returnToDashboard = React.useCallback(() => {
history.push("/home");
}, [history]);
return (
<Sidebar>
<SidebarButton
title={t("Return to App")}
image={<StyledBackIcon color="currentColor" />}
onClick={returnToApp}
minHeight={48}
<TeamButton
subheading={
<ReturnToApp align="center">
<BackIcon color="currentColor" /> {t("Return to App")}
</ReturnToApp>
}
teamName={team.name}
logoUrl={team.avatarUrl}
onClick={returnToDashboard}
/>
<Flex auto column>
<Scrollable shadow>
{Object.keys(groupedConfig).map((header) => (
<Section key={header}>
<Header title={header}>
{groupedConfig[header].map((item) => (
<SidebarLink
key={item.path}
to={item.path}
icon={<item.icon color="currentColor" />}
label={item.name}
/>
))}
</Header>
</Section>
))}
{!isCloudHosted && (
<Scrollable topShadow>
<Section>
<Header>{t("Account")}</Header>
<SidebarLink
to="/settings"
icon={<ProfileIcon color="currentColor" />}
label={t("Profile")}
/>
<SidebarLink
to="/settings/notifications"
icon={<EmailIcon color="currentColor" />}
label={t("Notifications")}
/>
{can.createApiKey && (
<SidebarLink
to="/settings/tokens"
icon={<CodeIcon color="currentColor" />}
label={t("API Tokens")}
/>
)}
</Section>
<Section>
<Header>{t("Team")}</Header>
{can.update && (
<SidebarLink
to="/settings/details"
icon={<TeamIcon color="currentColor" />}
label={t("Details")}
/>
)}
{can.update && (
<SidebarLink
to="/settings/security"
icon={<PadlockIcon color="currentColor" />}
label={t("Security")}
/>
)}
{can.update && (
<SidebarLink
to="/settings/features"
icon={<BeakerIcon color="currentColor" />}
label={t("Features")}
/>
)}
<SidebarLink
to="/settings/members"
icon={<UserIcon color="currentColor" />}
exact={false}
label={t("Members")}
/>
<SidebarLink
to="/settings/groups"
icon={<GroupIcon color="currentColor" />}
exact={false}
label={t("Groups")}
/>
<SidebarLink
to="/settings/shares"
icon={<LinkIcon color="currentColor" />}
label={t("Share Links")}
/>
{can.manage && (
<SidebarLink
to="/settings/import"
icon={<NewDocumentIcon color="currentColor" />}
label={t("Import")}
/>
)}
{can.export && (
<SidebarLink
to="/settings/export"
icon={<DownloadIcon color="currentColor" />}
label={t("Export")}
/>
)}
</Section>
{can.update && (env.SLACK_KEY || isHosted) && (
<Section>
<Header title={t("Installation")} />
<Header>{t("Integrations")}</Header>
{env.SLACK_KEY && (
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
)}
{isHosted && (
<SidebarLink
to="/settings/integrations/zapier"
icon={<ZapierIcon color="currentColor" />}
label="Zapier"
/>
)}
</Section>
)}
{can.update && !isHosted && (
<Section>
<Header>{t("Installation")}</Header>
<Version />
</Section>
)}
@@ -63,8 +165,13 @@ function SettingsSidebar() {
);
}
const StyledBackIcon = styled(BackIcon)`
margin-left: 4px;
const BackIcon = styled(ExpandedIcon)`
transform: rotate(90deg);
margin-left: -8px;
`;
const ReturnToApp = styled(Flex)`
height: 16px;
`;
export default observer(SettingsSidebar);
+1 -14
View File
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import Scrollable from "~/components/Scrollable";
import SearchPopover from "~/components/SearchPopover";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import Sidebar from "./Sidebar";
@@ -15,21 +14,17 @@ type Props = {
};
function SharedSidebar({ rootNode, shareId }: Props) {
const { ui, documents } = useStores();
const { documents } = useStores();
return (
<Sidebar>
<ScrollContainer flex>
<TopSection>
<SearchPopover shareId={shareId} />
</TopSection>
<Section>
<DocumentLink
index={0}
shareId={shareId}
depth={1}
node={rootNode}
activeDocumentId={ui.activeDocumentId}
activeDocument={documents.active}
/>
</Section>
@@ -42,12 +37,4 @@ const ScrollContainer = styled(Scrollable)`
padding-bottom: 16px;
`;
const TopSection = styled(Section)`
// this weird looking && increases the specificity of the style rule
&& {
margin-top: 16px;
margin-bottom: 16px;
}
`;
export default observer(SharedSidebar);

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