mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
219 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebeb201a9f | |||
| 9924fa6621 | |||
| ac319de1df | |||
| 2108ca29df | |||
| ea4de0dfb5 | |||
| 773c35ebc3 | |||
| 0ae4c7d6bd | |||
| 50faefbc45 | |||
| eb71a8f933 | |||
| a2f037531a | |||
| e70d4e60fd | |||
| 5e0b812083 | |||
| 1359f44814 | |||
| e1c90d3938 | |||
| e967641bb6 | |||
| 4d2a5ae748 | |||
| 56cae8a545 | |||
| e5e049a671 | |||
| 48438eea2d | |||
| 8e7dfdb6a0 | |||
| c8acf96790 | |||
| e7b7032284 | |||
| 16cd82a732 | |||
| 41a6f77998 | |||
| e2a6d828a9 | |||
| 2868ab2d00 | |||
| aa79bc85f1 | |||
| 5397907599 | |||
| f4fd9dae5f | |||
| bd06e03b1e | |||
| 5b2bb41ead | |||
| 2e759e4e81 | |||
| 5a89edbcb2 | |||
| 6eab716779 | |||
| 6de96b1d9d | |||
| 318a1120d4 | |||
| 86cb861ca7 | |||
| 2261514138 | |||
| 402695c2e3 | |||
| 9e810387c0 | |||
| b1ddf417be | |||
| 0014bcf22d | |||
| 606a4e0772 | |||
| 4807c60042 | |||
| dd02bd9c03 | |||
| 1639c657c8 | |||
| 25b961b3b8 | |||
| 144ba0ced9 | |||
| d340f8977d | |||
| 7145f7ef51 | |||
| 43bdb97639 | |||
| 136ee0ad1d | |||
| 517f2634e3 | |||
| 42cc991317 | |||
| e50e0bba53 | |||
| d0bb6c6a41 | |||
| 6aec085942 | |||
| 65d3c8309e | |||
| 5c7c9ceeb1 | |||
| 3f11b014c5 | |||
| 76862b626b | |||
| 8833e578f1 | |||
| 8c661345f0 | |||
| 89537aabc3 | |||
| 6672536cde | |||
| 34d4209dd5 | |||
| 27befbf3f7 | |||
| 5aa7b42f8b | |||
| 67b1fe5514 | |||
| fea50feb0d | |||
| 1b1b95d673 | |||
| 1137d45f92 | |||
| 091ef340f4 | |||
| 432fa970e5 | |||
| 59734f2bf7 | |||
| 4fa3270f4e | |||
| 3582a6a0a2 | |||
| 8c2a47db9d | |||
| 266a2f4485 | |||
| c20eac0b03 | |||
| 6b4feb51e0 | |||
| b79f86d347 | |||
| 411ab6b785 | |||
| 924ab156f3 | |||
| 7e17e82ac8 | |||
| ef22a5dc52 | |||
| 56a526e930 | |||
| a32857c715 | |||
| 882408bc0e | |||
| b80ee89588 | |||
| d81db7e4f6 | |||
| 401d1ba871 | |||
| 99e3a305d3 | |||
| 5e9151f02a | |||
| 9e218bd4f3 | |||
| d43f1b529d | |||
| 0856f5f6ae | |||
| ac068c0c07 | |||
| 9602d09964 | |||
| c22ed0c82e | |||
| 6159973df9 | |||
| 5c839998c1 | |||
| 80ef0a38d6 | |||
| 7270e65f0c | |||
| 76845a3308 | |||
| 5c8bcc11b4 | |||
| d8bfb0fe5d | |||
| bb555de1ba | |||
| 127115272a | |||
| d1de5871de | |||
| ec0564eb32 | |||
| 3eb947e9a5 | |||
| a724a21c21 | |||
| c4aad4d4bf | |||
| 795fe37bd6 | |||
| 262590e507 | |||
| 5f788012db | |||
| 2358c3d13d | |||
| a03b95221a | |||
| 3223341062 | |||
| ce645b158b | |||
| 74860ed961 | |||
| c376dc1011 | |||
| a956f54b5a | |||
| 1c99e8519a | |||
| 6079b71d3c | |||
| 749c8dc335 | |||
| 57d1643d77 | |||
| 1df7a42868 | |||
| 02cced078f | |||
| d7c331532d | |||
| 0261e0712c | |||
| f7111991dc | |||
| 10a190cd80 | |||
| 3721ea2333 | |||
| 1048ea8771 | |||
| a3cfef09f3 | |||
| ef71a54120 | |||
| 1c7bb65c7a | |||
| 093ee74a90 | |||
| 0054b7152e | |||
| 8b4b2ca741 | |||
| 911bb1f492 | |||
| c9f0c86719 | |||
| d0fe6ad93f | |||
| 4e53029377 | |||
| 7abb4f9ad6 | |||
| dec03b9d84 | |||
| d591158c4d | |||
| fa03f9c08d | |||
| b7055ef853 | |||
| 864ddbd438 | |||
| 30a4303a8e | |||
| 7725f29dc7 | |||
| 08825c7d97 | |||
| 448258746c | |||
| b002d51ace | |||
| 3e6a22e369 | |||
| 412f3ed9a4 | |||
| 78ad1b867a | |||
| c643f62d96 | |||
| 79ff9309fd | |||
| 9256c59e60 | |||
| 1d90f98a29 | |||
| 10ec8a59b4 | |||
| dfbd89ad53 | |||
| da9a8af543 | |||
| aada5c20cd | |||
| 8f86eadc5d | |||
| 53c6c5599a | |||
| e3ba87dcb0 | |||
| 3c5753621c | |||
| 3366fb46cd | |||
| 89bf5373aa | |||
| e6b0e434ea | |||
| 225f0dbf11 | |||
| 418d3305b2 | |||
| 5c07694f6b | |||
| 74722b80f2 | |||
| 4354e1055e | |||
| c3a8858c6b | |||
| 546022e5d6 | |||
| 33e532847e | |||
| c9d62420c8 | |||
| cc2a1865c5 | |||
| 1ec87da8a9 | |||
| d820b2a617 | |||
| 5e7ea165b4 | |||
| c68d55f49b | |||
| 7e349c9db1 | |||
| 13b067fb3f | |||
| 41c346d105 | |||
| 4788ab3bd6 | |||
| 5f00b4f744 | |||
| fd600ced09 | |||
| 0047384d70 | |||
| 8bff566c30 | |||
| fce90df3aa | |||
| 28ae1af2a3 | |||
| 9f0534d544 | |||
| 4edfab20fe | |||
| c38e045df2 | |||
| b7bfc4bb1a | |||
| a71ad43c31 | |||
| 199fa5844e | |||
| b466f1c8bb | |||
| 503e4e1f71 | |||
| 2bc52be2cf | |||
| 3ba730943c | |||
| 6828718cf0 | |||
| 9749a53558 | |||
| f4e4992508 | |||
| cf2f0b1b5c | |||
| 4a4ea0e531 | |||
| 8830773acb | |||
| f5d2c7890a | |||
| 434812dbe3 | |||
| ed5671209a | |||
| c32cec7bff |
+12
-5
@@ -82,6 +82,7 @@ jobs:
|
||||
command: yarn test:shared
|
||||
test-server:
|
||||
<<: *defaults
|
||||
parallelism: 3
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
@@ -91,7 +92,9 @@ jobs:
|
||||
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:server --forceExit
|
||||
command: |
|
||||
TESTFILES=$(circleci tests glob "server/**/*.test.ts" | circleci tests split)
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
bundle-size:
|
||||
<<: *defaults
|
||||
environment:
|
||||
@@ -142,7 +145,12 @@ jobs:
|
||||
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 .
|
||||
command: |
|
||||
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
|
||||
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
else
|
||||
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 .
|
||||
fi
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
@@ -166,9 +174,8 @@ workflows:
|
||||
- build
|
||||
- bundle-size:
|
||||
requires:
|
||||
- test-app
|
||||
- test-shared
|
||||
- test-server
|
||||
- build
|
||||
- types
|
||||
|
||||
build-docker:
|
||||
jobs:
|
||||
|
||||
+13
-3
@@ -51,10 +51,20 @@ 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
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
AWS_S3_ACL=private
|
||||
|
||||
# Specify what storage system to use. Possible value is one of "s3" or "local".
|
||||
# For "local", the avatar images and document attachments will be saved on local disk.
|
||||
FILE_STORAGE=local
|
||||
|
||||
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under
|
||||
# which all attachments/images go. Make sure that the process has permissions to create
|
||||
# this path and also to write files to it.
|
||||
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
||||
|
||||
# Maximum allowed size for the uploaded attachment.
|
||||
FILE_STORAGE_UPLOAD_MAX_SIZE=26214400
|
||||
|
||||
# –––––––––––––– AUTHENTICATION ––––––––––––––
|
||||
|
||||
@@ -183,5 +193,5 @@ RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
# Iframely API config
|
||||
IFRAMELY_URL=
|
||||
IFRAMELY_API_KEY=
|
||||
# IFRAMELY_URL=
|
||||
# IFRAMELY_API_KEY=
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-node",
|
||||
"eslint-plugin-react",
|
||||
"import"
|
||||
"eslint-plugin-lodash"
|
||||
],
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
@@ -55,6 +55,7 @@
|
||||
],
|
||||
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
"lodash/import-scope": ["warn", "method"],
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"import/newline-after-import": 2,
|
||||
|
||||
+5
-6
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"workerIdleMemoryLimit": "0.75",
|
||||
"maxWorkers": "50%",
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
@@ -8,13 +9,11 @@
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"<rootDir>/server/test/env.ts"
|
||||
],
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js", "<rootDir>/server/test/env.ts"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"testEnvironment": "node",
|
||||
"runner": "@getoutline/jest-runner-serial"
|
||||
"globalSetup": "<rootDir>/server/test/globalSetup.js",
|
||||
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
{
|
||||
"displayName": "app",
|
||||
|
||||
+10
-1
@@ -24,7 +24,16 @@ COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001 && \
|
||||
chown -R nodejs:nodejs $APP_PATH/build
|
||||
chown -R nodejs:nodejs $APP_PATH/build && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
ENV FILE_STORAGE_LOCAL_ROOT_DIR /var/lib/outline/data
|
||||
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
|
||||
VOLUME /var/lib/outline/data
|
||||
|
||||
USER nodejs
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.64.0
|
||||
Licensed Work: Outline 0.71.0
|
||||
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: 2027-08-18
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -11,14 +11,14 @@ test:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn sequelize db:drop --env=test
|
||||
yarn sequelize db:create --env=test
|
||||
yarn sequelize db:migrate --env=test
|
||||
NODE_ENV=test yarn sequelize db:migrate --env=test
|
||||
yarn test
|
||||
|
||||
watch:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn sequelize db:drop --env=test
|
||||
yarn sequelize db:create --env=test
|
||||
yarn sequelize db:migrate --env=test
|
||||
NODE_ENV=test yarn sequelize db:migrate --env=test
|
||||
yarn test:watch
|
||||
|
||||
destroy:
|
||||
|
||||
@@ -96,6 +96,10 @@ Or to run migrations on test database:
|
||||
yarn sequelize db:migrate --env test
|
||||
```
|
||||
|
||||
## License
|
||||
# Activity
|
||||
|
||||

|
||||
|
||||
# License
|
||||
|
||||
Outline is [BSL 1.1 licensed](LICENSE).
|
||||
|
||||
@@ -128,11 +128,6 @@
|
||||
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_MAX_SIZE": {
|
||||
"description": "Maximum file upload size in bytes",
|
||||
"value": "26214400",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_FORCE_PATH_STYLE": {
|
||||
"description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.",
|
||||
"value": "true",
|
||||
@@ -148,6 +143,11 @@
|
||||
"description": "S3 canned ACL for document attachments",
|
||||
"required": false
|
||||
},
|
||||
"FILE_STORAGE_UPLOAD_MAX_SIZE": {
|
||||
"description": "Maximum file upload size in bytes",
|
||||
"value": "26214400",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_HOST": {
|
||||
"description": "smtp.example.com (optional)",
|
||||
"required": false
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
searchPath,
|
||||
documentPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const openDocument = createAction({
|
||||
@@ -86,6 +87,48 @@ export const createDocument = createAction({
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createAction({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
!!activeDocumentId &&
|
||||
!!stores.documents.get(activeDocumentId)?.template &&
|
||||
stores.policies.abilities(currentTeamId).createDocument,
|
||||
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
|
||||
{
|
||||
starred: inStarredSection,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
export const createNestedDocument = createAction({
|
||||
name: ({ t }) => t("New nested document"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument &&
|
||||
stores.policies.abilities(activeDocumentId).createChildDocument,
|
||||
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, {
|
||||
parentDocumentId: activeDocumentId,
|
||||
}),
|
||||
{
|
||||
starred: inStarredSection,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
@@ -165,9 +208,14 @@ export const publishDocument = createAction({
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
stores.toasts.showToast(t("Document published"), {
|
||||
type: "success",
|
||||
});
|
||||
stores.toasts.showToast(
|
||||
t("Published {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
{
|
||||
type: "success",
|
||||
}
|
||||
);
|
||||
} else if (document) {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Publish document"),
|
||||
@@ -195,12 +243,20 @@ export const unpublishDocument = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document?.unpublish();
|
||||
await document.unpublish();
|
||||
|
||||
stores.toasts.showToast(t("Document unpublished"), {
|
||||
type: "success",
|
||||
});
|
||||
stores.toasts.showToast(
|
||||
t("Unpublished {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
{
|
||||
type: "success",
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -366,7 +422,7 @@ export const duplicateDocument = createAction({
|
||||
invariant(document, "Document must exist");
|
||||
const duped = await document.duplicate();
|
||||
// when duplicating, go straight to the duplicated document content
|
||||
history.push(duped.url);
|
||||
history.push(documentPath(duped));
|
||||
stores.toasts.showToast(t("Document duplicated"), {
|
||||
type: "success",
|
||||
});
|
||||
@@ -775,7 +831,16 @@ export const openDocumentInsights = createAction({
|
||||
icon: <LightBulbIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return !!activeDocumentId && can.read;
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!!activeDocumentId &&
|
||||
can.read &&
|
||||
!document?.isTemplate &&
|
||||
!document?.isDeleted
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
|
||||
@@ -6,14 +6,15 @@ import {
|
||||
EditIcon,
|
||||
OpenIcon,
|
||||
SettingsIcon,
|
||||
ShapesIcon,
|
||||
KeyboardIcon,
|
||||
EmailIcon,
|
||||
LogoutIcon,
|
||||
ProfileIcon,
|
||||
BrowserIcon,
|
||||
ShapesIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import {
|
||||
developersUrl,
|
||||
changelogUrl,
|
||||
@@ -26,14 +27,12 @@ import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { isMac } from "~/utils/browser";
|
||||
import history from "~/utils/history";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import {
|
||||
homePath,
|
||||
searchPath,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
archivePath,
|
||||
trashPath,
|
||||
settingsPath,
|
||||
@@ -67,15 +66,6 @@ export const navigateToDrafts = createAction({
|
||||
visible: ({ location }) => location.pathname !== draftsPath(),
|
||||
});
|
||||
|
||||
export const navigateToTemplates = createAction({
|
||||
name: ({ t }) => t("Templates"),
|
||||
analyticsName: "Navigate to templates",
|
||||
section: NavigationSection,
|
||||
icon: <ShapesIcon />,
|
||||
perform: () => history.push(templatesPath()),
|
||||
visible: ({ location }) => location.pathname !== templatesPath(),
|
||||
});
|
||||
|
||||
export const navigateToArchive = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Navigate to archive",
|
||||
@@ -103,7 +93,7 @@ export const navigateToSettings = createAction({
|
||||
icon: <SettingsIcon />,
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
perform: () => history.push(settingsPath("details")),
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
@@ -115,6 +105,15 @@ export const navigateToProfileSettings = createAction({
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToTemplateSettings = createAction({
|
||||
name: ({ t }) => t("Templates"),
|
||||
analyticsName: "Navigate to template settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <ShapesIcon />,
|
||||
perform: () => history.push(settingsPath("templates")),
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createAction({
|
||||
name: ({ t }) => t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
@@ -216,7 +215,6 @@ export const logout = createAction({
|
||||
export const rootNavigationActions = [
|
||||
navigateToHome,
|
||||
navigateToDrafts,
|
||||
navigateToTemplates,
|
||||
navigateToArchive,
|
||||
navigateToTrash,
|
||||
downloadApp,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { flattenDeep } from "lodash";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import * as React from "react";
|
||||
import { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable prefer-rest-params */
|
||||
/* global ga */
|
||||
import { escape } from "lodash";
|
||||
import escape from "lodash/escape";
|
||||
import * as React from "react";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function SlackLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 34 34"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g stroke="none" strokeWidth="1" fillRule="evenodd">
|
||||
<g transform="translate(0.000000, 17.822581)">
|
||||
<path d="M7.23870968,3.61935484 C7.23870968,5.56612903 5.6483871,7.15645161 3.7016129,7.15645161 C1.75483871,7.15645161 0.164516129,5.56612903 0.164516129,3.61935484 C0.164516129,1.67258065 1.75483871,0.0822580645 3.7016129,0.0822580645 L7.23870968,0.0822580645 L7.23870968,3.61935484 Z" />
|
||||
<path d="M9.02096774,3.61935484 C9.02096774,1.67258065 10.6112903,0.0822580645 12.5580645,0.0822580645 C14.5048387,0.0822580645 16.0951613,1.67258065 16.0951613,3.61935484 L16.0951613,12.4758065 C16.0951613,14.4225806 14.5048387,16.0129032 12.5580645,16.0129032 C10.6112903,16.0129032 9.02096774,14.4225806 9.02096774,12.4758065 C9.02096774,12.4758065 9.02096774,3.61935484 9.02096774,3.61935484 Z" />
|
||||
</g>
|
||||
<g>
|
||||
<path d="M12.5580645,7.23870968 C10.6112903,7.23870968 9.02096774,5.6483871 9.02096774,3.7016129 C9.02096774,1.75483871 10.6112903,0.164516129 12.5580645,0.164516129 C14.5048387,0.164516129 16.0951613,1.75483871 16.0951613,3.7016129 L16.0951613,7.23870968 L12.5580645,7.23870968 Z" />
|
||||
<path d="M12.5580645,9.02096774 C14.5048387,9.02096774 16.0951613,10.6112903 16.0951613,12.5580645 C16.0951613,14.5048387 14.5048387,16.0951613 12.5580645,16.0951613 L3.7016129,16.0951613 C1.75483871,16.0951613 0.164516129,14.5048387 0.164516129,12.5580645 C0.164516129,10.6112903 1.75483871,9.02096774 3.7016129,9.02096774 C3.7016129,9.02096774 12.5580645,9.02096774 12.5580645,9.02096774 Z" />
|
||||
</g>
|
||||
<g transform="translate(17.822581, 0.000000)">
|
||||
<path d="M8.93870968,12.5580645 C8.93870968,10.6112903 10.5290323,9.02096774 12.4758065,9.02096774 C14.4225806,9.02096774 16.0129032,10.6112903 16.0129032,12.5580645 C16.0129032,14.5048387 14.4225806,16.0951613 12.4758065,16.0951613 L8.93870968,16.0951613 L8.93870968,12.5580645 Z" />
|
||||
<path d="M7.15645161,12.5580645 C7.15645161,14.5048387 5.56612903,16.0951613 3.61935484,16.0951613 C1.67258065,16.0951613 0.0822580645,14.5048387 0.0822580645,12.5580645 L0.0822580645,3.7016129 C0.0822580645,1.75483871 1.67258065,0.164516129 3.61935484,0.164516129 C5.56612903,0.164516129 7.15645161,1.75483871 7.15645161,3.7016129 L7.15645161,12.5580645 Z" />
|
||||
</g>
|
||||
<g transform="translate(17.822581, 17.822581)">
|
||||
<path d="M3.61935484,8.93870968 C5.56612903,8.93870968 7.15645161,10.5290323 7.15645161,12.4758065 C7.15645161,14.4225806 5.56612903,16.0129032 3.61935484,16.0129032 C1.67258065,16.0129032 0.0822580645,14.4225806 0.0822580645,12.4758065 L0.0822580645,8.93870968 L3.61935484,8.93870968 Z" />
|
||||
<path d="M3.61935484,7.15645161 C1.67258065,7.15645161 0.0822580645,5.56612903 0.0822580645,3.61935484 C0.0822580645,1.67258065 1.67258065,0.0822580645 3.61935484,0.0822580645 L12.4758065,0.0822580645 C14.4225806,0.0822580645 16.0129032,1.67258065 16.0129032,3.61935484 C16.0129032,5.56612903 14.4225806,7.15645161 12.4758065,7.15645161 L3.61935484,7.15645161 Z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlackLogo;
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
|
||||
type Props = {
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export default function ChangeLanguage({ locale }: Props) {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
void changeLanguage(locale, i18n);
|
||||
}, [locale, i18n]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { sortBy, filter, uniq, isEqual } from "lodash";
|
||||
import filter from "lodash/filter";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -7,6 +7,7 @@ import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -17,16 +18,20 @@ type Props = {
|
||||
function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { ui } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const redirect = collection.id === ui.activeCollectionId;
|
||||
await collection.delete();
|
||||
onSubmit();
|
||||
|
||||
if (redirect) {
|
||||
history.push(homePath());
|
||||
}
|
||||
|
||||
await collection.delete();
|
||||
onSubmit();
|
||||
showToast(t("Collection deleted"), { type: "success" });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,39 +11,26 @@ import SearchActions from "~/components/SearchActions";
|
||||
import rootActions from "~/actions/root";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useSettingsActions from "~/hooks/useSettingsActions";
|
||||
import { CommandBarAction } from "~/types";
|
||||
import useTemplateActions from "~/hooks/useTemplateActions";
|
||||
|
||||
function CommandBar() {
|
||||
const { t } = useTranslation();
|
||||
const settingsActions = useSettingsActions();
|
||||
const templateActions = useTemplateActions();
|
||||
const commandBarActions = React.useMemo(
|
||||
() => [...rootActions, settingsActions],
|
||||
[settingsActions]
|
||||
() => [...rootActions, templateActions, settingsActions],
|
||||
[settingsActions, templateActions]
|
||||
);
|
||||
|
||||
useCommandBarActions(commandBarActions);
|
||||
|
||||
const { rootAction } = useKBar((state) => ({
|
||||
rootAction: state.currentRootActionId
|
||||
? (state.actions[
|
||||
state.currentRootActionId
|
||||
] as unknown as CommandBarAction)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<KBarPortal>
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchActions />
|
||||
<SearchInput
|
||||
placeholder={`${
|
||||
rootAction?.placeholder ||
|
||||
rootAction?.name ||
|
||||
t("Type a command or search")
|
||||
}…`}
|
||||
/>
|
||||
<SearchInput defaultPlaceholder={t("Type a command or search")} />
|
||||
<CommandBarResults />
|
||||
</Animator>
|
||||
</Positioner>
|
||||
|
||||
@@ -14,15 +14,48 @@ function ConnectionStatus() {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const codeToMessage = {
|
||||
1009: {
|
||||
title: t("Document is too large"),
|
||||
body: t(
|
||||
"This document has reached the maximum size and can no longer be edited"
|
||||
),
|
||||
},
|
||||
4401: {
|
||||
title: t("Authentication failed"),
|
||||
body: t("Please try logging out and back in again"),
|
||||
},
|
||||
4403: {
|
||||
title: t("Authorization failed"),
|
||||
body: t("You may have lost access to this document, try reloading"),
|
||||
},
|
||||
4503: {
|
||||
title: t("Too many users connected to document"),
|
||||
body: t("Your edits will sync once other users leave the document"),
|
||||
},
|
||||
};
|
||||
|
||||
const message = ui.multiplayerErrorCode
|
||||
? codeToMessage[ui.multiplayerErrorCode]
|
||||
: undefined;
|
||||
|
||||
return ui.multiplayerStatus === "connecting" ||
|
||||
ui.multiplayerStatus === "disconnected" ? (
|
||||
<Tooltip
|
||||
tooltip={
|
||||
<Centered>
|
||||
<strong>{t("Server connection lost")}</strong>
|
||||
<br />
|
||||
{t("Edits you make will sync once you’re online")}
|
||||
</Centered>
|
||||
message ? (
|
||||
<Centered>
|
||||
<strong>{message.title}</strong>
|
||||
<br />
|
||||
{message.body}
|
||||
</Centered>
|
||||
) : (
|
||||
<Centered>
|
||||
<strong>{t("Server connection lost")}</strong>
|
||||
<br />
|
||||
{t("Edits you make will sync once you’re online")}
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
|
||||
@@ -9,6 +9,7 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
|
||||
readOnly?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onChange?: (text: string) => void;
|
||||
onFocus?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
||||
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
||||
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
|
||||
@@ -35,6 +36,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
@@ -143,11 +145,13 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
|
||||
{children}
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onFocus={wrappedEvent(onFocus)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
@@ -158,7 +162,6 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
>
|
||||
{innerValue}
|
||||
</Content>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const MenuIconWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 6px;
|
||||
margin-left: -4px;
|
||||
color: ${({ theme }) => theme.textSecondary};
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default MenuIconWrapper;
|
||||
@@ -5,7 +5,7 @@ import { mergeRefs } from "react-merge-refs";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
import MenuIconWrapper from "./MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
|
||||
@@ -2,17 +2,17 @@ import * as React from "react";
|
||||
import { useMousePosition } from "~/hooks/useMousePosition";
|
||||
|
||||
type Positions = {
|
||||
/* Sub-menu x */
|
||||
/** Sub-menu x */
|
||||
x: number;
|
||||
/* Sub-menu y */
|
||||
/** Sub-menu y */
|
||||
y: number;
|
||||
/* Sub-menu height */
|
||||
/** Sub-menu height */
|
||||
h: number;
|
||||
/* Sub-menu width */
|
||||
/** Sub-menu width */
|
||||
w: number;
|
||||
/* Mouse x */
|
||||
/** Mouse x */
|
||||
mouseX: number;
|
||||
/* Mouse y */
|
||||
/** Mouse y */
|
||||
mouseY: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
MenuStateReturn,
|
||||
} from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
|
||||
import Flex from "~/components/Flex";
|
||||
import MenuIconWrapper from "~/components/MenuIconWrapper";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import {
|
||||
|
||||
@@ -46,6 +46,8 @@ type Props = MenuStateReturn & {
|
||||
onClose?: () => void;
|
||||
/** Called when the context menu is clicked. */
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
/** The maximum width of the context menu. */
|
||||
maxWidth?: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -57,11 +59,6 @@ const ContextMenu: React.FC<Props> = ({
|
||||
...rest
|
||||
}: Props) => {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const maxHeight = useMenuHeight({
|
||||
visible: rest.visible,
|
||||
elementRef: rest.unstable_disclosureRef,
|
||||
});
|
||||
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { setIsMenuOpen } = useMenuContext();
|
||||
@@ -99,21 +96,6 @@ const ContextMenu: React.FC<Props> = ({
|
||||
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 && !isSubMenu) {
|
||||
disableBodyScroll(scrollElement, {
|
||||
reserveScrollBarGap: true,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
scrollElement && !isSubMenu && enableBodyScroll(scrollElement);
|
||||
};
|
||||
}, [isSubMenu, rest.visible]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
return null;
|
||||
@@ -124,51 +106,96 @@ const ContextMenu: React.FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
<Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...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
|
||||
// positioning.
|
||||
const topAnchor = props.style?.top === "0";
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'placement' does not exist on type 'Extra... Remove this comment to see the full error message
|
||||
const rightAnchor = props.placement === "bottom-end";
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile && (
|
||||
<Backdrop
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
rest.hide?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Position {...props}>
|
||||
<Background
|
||||
dir="auto"
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={
|
||||
topAnchor
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
{(props) => (
|
||||
<InnerContextMenu
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
menuProps={props as any}
|
||||
{...rest}
|
||||
isSubMenu={isSubMenu}
|
||||
>
|
||||
{children}
|
||||
</InnerContextMenu>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type InnerContextMenuProps = MenuStateReturn & {
|
||||
isSubMenu: boolean;
|
||||
menuProps: { style?: React.CSSProperties; placement: string };
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inner context menu allows deferring expensive window measurement hooks etc
|
||||
* until the menu is actually opened.
|
||||
*/
|
||||
const InnerContextMenu = (props: InnerContextMenuProps) => {
|
||||
const { menuProps } = props;
|
||||
// kind of hacky, but this is an effective way of telling which way
|
||||
// the menu will _actually_ be placed when taking into account screen
|
||||
// positioning.
|
||||
const topAnchor = menuProps.style?.top === "0";
|
||||
const rightAnchor = menuProps.placement === "bottom-end";
|
||||
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMobile();
|
||||
|
||||
const maxHeight = useMenuHeight({
|
||||
visible: props.visible,
|
||||
elementRef: props.unstable_disclosureRef,
|
||||
});
|
||||
|
||||
// We must manually manage scroll lock for iOS support so that the scrollable
|
||||
// element can be passed into body-scroll-lock. See:
|
||||
// https://github.com/ariakit/ariakit/issues/469
|
||||
React.useEffect(() => {
|
||||
const scrollElement = backgroundRef.current;
|
||||
if (props.visible && scrollElement && !props.isSubMenu) {
|
||||
disableBodyScroll(scrollElement, {
|
||||
reserveScrollBarGap: true,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
scrollElement && !props.isSubMenu && enableBodyScroll(scrollElement);
|
||||
};
|
||||
}, [props.isSubMenu, props.visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile && (
|
||||
<Backdrop
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
props.hide?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Position {...menuProps}>
|
||||
<Background
|
||||
dir="auto"
|
||||
maxWidth={props.maxWidth}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={
|
||||
topAnchor && !isMobile
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{props.visible || props.animating ? props.children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextMenu;
|
||||
|
||||
export const Backdrop = styled.div`
|
||||
@@ -203,6 +230,7 @@ export const Position = styled.div`
|
||||
type BackgroundProps = {
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
maxWidth?: number;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
@@ -228,7 +256,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: ${(props: BackgroundProps) =>
|
||||
props.rightAnchor ? "75%" : "25%"} 0;
|
||||
max-width: 276px;
|
||||
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
|
||||
background: ${(props: BackgroundProps) => props.theme.menuBackground};
|
||||
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
|
||||
`};
|
||||
|
||||
@@ -12,9 +12,10 @@ import { MenuInternalLink } from "~/types";
|
||||
import {
|
||||
archivePath,
|
||||
collectionPath,
|
||||
templatesPath,
|
||||
settingsPath,
|
||||
trashPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import EmojiIcon from "./Icons/EmojiIcon";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -43,12 +44,12 @@ function useCategory(document: Document): MenuInternalLink | null {
|
||||
};
|
||||
}
|
||||
|
||||
if (document.isTemplate) {
|
||||
if (document.template) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <ShapesIcon />,
|
||||
title: t("Templates"),
|
||||
to: templatesPath(),
|
||||
to: settingsPath("templates"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,7 +106,13 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
path.forEach((node: NavigationNode) => {
|
||||
output.push({
|
||||
type: "route",
|
||||
title: node.title,
|
||||
title: node.emoji ? (
|
||||
<>
|
||||
<EmojiIcon emoji={node.emoji} /> {node.title}
|
||||
</>
|
||||
) : (
|
||||
node.title
|
||||
),
|
||||
to: node.url,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,11 +111,12 @@ function DocumentCard(props: Props) {
|
||||
|
||||
{document.emoji ? (
|
||||
<Squircle color={theme.slateLight}>
|
||||
<EmojiIcon emoji={document.emoji} size={26} />
|
||||
<EmojiIcon emoji={document.emoji} size={24} />
|
||||
</Squircle>
|
||||
) : (
|
||||
<Squircle color={collection?.color}>
|
||||
{collection?.icon &&
|
||||
collection?.icon !== "letter" &&
|
||||
collection?.icon !== "collection" &&
|
||||
!pin?.collectionId ? (
|
||||
<CollectionIcon collection={collection} color="white" />
|
||||
@@ -279,8 +280,8 @@ const Heading = styled.h3`
|
||||
overflow: hidden;
|
||||
|
||||
color: ${s("text")};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
font-family: ${s("fontFamily")};
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
export default observer(DocumentCard);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Editor } from "~/editor";
|
||||
import useIdle from "~/hooks/useIdle";
|
||||
|
||||
export type DocumentContextValue = {
|
||||
/** The current editor instance for this document. */
|
||||
@@ -16,4 +17,21 @@ const DocumentContext = React.createContext<DocumentContextValue>({
|
||||
|
||||
export const useDocumentContext = () => React.useContext(DocumentContext);
|
||||
|
||||
const activityEvents = [
|
||||
"click",
|
||||
"mousemove",
|
||||
"DOMMouseScroll",
|
||||
"mousewheel",
|
||||
"mousedown",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
"focus",
|
||||
];
|
||||
|
||||
export const useEditingFocus = () => {
|
||||
const { editor } = useDocumentContext();
|
||||
const isIdle = useIdle(3000, activityEvents);
|
||||
return isIdle && !!editor?.view.hasFocus();
|
||||
};
|
||||
|
||||
export default DocumentContext;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import FuzzySearch from "fuzzy-search";
|
||||
import { includes, difference, concat, filter, map, fill } from "lodash";
|
||||
import concat from "lodash/concat";
|
||||
import difference from "lodash/difference";
|
||||
import fill from "lodash/fill";
|
||||
import filter from "lodash/filter";
|
||||
import includes from "lodash/includes";
|
||||
import map from "lodash/map";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -10,7 +15,6 @@ import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -200,84 +204,86 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const ListItem = ({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: NavigationNode[];
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const node = data[index];
|
||||
const isCollection = node.type === "collection";
|
||||
let icon, title, path;
|
||||
const ListItem = observer(
|
||||
({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: NavigationNode[];
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const node = data[index];
|
||||
const isCollection = node.type === "collection";
|
||||
let icon, title: string, emoji: string | undefined, path;
|
||||
|
||||
if (isCollection) {
|
||||
const col = collections.get(node.collectionId as string);
|
||||
icon = col && (
|
||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||
);
|
||||
title = node.title;
|
||||
} else {
|
||||
const doc = documents.get(node.id);
|
||||
const { strippedTitle, emoji } = parseTitle(node.title);
|
||||
title = strippedTitle;
|
||||
|
||||
if (emoji) {
|
||||
icon = <EmojiIcon emoji={emoji} />;
|
||||
} else if (doc?.isStarred) {
|
||||
icon = <StarredIcon color={theme.yellow} />;
|
||||
if (isCollection) {
|
||||
const col = collections.get(node.collectionId as string);
|
||||
icon = col && (
|
||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||
);
|
||||
title = node.title;
|
||||
} else {
|
||||
icon = <DocumentIcon color={theme.textSecondary} />;
|
||||
const doc = documents.get(node.id);
|
||||
emoji = doc?.emoji ?? node.emoji;
|
||||
title = doc?.title ?? node.title;
|
||||
|
||||
if (emoji) {
|
||||
icon = <EmojiIcon emoji={emoji} />;
|
||||
} else if (doc?.isStarred) {
|
||||
icon = <StarredIcon color={theme.yellow} />;
|
||||
} else {
|
||||
icon = <DocumentIcon color={theme.textSecondary} />;
|
||||
}
|
||||
|
||||
path = ancestors(node)
|
||||
.map((a) => a.title)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
path = ancestors(node)
|
||||
.map((a) => parseTitle(a.title).strippedTitle)
|
||||
.join(" / ");
|
||||
return searchTerm ? (
|
||||
<DocumentExplorerSearchResult
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
path={path}
|
||||
/>
|
||||
) : (
|
||||
<DocumentExplorerNode
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
onDisclosureClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
toggleCollapse(index);
|
||||
}}
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
expanded={isExpanded(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
depth={node.depth as number}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return searchTerm ? (
|
||||
<DocumentExplorerSearchResult
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
path={path}
|
||||
/>
|
||||
) : (
|
||||
<DocumentExplorerNode
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
onDisclosureClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
toggleCollapse(index);
|
||||
}}
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
expanded={isExpanded(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
depth={node.depth as number}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
const focusSearchInput = () => {
|
||||
inputSearchRef.current?.focus();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -9,7 +8,6 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import EventBoundary from "~/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -18,12 +16,11 @@ 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 DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import EmojiIcon from "./Icons/EmojiIcon";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -51,7 +48,6 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
const {
|
||||
@@ -71,8 +67,6 @@ function DocumentListItem(
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
const can = usePolicy(team);
|
||||
const canCollection = usePolicy(document.collectionId);
|
||||
|
||||
return (
|
||||
<CompositeItem
|
||||
@@ -83,7 +77,7 @@ function DocumentListItem(
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
@@ -92,6 +86,12 @@ function DocumentListItem(
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.emoji && (
|
||||
<>
|
||||
<EmojiIcon emoji={document.emoji} size={24} />
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
@@ -135,25 +135,6 @@ function DocumentListItem(
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
{document.isTemplate &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted &&
|
||||
can.createDocument &&
|
||||
canCollection.update && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentPath(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
</>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
@@ -262,8 +243,8 @@ const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
margin-bottom: 0.25em;
|
||||
white-space: nowrap;
|
||||
color: ${s("text")};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
font-family: ${s("fontFamily")};
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sortBy } from "lodash";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { deburr, difference, sortBy } from "lodash";
|
||||
import deburr from "lodash/deburr";
|
||||
import difference from "lodash/difference";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
@@ -46,6 +48,7 @@ export type Props = Optional<
|
||||
> & {
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
previewsDisabled?: boolean;
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => any;
|
||||
@@ -60,6 +63,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
onHeadingsChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
previewsDisabled,
|
||||
} = props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
@@ -337,7 +341,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onHoverLink={handleLinkActive}
|
||||
onHoverLink={previewsDisabled ? undefined : handleLinkActive}
|
||||
onClickLink={handleClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import styled from "styled-components";
|
||||
import Button from "~/components/Button";
|
||||
import { hover } from "~/styles";
|
||||
import Flex from "../Flex";
|
||||
|
||||
export const EmojiButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
&: ${hover},
|
||||
&:active,
|
||||
&[aria-expanded= "true"] {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Emoji = styled(Flex)<{ size?: number }>`
|
||||
line-height: 1.6;
|
||||
${(props) => (props.size ? `font-size: ${props.size}px` : "")}
|
||||
`;
|
||||
@@ -0,0 +1,269 @@
|
||||
import data from "@emoji-mart/data";
|
||||
import Picker from "@emoji-mart/react";
|
||||
import { SmileyIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { toRGB } from "@shared/utils/color";
|
||||
import Button from "~/components/Button";
|
||||
import Popover from "~/components/Popover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { Emoji, EmojiButton } from "./components";
|
||||
|
||||
/* Locales supported by emoji-mart */
|
||||
const supportedLocales = [
|
||||
"en",
|
||||
"ar",
|
||||
"be",
|
||||
"cs",
|
||||
"de",
|
||||
"es",
|
||||
"fa",
|
||||
"fi",
|
||||
"fr",
|
||||
"hi",
|
||||
"it",
|
||||
"ja",
|
||||
"kr",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt",
|
||||
"ru",
|
||||
"sa",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh",
|
||||
];
|
||||
|
||||
/**
|
||||
* React hook to derive emoji picker's theme from UI theme
|
||||
*
|
||||
* @returns {string} Theme to use for emoji picker
|
||||
*/
|
||||
function usePickerTheme(): string {
|
||||
const { ui } = useStores();
|
||||
const { theme } = ui;
|
||||
|
||||
if (theme === "system") {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** The selected emoji, if any */
|
||||
value?: string | null;
|
||||
/** Callback when an emoji is selected */
|
||||
onChange: (emoji: string | null) => void | Promise<void>;
|
||||
/** Callback when the picker is opened */
|
||||
onOpen?: () => void;
|
||||
/** Callback when the picker is closed */
|
||||
onClose?: () => void;
|
||||
/** Callback when the picker is clicked outside of */
|
||||
onClickOutside: () => void;
|
||||
/** Whether to auto focus the search input on open */
|
||||
autoFocus?: boolean;
|
||||
/** Class name to apply to the trigger button */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function EmojiPicker({
|
||||
value,
|
||||
onOpen,
|
||||
onClose,
|
||||
onChange,
|
||||
onClickOutside,
|
||||
autoFocus,
|
||||
className,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const pickerTheme = usePickerTheme();
|
||||
const theme = useTheme();
|
||||
const locale = useUserLocale(true) ?? "en";
|
||||
|
||||
const popover = usePopoverState({
|
||||
placement: "bottom-start",
|
||||
modal: true,
|
||||
unstable_offset: [0, 0],
|
||||
});
|
||||
|
||||
const [emojisPerLine, setEmojisPerLine] = React.useState(9);
|
||||
|
||||
const pickerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (popover.visible) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
}, [popover.visible, onOpen, onClose]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (popover.visible && pickerRef.current) {
|
||||
// 28 is picker's observed width when perLine is set to 0
|
||||
// and 36 is the default emojiButtonSize
|
||||
// Ref: https://github.com/missive/emoji-mart#options--props
|
||||
setEmojisPerLine(Math.floor((pickerRef.current.clientWidth - 28) / 36));
|
||||
}
|
||||
}, [popover.visible]);
|
||||
|
||||
const handleEmojiChange = React.useCallback(
|
||||
async (emoji) => {
|
||||
popover.hide();
|
||||
await onChange(emoji ? emoji.native : null);
|
||||
},
|
||||
[popover, onChange]
|
||||
);
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (popover.visible) {
|
||||
popover.hide();
|
||||
} else {
|
||||
popover.show();
|
||||
}
|
||||
},
|
||||
[popover]
|
||||
);
|
||||
|
||||
const handleClickOutside = React.useCallback(() => {
|
||||
// It was observed that onClickOutside got triggered
|
||||
// even when the picker wasn't open or opened at all.
|
||||
// Hence, this guard here...
|
||||
if (popover.visible) {
|
||||
onClickOutside();
|
||||
}
|
||||
}, [popover.visible, onClickOutside]);
|
||||
|
||||
// Auto focus search input when picker is opened
|
||||
React.useLayoutEffect(() => {
|
||||
if (autoFocus && popover.visible) {
|
||||
requestAnimationFrame(() => {
|
||||
const searchInput = pickerRef.current
|
||||
?.querySelector("em-emoji-picker")
|
||||
?.shadowRoot?.querySelector(
|
||||
"input[type=search]"
|
||||
) as HTMLInputElement | null;
|
||||
searchInput?.focus();
|
||||
});
|
||||
}
|
||||
}, [autoFocus, popover.visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<EmojiButton
|
||||
{...props}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
icon={
|
||||
value ? (
|
||||
<Emoji size={32} align="center" justify="center">
|
||||
{value}
|
||||
</Emoji>
|
||||
) : (
|
||||
<StyledSmileyIcon size={32} color={theme.textTertiary} />
|
||||
)
|
||||
}
|
||||
neutral
|
||||
borderOnHover
|
||||
/>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<PickerPopover
|
||||
{...popover}
|
||||
tabIndex={0}
|
||||
// This prevents picker from closing when any of its
|
||||
// children are focused, e.g, clicking on search bar or
|
||||
// a click on skin tone button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
width={352}
|
||||
aria-label={t("Emoji Picker")}
|
||||
>
|
||||
{popover.visible && (
|
||||
<>
|
||||
{value && (
|
||||
<RemoveButton neutral onClick={() => handleEmojiChange(null)}>
|
||||
{t("Remove")}
|
||||
</RemoveButton>
|
||||
)}
|
||||
<PickerStyles ref={pickerRef}>
|
||||
<Picker
|
||||
// https://github.com/missive/emoji-mart/issues/800
|
||||
locale={
|
||||
locale === "ko"
|
||||
? "kr"
|
||||
: supportedLocales.includes(locale)
|
||||
? locale
|
||||
: "en"
|
||||
}
|
||||
data={data}
|
||||
onEmojiSelect={handleEmojiChange}
|
||||
theme={pickerTheme}
|
||||
previewPosition="none"
|
||||
perLine={emojisPerLine}
|
||||
onClickOutside={handleClickOutside}
|
||||
/>
|
||||
</PickerStyles>
|
||||
</>
|
||||
)}
|
||||
</PickerPopover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledSmileyIcon = styled(SmileyIcon)`
|
||||
flex-shrink: 0;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const RemoveButton = styled(Button)`
|
||||
margin-left: -12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 6px;
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
|
||||
> :first-child {
|
||||
min-height: unset;
|
||||
line-height: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
const PickerPopover = styled(Popover)`
|
||||
z-index: ${depths.popover};
|
||||
> :first-child {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 0;
|
||||
max-height: 488px;
|
||||
overflow: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
const PickerStyles = styled.div`
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
em-emoji-picker {
|
||||
--shadow: none;
|
||||
--font-family: ${s("fontFamily")};
|
||||
--rgb-background: ${(props) => toRGB(props.theme.menuBackground)};
|
||||
--rgb-accent: ${(props) => toRGB(props.theme.accent)};
|
||||
--border-radius: 6px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
min-height: 443px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default EmojiPicker;
|
||||
@@ -80,7 +80,7 @@ const Note = styled(Text)`
|
||||
margin-bottom: 0;
|
||||
line-height: 1.2em;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
font-weight: 500;
|
||||
color: ${s("textTertiary")};
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { throttle } from "lodash";
|
||||
import throttle from "lodash/throttle";
|
||||
import { observer } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
@@ -6,6 +6,7 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -14,16 +15,16 @@ import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { supportsPassiveListener } from "~/utils/browser";
|
||||
|
||||
type Props = {
|
||||
left?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
hasSidebar?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Header({ left, title, actions, hasSidebar }: Props) {
|
||||
function Header({ left, title, actions, hasSidebar, className }: Props) {
|
||||
const { ui } = useStores();
|
||||
const isMobile = useMobile();
|
||||
const hasMobileSidebar = hasSidebar && isMobile;
|
||||
@@ -54,6 +55,7 @@ function Header({ left, title, actions, hasSidebar }: Props) {
|
||||
<Wrapper
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
$passThrough={passThrough}
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeRegExp } from "lodash";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import * as React from "react";
|
||||
import replace from "string-replace-to-array";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -4,7 +4,7 @@ import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
export const CARD_MARGIN = 16;
|
||||
export const CARD_MARGIN = 10;
|
||||
|
||||
const NUMBER_OF_LINES = 10;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { m } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { depths } from "@shared/styles";
|
||||
import { UnfurlType } from "@shared/types";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -17,16 +17,24 @@ import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_OPEN = 500;
|
||||
const DELAY_CLOSE = 600;
|
||||
|
||||
type Props = {
|
||||
/* The HTML element that is being hovered over */
|
||||
/** The HTML element that is being hovered over */
|
||||
element: HTMLAnchorElement;
|
||||
/* A callback on close of the hover preview */
|
||||
/** A callback on close of the hover preview */
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
enum Direction {
|
||||
UP,
|
||||
DOWN,
|
||||
}
|
||||
|
||||
const POINTER_HEIGHT = 22;
|
||||
const POINTER_WIDTH = 22;
|
||||
|
||||
function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const url = element.href || element.dataset.url;
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
@@ -36,31 +44,46 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const stores = useStores();
|
||||
const [cardLeft, setCardLeft] = React.useState(0);
|
||||
const [cardTop, setCardTop] = React.useState(0);
|
||||
const [pointerOffset, setPointerOffset] = React.useState(0);
|
||||
const [pointerLeft, setPointerLeft] = React.useState(0);
|
||||
const [pointerTop, setPointerTop] = React.useState(0);
|
||||
const [pointerDir, setPointerDir] = React.useState(Direction.UP);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isVisible && cardRef.current) {
|
||||
const elem = element.getBoundingClientRect();
|
||||
const card = cardRef.current.getBoundingClientRect();
|
||||
|
||||
const top = elem.bottom + window.scrollY;
|
||||
setCardTop(top);
|
||||
let cTop = elem.bottom + window.scrollY + CARD_MARGIN;
|
||||
let pTop = -POINTER_HEIGHT;
|
||||
let pDir = Direction.UP;
|
||||
if (cTop + card.height > window.innerHeight + window.scrollY) {
|
||||
// shift card upwards if it goes out of screen
|
||||
const bottom = elem.top + window.scrollY;
|
||||
cTop = bottom - card.height;
|
||||
// shift a little further to leave some margin between card and element boundary
|
||||
cTop -= CARD_MARGIN;
|
||||
// pointer should be shifted downwards to align with card's bottom
|
||||
pTop = card.height;
|
||||
pDir = Direction.DOWN;
|
||||
}
|
||||
setCardTop(cTop);
|
||||
setPointerTop(pTop);
|
||||
setPointerDir(pDir);
|
||||
|
||||
let left = elem.left;
|
||||
let pointerOffset = elem.width / 2;
|
||||
if (left + card.width > window.innerWidth) {
|
||||
let cLeft = elem.left;
|
||||
let pLeft = elem.width / 2;
|
||||
if (cLeft + card.width > window.innerWidth) {
|
||||
// shift card leftwards by the amount it went out of screen
|
||||
let shiftBy = left + card.width - window.innerWidth;
|
||||
// shift a littler further to leave some margin between card and window boundary
|
||||
let shiftBy = cLeft + card.width - window.innerWidth;
|
||||
// shift a little further to leave some margin between card and window boundary
|
||||
shiftBy += CARD_MARGIN;
|
||||
left -= shiftBy;
|
||||
cLeft -= shiftBy;
|
||||
|
||||
// shift pointer rightwards by same amount so as to position it back correctly
|
||||
pointerOffset += shiftBy;
|
||||
pLeft += shiftBy;
|
||||
}
|
||||
setCardLeft(left);
|
||||
|
||||
setPointerOffset(pointerOffset);
|
||||
setCardLeft(cLeft);
|
||||
setPointerLeft(pLeft);
|
||||
}
|
||||
}, [isVisible, element]);
|
||||
|
||||
@@ -103,18 +126,18 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
useKeyDown("Escape", closePreview);
|
||||
useEventListener("scroll", closePreview, window, { capture: true });
|
||||
|
||||
const stopCloseTimer = () => {
|
||||
const stopCloseTimer = React.useCallback(() => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
timerClose.current = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startOpenTimer = () => {
|
||||
const startOpenTimer = React.useCallback(() => {
|
||||
if (!timerOpen.current) {
|
||||
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startCloseTimer = React.useCallback(() => {
|
||||
stopOpenTimer();
|
||||
@@ -149,7 +172,7 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
|
||||
stopCloseTimer();
|
||||
};
|
||||
}, [element, startCloseTimer, data]);
|
||||
}, [element, startCloseTimer, data, startOpenTimer, stopCloseTimer]);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingIndicator />;
|
||||
@@ -193,7 +216,11 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer offset={pointerOffset} />
|
||||
<Pointer
|
||||
top={pointerTop}
|
||||
left={pointerLeft}
|
||||
direction={pointerDir}
|
||||
/>
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
@@ -217,7 +244,6 @@ const Animate = styled(m.div)`
|
||||
`;
|
||||
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${depths.hoverPreview};
|
||||
display: flex;
|
||||
@@ -227,11 +253,11 @@ const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div<{ offset: number }>`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
|
||||
top: ${(props) => props.top}px;
|
||||
left: ${(props) => props.left}px;
|
||||
width: ${POINTER_WIDTH}px;
|
||||
height: ${POINTER_HEIGHT}px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
@@ -241,20 +267,26 @@ const Pointer = styled.div<{ offset: number }>`
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
${({ direction }) => (direction === Direction.UP ? "bottom: 0" : "top: 0")};
|
||||
${({ direction }) => (direction === Direction.UP ? "right: 0" : "left: 0")};
|
||||
}
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
border-bottom-color: ${(props) =>
|
||||
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
|
||||
right: -1px;
|
||||
${({ direction, theme }) =>
|
||||
direction === Direction.UP
|
||||
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
|
||||
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
|
||||
${({ direction }) =>
|
||||
direction === Direction.UP ? "right: -1px" : "left: -1px"};
|
||||
}
|
||||
|
||||
&:after {
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: ${s("menuBackground")};
|
||||
${({ direction, theme }) =>
|
||||
direction === Direction.UP
|
||||
? `border-bottom-color: ${theme.menuBackground}`
|
||||
: `border-top-color: ${theme.menuBackground}`};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
|
||||
) {
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column>
|
||||
<Flex column ref={ref}>
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
|
||||
<Card ref={ref}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Flex column>
|
||||
<Title>{title}</Title>
|
||||
|
||||
@@ -49,11 +49,7 @@ import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
|
||||
const style = {
|
||||
width: 30,
|
||||
height: 30,
|
||||
};
|
||||
import LetterIcon from "./Icons/LetterIcon";
|
||||
|
||||
const TwitterPicker = lazyWithRetry(
|
||||
() => import("react-color/lib/components/twitter/Twitter")
|
||||
@@ -136,6 +132,10 @@ export const icons = {
|
||||
component: LightningIcon,
|
||||
keywords: "lightning fast zap",
|
||||
},
|
||||
letter: {
|
||||
component: LetterIcon,
|
||||
keywords: "letter",
|
||||
},
|
||||
math: {
|
||||
component: MathIcon,
|
||||
keywords: "math formula",
|
||||
@@ -206,11 +206,19 @@ type Props = {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onChange: (color: string, icon: string) => void;
|
||||
initial: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
function IconPicker({
|
||||
onOpen,
|
||||
onClose,
|
||||
icon,
|
||||
initial,
|
||||
color,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState({
|
||||
@@ -230,7 +238,9 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
as={icons[icon || "collection"].component}
|
||||
color={color}
|
||||
size={30}
|
||||
/>
|
||||
>
|
||||
{initial}
|
||||
</Icon>
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
@@ -238,6 +248,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
{...menu}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
maxWidth={308}
|
||||
aria-label={t("Choose icon")}
|
||||
>
|
||||
<Icons>
|
||||
@@ -251,13 +262,14 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
<IconButton
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
"--delay": `${index * 8}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<Icon as={icons[name].component} color={color} size={30} />
|
||||
<Icon as={icons[name].component} color={color} size={30}>
|
||||
{initial}
|
||||
</Icon>
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
@@ -318,7 +330,7 @@ const Icons = styled.div`
|
||||
padding: 8px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: 276px;
|
||||
width: 304px;
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -329,6 +341,7 @@ const Button = styled(NudeButton)`
|
||||
`;
|
||||
|
||||
const IconButton = styled(NudeButton)`
|
||||
vertical-align: top;
|
||||
border-radius: 4px;
|
||||
margin: 0px 6px 6px 0px;
|
||||
width: 30px;
|
||||
|
||||
@@ -39,7 +39,11 @@ function ResolvedCollectionIcon({
|
||||
if (collection.icon && collection.icon !== "collection") {
|
||||
try {
|
||||
const Component = icons[collection.icon].component;
|
||||
return <Component color={color} size={size} />;
|
||||
return (
|
||||
<Component color={color} size={size}>
|
||||
{collection.initial}
|
||||
</Component>
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.warn("Failed to render custom icon", {
|
||||
icon: collection.icon,
|
||||
|
||||
@@ -2,9 +2,9 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
/* The emoji to render */
|
||||
/** The emoji to render */
|
||||
emoji: string;
|
||||
/* The size of the emoji, 24px is default to match standard icons */
|
||||
/** The size of the emoji, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
@@ -29,5 +29,5 @@ const Span = styled.span<{ $size: number }>`
|
||||
width: ${(props) => props.$size}px;
|
||||
height: ${(props) => props.$size}px;
|
||||
text-indent: -0.15em;
|
||||
font-size: 14px;
|
||||
font-size: ${(props) => props.$size - 10}px;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Squircle from "../Squircle";
|
||||
|
||||
type Props = {
|
||||
/** The width and height of the icon, including standard padding. */
|
||||
size?: number;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A squircle shaped icon with a letter inside, used for collections.
|
||||
*/
|
||||
const LetterIcon = ({ children, size = 24, ...rest }: Props) => (
|
||||
<LetterIconWrapper $size={size}>
|
||||
<Squircle size={Math.round(size * 0.66)} {...rest}>
|
||||
{children}
|
||||
</Squircle>
|
||||
</LetterIconWrapper>
|
||||
);
|
||||
|
||||
const LetterIconWrapper = styled.div<{ $size: number }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: ${({ $size }) => $size}px;
|
||||
height: ${({ $size }) => $size}px;
|
||||
|
||||
font-weight: 700;
|
||||
font-size: ${({ $size }) => $size / 2}px;
|
||||
color: ${s("background")};
|
||||
`;
|
||||
|
||||
export default LetterIcon;
|
||||
@@ -5,7 +5,7 @@ type Props = {
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
/* Whether the safe area should be removed and have graphic across full size */
|
||||
/** Whether the safe area should be removed and have graphic across full size */
|
||||
cover?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ export type Props = React.InputHTMLAttributes<
|
||||
margin?: string | number;
|
||||
error?: string;
|
||||
icon?: React.ReactNode;
|
||||
/* Callback is triggered with the CMD+Enter keyboard combo */
|
||||
/** Callback is triggered with the CMD+Enter keyboard combo */
|
||||
onRequestSubmit?: (
|
||||
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => unknown;
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function InputSelectPermission(
|
||||
const handleChange = React.useCallback(
|
||||
(value) => {
|
||||
if (value === "no_access") {
|
||||
value = "";
|
||||
value = null;
|
||||
}
|
||||
|
||||
onChange?.(value);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
/* Set to true if displaying a single symbol character to disable monospace */
|
||||
/** Set to true if displaying a single symbol character to disable monospace */
|
||||
symbol?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { find } from "lodash";
|
||||
import find from "lodash/find";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -126,6 +126,7 @@ const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
|
||||
export const Actions = styled(Flex)<{ $selected?: boolean }>`
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white : props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { times } from "lodash";
|
||||
import times from "lodash/times";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Fade from "~/components/Fade";
|
||||
|
||||
@@ -94,11 +94,9 @@ const Modal: React.FC<Props> = ({
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text as="span" size="large">
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Text>
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Header>
|
||||
</Centered>
|
||||
</Small>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { LocationDescriptor, LocationDescriptorObject } from "history";
|
||||
import * as React from "react";
|
||||
import { match, NavLink, Route } from "react-router-dom";
|
||||
|
||||
@@ -9,10 +9,20 @@ type Props = React.ComponentProps<typeof NavLink> & {
|
||||
[x: string]: string | undefined;
|
||||
}>
|
||||
| boolean
|
||||
| null
|
||||
| null,
|
||||
location: LocationDescriptorObject
|
||||
) => React.ReactNode;
|
||||
/**
|
||||
* If true, the tab will only be active if the path matches exactly.
|
||||
*/
|
||||
exact?: boolean;
|
||||
/**
|
||||
* CSS properties to apply to the link when it is active.
|
||||
*/
|
||||
activeStyle?: React.CSSProperties;
|
||||
/**
|
||||
* The path to match against the current location.
|
||||
*/
|
||||
to: LocationDescriptor;
|
||||
};
|
||||
|
||||
@@ -25,7 +35,10 @@ function NavLinkWithChildrenFunc(
|
||||
{({ match, location }) => (
|
||||
<NavLink {...rest} to={to} exact={exact} ref={ref}>
|
||||
{children
|
||||
? children(rest.isActive ? rest.isActive(match, location) : match)
|
||||
? children(
|
||||
rest.isActive ? rest.isActive(match, location) : match,
|
||||
location
|
||||
)
|
||||
: null}
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SubscribeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Relative from "../Sidebar/components/Relative";
|
||||
|
||||
const NotificationIcon = () => {
|
||||
const { notifications } = useStores();
|
||||
const theme = useTheme();
|
||||
const count = notifications.approximateUnreadCount;
|
||||
|
||||
return (
|
||||
<Relative style={{ height: 24 }}>
|
||||
<SubscribeIcon color={theme.textTertiary} />
|
||||
<SubscribeIcon />
|
||||
{count > 0 && <Badge />}
|
||||
</Relative>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={notification.path} onClick={handleClick}>
|
||||
<Link to={notification.path ?? ""} onClick={handleClick}>
|
||||
<Container gap={8} $unread={!notification.viewedAt}>
|
||||
<StyledAvatar model={notification.actor} size={AvatarSize.Large} />
|
||||
<Flex column>
|
||||
@@ -64,6 +64,7 @@ function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
{notification.comment && (
|
||||
<StyledCommentEditor
|
||||
defaultValue={toJS(notification.comment.data)}
|
||||
previewsDisabled
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
@@ -87,6 +88,7 @@ const StyledAvatar = styled(Avatar)`
|
||||
const Container = styled(Flex)<{ $unread: boolean }>`
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
padding-right: 40px;
|
||||
margin: 0 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import Tooltip from "../Tooltip";
|
||||
import NotificationListItem from "./NotificationListItem";
|
||||
|
||||
type Props = {
|
||||
/* Callback when the notification panel wants to close. */
|
||||
/** Callback when the notification panel wants to close. */
|
||||
onRequestClose: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { shallow } from "enzyme";
|
||||
import { TFunction } from "i18next";
|
||||
import * as React from "react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/base/Store";
|
||||
import { runAllPromises } from "~/test/support";
|
||||
import { Component as PaginatedList } from "./PaginatedList";
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { isEqual } from "lodash";
|
||||
import isEqual from "lodash/isEqual";
|
||||
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 { DEFAULT_PAGINATION_LIMIT } from "~/stores/base/Store";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
@@ -15,6 +16,8 @@ type Props = PopoverProps & {
|
||||
tabIndex?: number;
|
||||
scrollable?: boolean;
|
||||
mobilePosition?: "top" | "bottom";
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
const Popover: React.FC<Props> = ({
|
||||
@@ -28,6 +31,21 @@ const Popover: React.FC<Props> = ({
|
||||
}: Props) => {
|
||||
const isMobile = useMobile();
|
||||
|
||||
// Custom Escape handler rather than using hideOnEsc from reakit so we can
|
||||
// prevent default behavior of exiting fullscreen.
|
||||
useKeyDown(
|
||||
"Escape",
|
||||
(event) => {
|
||||
if (rest.visible && rest.hideOnEsc !== false) {
|
||||
event.preventDefault();
|
||||
rest.hide();
|
||||
}
|
||||
},
|
||||
{
|
||||
allowInInput: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Dialog {...rest} modal>
|
||||
@@ -44,7 +62,7 @@ const Popover: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<ReakitPopover {...rest}>
|
||||
<ReakitPopover {...rest} hideOnEsc={false}>
|
||||
<Contents
|
||||
$shrink={shrink}
|
||||
$width={width}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { debounce } from "lodash";
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, SearchIcon, ShapesIcon, HomeIcon } from "outline-icons";
|
||||
import { EditIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
@@ -14,29 +14,26 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import {
|
||||
homePath,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ArchiveLink from "./components/ArchiveLink";
|
||||
import Collections from "./components/Collections";
|
||||
import DragPlaceholder from "./components/DragPlaceholder";
|
||||
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
|
||||
import HistoryNavigation from "./components/HistoryNavigation";
|
||||
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 ToggleButton from "./components/ToggleButton";
|
||||
import TrashLink from "./components/TrashLink";
|
||||
|
||||
function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const { documents, ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team);
|
||||
@@ -44,7 +41,6 @@ function AppSidebar() {
|
||||
React.useEffect(() => {
|
||||
if (!user.isViewer) {
|
||||
void documents.fetchDrafts();
|
||||
void documents.fetchTemplates();
|
||||
}
|
||||
}, [documents, user.isViewer]);
|
||||
|
||||
@@ -65,23 +61,34 @@ function AppSidebar() {
|
||||
<DragPlaceholder />
|
||||
|
||||
<OrganizationMenu>
|
||||
{(props: HeaderButtonProps) => (
|
||||
<HeaderButton
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
title={team.name}
|
||||
image={
|
||||
<TeamLogo
|
||||
model={team}
|
||||
size={Desktop.hasInsetTitlebar() ? 24 : 32}
|
||||
size={24}
|
||||
alt={t("Logo")}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
style={
|
||||
// Move the logo over to align with smaller size
|
||||
Desktop.hasInsetTitlebar() ? { paddingLeft: 8 } : undefined
|
||||
}
|
||||
showDisclosure
|
||||
/>
|
||||
>
|
||||
<Tooltip
|
||||
tooltip={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
)}
|
||||
</OrganizationMenu>
|
||||
<Scrollable flex shadow>
|
||||
@@ -105,9 +112,11 @@ function AppSidebar() {
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts}
|
||||
</Drafts>
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
@@ -122,19 +131,6 @@ function AppSidebar() {
|
||||
<Section>
|
||||
{can.createDocument && (
|
||||
<>
|
||||
<SidebarLink
|
||||
to={templatesPath()}
|
||||
icon={<ShapesIcon />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isTemplate &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isArchived
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ArchiveLink />
|
||||
<TrashLink />
|
||||
</>
|
||||
|
||||
@@ -120,7 +120,7 @@ const Position = styled(Flex)`
|
||||
const Sidebar = styled(m.div)<{
|
||||
$border?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
background: ${s("background")};
|
||||
max-width: 80%;
|
||||
@@ -129,6 +129,7 @@ const Sidebar = styled(m.div)<{
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
import { groupBy } from "lodash";
|
||||
import groupBy from "lodash/groupBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { BackIcon } from "outline-icons";
|
||||
import { BackIcon, SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import HeaderButton from "./components/HeaderButton";
|
||||
import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import Version from "./components/Version";
|
||||
|
||||
function SettingsSidebar() {
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const configs = useSettingsConfig();
|
||||
const groupedConfig = groupBy(configs, "group");
|
||||
|
||||
@@ -31,12 +37,26 @@ function SettingsSidebar() {
|
||||
return (
|
||||
<Sidebar>
|
||||
<HistoryNavigation />
|
||||
<HeaderButton
|
||||
<SidebarButton
|
||||
title={t("Return to App")}
|
||||
image={<StyledBackIcon />}
|
||||
onClick={returnToApp}
|
||||
minHeight={Desktop.hasInsetTitlebar() ? undefined : 48}
|
||||
/>
|
||||
>
|
||||
<Tooltip
|
||||
tooltip={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
@@ -47,6 +67,11 @@ function SettingsSidebar() {
|
||||
<SidebarLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
active={
|
||||
item.path !== settingsPath()
|
||||
? location.pathname.startsWith(item.path)
|
||||
: undefined
|
||||
}
|
||||
icon={<item.icon />}
|
||||
label={item.name}
|
||||
/>
|
||||
|
||||
@@ -11,9 +11,9 @@ import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
import HeaderButton from "./components/HeaderButton";
|
||||
import Section from "./components/Section";
|
||||
import DocumentLink from "./components/SharedDocumentLink";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
|
||||
type Props = {
|
||||
rootNode: NavigationNode;
|
||||
@@ -28,7 +28,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
return (
|
||||
<Sidebar>
|
||||
{team && (
|
||||
<HeaderButton
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
|
||||
onClick={() =>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -11,29 +10,29 @@ import useMenuContext from "~/hooks/useMenuContext";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import AccountMenu from "~/menus/AccountMenu";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import { fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Avatar from "../Avatar";
|
||||
import NotificationIcon from "../Notifications/NotificationIcon";
|
||||
import NotificationsPopover from "../Notifications/NotificationsPopover";
|
||||
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
|
||||
const ANIMATION_MS = 250;
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
{ children }: Props,
|
||||
{ children, className }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const previousLocation = usePrevious(location);
|
||||
@@ -46,8 +45,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isHovering, setHovering] = React.useState(false);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const [hasPointerMoved, setPointerMoved] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
@@ -99,6 +100,34 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
[width]
|
||||
);
|
||||
|
||||
const handlePointerMove = React.useCallback(() => {
|
||||
if (ui.sidebarIsClosed) {
|
||||
setHovering(true);
|
||||
setPointerMoved(true);
|
||||
}
|
||||
}, [ui.sidebarIsClosed]);
|
||||
|
||||
const handlePointerLeave = React.useCallback(
|
||||
(ev) => {
|
||||
if (hasPointerMoved) {
|
||||
setHovering(
|
||||
ev.pageX < width &&
|
||||
ev.pageX > 0 &&
|
||||
ev.pageY < window.innerHeight &&
|
||||
ev.pageY > 0
|
||||
);
|
||||
}
|
||||
},
|
||||
[width, hasPointerMoved]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ui.sidebarIsClosed) {
|
||||
setHovering(false);
|
||||
setPointerMoved(false);
|
||||
}
|
||||
}, [ui.sidebarIsClosed]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), ANIMATION_MS);
|
||||
@@ -147,23 +176,19 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
[width]
|
||||
);
|
||||
|
||||
const toggleStyle = React.useMemo(
|
||||
() => ({
|
||||
right: "auto",
|
||||
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
|
||||
}),
|
||||
[width, theme.sidebarCollapsedWidth, collapsed]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
ref={ref}
|
||||
style={style}
|
||||
$isHovering={isHovering}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
className={className}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
column
|
||||
>
|
||||
{ui.mobileSidebarVisible && (
|
||||
@@ -175,26 +200,32 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
{user && (
|
||||
<AccountMenu>
|
||||
{(props: HeaderButtonProps) => (
|
||||
<HeaderButton
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
position="bottom"
|
||||
image={
|
||||
<StyledAvatar
|
||||
<Avatar
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
{(rest: HeaderButtonProps) => (
|
||||
<HeaderButton {...rest} image={<NotificationIcon />} />
|
||||
{(rest: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...rest}
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
/>
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
</HeaderButton>
|
||||
</SidebarButton>
|
||||
)}
|
||||
</AccountMenu>
|
||||
)}
|
||||
@@ -202,28 +233,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
{ui.sidebarIsClosed && (
|
||||
<Toggle
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={"right"}
|
||||
aria-label={t("Expand")}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<Toggle
|
||||
style={toggleStyle}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={ui.sidebarIsClosed ? "right" : "left"}
|
||||
aria-label={ui.sidebarIsClosed ? t("Expand") : t("Collapse")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
margin-left: 4px;
|
||||
`;
|
||||
|
||||
const Backdrop = styled.a`
|
||||
animation: ${fadeIn} 250ms ease-in-out;
|
||||
position: fixed;
|
||||
@@ -240,16 +254,33 @@ type ContainerProps = {
|
||||
$mobileSidebarVisible: boolean;
|
||||
$isAnimating: boolean;
|
||||
$isSmallerThanMinimum: boolean;
|
||||
$isHovering: boolean;
|
||||
$collapsed: boolean;
|
||||
};
|
||||
|
||||
const hoverStyles = (props: ContainerProps) => `
|
||||
transform: none;
|
||||
box-shadow: ${
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
: props.$isSmallerThanMinimum
|
||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||
: "none"
|
||||
};
|
||||
|
||||
${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<ContainerProps>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: ${s("sidebarBackground")};
|
||||
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
|
||||
transition: box-shadow 100ms ease-in-out, opacity 100ms ease-in-out,
|
||||
transform 100ms ease-out,
|
||||
${s("backgroundTransition")}
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
@@ -259,19 +290,17 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
z-index: ${depths.sidebar};
|
||||
max-width: 80%;
|
||||
min-width: 280px;
|
||||
padding-top: ${Desktop.hasInsetTitlebar() ? 36 : 0}px;
|
||||
${draggableOnDesktop()}
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
${Positioner} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
& > div {
|
||||
opacity: ${(props) => (props.$collapsed && !props.$isHovering ? "0" : "1")};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
@@ -280,28 +309,15 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
? `calc(-100% + ${Desktop.hasInsetTitlebar() ? 8 : 16}px)`
|
||||
: 0});
|
||||
|
||||
${(props: ContainerProps) => props.$isHovering && css(hoverStyles)}
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
transform: none;
|
||||
box-shadow: ${(props: ContainerProps) =>
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
: props.$isSmallerThanMinimum
|
||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||
: "none"};
|
||||
${hoverStyles}
|
||||
|
||||
${Positioner} {
|
||||
display: block;
|
||||
}
|
||||
|
||||
${ToggleButton} {
|
||||
& > div {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:hover):not(:focus-within) > div {
|
||||
opacity: ${(props: ContainerProps) => (props.$collapsed ? "0" : "1")};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
}
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -324,6 +324,7 @@ function InnerDocumentLink(
|
||||
starred: inStarredSection,
|
||||
},
|
||||
}}
|
||||
emoji={document?.emoji || node.emoji}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={title}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { ExpandedIcon, MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
export type HeaderButtonProps = React.ComponentProps<typeof Button> & {
|
||||
title: React.ReactNode;
|
||||
image: React.ReactNode;
|
||||
minHeight?: number;
|
||||
rounded?: boolean;
|
||||
showDisclosure?: boolean;
|
||||
showMoreMenu?: boolean;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
|
||||
function _HeaderButton(
|
||||
{
|
||||
showDisclosure,
|
||||
showMoreMenu,
|
||||
image,
|
||||
title,
|
||||
minHeight = 0,
|
||||
children,
|
||||
...rest
|
||||
}: HeaderButtonProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<Flex justify="space-between" align="center" shrink={false}>
|
||||
<Button
|
||||
{...rest}
|
||||
minHeight={minHeight}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Title gap={8} align="center">
|
||||
{image}
|
||||
{title}
|
||||
</Title>
|
||||
{showDisclosure && <ExpandedIcon />}
|
||||
{showMoreMenu && <MoreIcon />}
|
||||
</Button>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const Title = styled(Flex)`
|
||||
color: ${s("text")};
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Button = styled(Flex)<{ minHeight: number }>`
|
||||
flex: 1;
|
||||
color: ${s("textTertiary")};
|
||||
align-items: center;
|
||||
padding: 8px 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
flex-shrink: 0;
|
||||
min-height: ${(props) => props.minHeight}px;
|
||||
|
||||
-webkit-appearance: none;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
cursor: var(--pointer);
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
color: ${s("sidebarText")};
|
||||
transition: background 100ms ease-in-out;
|
||||
background: ${s("sidebarActiveBackground")};
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-left: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default HeaderButton;
|
||||
@@ -3,12 +3,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { isMac } from "~/utils/browser";
|
||||
|
||||
function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import includes from "lodash/includes";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -6,14 +7,14 @@ import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import Disclosure from "./Disclosure";
|
||||
import { descendants } from "~/utils/tree";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
collection?: Collection;
|
||||
activeDocumentId: string | undefined;
|
||||
activeDocument: Document | undefined;
|
||||
activeDocumentId?: string;
|
||||
activeDocument?: Document;
|
||||
isDraft?: boolean;
|
||||
depth: number;
|
||||
index: number;
|
||||
@@ -41,10 +42,19 @@ function DocumentLink(
|
||||
const hasChildDocuments =
|
||||
!!node.children.length || activeDocument?.parentDocumentId === node.id;
|
||||
const document = documents.get(node.id);
|
||||
|
||||
const showChildren = React.useMemo(
|
||||
() => !!hasChildDocuments,
|
||||
[hasChildDocuments]
|
||||
() =>
|
||||
!!(
|
||||
hasChildDocuments &&
|
||||
((activeDocumentId &&
|
||||
includes(
|
||||
descendants(node).map((n) => n.id),
|
||||
activeDocumentId
|
||||
)) ||
|
||||
isActiveDocument ||
|
||||
depth <= 1)
|
||||
),
|
||||
[hasChildDocuments, activeDocumentId, isActiveDocument, depth, node]
|
||||
);
|
||||
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
@@ -55,12 +65,6 @@ function DocumentLink(
|
||||
}
|
||||
}, [showChildren]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActiveDocument) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [isActiveDocument]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
@@ -105,14 +109,10 @@ function DocumentLink(
|
||||
title: node.title,
|
||||
},
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && depth !== 0 && (
|
||||
<Disclosure expanded={expanded} onClick={handleDisclosureClick} />
|
||||
)}
|
||||
{title}
|
||||
</>
|
||||
}
|
||||
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
emoji={node.emoji}
|
||||
label={title}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
scrollIntoViewIfNeeded={!document?.isStarred}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { extraArea, s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import { draggableOnDesktop, undraggableOnDesktop } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
|
||||
position: "top" | "bottom";
|
||||
title: React.ReactNode;
|
||||
image: React.ReactNode;
|
||||
showMoreMenu?: boolean;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
|
||||
function _SidebarButton(
|
||||
{
|
||||
position = "top",
|
||||
showMoreMenu,
|
||||
image,
|
||||
title,
|
||||
children,
|
||||
...rest
|
||||
}: SidebarButtonProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<Container
|
||||
justify="space-between"
|
||||
align="center"
|
||||
shrink={false}
|
||||
$position={position}
|
||||
>
|
||||
<Button
|
||||
{...rest}
|
||||
$position={position}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Content gap={8} align="center">
|
||||
{image}
|
||||
{title && <Title as="span">{title}</Title>}
|
||||
</Content>
|
||||
{showMoreMenu && <StyledMoreIcon />}
|
||||
</Button>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const StyledMoreIcon = styled(MoreIcon)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ $position: "top" | "bottom" }>`
|
||||
padding-top: ${(props) =>
|
||||
props.$position === "top" && Desktop.hasInsetTitlebar() ? 36 : 0}px;
|
||||
${draggableOnDesktop()}
|
||||
`;
|
||||
|
||||
const Title = styled(Text)`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Button = styled(Flex)<{
|
||||
$position: "top" | "bottom";
|
||||
}>`
|
||||
flex: 1;
|
||||
color: ${s("textTertiary")};
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
border: 0;
|
||||
margin: ${(props) => (props.$position === "top" ? 16 : 8)}px 0;
|
||||
background: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
-webkit-appearance: none;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
cursor: var(--pointer);
|
||||
position: relative;
|
||||
|
||||
${undraggableOnDesktop()}
|
||||
${extraArea(4)}
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
color: ${s("sidebarText")};
|
||||
transition: background 100ms ease-in-out;
|
||||
background: ${s("sidebarActiveBackground")};
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-left: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default SidebarButton;
|
||||
@@ -5,6 +5,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import EventBoundary from "~/components/EventBoundary";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
@@ -21,16 +22,17 @@ type Props = Omit<NavLinkProps, "to"> & {
|
||||
to?: LocationDescriptor;
|
||||
innerRef?: (ref: HTMLElement | null | undefined) => void;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
/* Callback when we expect the user to click on the link. Used for prefetching data. */
|
||||
/** Callback when we expect the user to click on the link. Used for prefetching data. */
|
||||
onClickIntent?: () => void;
|
||||
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
icon?: React.ReactNode;
|
||||
emoji?: string | null;
|
||||
label?: React.ReactNode;
|
||||
menu?: React.ReactNode;
|
||||
showActions?: boolean;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
/* If set, a disclosure will be rendered to the left of any icon */
|
||||
/** If set, a disclosure will be rendered to the left of any icon */
|
||||
expanded?: boolean;
|
||||
isActiveDrop?: boolean;
|
||||
isDraft?: boolean;
|
||||
@@ -48,6 +50,7 @@ function SidebarLink(
|
||||
onClick,
|
||||
onClickIntent,
|
||||
to,
|
||||
emoji,
|
||||
label,
|
||||
active,
|
||||
isActiveDrop,
|
||||
@@ -136,6 +139,7 @@ function SidebarLink(
|
||||
/>
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{emoji && <EmojiIcon emoji={emoji} />}
|
||||
<Label>{label}</Label>
|
||||
</Content>
|
||||
</Link>
|
||||
@@ -152,6 +156,7 @@ const Content = styled.span`
|
||||
|
||||
${Disclosure} {
|
||||
margin-top: 2px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -292,7 +297,7 @@ const Label = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 4.8em;
|
||||
line-height: 1.6;
|
||||
line-height: 24px;
|
||||
|
||||
* {
|
||||
unicode-bidi: plaintext;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useDrag, useDrop } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import Star from "~/models/Star";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
@@ -42,14 +41,10 @@ function useLabelAndIcon({ documentId, collectionId }: Star) {
|
||||
if (documentId) {
|
||||
const document = documents.get(documentId);
|
||||
if (document) {
|
||||
const { emoji } = parseTitle(document?.title);
|
||||
|
||||
return {
|
||||
label: emoji
|
||||
? document.title.replace(emoji, "")
|
||||
: document.titleWithDefault,
|
||||
icon: emoji ? (
|
||||
<EmojiIcon emoji={emoji} />
|
||||
label: document.titleWithDefault,
|
||||
icon: document.emoji ? (
|
||||
<EmojiIcon emoji={document.emoji} />
|
||||
) : (
|
||||
<StarredIcon color={theme.yellow} />
|
||||
),
|
||||
@@ -148,6 +143,10 @@ function StarredLink({ star }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { emoji } = document;
|
||||
const label = emoji
|
||||
? document.title.replace(emoji, "")
|
||||
: document.titleWithDefault;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Arrow from "~/components/Arrow";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
|
||||
type Props = {
|
||||
direction: "left" | "right";
|
||||
style?: React.CSSProperties;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
const Toggle = React.forwardRef<HTMLButtonElement, Props>(function Toggle_(
|
||||
{ direction = "left", onClick, style }: Props,
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [hovering, setHovering] = React.useState(false);
|
||||
const positionRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Not using CSS hover here so that we can disable pointer events on this
|
||||
// div and allow click through to the editor elements behind.
|
||||
useEventListener("mousemove", (event: MouseEvent) => {
|
||||
if (!positionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bound = positionRef.current.getBoundingClientRect();
|
||||
const withinBounds =
|
||||
event.clientX >= bound.left && event.clientX <= bound.right;
|
||||
if (withinBounds !== hovering) {
|
||||
setHovering(withinBounds);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Positioner style={style} ref={positionRef} $hovering={hovering}>
|
||||
<ToggleButton
|
||||
ref={ref}
|
||||
$direction={direction}
|
||||
onClick={onClick}
|
||||
aria-label={t("Toggle sidebar")}
|
||||
>
|
||||
<Arrow />
|
||||
</ToggleButton>
|
||||
</Positioner>
|
||||
);
|
||||
});
|
||||
|
||||
export const ToggleButton = styled.button<{ $direction?: "left" | "right" }>`
|
||||
opacity: 0;
|
||||
background: none;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
transform: translateY(-50%)
|
||||
scaleX(${(props) => (props.$direction === "left" ? 1 : -1)});
|
||||
position: fixed;
|
||||
top: 50vh;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
color: ${s("divider")};
|
||||
|
||||
&:active {
|
||||
color: ${s("sidebarText")};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
pointer-events: all;
|
||||
cursor: var(--pointer);
|
||||
`}
|
||||
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Positioner = styled.div<{ $hovering: boolean }>`
|
||||
display: none;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -30px;
|
||||
width: 30px;
|
||||
pointer-events: none;
|
||||
|
||||
&:focus-within ${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$hovering &&
|
||||
css`
|
||||
${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default Toggle;
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from "styled-components";
|
||||
import { hover } from "~/styles";
|
||||
import SidebarButton from "./SidebarButton";
|
||||
|
||||
const ToggleButton = styled(SidebarButton)`
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:${hover},
|
||||
&:active {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ToggleButton;
|
||||
+20
-12
@@ -3,29 +3,37 @@ import styled from "styled-components";
|
||||
import Flex from "./Flex";
|
||||
|
||||
type Props = {
|
||||
/** The width and height of the squircle */
|
||||
size?: number;
|
||||
/** The color of the squircle */
|
||||
color?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Squircle: React.FC<Props> = ({ color, size = 28, children }: Props) => (
|
||||
<Wrapper
|
||||
style={{ width: size, height: size }}
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<svg width={size} height={size} viewBox="0 0 28 28">
|
||||
<path
|
||||
fill={color}
|
||||
d="M0 11.1776C0 1.97285 1.97285 0 11.1776 0H16.8224C26.0272 0 28 1.97285 28 11.1776V16.8224C28 26.0272 26.0272 28 16.8224 28H11.1776C1.97285 28 0 26.0272 0 16.8224V11.1776Z"
|
||||
/>
|
||||
const Squircle: React.FC<Props> = ({
|
||||
color,
|
||||
size = 28,
|
||||
children,
|
||||
className,
|
||||
}: Props) => (
|
||||
<Wrapper size={size} align="center" justify="center" className={className}>
|
||||
<svg width={size} height={size} fill={color} viewBox="0 0 28 28">
|
||||
<path d="M0 11.1776C0 1.97285 1.97285 0 11.1776 0H16.8224C26.0272 0 28 1.97285 28 11.1776V16.8224C28 26.0272 26.0272 28 16.8224 28H11.1776C1.97285 28 0 26.0272 0 16.8224V11.1776Z" />
|
||||
</svg>
|
||||
<Content>{children}</Content>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
const Wrapper = styled(Flex)<{ size: number }>`
|
||||
position: relative;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
|
||||
svg {
|
||||
transition: fill 150ms ease-in-out;
|
||||
transition-delay: var(--delay);
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
|
||||
+13
-2
@@ -1,6 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -14,12 +15,18 @@ import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
/** Target collection */
|
||||
collection?: Collection;
|
||||
/** Target document */
|
||||
document?: Document;
|
||||
/** Size of the star */
|
||||
size?: number;
|
||||
/** Color override for the star */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
function Star({ size, document, collection, ...rest }: Props) {
|
||||
function Star({ size, document, collection, color, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
@@ -36,6 +43,10 @@ function Star({ size, document, collection, ...rest }: Props) {
|
||||
<NudeButton
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
tooltip={{
|
||||
tooltip: target.isStarred ? t("Unstar document") : t("Star document"),
|
||||
delay: 500,
|
||||
}}
|
||||
action={
|
||||
collection
|
||||
? collection.isStarred
|
||||
@@ -55,7 +66,7 @@ function Star({ size, document, collection, ...rest }: Props) {
|
||||
) : (
|
||||
<AnimatedStar
|
||||
size={size}
|
||||
color={theme.textTertiary}
|
||||
color={color ?? theme.textTertiary}
|
||||
as={UnstarredIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
+39
-11
@@ -1,4 +1,7 @@
|
||||
import { m } from "framer-motion";
|
||||
import { LocationDescriptor } from "history";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
@@ -6,8 +9,19 @@ import NavLink from "~/components/NavLink";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
|
||||
to: string;
|
||||
/**
|
||||
* The path to match against the current location.
|
||||
*/
|
||||
to: LocationDescriptor;
|
||||
/**
|
||||
* If true, the tab will only be active if the path matches exactly.
|
||||
*/
|
||||
exact?: boolean;
|
||||
/**
|
||||
* If true, the tab will only be active if the query string matches exactly.
|
||||
* By default query string parameters are ignored for location mathing.
|
||||
*/
|
||||
exactQueryString?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -45,24 +59,38 @@ const transition = {
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
const Tab: React.FC<Props> = ({ children, ...rest }: Props) => {
|
||||
const Tab: React.FC<Props> = ({
|
||||
children,
|
||||
exact,
|
||||
exactQueryString,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const theme = useTheme();
|
||||
const activeStyle = {
|
||||
color: theme.textSecondary,
|
||||
};
|
||||
|
||||
return (
|
||||
<TabLink {...rest} activeStyle={activeStyle}>
|
||||
{(match) => (
|
||||
<TabLink
|
||||
{...rest}
|
||||
exact={exact || exactQueryString}
|
||||
activeStyle={activeStyle}
|
||||
>
|
||||
{(match, location) => (
|
||||
<>
|
||||
{children}
|
||||
{match && (
|
||||
<Active
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
/>
|
||||
)}
|
||||
{match &&
|
||||
(!exactQueryString ||
|
||||
isEqual(
|
||||
queryString.parse(location.search ?? ""),
|
||||
queryString.parse(rest.to.search as string)
|
||||
)) && (
|
||||
<Active
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabLink>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEqual } from "lodash";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -4,7 +4,8 @@ import Avatar from "./Avatar";
|
||||
|
||||
const TeamLogo = styled(Avatar)`
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${s("divider")};
|
||||
box-shadow: inset 0 0 0 1px ${s("divider")};
|
||||
border: 0;
|
||||
`;
|
||||
|
||||
export default TeamLogo;
|
||||
|
||||
+13
-8
@@ -1,4 +1,4 @@
|
||||
import styled from "styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
type Props = {
|
||||
type?: "secondary" | "tertiary" | "danger";
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
*/
|
||||
const Text = styled.p<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "initial")};
|
||||
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
@@ -31,12 +31,17 @@ const Text = styled.p<Props>`
|
||||
: props.size === "xsmall"
|
||||
? "13px"
|
||||
: "inherit"};
|
||||
font-weight: ${(props) =>
|
||||
props.weight === "bold"
|
||||
? 500
|
||||
: props.weight === "normal"
|
||||
? "normal"
|
||||
: "inherit"};
|
||||
|
||||
${(props) =>
|
||||
props.weight &&
|
||||
css`
|
||||
font-weight: ${props.weight === "bold"
|
||||
? 500
|
||||
: props.weight === "normal"
|
||||
? 400
|
||||
: "inherit"};
|
||||
`}
|
||||
|
||||
white-space: normal;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserRole } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input from "~/components/Input";
|
||||
@@ -15,7 +16,7 @@ export function UserChangeToViewerDialog({ user, onSubmit }: Props) {
|
||||
const { users } = useStores();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await users.demote(user, "viewer");
|
||||
await users.demote(user, UserRole.Viewer);
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
@@ -41,7 +42,7 @@ export function UserChangeToMemberDialog({ user, onSubmit }: Props) {
|
||||
const { users } = useStores();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await users.demote(user, "member");
|
||||
await users.demote(user, UserRole.Member);
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import invariant from "invariant";
|
||||
import { find } from "lodash";
|
||||
import find from "lodash/find";
|
||||
import { action, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -254,9 +254,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
});
|
||||
|
||||
this.socket.on("comments.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||
comments.inThread(event.modelId).forEach((comment) => {
|
||||
comments.remove(comment.id);
|
||||
});
|
||||
comments.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
|
||||
@@ -317,7 +315,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
});
|
||||
}
|
||||
|
||||
auth.team?.updateFromJson(event);
|
||||
auth.team?.updateData(event);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import data, { type Emoji as TEmoji, EmojiMartData } from "@emoji-mart/data";
|
||||
import FuzzySearch from "fuzzy-search";
|
||||
import gemojies from "gemoji";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import React from "react";
|
||||
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
|
||||
import EmojiMenuItem from "./EmojiMenuItem";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
@@ -14,14 +16,14 @@ type Emoji = {
|
||||
attrs: { markup: string; "data-name": string };
|
||||
};
|
||||
|
||||
const searcher = new FuzzySearch<{
|
||||
names: string[];
|
||||
description: string;
|
||||
emoji: string;
|
||||
}>(gemojies, ["names"], {
|
||||
caseSensitive: true,
|
||||
sort: true,
|
||||
});
|
||||
const searcher = new FuzzySearch<TEmoji>(
|
||||
Object.values((data as EmojiMartData).emojis),
|
||||
["keywords"],
|
||||
{
|
||||
caseSensitive: true,
|
||||
sort: true,
|
||||
}
|
||||
);
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps<Emoji>,
|
||||
@@ -34,14 +36,17 @@ const EmojiMenu = (props: Props) => {
|
||||
const items = React.useMemo(() => {
|
||||
const n = search.toLowerCase();
|
||||
const result = searcher.search(n).map((item) => {
|
||||
const description = item.description;
|
||||
const name = item.names[0];
|
||||
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||
// avoid multiple formats being written into documents.
|
||||
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
|
||||
const emoji = item.skins[0].native;
|
||||
|
||||
return {
|
||||
...item,
|
||||
name: "emoji",
|
||||
title: name,
|
||||
description,
|
||||
attrs: { markup: name, "data-name": name },
|
||||
title: emoji,
|
||||
description: capitalize(item.name.toLowerCase()),
|
||||
emoji,
|
||||
attrs: { markup: shortcode, "data-name": shortcode },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -65,8 +65,6 @@ export default function FindAndReplace({ readOnly }: Props) {
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
// Close handlers
|
||||
useKeyDown("Escape", popover.hide);
|
||||
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
|
||||
|
||||
// Keyboard shortcuts
|
||||
|
||||
@@ -8,8 +8,6 @@ import { depths, s } from "@shared/styles";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useViewportHeight from "~/hooks/useViewportHeight";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
@@ -39,27 +37,11 @@ function usePosition({
|
||||
const { view } = useEditor();
|
||||
const { selection } = view.state;
|
||||
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
|
||||
const viewportHeight = useViewportHeight();
|
||||
const isTouchDevice = useMediaQuery("(hover: none) and (pointer: coarse)");
|
||||
|
||||
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
|
||||
return defaultPosition;
|
||||
}
|
||||
|
||||
// If we're on a mobile device then stick the floating toolbar to the bottom
|
||||
// of the screen above the virtual keyboard.
|
||||
if (isTouchDevice && viewportHeight) {
|
||||
return {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: viewportHeight - menuHeight,
|
||||
offset: 0,
|
||||
maxWidth: 1000,
|
||||
blockSelection: false,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
// based on the start and end of the selection calculate the position at
|
||||
// the center top
|
||||
let fromPos;
|
||||
@@ -92,7 +74,7 @@ function usePosition({
|
||||
// position at the top right of code blocks
|
||||
const codeBlock = findParentNode(isCode)(view.state.selection);
|
||||
|
||||
if (codeBlock) {
|
||||
if (codeBlock && view.state.selection.empty) {
|
||||
const element = view.nodeDOM(codeBlock.pos);
|
||||
const bounds = (element as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
@@ -192,7 +174,7 @@ function usePosition({
|
||||
left: Math.round(left - offsetParent.left),
|
||||
top: Math.round(top - offsetParent.top),
|
||||
offset: Math.round(offset),
|
||||
maxWidth: offsetParent.width,
|
||||
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
|
||||
blockSelection: codeBlock || isColSelection || isRowSelection,
|
||||
visible: true,
|
||||
};
|
||||
@@ -305,18 +287,6 @@ const Wrapper = styled.div<WrapperProps>`
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
transform: scale(1);
|
||||
border-radius: 0;
|
||||
width: 100vw;
|
||||
position: fixed;
|
||||
}
|
||||
`;
|
||||
|
||||
export default FloatingToolbar;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { some } from "lodash";
|
||||
import some from "lodash/some";
|
||||
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import getMarkRange from "@shared/editor/queries/getMarkRange";
|
||||
import isInCode from "@shared/editor/queries/isInCode";
|
||||
import isMarkActive from "@shared/editor/queries/isMarkActive";
|
||||
import isNodeActive from "@shared/editor/queries/isNodeActive";
|
||||
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
|
||||
@@ -216,13 +217,11 @@ export default function SelectionToolbar(props: Props) {
|
||||
const range = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const isImageSelection =
|
||||
selection instanceof NodeSelection && selection.node.type.name === "image";
|
||||
const isCodeSelection =
|
||||
isNodeActive(state.schema.nodes.code_block)(state) ||
|
||||
isNodeActive(state.schema.nodes.code_fence)(state);
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
|
||||
if (isCodeSelection) {
|
||||
if (isCodeSelection && selection.empty) {
|
||||
items = getCodeMenuItems(state, readOnly, dictionary);
|
||||
} else if (isTableSelection) {
|
||||
items = getTableMenuItems(dictionary);
|
||||
@@ -231,7 +230,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
} else if (rowIndex !== undefined) {
|
||||
items = getTableRowMenuItems(state, rowIndex, dictionary);
|
||||
} else if (isImageSelection) {
|
||||
items = getImageMenuItems(state, dictionary);
|
||||
items = readOnly ? [] : getImageMenuItems(state, dictionary);
|
||||
} else if (isDividerSelection) {
|
||||
items = getDividerMenuItems(state, dictionary);
|
||||
} else if (readOnly) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import commandScore from "command-score";
|
||||
import { capitalize } from "lodash";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
@@ -79,6 +79,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const { view, commands } = useEditor();
|
||||
const { showToast: onShowToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
const hasActivated = React.useRef(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [position, setPosition] = React.useState<Position>(defaultPosition);
|
||||
@@ -87,6 +88,12 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
>();
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.isActive) {
|
||||
hasActivated.current = true;
|
||||
}
|
||||
}, [props.isActive]);
|
||||
|
||||
const calculatePosition = React.useCallback(
|
||||
(props: Props) => {
|
||||
if (!props.isActive) {
|
||||
@@ -237,6 +244,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return triggerFilePick(
|
||||
AttachmentValidation.imageContentTypes.join(", ")
|
||||
);
|
||||
case "video":
|
||||
return triggerFilePick("video/*");
|
||||
case "attachment":
|
||||
return triggerFilePick("*");
|
||||
case "embed":
|
||||
@@ -534,73 +543,77 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return (
|
||||
<Portal>
|
||||
<Wrapper active={isActive} ref={menuRef} hiddenScrollbars {...position}>
|
||||
{insertItem ? (
|
||||
<LinkInputWrapper>
|
||||
<LinkInput
|
||||
type="text"
|
||||
placeholder={
|
||||
insertItem.title
|
||||
? dictionary.pasteLinkWithTitle(insertItem.title)
|
||||
: dictionary.pasteLink
|
||||
}
|
||||
onKeyDown={handleLinkInputKeydown}
|
||||
onPaste={handleLinkInputPaste}
|
||||
autoFocus
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator") {
|
||||
return (
|
||||
<ListItem key={index}>
|
||||
<hr />
|
||||
{(isActive || hasActivated.current) && (
|
||||
<>
|
||||
{insertItem ? (
|
||||
<LinkInputWrapper>
|
||||
<LinkInput
|
||||
type="text"
|
||||
placeholder={
|
||||
insertItem.title
|
||||
? dictionary.pasteLinkWithTitle(insertItem.title)
|
||||
: dictionary.pasteLink
|
||||
}
|
||||
onKeyDown={handleLinkInputKeydown}
|
||||
onPaste={handleLinkInputPaste}
|
||||
autoFocus
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator") {
|
||||
return (
|
||||
<ListItem key={index}>
|
||||
<hr />
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePointer = () => {
|
||||
if (selectedIndex !== index) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointer}
|
||||
onPointerDown={handlePointer}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<ListItem>
|
||||
<Empty>{dictionary.noResults}</Empty>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePointer = () => {
|
||||
if (selectedIndex !== index) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointer}
|
||||
onPointerDown={handlePointer}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<ListItem>
|
||||
<Empty>{dictionary.noResults}</Empty>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
)}
|
||||
</List>
|
||||
)}
|
||||
{uploadFile && (
|
||||
<VisuallyHidden>
|
||||
<label>
|
||||
<Trans>Import document</Trans>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
onChange={handleFilesPicked}
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
{uploadFile && (
|
||||
<VisuallyHidden>
|
||||
<label>
|
||||
<Trans>Import document</Trans>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
onChange={handleFilesPicked}
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Portal>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default styled.button.attrs((props) => ({
|
||||
background: none;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
padding: 0;
|
||||
opacity: 0.7;
|
||||
opacity: 0.8;
|
||||
outline: none;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
|
||||
@@ -17,12 +17,6 @@ type Props = {
|
||||
items: MenuItem[];
|
||||
};
|
||||
|
||||
const FlexibleWrapper = styled.div`
|
||||
color: ${s("textSecondary")};
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
/*
|
||||
* Renders a dropdown menu in the floating toolbar.
|
||||
*/
|
||||
@@ -120,6 +114,13 @@ function ToolbarMenu(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const FlexibleWrapper = styled.div`
|
||||
color: ${s("textSecondary")};
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const Arrow = styled(ExpandedIcon)`
|
||||
margin-right: -4px;
|
||||
color: ${s("textSecondary")};
|
||||
@@ -128,6 +129,7 @@ const Arrow = styled(ExpandedIcon)`
|
||||
const Label = styled.span`
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
export default ToolbarMenu;
|
||||
|
||||
+20
-20
@@ -302,6 +302,7 @@ export class Editor extends React.PureComponent<
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
window.removeEventListener("theme-changed", this.dispatchThemeChanged);
|
||||
this.view.destroy();
|
||||
this.mutationObserver?.disconnect();
|
||||
}
|
||||
|
||||
@@ -349,27 +350,26 @@ export class Editor extends React.PureComponent<
|
||||
private createNodeViews() {
|
||||
return this.extensions.extensions
|
||||
.filter((extension: ReactNode) => extension.component)
|
||||
.reduce((nodeViews, extension: ReactNode) => {
|
||||
const nodeView = (
|
||||
node: ProsemirrorNode,
|
||||
view: EditorView,
|
||||
getPos: () => number,
|
||||
decorations: Decoration[]
|
||||
) =>
|
||||
new ComponentView(extension.component, {
|
||||
editor: this,
|
||||
extension,
|
||||
node,
|
||||
view,
|
||||
getPos,
|
||||
decorations,
|
||||
});
|
||||
|
||||
return {
|
||||
.reduce(
|
||||
(nodeViews, extension: ReactNode) => ({
|
||||
...nodeViews,
|
||||
[extension.name]: nodeView,
|
||||
};
|
||||
}, {});
|
||||
[extension.name]: (
|
||||
node: ProsemirrorNode,
|
||||
view: EditorView,
|
||||
getPos: () => number,
|
||||
decorations: Decoration[]
|
||||
) =>
|
||||
new ComponentView(extension.component, {
|
||||
editor: this,
|
||||
extension,
|
||||
node,
|
||||
view,
|
||||
getPos,
|
||||
decorations,
|
||||
}),
|
||||
}),
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
private createCommands() {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CalendarIcon,
|
||||
MathIcon,
|
||||
DoneIcon,
|
||||
EmbedIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
@@ -101,6 +102,12 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
shortcut: `${metaDisplay} k`,
|
||||
keywords: "link url uri href",
|
||||
},
|
||||
{
|
||||
name: "video",
|
||||
title: dictionary.video,
|
||||
icon: <EmbedIcon />,
|
||||
keywords: "mov avi upload player",
|
||||
},
|
||||
{
|
||||
name: "attachment",
|
||||
title: dictionary.file,
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function formattingMenuItems(
|
||||
const isTable = isInTable(state);
|
||||
const isList = isInList(state);
|
||||
const isCode = isInCode(state);
|
||||
const isCodeBlock = isInCode(state, { onlyBlock: true });
|
||||
const allowBlocks = !isTable && !isList;
|
||||
|
||||
return [
|
||||
@@ -83,6 +84,7 @@ export default function formattingMenuItems(
|
||||
tooltip: dictionary.codeInline,
|
||||
icon: <CodeIcon />,
|
||||
active: isMarkActive(schema.marks.code_inline),
|
||||
visible: !isCodeBlock,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
@@ -166,8 +168,8 @@ export default function formattingMenuItems(
|
||||
name: "comment",
|
||||
tooltip: dictionary.comment,
|
||||
icon: <CommentIcon />,
|
||||
label: isCodeBlock ? dictionary.comment : undefined,
|
||||
active: isMarkActive(schema.marks.comment),
|
||||
visible: !isCode,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -8,4 +8,10 @@ declare global {
|
||||
|
||||
const env = window.env;
|
||||
|
||||
if (!env) {
|
||||
throw new Error(
|
||||
"Config could not be be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed"
|
||||
);
|
||||
}
|
||||
|
||||
export default env;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRegisterActions } from "kbar";
|
||||
import { flattenDeep } from "lodash";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { actionToKBar } from "~/actions";
|
||||
import { Action } from "~/types";
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
export default function useDebouncedCallback<T>(
|
||||
callback: (...params: T[]) => unknown,
|
||||
wait: number
|
||||
) {
|
||||
// track args & timeout handle between calls
|
||||
const argsRef = React.useRef<T[]>();
|
||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
function cleanup() {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
}
|
||||
|
||||
// make sure our timeout gets cleared if consuming component gets unmounted
|
||||
React.useEffect(() => cleanup, []);
|
||||
return function (...args: T[]) {
|
||||
argsRef.current = args;
|
||||
cleanup();
|
||||
timeout.current = setTimeout(() => {
|
||||
if (argsRef.current) {
|
||||
callback(...argsRef.current);
|
||||
}
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
@@ -83,6 +83,8 @@ export default function useDictionary() {
|
||||
insertDateTime: t("Current date and time"),
|
||||
indent: t("Indent"),
|
||||
outdent: t("Outdent"),
|
||||
video: t("Video"),
|
||||
untitled: t("Untitled"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { find } from "lodash";
|
||||
import find from "lodash/find";
|
||||
import * as React from "react";
|
||||
import embeds, { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Options = {
|
||||
fontSize?: string;
|
||||
lineHeight?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Measures the width of an emoji character
|
||||
*
|
||||
* @param emoji The emoji to measure
|
||||
* @param options Options to pass to the measurement element
|
||||
* @returns The width of the emoji in pixels
|
||||
*/
|
||||
export default function useEmojiWidth(
|
||||
emoji: string | undefined,
|
||||
{ fontSize = "2.25em", lineHeight = "1.25" }: Options
|
||||
) {
|
||||
return React.useMemo(() => {
|
||||
const element = window.document.createElement("span");
|
||||
if (!emoji) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
element.innerText = `${emoji}\u00A0`;
|
||||
element.style.visibility = "hidden";
|
||||
element.style.position = "absolute";
|
||||
element.style.left = "-9999px";
|
||||
element.style.lineHeight = lineHeight;
|
||||
element.style.fontSize = fontSize;
|
||||
element.style.width = "max-content";
|
||||
window.document.body?.appendChild(element);
|
||||
const width = window.getComputedStyle(element).width;
|
||||
window.document.body?.removeChild(element);
|
||||
return parseInt(width, 10);
|
||||
}, [emoji, fontSize, lineHeight]);
|
||||
}
|
||||
+10
-6
@@ -1,4 +1,4 @@
|
||||
import { throttle } from "lodash";
|
||||
import throttle from "lodash/throttle";
|
||||
import * as React from "react";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
|
||||
@@ -17,10 +17,14 @@ const activityEvents = [
|
||||
/**
|
||||
* Hook to detect user idle state.
|
||||
*
|
||||
* @param {number} timeToIdle
|
||||
* @param timeToIdle The time in ms until idle
|
||||
* @param events The events to listen to
|
||||
* @returns boolean if the user is idle
|
||||
*/
|
||||
export default function useIdle(timeToIdle: number = 3 * Minute) {
|
||||
export default function useIdle(
|
||||
timeToIdle: number = 3 * Minute,
|
||||
events = activityEvents
|
||||
) {
|
||||
const [isIdle, setIsIdle] = React.useState(false);
|
||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
@@ -40,15 +44,15 @@ export default function useIdle(timeToIdle: number = 3 * Minute) {
|
||||
onActivity();
|
||||
}, 1000);
|
||||
|
||||
activityEvents.forEach((eventName) =>
|
||||
events.forEach((eventName) =>
|
||||
window.addEventListener(eventName, handleUserActivityEvent)
|
||||
);
|
||||
return () => {
|
||||
activityEvents.forEach((eventName) =>
|
||||
events.forEach((eventName) =>
|
||||
window.removeEventListener(eventName, handleUserActivityEvent)
|
||||
);
|
||||
};
|
||||
}, [onActivity]);
|
||||
}, [events, onActivity]);
|
||||
|
||||
return isIdle;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
let importingLock = false;
|
||||
|
||||
@@ -50,7 +51,7 @@ export default function useImportDocument(
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
history.push(doc.url);
|
||||
history.push(documentPath(doc));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user