mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
363 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e9beac59f | |||
| a0f7c76405 | |||
| 454a4e9a8d | |||
| ef9c410d97 | |||
| 7c2f779f68 | |||
| c45de6904b | |||
| 4758778fc7 | |||
| 401ae73a04 | |||
| 0ddbd9c608 | |||
| 6c4e2a9d11 | |||
| d8f1f55a80 | |||
| 9b811c999d | |||
| 5a60329021 | |||
| 042ea7b61f | |||
| 80acc16791 | |||
| 3c25b2b047 | |||
| 16f1328a83 | |||
| d1a7a30c00 | |||
| 5b67273d8f | |||
| fdd8ecc79d | |||
| 7c15d03b50 | |||
| b691311f88 | |||
| eda023c908 | |||
| 2331bbbd36 | |||
| 228d1faa9f | |||
| ff6d30581a | |||
| 027545a768 | |||
| 91585ee09d | |||
| a13f2c7311 | |||
| d4a51b420f | |||
| faa02623b3 | |||
| 2baf4d7d8b | |||
| 2b21ac1b97 | |||
| d2fcd1dee6 | |||
| 3b43460a0a | |||
| 1864ed605f | |||
| 20932a08d0 | |||
| 7e1ea69939 | |||
| a3983c36c9 | |||
| ccdcda372f | |||
| 07ad5032b4 | |||
| 286aea2701 | |||
| 30e63e022c | |||
| b88670b58d | |||
| ddc883bfcd | |||
| 67691477a9 | |||
| e3807a1c75 | |||
| f95ce018e1 | |||
| 2201fd7bd6 | |||
| fbb793ab8e | |||
| 31f8a3fb44 | |||
| 03ebca2f0c | |||
| 2a17e0cbf6 | |||
| 9ac1e13227 | |||
| bd0240b7a5 | |||
| 81bd68380e | |||
| b3d8bd1cc8 | |||
| a30487c2d7 | |||
| 8b3c58a162 | |||
| 43a91626b2 | |||
| 15c8a4867f | |||
| d94caf2783 | |||
| aaeb6f7dc6 | |||
| e0289aed40 | |||
| 8865d394c6 | |||
| 1d893a06f9 | |||
| 0c291ee806 | |||
| 8732155dbb | |||
| 56e01b784d | |||
| e47d493d13 | |||
| 2677c964a5 | |||
| f8927ff819 | |||
| 72adcd10ef | |||
| 7bc37cb700 | |||
| 217e53d8b6 | |||
| 404f5ff871 | |||
| 0db6f39f43 | |||
| 479b805613 | |||
| 48f1047016 | |||
| caf7333682 | |||
| cd59af4a9b | |||
| 8d549abaa9 | |||
| 5e705f3dc7 | |||
| 5d71398ea6 | |||
| dbd85d62cb | |||
| d180ecbe96 | |||
| 9046abb682 | |||
| 5810ddb589 | |||
| 0d30220017 | |||
| b60e46a961 | |||
| 3c6e2aaac6 | |||
| eae6204d55 | |||
| 1e78079ade | |||
| d3fc6fc0fd | |||
| 0f10fe4052 | |||
| d928d456de | |||
| 5206beaf19 | |||
| 70113be9af | |||
| 04ea3431e7 | |||
| d3ce70016e | |||
| 46d6664307 | |||
| 2427f4747a | |||
| 60b456f35a | |||
| 64b2718673 | |||
| 4b14fa5dd7 | |||
| abb38ea447 | |||
| e81f97b2de | |||
| e653b185a4 | |||
| 39e12cef65 | |||
| b1230d0c81 | |||
| 6e9e1c15a5 | |||
| 66331d3d4f | |||
| ea07b72c7a | |||
| 5dcd7a74ca | |||
| 5c83070941 | |||
| a9ab196a18 | |||
| b9fc301589 | |||
| c56add74c6 | |||
| 5ae4834333 | |||
| 437865e7aa | |||
| 042f2ff737 | |||
| 5656384cc4 | |||
| 098d91808b | |||
| 21d446881e | |||
| cf32d227e6 | |||
| e59e121179 | |||
| 98a182c892 | |||
| 6bc1b789ee | |||
| a8674c7dda | |||
| c952dfa065 | |||
| 9a95fa47a0 | |||
| 5bfb2c89c8 | |||
| 9b6a645928 | |||
| d550fb79d3 | |||
| ed9cf4cee3 | |||
| 8cc2853102 | |||
| 814bacbead | |||
| 9431df45c2 | |||
| a75d6b298e | |||
| ff1bc5db2a | |||
| e6e9512979 | |||
| 29db1ef1bf | |||
| b54a370e01 | |||
| cce22bcdee | |||
| da62c2c044 | |||
| 201690e342 | |||
| 4fddc0fd87 | |||
| 72c7b0373b | |||
| 7c5d834f39 | |||
| f1f3159b12 | |||
| a62739be8f | |||
| 3124423eeb | |||
| 40dbbe10c5 | |||
| a4ba1f18bc | |||
| 0aabcb8d22 | |||
| 3c2e7b5b63 | |||
| 9d4a1965b0 | |||
| ea527bf147 | |||
| 02744411f3 | |||
| f843a20a54 | |||
| 7aec0e24ef | |||
| 843092e5f0 | |||
| 1fd7f75929 | |||
| 768fcbf6c4 | |||
| f214db0ab7 | |||
| aec190245b | |||
| 88775cd287 | |||
| 64b2bfe704 | |||
| 8957e86c12 | |||
| 4bec08ee05 | |||
| a3d70622af | |||
| 92c8eff61d | |||
| 73c2a67fa5 | |||
| c2af5db0f8 | |||
| 1e4b59ac6c | |||
| 9933f30c25 | |||
| bbee4b5791 | |||
| 89d5527d39 | |||
| f3d8129a13 | |||
| 87a675d02b | |||
| a2fae1f1eb | |||
| e001b8d161 | |||
| 5000500f96 | |||
| 8a33bdcb8a | |||
| 9987c15daf | |||
| 06f2d7a993 | |||
| a234a92f80 | |||
| 7314a71bf1 | |||
| edb9099c9d | |||
| 94882d4d3a | |||
| 453bbb3b25 | |||
| e58163ef5f | |||
| 7940cef517 | |||
| 86d6117a31 | |||
| 4e69ae1ffe | |||
| 06033ac781 | |||
| 25e8c32b84 | |||
| 08601a9f84 | |||
| 831318d941 | |||
| d96bf5106d | |||
| a094087342 | |||
| 8d69de1be0 | |||
| 6d556c7a55 | |||
| eb62b961a4 | |||
| 0e5a576439 | |||
| e7861d0bb9 | |||
| 25ae923130 | |||
| d7ae829d92 | |||
| cef048572a | |||
| 51ed458ab2 | |||
| 38e8a649ef | |||
| 127728db29 | |||
| 8b87ab0fd7 | |||
| ba65c52e97 | |||
| f76f2e61d8 | |||
| b3c08fdb48 | |||
| 9cfe5da70b | |||
| 5fa8ebc587 | |||
| 9ef375d83c | |||
| 9d04d5ebd9 | |||
| 4494a30441 | |||
| 7bce4c807d | |||
| 80a9bae761 | |||
| f40e1f09fd | |||
| 2c3e842b3c | |||
| f1d1035550 | |||
| c23e415237 | |||
| 137c33c323 | |||
| e00c30bd1f | |||
| 1ddb9aba3b | |||
| 746c27e718 | |||
| d319bb7d9a | |||
| 3f7e66980b | |||
| d5c1336580 | |||
| a810d9176f | |||
| ffbf138748 | |||
| b9844472b9 | |||
| 4363d4e1e5 | |||
| 46c4185078 | |||
| 424dea8197 | |||
| 0d0b53f292 | |||
| d18994b14e | |||
| a8a506af3e | |||
| ba3a47d138 | |||
| c70bef0fdd | |||
| 5f4e942d31 | |||
| 874bdb1e11 | |||
| 7f8a177b01 | |||
| 5e76d72ae6 | |||
| 45641103ba | |||
| 555691c79b | |||
| 20ea422f77 | |||
| 045dbb932e | |||
| 582c4ba99d | |||
| d519e93300 | |||
| 5dab811c5f | |||
| ef7ae719a6 | |||
| aa80f5667f | |||
| d57ec39113 | |||
| be3bcebf6b | |||
| e9ec31e5b8 | |||
| 33b0354cfe | |||
| d5341a486c | |||
| e340e568e2 | |||
| e8b9a1b650 | |||
| bb0cd891bd | |||
| fbd16d4b9a | |||
| 3317bf2396 | |||
| 4e75b4029a | |||
| 73fe70817f | |||
| 3c5dc10446 | |||
| d587360f4b | |||
| 458f24185a | |||
| 502d8b9e8d | |||
| 7e6831c803 | |||
| 965d85fb6f | |||
| 31b9c2b8a4 | |||
| dbad4a9b84 | |||
| f3caaed7fe | |||
| ac6047bbb7 | |||
| ea885133ac | |||
| b1e2ff0713 | |||
| dca64fe84b | |||
| 1e50facd5d | |||
| ce87624289 | |||
| a19c19985e | |||
| 9b257e9593 | |||
| aff9413b0f | |||
| de46178871 | |||
| fd700e6fd6 | |||
| b2ab3b010d | |||
| ff72d381fb | |||
| a24f09afe3 | |||
| a6844c7c8a | |||
| fadf51907d | |||
| 42dea7859c | |||
| 164263f13a | |||
| 35dbeae80b | |||
| e2bc2f2067 | |||
| 7ce97f4d50 | |||
| 0ce9931910 | |||
| 693dfecab7 | |||
| 86a7d1c548 | |||
| 44ed374636 | |||
| 8c5c445f0d | |||
| 2a0780cfff | |||
| 8d157ff962 | |||
| 26a8c5e4ab | |||
| d9d15bffa5 | |||
| bb2ee9dd40 | |||
| 90b13d5f27 | |||
| 679a86fe6f | |||
| ab30bfcf24 | |||
| 9dfdafa116 | |||
| 06be19090c | |||
| 07ae67924f | |||
| 1cf597aca7 | |||
| a0df79ea5a | |||
| 738fa55e12 | |||
| c8ee501377 | |||
| 3421f24896 | |||
| 33e67a11ed | |||
| cfe7bebd95 | |||
| ac8946f0c5 | |||
| 0504e91aa6 | |||
| aebd626954 | |||
| 9942bbee3e | |||
| d096ba486f | |||
| be5cddc14f | |||
| 6e12e8be3a | |||
| 9918b9bf13 | |||
| 3d6a875631 | |||
| cda8acddbb | |||
| 2ceba5039b | |||
| 7d7781d795 | |||
| 5ee6cdb2ca | |||
| 454a338d24 | |||
| 85299c6104 | |||
| 001a083e15 | |||
| 7ec4e20546 | |||
| 94202920f8 | |||
| f2ea8daf65 | |||
| df1c360b2a | |||
| 60b15b7b46 | |||
| 9280904476 | |||
| 20f3c55914 | |||
| d8b4fef554 | |||
| 2942e9c78e | |||
| 4b810bcdb7 | |||
| 12bfa6c58d | |||
| ba2bfc7c89 | |||
| 0f8c444af0 | |||
| 4dade03c33 | |||
| ef075c0fa2 | |||
| 4f019b7a99 | |||
| 217d41332f | |||
| f1ce23dce9 | |||
| 01707d733a | |||
| 106b335602 | |||
| b0da3b7cab | |||
| 6b978fc780 | |||
| 473eb93377 | |||
| c418829810 |
+6
-2
@@ -30,7 +30,7 @@ REDIS_URL=redis://localhost:6379
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
URL=http://localhost:3000
|
||||
URL=https://app.outline.dev:3000
|
||||
PORT=3000
|
||||
|
||||
# See [documentation](docs/SERVICES.md) on running a separate collaboration
|
||||
@@ -99,7 +99,7 @@ OIDC_USERINFO_URI=
|
||||
OIDC_USERNAME_CLAIM=preferred_username
|
||||
|
||||
# Display name for OIDC authentication
|
||||
OIDC_DISPLAY_NAME=OpenID
|
||||
OIDC_DISPLAY_NAME=OpenID Connect
|
||||
|
||||
# Space separated auth scopes.
|
||||
OIDC_SCOPES=openid profile email
|
||||
@@ -181,3 +181,7 @@ RATE_LIMITER_ENABLED=true
|
||||
# Configure default throttling parameters for rate limiter
|
||||
RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
# Iframely API config
|
||||
IFRAMELY_URL=
|
||||
IFRAMELY_API_KEY=
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"extraFileExtensions": [".json"],
|
||||
"project": "./tsconfig.json",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
@@ -36,6 +37,14 @@
|
||||
"component": true,
|
||||
"html": true
|
||||
}],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{
|
||||
"checksVoidReturn": false
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature request
|
||||
url: https://github.com/outline/outline/discussions/new
|
||||
url: https://github.com/outline/outline/discussions/new?category=ideas
|
||||
about: Request a feature to be added to the project
|
||||
- name: Self hosting questions
|
||||
url: https://github.com/outline/outline/discussions/new
|
||||
url: https://github.com/outline/outline/discussions/new?category=self-hosting
|
||||
about: Ask questions and discuss running Outline with community members
|
||||
|
||||
@@ -7,6 +7,10 @@ WORKDIR $APP_PATH
|
||||
# ---
|
||||
FROM node:18-alpine AS runner
|
||||
|
||||
RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
ENV NODE_ENV production
|
||||
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:16.14.2-alpine3.15 AS deps
|
||||
|
||||
RUN apk --no-cache add curl
|
||||
FROM node:18-alpine AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
up:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn install-local-ssl
|
||||
yarn install --pure-lockfile
|
||||
yarn dev:watch
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
|
||||
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
|
||||
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
|
||||
|
||||
@@ -3,13 +3,7 @@
|
||||
"description": "Open source wiki and knowledge base for growing teams",
|
||||
"website": "https://www.getoutline.com/",
|
||||
"repository": "https://github.com/outline/outline",
|
||||
"keywords": [
|
||||
"wiki",
|
||||
"team",
|
||||
"node",
|
||||
"markdown",
|
||||
"slack"
|
||||
],
|
||||
"keywords": ["wiki", "team", "node", "markdown", "slack"],
|
||||
"success_url": "/",
|
||||
"formation": {
|
||||
"web": {
|
||||
@@ -94,7 +88,7 @@
|
||||
},
|
||||
"OIDC_DISPLAY_NAME": {
|
||||
"description": "Display name for OIDC authentication",
|
||||
"value": "OpenID",
|
||||
"value": "OpenID Connect",
|
||||
"required": false
|
||||
},
|
||||
"OIDC_SCOPES": {
|
||||
@@ -188,7 +182,7 @@
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ANALYTICS_ID": {
|
||||
"description": "UA-xxxx (optional)",
|
||||
"description": "G-xxxx (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SENTRY_DSN": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
"plugins": [
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
StarredIcon,
|
||||
TrashIcon,
|
||||
UnstarredIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -12,6 +13,7 @@ import Collection from "~/models/Collection";
|
||||
import CollectionEdit from "~/scenes/CollectionEdit";
|
||||
import CollectionNew from "~/scenes/CollectionNew";
|
||||
import CollectionPermissions from "~/scenes/CollectionPermissions";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
@@ -122,13 +124,13 @@ export const starCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
collection?.star();
|
||||
await collection?.star();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -148,13 +150,47 @@ export const unstarCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
collection?.unstar();
|
||||
await collection?.unstar();
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createAction({
|
||||
name: ({ t }) => t("Delete"),
|
||||
analyticsName: "Delete collection",
|
||||
section: CollectionSection,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
perform: ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
isCentered: true,
|
||||
title: t("Delete collection"),
|
||||
content: (
|
||||
<CollectionDeleteDialog
|
||||
collection={collection}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -163,4 +199,5 @@ export const rootCollectionActions = [
|
||||
createCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
deleteCollection,
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createAction } from "~/actions";
|
||||
import { DeveloperSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
@@ -35,16 +36,27 @@ export const createTestUsers = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleDebugLogging = createAction({
|
||||
name: ({ t }) => t("Toggle debug logging"),
|
||||
icon: <ToolsIcon />,
|
||||
section: DeveloperSection,
|
||||
perform: async ({ t }) => {
|
||||
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
|
||||
stores.toasts.showToast(
|
||||
Logger.debugLoggingEnabled
|
||||
? t("Debug logging enabled")
|
||||
: t("Debug logging disabled")
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const developer = createAction({
|
||||
name: ({ t }) => t("Developer"),
|
||||
name: ({ t }) => t("Development"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DeveloperSection,
|
||||
visible: ({ event }) =>
|
||||
env.ENVIRONMENT === "development" ||
|
||||
(event instanceof KeyboardEvent && event.altKey),
|
||||
children: [clearIndexedDB, createTestUsers],
|
||||
children: [clearIndexedDB, toggleDebugLogging, createTestUsers],
|
||||
});
|
||||
|
||||
export const rootDeveloperActions = [developer];
|
||||
|
||||
@@ -61,8 +61,11 @@ export const openDocument = createAction({
|
||||
// cache if the document is renamed
|
||||
id: path.url,
|
||||
name: path.title,
|
||||
icon: () =>
|
||||
stores.documents.get(path.id)?.isStarred ? <StarredIcon /> : null,
|
||||
icon: function _Icon() {
|
||||
return stores.documents.get(path.id)?.isStarred ? (
|
||||
<StarredIcon />
|
||||
) : null;
|
||||
},
|
||||
section: DocumentSection,
|
||||
perform: () => history.push(path.url),
|
||||
}));
|
||||
@@ -98,13 +101,13 @@ export const starDocument = createAction({
|
||||
!document?.isStarred && stores.policies.abilities(activeDocumentId).star
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.star();
|
||||
await document?.star();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -124,13 +127,13 @@ export const unstarDocument = createAction({
|
||||
stores.policies.abilities(activeDocumentId).unstar
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.unstar();
|
||||
await document?.unstar();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -159,7 +162,7 @@ export const publishDocument = createAction({
|
||||
}
|
||||
|
||||
if (document?.collectionId) {
|
||||
await document.save({
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
stores.toasts.showToast(t("Document published"), {
|
||||
@@ -186,14 +189,14 @@ export const unpublishDocument = createAction({
|
||||
}
|
||||
return stores.policies.abilities(activeDocumentId).unpublish;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
document?.unpublish();
|
||||
await document?.unpublish();
|
||||
|
||||
stores.toasts.showToast(t("Document unpublished"), {
|
||||
type: "success",
|
||||
@@ -218,14 +221,14 @@ export const subscribeDocument = createAction({
|
||||
stores.policies.abilities(activeDocumentId).subscribe
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
document?.subscribe();
|
||||
await document?.subscribe();
|
||||
|
||||
stores.toasts.showToast(t("Subscribed to document notifications"), {
|
||||
type: "success",
|
||||
@@ -250,14 +253,14 @@ export const unsubscribeDocument = createAction({
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
if (!activeDocumentId || !currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
document?.unsubscribe(currentUserId);
|
||||
await document?.unsubscribe(currentUserId);
|
||||
|
||||
stores.toasts.showToast(t("Unsubscribed from document notifications"), {
|
||||
type: "success",
|
||||
@@ -274,13 +277,13 @@ export const downloadDocumentAsHTML = createAction({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download(ExportContentType.Html);
|
||||
await document?.download(ExportContentType.Html);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -321,13 +324,13 @@ export const downloadDocumentAsMarkdown = createAction({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download(ExportContentType.Markdown);
|
||||
await document?.download(ExportContentType.Markdown);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -404,13 +407,19 @@ export const pinDocumentToCollection = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.pin(document.collectionId);
|
||||
try {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.pin(document.collectionId);
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection || !location.pathname.startsWith(collection?.url)) {
|
||||
stores.toasts.showToast(t("Pinned to collection"));
|
||||
if (!collection || !location.pathname.startsWith(collection?.url)) {
|
||||
stores.toasts.showToast(t("Pinned to collection"));
|
||||
}
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -443,10 +452,16 @@ export const pinDocumentToHome = createAction({
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
await document?.pin();
|
||||
try {
|
||||
await document?.pin();
|
||||
|
||||
if (location.pathname !== homePath()) {
|
||||
stores.toasts.showToast(t("Pinned to team home"));
|
||||
if (location.pathname !== homePath()) {
|
||||
stores.toasts.showToast(t("Pinned to team home"));
|
||||
}
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -30,15 +30,13 @@ import { isMac } from "~/utils/browser";
|
||||
import history from "~/utils/history";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
profileSettingsPath,
|
||||
accountPreferencesPath,
|
||||
homePath,
|
||||
searchPath,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
archivePath,
|
||||
trashPath,
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const navigateToHome = createAction({
|
||||
@@ -105,7 +103,7 @@ export const navigateToSettings = createAction({
|
||||
icon: <SettingsIcon />,
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
perform: () => history.push(organizationSettingsPath()),
|
||||
perform: () => history.push(settingsPath("details")),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
@@ -114,7 +112,16 @@ export const navigateToProfileSettings = createAction({
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <ProfileIcon />,
|
||||
perform: () => history.push(profileSettingsPath()),
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createAction({
|
||||
name: ({ t }) => t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
perform: () => history.push(settingsPath("notifications")),
|
||||
});
|
||||
|
||||
export const navigateToAccountPreferences = createAction({
|
||||
@@ -123,7 +130,7 @@ export const navigateToAccountPreferences = createAction({
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <SettingsIcon />,
|
||||
perform: () => history.push(accountPreferencesPath()),
|
||||
perform: () => history.push(settingsPath("preferences")),
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createAction({
|
||||
@@ -135,6 +142,14 @@ export const openAPIDocumentation = createAction({
|
||||
perform: () => window.open(developersUrl()),
|
||||
});
|
||||
|
||||
export const toggleSidebar = createAction({
|
||||
name: ({ t }) => t("Toggle sidebar"),
|
||||
analyticsName: "Toggle sidebar",
|
||||
keywords: "hide show navigation",
|
||||
section: NavigationSection,
|
||||
perform: ({ stores }) => stores.ui.toggleCollapsedSidebar(),
|
||||
});
|
||||
|
||||
export const openFeedbackUrl = createAction({
|
||||
name: ({ t }) => t("Send us feedback"),
|
||||
analyticsName: "Open feedback",
|
||||
@@ -210,5 +225,6 @@ export const rootNavigationActions = [
|
||||
openBugReportUrl,
|
||||
openChangelog,
|
||||
openKeyboardShortcuts,
|
||||
toggleSidebar,
|
||||
logout,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MarkAsReadIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { createAction } from "..";
|
||||
import { NotificationSection } from "../sections";
|
||||
|
||||
export const markNotificationsAsRead = createAction({
|
||||
name: ({ t }) => t("Mark notifications as read"),
|
||||
analyticsName: "Mark notifications as read",
|
||||
section: NotificationSection,
|
||||
icon: <MarkAsReadIcon />,
|
||||
shortcut: ["Shift+Escape"],
|
||||
perform: ({ stores }) => stores.notifications.markAllAsRead(),
|
||||
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
|
||||
});
|
||||
|
||||
export const rootNotificationActions = [markNotificationsAsRead];
|
||||
@@ -43,8 +43,9 @@ export const changeTheme = createAction({
|
||||
isContextMenu ? t("Appearance") : t("Change theme"),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: () =>
|
||||
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
|
||||
icon: function _Icon() {
|
||||
return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
|
||||
},
|
||||
keywords: "appearance display",
|
||||
section: SettingsSection,
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
|
||||
@@ -16,18 +16,20 @@ export const createTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: () => (
|
||||
<StyledTeamLogo
|
||||
alt={session.name}
|
||||
model={{
|
||||
initial: session.name[0],
|
||||
avatarUrl: session.avatarUrl,
|
||||
id: session.id,
|
||||
color: stringToColor(session.id),
|
||||
}}
|
||||
size={24}
|
||||
/>
|
||||
),
|
||||
icon: function _Icon() {
|
||||
return (
|
||||
<StyledTeamLogo
|
||||
alt={session.name}
|
||||
model={{
|
||||
initial: session.name[0],
|
||||
avatarUrl: session.avatarUrl,
|
||||
id: session.id,
|
||||
color: stringToColor(session.id),
|
||||
}}
|
||||
size={24}
|
||||
/>
|
||||
);
|
||||
},
|
||||
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
|
||||
perform: () => (window.location.href = session.url),
|
||||
})) ?? [];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import Invite from "~/scenes/Invite";
|
||||
import { UserDeleteDialog } from "~/components/UserDialogs";
|
||||
import { createAction } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
|
||||
@@ -21,4 +22,31 @@ export const inviteUser = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteUserActionFactory = (userId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Delete user")}…`,
|
||||
analyticsName: "Delete user",
|
||||
keywords: "leave",
|
||||
dangerous: true,
|
||||
section: UserSection,
|
||||
visible: ({ stores }) => stores.policies.abilities(userId).delete,
|
||||
perform: ({ t }) => {
|
||||
const user = stores.users.get(userId);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete user"),
|
||||
isCentered: true,
|
||||
content: (
|
||||
<UserDeleteDialog
|
||||
user={user}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const rootUserActions = [inviteUser];
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
|
||||
return definition.perform?.(context);
|
||||
}
|
||||
: undefined,
|
||||
id: uuidv4(),
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { rootCollectionActions } from "./definitions/collections";
|
||||
import { rootDeveloperActions } from "./definitions/developer";
|
||||
import { rootDocumentActions } from "./definitions/documents";
|
||||
import { rootNavigationActions } from "./definitions/navigation";
|
||||
import { rootNotificationActions } from "./definitions/notifications";
|
||||
import { rootRevisionActions } from "./definitions/revisions";
|
||||
import { rootSettingsActions } from "./definitions/settings";
|
||||
import { rootTeamActions } from "./definitions/teams";
|
||||
@@ -12,6 +13,7 @@ export default [
|
||||
...rootDocumentActions,
|
||||
...rootUserActions,
|
||||
...rootNavigationActions,
|
||||
...rootNotificationActions,
|
||||
...rootRevisionActions,
|
||||
...rootSettingsActions,
|
||||
...rootDeveloperActions,
|
||||
|
||||
@@ -12,6 +12,8 @@ export const SettingsSection = ({ t }: ActionContext) => t("Settings");
|
||||
|
||||
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
|
||||
|
||||
export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { Action, ActionContext } from "~/types";
|
||||
|
||||
export type Props = React.ComponentPropsWithoutRef<"button"> & {
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Show the button in a disabled state */
|
||||
disabled?: boolean;
|
||||
/** Hide the button entirely if action is not applicable */
|
||||
@@ -18,14 +19,17 @@ export type Props = React.ComponentPropsWithoutRef<"button"> & {
|
||||
/**
|
||||
* Button that can be used to trigger an action definition.
|
||||
*/
|
||||
const ActionButton = React.forwardRef(
|
||||
(
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
function _ActionButton(
|
||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
) {
|
||||
const [executing, setExecuting] = React.useState(false);
|
||||
const disabled = rest.disabled;
|
||||
|
||||
if (action && !context) {
|
||||
throw new Error("Context must be provided with action");
|
||||
}
|
||||
if (!context || !action) {
|
||||
return <button {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@ import * as React from "react";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
const Analytics: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Analytics: React.FC = ({ children }: Props) => {
|
||||
// Google Analytics 3
|
||||
React.useEffect(() => {
|
||||
if (!env.GOOGLE_ANALYTICS_ID) {
|
||||
if (!env.GOOGLE_ANALYTICS_ID?.startsWith("UA-")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,25 +41,36 @@ const Analytics: React.FC = ({ children }) => {
|
||||
|
||||
// Google Analytics 4
|
||||
React.useEffect(() => {
|
||||
if (env.analytics.service !== IntegrationService.GoogleAnalytics) {
|
||||
const measurementIds = [];
|
||||
|
||||
if (env.analytics.service === IntegrationService.GoogleAnalytics) {
|
||||
measurementIds.push(escape(env.analytics.settings?.measurementId));
|
||||
}
|
||||
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
|
||||
measurementIds.push(env.GOOGLE_ANALYTICS_ID);
|
||||
}
|
||||
if (measurementIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measurementId = escape(env.analytics.settings?.measurementId);
|
||||
const params = {
|
||||
allow_google_signals: false,
|
||||
restricted_data_processing: true,
|
||||
};
|
||||
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.gtag = function () {
|
||||
window.dataLayer.push(arguments);
|
||||
};
|
||||
window.gtag("js", new Date());
|
||||
window.gtag("config", measurementId, {
|
||||
allow_google_signals: false,
|
||||
restricted_data_processing: true,
|
||||
});
|
||||
|
||||
for (const measurementId of measurementIds) {
|
||||
window.gtag("config", measurementId, params);
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.type = "text/javascript";
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementIds[0]}`;
|
||||
script.async = true;
|
||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||
}, []);
|
||||
|
||||
@@ -20,6 +20,10 @@ function ArrowKeyNavigation(
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev) => {
|
||||
if (onEscape) {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
onEscape(ev);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
|
||||
type Props = {
|
||||
children: JSX.Element;
|
||||
@@ -18,20 +18,18 @@ const Authenticated = ({ children }: Props) => {
|
||||
// Watching for language changes here as this is the earliest point we have
|
||||
// the user available and means we can start loading translations faster
|
||||
React.useEffect(() => {
|
||||
changeLanguage(language, i18n);
|
||||
void changeLanguage(language, i18n);
|
||||
}, [i18n, language]);
|
||||
|
||||
if (auth.authenticated) {
|
||||
const { user, team } = auth;
|
||||
|
||||
if (!team || !user) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
auth.logout(true);
|
||||
if (auth.isFetching) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
void auth.logout(true);
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { Editor as TEditor } from "~/editor";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import {
|
||||
searchPath,
|
||||
newDocumentPath,
|
||||
@@ -25,18 +26,22 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
|
||||
const DocumentComments = React.lazy(
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
);
|
||||
const DocumentHistory = React.lazy(
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
const DocumentInsights = React.lazy(
|
||||
const DocumentInsights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
);
|
||||
const CommandBar = React.lazy(() => import("~/components/CommandBar"));
|
||||
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const can = usePolicy(ui.activeCollectionId);
|
||||
@@ -61,7 +66,7 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
const { activeCollectionId } = ui;
|
||||
if (!activeCollectionId || !can.update) {
|
||||
if (!activeCollectionId || !can.createDocument) {
|
||||
return;
|
||||
}
|
||||
history.push(newDocumentPath(activeCollectionId));
|
||||
@@ -96,7 +101,10 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
team?.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
key={ui.activeDocumentId ? "active" : "inactive"}
|
||||
>
|
||||
{(showHistory || showInsights || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<SidebarRight>
|
||||
@@ -118,7 +126,9 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<React.Suspense fallback={null}>
|
||||
<CommandBar />
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</DocumentContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import Initials from "./Initials";
|
||||
|
||||
export enum AvatarSize {
|
||||
Small = 16,
|
||||
Medium = 24,
|
||||
Large = 32,
|
||||
XLarge = 48,
|
||||
XXLarge = 64,
|
||||
}
|
||||
|
||||
export interface IAvatar {
|
||||
avatarUrl: string | null;
|
||||
color?: string;
|
||||
@@ -12,9 +19,8 @@ export interface IAvatar {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
size: number;
|
||||
size: AvatarSize;
|
||||
src?: string;
|
||||
icon?: React.ReactNode;
|
||||
model?: IAvatar;
|
||||
alt?: string;
|
||||
showBorder?: boolean;
|
||||
@@ -24,7 +30,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { icon, showBorder, model, style, ...rest } = props;
|
||||
const { showBorder, model, style, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
@@ -44,13 +50,12 @@ function Avatar(props: Props) {
|
||||
) : (
|
||||
<Initials $showBorder={showBorder} {...rest} />
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
</Relative>
|
||||
);
|
||||
}
|
||||
|
||||
Avatar.defaultProps = {
|
||||
size: 24,
|
||||
size: AvatarSize.Medium,
|
||||
};
|
||||
|
||||
const Relative = styled.div`
|
||||
@@ -59,18 +64,6 @@ const Relative = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
background: ${s("accent")};
|
||||
border: 2px solid ${s("background")};
|
||||
border-radius: 100%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
|
||||
@@ -7,7 +7,11 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
|
||||
background-color: ${({ yellow, primary, theme }) =>
|
||||
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
|
||||
color: ${({ primary, yellow, theme }) =>
|
||||
primary ? theme.white : yellow ? theme.almostBlack : theme.textTertiary};
|
||||
primary
|
||||
? theme.accentText
|
||||
: yellow
|
||||
? theme.almostBlack
|
||||
: theme.textTertiary};
|
||||
border: 1px solid
|
||||
${({ primary, yellow, theme }) =>
|
||||
primary || yellow
|
||||
|
||||
@@ -60,7 +60,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
${(props) =>
|
||||
props.$neutral &&
|
||||
`
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
background: inherit;
|
||||
color: ${props.theme.buttonNeutralText};
|
||||
box-shadow: ${
|
||||
props.$borderOnHover
|
||||
|
||||
@@ -3,6 +3,7 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
withStickyHeader?: boolean;
|
||||
};
|
||||
|
||||
@@ -26,7 +27,7 @@ const Content = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent: React.FC<Props> = ({ children, ...rest }) => (
|
||||
const CenteredContent: React.FC<Props> = ({ children, ...rest }: Props) => (
|
||||
<Container {...rest}>
|
||||
<Content>{children}</Content>
|
||||
</Container>
|
||||
|
||||
@@ -57,7 +57,7 @@ function Collaborators(props: Props) {
|
||||
|
||||
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
|
||||
setRequestedUserIds(ids);
|
||||
users.fetchPage({ ids, limit: 100 });
|
||||
void users.fetchPage({ ids, limit: 100 });
|
||||
}
|
||||
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
|
||||
|
||||
|
||||
@@ -71,9 +71,9 @@ function CollectionDescription({ collection }: Props) {
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(getValue) => {
|
||||
async (getValue) => {
|
||||
setDirty(true);
|
||||
handleSave(getValue);
|
||||
await handleSave(getValue);
|
||||
},
|
||||
[handleSave]
|
||||
);
|
||||
@@ -111,7 +111,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
onBlur={handleStopEditing}
|
||||
maxLength={1000}
|
||||
embedsDisabled
|
||||
readOnlyWriteCheckboxes
|
||||
canUpdate
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
|
||||
@@ -25,9 +25,9 @@ function CommandBar() {
|
||||
|
||||
const { rootAction } = useKBar((state) => ({
|
||||
rootAction: state.currentRootActionId
|
||||
? ((state.actions[
|
||||
? (state.actions[
|
||||
state.currentRootActionId
|
||||
] as unknown) as CommandBarAction)
|
||||
] as unknown as CommandBarAction)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
@@ -52,7 +52,11 @@ function CommandBar() {
|
||||
);
|
||||
}
|
||||
|
||||
const KBarPortal: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const KBarPortal: React.FC = ({ children }: Props) => {
|
||||
const { showing } = useKBar((state) => ({
|
||||
showing: state.visualState !== "hidden",
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@ import styled, { css, useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
action: ActionImpl;
|
||||
@@ -55,22 +56,36 @@ function CommandBarItem(
|
||||
{action.children?.length ? "…" : ""}
|
||||
</Content>
|
||||
{action.shortcut?.length ? (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridAutoFlow: "column",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
{action.shortcut.map((sc: string) => (
|
||||
<Key key={sc}>{sc}</Key>
|
||||
<Shortcut>
|
||||
{action.shortcut.map((sc: string, index) => (
|
||||
<React.Fragment key={sc}>
|
||||
{index > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
<Text size="xsmall" as="span" type="secondary">
|
||||
then
|
||||
</Text>{" "}
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{sc.split("+").map((s) => (
|
||||
<Key key={s}>{s}</Key>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Shortcut>
|
||||
) : null}
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
const Shortcut = styled.div`
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const Icon = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -8,24 +8,33 @@ export default function CommandBarResults() {
|
||||
const { results, rootActionId } = useMatches();
|
||||
|
||||
return (
|
||||
<KBarResults
|
||||
items={results}
|
||||
maxHeight={400}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<Header>{item}</Header>
|
||||
) : (
|
||||
<CommandBarItem
|
||||
action={item}
|
||||
active={active}
|
||||
currentRootActionId={rootActionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Container>
|
||||
<KBarResults
|
||||
items={results}
|
||||
maxHeight={400}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<Header>{item}</Header>
|
||||
) : (
|
||||
<CommandBarItem
|
||||
action={item}
|
||||
active={active}
|
||||
currentRootActionId={rootActionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Cannot style KBarResults unfortunately, so we must wrap and target the inner
|
||||
const Container = styled.div`
|
||||
> div {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
@@ -17,6 +17,7 @@ type Props = {
|
||||
danger?: boolean;
|
||||
/** Keep the submit button disabled */
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ConfirmationDialog: React.FC<Props> = ({
|
||||
@@ -26,7 +27,7 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
savingText,
|
||||
danger,
|
||||
disabled = false,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const { dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
+107
-101
@@ -30,69 +30,74 @@ export type RefHandle = {
|
||||
* Defines a content editable component with the same interface as a native
|
||||
* HTMLInputElement (or, as close as we can get).
|
||||
*/
|
||||
const ContentEditable = React.forwardRef(
|
||||
(
|
||||
{
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
maxLength,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
readOnly,
|
||||
dir,
|
||||
onClick,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) => {
|
||||
const contentRef = React.useRef<HTMLSpanElement>(null);
|
||||
const [innerValue, setInnerValue] = React.useState<string>(value);
|
||||
const lastValue = React.useRef("");
|
||||
const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
{
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
maxLength,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
readOnly,
|
||||
dir,
|
||||
onClick,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) {
|
||||
const contentRef = React.useRef<HTMLSpanElement>(null);
|
||||
const [innerValue, setInnerValue] = React.useState<string>(value);
|
||||
const lastValue = React.useRef(value);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
// looks unnecessary but required because of https://github.com/outline/outline/issues/5198
|
||||
if (!contentRef.current.innerText) {
|
||||
placeCaret(contentRef.current, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
focusAtStart: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
// looks unnecessary but required because of https://github.com/outline/outline/issues/5198
|
||||
if (!contentRef.current.innerText) {
|
||||
placeCaret(contentRef.current, true);
|
||||
}
|
||||
},
|
||||
focusAtEnd: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, false);
|
||||
}
|
||||
},
|
||||
getComputedDirection: () => {
|
||||
if (contentRef.current) {
|
||||
return window.getComputedStyle(contentRef.current).direction;
|
||||
}
|
||||
return "ltr";
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
focusAtStart: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, true);
|
||||
}
|
||||
},
|
||||
focusAtEnd: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, false);
|
||||
}
|
||||
},
|
||||
getComputedDirection: () => {
|
||||
if (contentRef.current) {
|
||||
return window.getComputedStyle(contentRef.current).direction;
|
||||
}
|
||||
return "ltr";
|
||||
},
|
||||
}));
|
||||
|
||||
const wrappedEvent = (
|
||||
const wrappedEvent =
|
||||
(
|
||||
callback:
|
||||
| React.FocusEventHandler<HTMLSpanElement>
|
||||
| React.FormEventHandler<HTMLSpanElement>
|
||||
| React.KeyboardEventHandler<HTMLSpanElement>
|
||||
| undefined
|
||||
) => (event: any) => {
|
||||
const text = contentRef.current?.innerText || "";
|
||||
) =>
|
||||
(event: any) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.currentTarget.textContent || "";
|
||||
|
||||
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
|
||||
event?.preventDefault();
|
||||
@@ -101,62 +106,62 @@ const ContentEditable = React.forwardRef(
|
||||
|
||||
if (text !== lastValue.current) {
|
||||
lastValue.current = text;
|
||||
onChange && onChange(text);
|
||||
onChange?.(text);
|
||||
}
|
||||
|
||||
callback?.(event);
|
||||
};
|
||||
|
||||
// This is to account for being within a React.Suspense boundary, in this
|
||||
// case the component may be rendered with display: none. React 18 may solve
|
||||
// this in the future by delaying useEffect hooks:
|
||||
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
|
||||
const isVisible = useOnScreen(contentRef);
|
||||
// This is to account for being within a React.Suspense boundary, in this
|
||||
// case the component may be rendered with display: none. React 18 may solve
|
||||
// this in the future by delaying useEffect hooks:
|
||||
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
|
||||
const isVisible = useOnScreen(contentRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && isVisible && !disabled && !readOnly) {
|
||||
contentRef.current?.focus();
|
||||
}
|
||||
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && isVisible && !disabled && !readOnly) {
|
||||
contentRef.current?.focus();
|
||||
}
|
||||
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== contentRef.current?.innerText) {
|
||||
setInnerValue(value);
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current && value !== contentRef.current.textContent) {
|
||||
setInnerValue(value);
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
|
||||
// Ensure only plain text can be pasted into input when pasting from another
|
||||
// rich text source
|
||||
const handlePaste = React.useCallback(
|
||||
(event: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
window.document.execCommand("insertText", false, text);
|
||||
},
|
||||
[]
|
||||
);
|
||||
// Ensure only plain text can be pasted into input when pasting from another
|
||||
// rich text source. Note: If `onPaste` prop is passed then it takes
|
||||
// priority over this behavior.
|
||||
const handlePaste = React.useCallback(
|
||||
(event: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
window.document.execCommand("insertText", false, text);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
</Content>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
</Content>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function placeCaret(element: HTMLElement, atStart: boolean) {
|
||||
if (
|
||||
@@ -180,6 +185,7 @@ const Content = styled.span`
|
||||
outline: none;
|
||||
resize: none;
|
||||
cursor: text;
|
||||
word-break: anywhere;
|
||||
|
||||
&:empty {
|
||||
display: inline-block;
|
||||
|
||||
@@ -8,6 +8,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
@@ -21,6 +22,7 @@ type Props = {
|
||||
level?: number;
|
||||
icon?: React.ReactElement;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
|
||||
const MenuItem = (
|
||||
@@ -37,34 +39,26 @@ const MenuItem = (
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) => {
|
||||
const handleClick = React.useCallback(
|
||||
(ev) => {
|
||||
if (onClick) {
|
||||
const content = React.useCallback(
|
||||
(props) => {
|
||||
const handleClick = async (ev: React.MouseEvent) => {
|
||||
hide?.();
|
||||
|
||||
if (onClick) {
|
||||
ev.preventDefault();
|
||||
await onClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const handleMouseDown = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
onClick(ev);
|
||||
}
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
hide?.();
|
||||
},
|
||||
[onClick, hide]
|
||||
);
|
||||
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const handleMouseDown = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{(props) => (
|
||||
return (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$active={active}
|
||||
@@ -85,7 +79,19 @@ const MenuItem = (
|
||||
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
|
||||
{children}
|
||||
</MenuAnchor>
|
||||
)}
|
||||
);
|
||||
},
|
||||
[active, as, hide, icon, onClick, ref, children, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,8 +24,12 @@ type Positions = {
|
||||
export default function MouseSafeArea(props: {
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const { x = 0, y = 0, height: h = 0, width: w = 0 } =
|
||||
props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const {
|
||||
x = 0,
|
||||
y = 0,
|
||||
height: h = 0,
|
||||
width: w = 0,
|
||||
} = props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const [mouseX, mouseY] = useMousePosition();
|
||||
const positions = { x, y, h, w, mouseX, mouseY };
|
||||
|
||||
|
||||
@@ -44,36 +44,35 @@ type SubMenuProps = MenuStateReturn & {
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenu = React.forwardRef(
|
||||
(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState();
|
||||
const SubMenu = React.forwardRef(function _Template(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor disclosure {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Submenu")}
|
||||
onClick={parentMenuState.hide}
|
||||
>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor disclosure {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Submenu")}
|
||||
onClick={parentMenuState.hide}
|
||||
parentMenuState={parentMenuState}
|
||||
>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
return items
|
||||
@@ -134,6 +133,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
return (
|
||||
<MenuItem
|
||||
as={Link}
|
||||
id={`${item.title}-${index}`}
|
||||
to={item.to}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
@@ -149,6 +149,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
if (item.type === "link") {
|
||||
return (
|
||||
<MenuItem
|
||||
id={`${item.title}-${index}`}
|
||||
href={item.href}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
@@ -167,6 +168,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
return (
|
||||
<MenuItem
|
||||
as="button"
|
||||
id={`${item.title}-${index}`}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
@@ -185,6 +187,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
as={SubMenu}
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={<Title title={item.title} icon={item.icon} />}
|
||||
|
||||
@@ -39,13 +39,14 @@ export type Placement =
|
||||
type Props = MenuStateReturn & {
|
||||
"aria-label"?: string;
|
||||
/** The parent menu state if this is a submenu. */
|
||||
parentMenuState?: MenuStateReturn;
|
||||
parentMenuState?: Omit<MenuStateReturn, "items">;
|
||||
/** Called when the context menu is opened. */
|
||||
onOpen?: () => void;
|
||||
/** Called when the context menu is closed. */
|
||||
onClose?: () => void;
|
||||
/** Called when the context menu is clicked. */
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ContextMenu: React.FC<Props> = ({
|
||||
@@ -54,14 +55,18 @@ const ContextMenu: React.FC<Props> = ({
|
||||
onClose,
|
||||
parentMenuState,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
|
||||
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();
|
||||
const isMobile = useMobile();
|
||||
const isSubMenu = !!parentMenuState;
|
||||
|
||||
useUnmount(() => {
|
||||
setIsMenuOpen(false);
|
||||
@@ -71,7 +76,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
if (rest.visible && !previousVisible) {
|
||||
onOpen?.();
|
||||
|
||||
if (!parentMenuState) {
|
||||
if (!isSubMenu) {
|
||||
setIsMenuOpen(true);
|
||||
}
|
||||
}
|
||||
@@ -79,7 +84,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
if (!rest.visible && previousVisible) {
|
||||
onClose?.();
|
||||
|
||||
if (!parentMenuState) {
|
||||
if (!isSubMenu) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
@@ -90,7 +95,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
rest.visible,
|
||||
ui.sidebarCollapsed,
|
||||
setIsMenuOpen,
|
||||
parentMenuState,
|
||||
isSubMenu,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -99,13 +104,15 @@ const ContextMenu: React.FC<Props> = ({
|
||||
// https://github.com/ariakit/ariakit/issues/469
|
||||
React.useEffect(() => {
|
||||
const scrollElement = backgroundRef.current;
|
||||
if (rest.visible && scrollElement) {
|
||||
disableBodyScroll(scrollElement);
|
||||
if (rest.visible && scrollElement && !isSubMenu) {
|
||||
disableBodyScroll(scrollElement, {
|
||||
reserveScrollBarGap: true,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
scrollElement && enableBodyScroll(scrollElement);
|
||||
scrollElement && !isSubMenu && enableBodyScroll(scrollElement);
|
||||
};
|
||||
}, [rest.visible]);
|
||||
}, [isSubMenu, rest.visible]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
@@ -144,7 +151,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={
|
||||
maxHeight && topAnchor
|
||||
topAnchor
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const DefaultCollectionInputSelect = ({
|
||||
const { showToast } = useToasts();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
async function fetchData() {
|
||||
if (!collections.isLoaded && !fetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
@@ -48,7 +48,7 @@ const DefaultCollectionInputSelect = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
load();
|
||||
void fetchData();
|
||||
}, [showToast, fetchError, t, fetching, collections]);
|
||||
|
||||
const options = React.useMemo(
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function DesktopEventHandler() {
|
||||
action: {
|
||||
text: "Install now",
|
||||
onClick: () => {
|
||||
Desktop.bridge?.restartAndInstall();
|
||||
void Desktop.bridge?.restartAndInstall();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
document: Document;
|
||||
onlyText?: boolean;
|
||||
};
|
||||
@@ -58,7 +59,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
document,
|
||||
children,
|
||||
onlyText,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
@@ -129,7 +130,11 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <Breadcrumb items={items} children={children} highlightFirstItem />;
|
||||
return (
|
||||
<Breadcrumb items={items} highlightFirstItem>
|
||||
{children}
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
|
||||
@@ -14,6 +14,7 @@ import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import EmojiIcon from "./Icons/EmojiIcon";
|
||||
import Squircle from "./Squircle";
|
||||
@@ -57,10 +58,10 @@ function DocumentCard(props: Props) {
|
||||
};
|
||||
|
||||
const handleUnpin = React.useCallback(
|
||||
(ev) => {
|
||||
async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
pin?.delete();
|
||||
await pin?.delete();
|
||||
},
|
||||
[pin]
|
||||
);
|
||||
@@ -179,7 +180,7 @@ const Fold = styled.svg`
|
||||
const PinButton = styled(NudeButton)`
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
&:hover,
|
||||
&:${hover},
|
||||
&:active {
|
||||
color: ${s("text")};
|
||||
}
|
||||
@@ -209,7 +210,7 @@ const Reorderable = styled.div<{ $isDragging: boolean }>`
|
||||
z-index: ${(props) => (props.$isDragging ? 1 : "inherit")};
|
||||
pointer-events: ${(props) => (props.$isDragging ? "none" : "inherit")};
|
||||
|
||||
&:hover ${Actions} {
|
||||
&: ${hover} ${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
@@ -248,7 +249,7 @@ const DocumentLink = styled(Link)<{
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:${hover},
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
|
||||
@@ -45,10 +45,8 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
);
|
||||
const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>(
|
||||
0
|
||||
);
|
||||
const [nodes, setNodes] = React.useState<NavigationNode[]>([]);
|
||||
const [initialScrollOffset, setInitialScrollOffset] =
|
||||
React.useState<number>(0);
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
|
||||
const [itemRefs, setItemRefs] = React.useState<
|
||||
@@ -79,19 +77,6 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
setActiveNode(0);
|
||||
}, [searchTerm]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let results;
|
||||
|
||||
if (searchTerm) {
|
||||
results = searchIndex.search(searchTerm);
|
||||
} else {
|
||||
results = items.filter((item) => item.type === "collection");
|
||||
}
|
||||
|
||||
setInitialScrollOffset(0);
|
||||
setNodes(results);
|
||||
}, [searchTerm, items, searchIndex]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setItemRefs((itemRefs) =>
|
||||
map(
|
||||
@@ -105,6 +90,22 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
function getNodes() {
|
||||
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
||||
return expandedNodes.includes(item.id)
|
||||
? [item, ...descendants(item, 1).flatMap(includeDescendants)]
|
||||
: [item];
|
||||
}
|
||||
|
||||
return searchTerm
|
||||
? searchIndex.search(searchTerm)
|
||||
: items
|
||||
.filter((item) => item.type === "collection")
|
||||
.flatMap(includeDescendants);
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
if (itemRefs[node] && itemRefs[node].current) {
|
||||
@@ -130,7 +131,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
scrollOffset: number;
|
||||
};
|
||||
const itemsHeight = itemCount * itemSize;
|
||||
return itemsHeight < height ? 0 : scrollOffset;
|
||||
return itemsHeight < Number(height) ? 0 : scrollOffset;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
@@ -145,7 +146,6 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
const expand = (node: number) => {
|
||||
@@ -156,9 +156,18 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
collections.orderedData
|
||||
.filter(
|
||||
(collection) => expandedNodes.includes(collection.id) || searchTerm
|
||||
)
|
||||
.forEach((collection) => {
|
||||
void collection.fetchDocuments();
|
||||
});
|
||||
}, [collections, expandedNodes, searchTerm]);
|
||||
|
||||
const isSelected = (node: number) => {
|
||||
if (!selectedNode) {
|
||||
return false;
|
||||
@@ -169,7 +178,8 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
return selectedNodeId === nodeId;
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) => nodes[node].children.length > 0;
|
||||
const hasChildren = (node: number) =>
|
||||
nodes[node].children.length > 0 || nodes[node].type === "collection";
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
@@ -219,7 +229,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
} else if (doc?.isStarred) {
|
||||
icon = <StarredIcon color={theme.yellow} />;
|
||||
} else {
|
||||
icon = <DocumentIcon />;
|
||||
icon = <DocumentIcon color={theme.textSecondary} />;
|
||||
}
|
||||
|
||||
path = ancestors(node)
|
||||
@@ -281,12 +291,14 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown": {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setActiveNode(next());
|
||||
scrollNodeIntoView(next());
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (activeNode === 0) {
|
||||
focusSearchInput();
|
||||
} else {
|
||||
@@ -323,16 +335,21 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const innerElementType = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ style, ...rest }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
));
|
||||
>(function innerElementType(
|
||||
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
|
||||
|
||||
@@ -181,8 +181,7 @@ const Actions = styled(EventBoundary)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
${NudeButton} {
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
&: ${hover}, &[aria-expanded= "true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
}
|
||||
@@ -234,7 +233,7 @@ const DocumentLink = styled(Link)<{
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
&:${hover} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -259,10 +258,8 @@ const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${s("text")};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import DocumentTasks from "~/components/DocumentTasks";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -14,11 +15,13 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showLastViewed?: boolean;
|
||||
showParentDocuments?: boolean;
|
||||
document: Document;
|
||||
revision?: Revision;
|
||||
replace?: boolean;
|
||||
to?: LocationDescriptor;
|
||||
};
|
||||
@@ -29,11 +32,12 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
showLastViewed,
|
||||
showParentDocuments,
|
||||
document,
|
||||
revision,
|
||||
children,
|
||||
replace,
|
||||
to,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const user = useCurrentUser();
|
||||
@@ -64,7 +68,16 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
const userName = updatedBy.name;
|
||||
let content;
|
||||
|
||||
if (deletedAt) {
|
||||
if (revision) {
|
||||
content = (
|
||||
<span>
|
||||
{revision.createdBy?.id === user.id
|
||||
? t("You updated")
|
||||
: t("{{ userName }} updated", { userName })}{" "}
|
||||
<Time dateTime={revision.createdAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{lastUpdatedByCurrentUser
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dateToRelative } from "@shared/utils/date";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
@@ -33,9 +33,10 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
documentViews,
|
||||
(view) => !presentIds.includes(view.user.id)
|
||||
);
|
||||
const users = React.useMemo(() => sortedViews.map((v) => v.user), [
|
||||
sortedViews,
|
||||
]);
|
||||
const users = React.useMemo(
|
||||
() => sortedViews.map((v) => v.user),
|
||||
[sortedViews]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -52,7 +53,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
? t("Currently editing")
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }} ago", {
|
||||
timeAgo: formatDistanceToNow(
|
||||
timeAgo: dateToRelative(
|
||||
view ? Date.parse(view.lastViewedAt) : new Date()
|
||||
),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
|
||||
type Props = {
|
||||
enabled: boolean;
|
||||
collection: Collection;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function DocumentsLoader({ collection, enabled, children }: Props) {
|
||||
React.useEffect(() => {
|
||||
if (enabled) {
|
||||
void collection.fetchDocuments();
|
||||
}
|
||||
}, [collection, enabled]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default observer(DocumentsLoader);
|
||||
@@ -1,4 +1,3 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, difference, sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
@@ -10,6 +9,7 @@ import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
@@ -23,14 +23,16 @@ import useDictionary from "~/hooks/useDictionary";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
|
||||
const LazyLoadedEditor = React.lazy(() => import("~/editor"));
|
||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
|
||||
export type Props = Optional<
|
||||
EditorProps,
|
||||
@@ -59,6 +61,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
} = props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
const { auth, comments, documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
@@ -67,10 +71,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = auth.user?.preferences;
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
const [
|
||||
activeLinkElement,
|
||||
setActiveLink,
|
||||
] = React.useState<HTMLAnchorElement | null>(null);
|
||||
const [activeLinkElement, setActiveLink] =
|
||||
React.useState<HTMLAnchorElement | null>(null);
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
|
||||
@@ -93,8 +95,10 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
try {
|
||||
const document = await documents.fetch(slug);
|
||||
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
|
||||
const time = dateToRelative(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
shorten: true,
|
||||
locale,
|
||||
});
|
||||
|
||||
return [
|
||||
@@ -350,7 +354,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
)}
|
||||
{activeLinkElement && !shareId && (
|
||||
<HoverPreview
|
||||
id={props.id}
|
||||
element={activeLinkElement}
|
||||
onClose={handleLinkInactive}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const EventBoundary: React.FC<Props> = ({ children, className }) => {
|
||||
const EventBoundary: React.FC<Props> = ({ children, className }: Props) => {
|
||||
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
UnpublishIcon,
|
||||
LightningIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -25,6 +24,7 @@ import Item, { Actions } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import { hover } from "~/styles";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -51,27 +51,24 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
ref.current?.focus();
|
||||
};
|
||||
|
||||
const prefetchRevision = () => {
|
||||
const prefetchRevision = async () => {
|
||||
if (event.name === "revisions.create" && event.modelId) {
|
||||
revisions.fetch(event.modelId);
|
||||
await revisions.fetch(event.modelId);
|
||||
}
|
||||
};
|
||||
|
||||
switch (event.name) {
|
||||
case "revisions.create":
|
||||
icon = <EditIcon size={16} />;
|
||||
meta = t("{{userName}} edited", opts);
|
||||
meta = latest ? (
|
||||
<>
|
||||
{t("Current version")} · {event.actor.name}
|
||||
</>
|
||||
) : (
|
||||
t("{{userName}} edited", opts)
|
||||
);
|
||||
to = {
|
||||
pathname: documentHistoryPath(document, event.modelId || ""),
|
||||
state: { retainScrollPosition: true },
|
||||
};
|
||||
break;
|
||||
|
||||
case "documents.live_editing":
|
||||
icon = <LightningIcon size={16} />;
|
||||
meta = t("Latest");
|
||||
to = {
|
||||
pathname: documentHistoryPath(document),
|
||||
pathname: documentHistoryPath(document, event.modelId || "latest"),
|
||||
state: { retainScrollPosition: true },
|
||||
};
|
||||
break;
|
||||
@@ -152,7 +149,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
</Subtitle>
|
||||
}
|
||||
actions={
|
||||
isRevision && isActive && event.modelId ? (
|
||||
isRevision && isActive && event.modelId && !latest ? (
|
||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||
) : undefined
|
||||
}
|
||||
@@ -163,15 +160,16 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const BaseItem = React.forwardRef(
|
||||
({ to, ...rest }: ItemProps, ref?: React.Ref<HTMLAnchorElement>) => {
|
||||
if (to) {
|
||||
return <CompositeListItem to={to} ref={ref} {...rest} />;
|
||||
}
|
||||
|
||||
return <ListItem ref={ref} {...rest} />;
|
||||
const BaseItem = React.forwardRef(function _BaseItem(
|
||||
{ to, ...rest }: ItemProps,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
if (to) {
|
||||
return <CompositeListItem to={to} ref={ref} {...rest} />;
|
||||
}
|
||||
);
|
||||
|
||||
return <ListItem ref={ref} {...rest} />;
|
||||
});
|
||||
|
||||
const Subtitle = styled.span`
|
||||
svg {
|
||||
@@ -220,7 +218,7 @@ const ItemStyle = css`
|
||||
${Actions} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
&: ${hover} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
const [format, setFormat] = React.useState<FileOperationFormat>(
|
||||
FileOperationFormat.MarkdownZip
|
||||
);
|
||||
const [includeAttachments, setIncludeAttachments] =
|
||||
React.useState<boolean>(true);
|
||||
const user = useCurrentUser();
|
||||
const { showToast } = useToasts();
|
||||
const { collections } = useStores();
|
||||
@@ -34,11 +36,18 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleIncludeAttachmentsChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIncludeAttachments(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (collection) {
|
||||
await collection.export(format);
|
||||
await collection.export(format, includeAttachments);
|
||||
} else {
|
||||
await collections.export(format);
|
||||
await collections.export(format, includeAttachments);
|
||||
}
|
||||
onSubmit();
|
||||
showToast(t("Export started"), { type: "success" });
|
||||
@@ -90,7 +99,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
)}
|
||||
<Flex gap={12} column>
|
||||
{items.map((item) => (
|
||||
<Option>
|
||||
<Option key={item.value}>
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
@@ -107,6 +116,23 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
</Option>
|
||||
))}
|
||||
</Flex>
|
||||
<hr />
|
||||
<Option>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includeAttachments"
|
||||
checked={includeAttachments}
|
||||
onChange={handleIncludeAttachmentsChange}
|
||||
/>
|
||||
<div>
|
||||
<Text size="small" weight="bold">
|
||||
{t("Include attachments")}
|
||||
</Text>
|
||||
<Text size="small">
|
||||
{t("Including uploaded images and files in the exported data")}.
|
||||
</Text>{" "}
|
||||
</div>
|
||||
</Option>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ const Flex = styled.div<{
|
||||
align-items: ${({ align }) => align};
|
||||
justify-content: ${({ justify }) => justify};
|
||||
flex-wrap: ${({ wrap }) => (wrap ? "wrap" : "initial")};
|
||||
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
|
||||
flex-shrink: ${({ shrink }) =>
|
||||
shrink === true ? 1 : shrink === false ? 0 : "initial"};
|
||||
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
||||
@@ -14,6 +14,7 @@ import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
@@ -27,11 +28,8 @@ type Props = {
|
||||
function GroupListItem({ group, showFacepile, renderActions }: Props) {
|
||||
const { groupMemberships } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [
|
||||
membersModalOpen,
|
||||
setMembersModalOpen,
|
||||
setMembersModalClosed,
|
||||
] = useBoolean();
|
||||
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
|
||||
useBoolean();
|
||||
const memberCount = group.memberCount;
|
||||
const membershipsInGroup = groupMemberships.inGroup(group.id);
|
||||
const users = membershipsInGroup
|
||||
@@ -87,7 +85,7 @@ const Image = styled(Flex)`
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
&:hover {
|
||||
&: ${hover} {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Scrollable from "~/components/Scrollable";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
onRequestClose: () => void;
|
||||
@@ -17,7 +18,7 @@ const Guide: React.FC<Props> = ({
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({
|
||||
animated: 250,
|
||||
});
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isExternalUrl } from "@shared/utils/urls";
|
||||
import HoverPreviewDocument from "~/components/HoverPreviewDocument";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { fadeAndSlideDown } from "~/styles/animations";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 300;
|
||||
|
||||
type Props = {
|
||||
/* The document associated with the editor, if any */
|
||||
id?: string;
|
||||
/* The HTML element that is being hovered over */
|
||||
element: HTMLAnchorElement;
|
||||
/* A callback on close of the hover preview */
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function HoverPreviewInternal({ element, id, onClose }: Props) {
|
||||
const { documents } = useStores();
|
||||
const slug = parseDocumentSlug(element.href);
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const startCloseTimer = () => {
|
||||
stopOpenTimer();
|
||||
timerClose.current = setTimeout(() => {
|
||||
if (isVisible) {
|
||||
setVisible(false);
|
||||
}
|
||||
onClose();
|
||||
}, DELAY_CLOSE);
|
||||
};
|
||||
|
||||
const stopCloseTimer = () => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
|
||||
const startOpenTimer = () => {
|
||||
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
|
||||
};
|
||||
|
||||
const stopOpenTimer = () => {
|
||||
if (timerOpen.current) {
|
||||
clearTimeout(timerOpen.current);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (slug) {
|
||||
documents.prefetchDocument(slug);
|
||||
}
|
||||
|
||||
startOpenTimer();
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseenter", stopCloseTimer);
|
||||
}
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
element.addEventListener("mouseout", startCloseTimer);
|
||||
element.addEventListener("mouseover", stopCloseTimer);
|
||||
element.addEventListener("mouseover", startOpenTimer);
|
||||
return () => {
|
||||
element.removeEventListener("mouseout", startCloseTimer);
|
||||
element.removeEventListener("mouseover", stopCloseTimer);
|
||||
element.removeEventListener("mouseover", startOpenTimer);
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseenter", stopCloseTimer);
|
||||
}
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
}, [element, slug]);
|
||||
|
||||
const anchorBounds = element.getBoundingClientRect();
|
||||
const cardBounds = cardRef.current?.getBoundingClientRect();
|
||||
const left = cardBounds
|
||||
? Math.min(anchorBounds.left, window.innerWidth - 16 - 350)
|
||||
: anchorBounds.left;
|
||||
const leftOffset = anchorBounds.left - left;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position
|
||||
top={anchorBounds.bottom + window.scrollY}
|
||||
left={left}
|
||||
aria-hidden
|
||||
>
|
||||
<div ref={cardRef}>
|
||||
<HoverPreviewDocument url={element.href} id={id}>
|
||||
{(content: React.ReactNode) =>
|
||||
isVisible ? (
|
||||
<Animate>
|
||||
<Card>
|
||||
<Margin />
|
||||
<CardContent>{content}</CardContent>
|
||||
</Card>
|
||||
<Pointer offset={leftOffset + anchorBounds.width / 2} />
|
||||
</Animate>
|
||||
) : null
|
||||
}
|
||||
</HoverPreviewDocument>
|
||||
</div>
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ element, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// previews only work for internal doc links for now
|
||||
if (isExternalUrl(element.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HoverPreviewInternal {...rest} element={element} />;
|
||||
}
|
||||
|
||||
const Animate = styled.div`
|
||||
animation: ${fadeAndSlideDown} 150ms ease;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
// fills the gap between the card and pointer to avoid a dead zone
|
||||
const Margin = styled.div`
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 11px;
|
||||
`;
|
||||
|
||||
const CardContent = styled.div`
|
||||
overflow: hidden;
|
||||
max-height: 20em;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// &:after — gradient mask for overflow text
|
||||
const Card = styled.div`
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${s("background")};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
padding: 16px;
|
||||
width: 350px;
|
||||
font-size: 0.9em;
|
||||
position: relative;
|
||||
|
||||
.placeholder,
|
||||
.heading-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${(props) => transparentize(1, props.theme.background)} 75%,
|
||||
${s("background")} 90%
|
||||
);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1.7em;
|
||||
border-bottom: 16px solid ${s("background")};
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${depths.hoverPreview};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div<{ offset: number }>`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
border-bottom-color: ${(props) =>
|
||||
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: ${s("background")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { transparentize } from "polished";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
export const CARD_MARGIN = 16;
|
||||
|
||||
const NUMBER_OF_LINES = 10;
|
||||
|
||||
const sharedVars = css`
|
||||
--line-height: 1.6em;
|
||||
`;
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
export const Preview = styled(Link)`
|
||||
cursor: ${(props: any) =>
|
||||
props.as === "div" ? "default" : "var(--pointer)"};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
min-width: 350px;
|
||||
max-width: 375px;
|
||||
`;
|
||||
|
||||
export const Title = styled.h2`
|
||||
font-size: 1.25em;
|
||||
margin: 0;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
export const Info = styled(StyledText).attrs(() => ({
|
||||
type: "tertiary",
|
||||
size: "xsmall",
|
||||
}))`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const Description = styled(StyledText)`
|
||||
${sharedVars}
|
||||
margin-top: 0.5em;
|
||||
line-height: var(--line-height);
|
||||
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
|
||||
`;
|
||||
|
||||
export const Thumbnail = styled.img`
|
||||
object-fit: cover;
|
||||
height: 200px;
|
||||
background: ${s("menuBackground")};
|
||||
`;
|
||||
|
||||
export const CardContent = styled.div`
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// &:after — gradient mask for overflow text
|
||||
export const Card = styled.div<{ fadeOut?: boolean; $borderRadius?: string }>`
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${s("menuBackground")};
|
||||
padding: 16px;
|
||||
font-size: 0.9em;
|
||||
position: relative;
|
||||
|
||||
.placeholder,
|
||||
.heading-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// fills the gap between the card and pointer to avoid a dead zone
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.fadeOut !== false
|
||||
? `&:after {
|
||||
${sharedVars}
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${transparentize(1, props.theme.menuBackground)} 0%,
|
||||
${transparentize(1, props.theme.menuBackground)} 75%,
|
||||
${props.theme.menuBackground} 90%
|
||||
);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--line-height);
|
||||
border-bottom: 16px solid ${props.theme.menuBackground};
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}`
|
||||
: ""}
|
||||
`;
|
||||
@@ -0,0 +1,261 @@
|
||||
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 { UnfurlType } from "@shared/types";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { CARD_MARGIN } from "./Components";
|
||||
import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 600;
|
||||
|
||||
type Props = {
|
||||
/* The HTML element that is being hovered over */
|
||||
element: HTMLAnchorElement;
|
||||
/* A callback on close of the hover preview */
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const url = element.href || element.dataset.url;
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement>(null);
|
||||
const stores = useStores();
|
||||
const [cardLeft, setCardLeft] = React.useState(0);
|
||||
const [cardTop, setCardTop] = React.useState(0);
|
||||
const [pointerOffset, setPointerOffset] = React.useState(0);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isVisible && cardRef.current) {
|
||||
const elem = element.getBoundingClientRect();
|
||||
const card = cardRef.current.getBoundingClientRect();
|
||||
|
||||
const top = elem.bottom + window.scrollY;
|
||||
setCardTop(top);
|
||||
|
||||
let left = elem.left;
|
||||
let pointerOffset = elem.width / 2;
|
||||
if (left + 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
|
||||
shiftBy += CARD_MARGIN;
|
||||
left -= shiftBy;
|
||||
|
||||
// shift pointer rightwards by same amount so as to position it back correctly
|
||||
pointerOffset += shiftBy;
|
||||
}
|
||||
setCardLeft(left);
|
||||
|
||||
setPointerOffset(pointerOffset);
|
||||
}
|
||||
}, [isVisible, element]);
|
||||
|
||||
const { data, request, loading } = useRequest(
|
||||
React.useCallback(
|
||||
() =>
|
||||
client.post("/urls.unfurl", {
|
||||
url,
|
||||
documentId: stores.ui.activeDocumentId,
|
||||
}),
|
||||
[url, stores.ui.activeDocumentId]
|
||||
)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (url) {
|
||||
stopOpenTimer();
|
||||
setVisible(false);
|
||||
|
||||
void request();
|
||||
}
|
||||
}, [url, request]);
|
||||
|
||||
const stopOpenTimer = () => {
|
||||
if (timerOpen.current) {
|
||||
clearTimeout(timerOpen.current);
|
||||
timerOpen.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const closePreview = React.useCallback(() => {
|
||||
if (isVisible) {
|
||||
stopOpenTimer();
|
||||
setVisible(false);
|
||||
onClose();
|
||||
}
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
useOnClickOutside(cardRef, closePreview);
|
||||
useKeyDown("Escape", closePreview);
|
||||
useEventListener("scroll", closePreview, window, { capture: true });
|
||||
|
||||
const stopCloseTimer = () => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
timerClose.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const startOpenTimer = () => {
|
||||
if (!timerOpen.current) {
|
||||
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
|
||||
}
|
||||
};
|
||||
|
||||
const startCloseTimer = React.useCallback(() => {
|
||||
stopOpenTimer();
|
||||
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
|
||||
}, [closePreview]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
|
||||
if (data) {
|
||||
startOpenTimer();
|
||||
|
||||
if (card) {
|
||||
card.addEventListener("mouseenter", stopCloseTimer);
|
||||
card.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
element.addEventListener("mouseout", startCloseTimer);
|
||||
element.addEventListener("mouseover", stopCloseTimer);
|
||||
element.addEventListener("mouseover", startOpenTimer);
|
||||
}
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("mouseout", startCloseTimer);
|
||||
element.removeEventListener("mouseover", stopCloseTimer);
|
||||
element.removeEventListener("mouseover", startOpenTimer);
|
||||
|
||||
if (card) {
|
||||
card.removeEventListener("mouseenter", stopCloseTimer);
|
||||
card.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
stopCloseTimer();
|
||||
};
|
||||
}, [element, startCloseTimer, data]);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
animate={{ opacity: 1, y: 0, pointerEvents: "auto" }}
|
||||
>
|
||||
{data.type === UnfurlType.Mention ? (
|
||||
<HoverPreviewMention
|
||||
ref={cardRef}
|
||||
url={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
info={data.meta.info}
|
||||
color={data.meta.color}
|
||||
/>
|
||||
) : data.type === UnfurlType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
id={data.meta.id}
|
||||
url={data.url}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
info={data.meta.info}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
thumbnailUrl={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer offset={pointerOffset} />
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ element, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HoverPreviewInternal {...rest} element={element} />;
|
||||
}
|
||||
|
||||
const Animate = styled(m.div)`
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${depths.hoverPreview};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div<{ offset: number }>`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
border-bottom-color: ${(props) =>
|
||||
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: ${s("menuBackground")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Info,
|
||||
Card,
|
||||
CardContent,
|
||||
Description,
|
||||
} from "./Components";
|
||||
|
||||
type Props = {
|
||||
/** Document id associated with the editor, if any */
|
||||
id?: string;
|
||||
/** Document url */
|
||||
url: string;
|
||||
/** Title for the preview card */
|
||||
title: string;
|
||||
/** Info about last activity on the document */
|
||||
info: string;
|
||||
/** Text preview of document content */
|
||||
description: string;
|
||||
};
|
||||
|
||||
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
|
||||
{ id, url, title, info, description }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview to={url}>
|
||||
<Card ref={ref}>
|
||||
<CardContent>
|
||||
<Flex column gap={2}>
|
||||
<Title>{title}</Title>
|
||||
<Info>{info}</Info>
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={id}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewDocument;
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from "react";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Description,
|
||||
Card,
|
||||
CardContent,
|
||||
Thumbnail,
|
||||
} from "./Components";
|
||||
|
||||
type Props = {
|
||||
/** Link url */
|
||||
url: string;
|
||||
/** Title for the preview card */
|
||||
title: string;
|
||||
/** Url for thumbnail served by the link provider */
|
||||
thumbnailUrl: string;
|
||||
/** Some description about the link provider */
|
||||
description: string;
|
||||
};
|
||||
|
||||
const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
|
||||
{ url, thumbnailUrl, title, description }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column>
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
|
||||
<Card ref={ref}>
|
||||
<CardContent>
|
||||
<Flex column>
|
||||
<Title>{title}</Title>
|
||||
<Description>{description}</Description>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewLink;
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
|
||||
type Props = {
|
||||
/** Resource url, avatar url in case of user mention */
|
||||
url: string;
|
||||
/** Title for the preview card*/
|
||||
title: string;
|
||||
/** Info about mentioned user's recent activity */
|
||||
info: string;
|
||||
/** Used for avatar's background color in absence of avatar url */
|
||||
color: string;
|
||||
};
|
||||
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ url, title, info, color }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview as="div">
|
||||
<Card fadeOut={false} ref={ref}>
|
||||
<CardContent>
|
||||
<Flex gap={12}>
|
||||
<Avatar
|
||||
model={{
|
||||
avatarUrl: url,
|
||||
initial: title ? title[0] : "?",
|
||||
color,
|
||||
}}
|
||||
size={AvatarSize.XLarge}
|
||||
/>
|
||||
<Flex column gap={2} justify="center">
|
||||
<Title>{title}</Title>
|
||||
<Info>{info}</Info>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewMention;
|
||||
@@ -0,0 +1,3 @@
|
||||
import HoverPreview from "./HoverPreview";
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -1,62 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Editor from "~/components/Editor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/* The document associated with the editor, if any */
|
||||
id?: string;
|
||||
/* The URL we want a preview for */
|
||||
url: string;
|
||||
children: (content: React.ReactNode) => React.ReactNode;
|
||||
};
|
||||
|
||||
function HoverPreviewDocument({ url, id, children }: Props) {
|
||||
const { documents } = useStores();
|
||||
const slug = parseDocumentSlug(url);
|
||||
|
||||
if (slug) {
|
||||
documents.prefetchDocument(slug);
|
||||
}
|
||||
|
||||
const document = slug ? documents.getByUrl(slug) : undefined;
|
||||
if (!document || document.id === id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(
|
||||
<Content to={document.url}>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<DocumentMeta document={document} />
|
||||
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={document.id}
|
||||
defaultValue={document.getSummary()}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Content>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled(Link)`
|
||||
cursor: var(--pointer);
|
||||
`;
|
||||
|
||||
const Heading = styled.h2`
|
||||
margin: 0 0 0.75em;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
export default observer(HoverPreviewDocument);
|
||||
@@ -47,6 +47,7 @@ import Flex from "~/components/Flex";
|
||||
import { LabelText } from "~/components/Input";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
|
||||
const style = {
|
||||
@@ -54,7 +55,7 @@ const style = {
|
||||
height: 30,
|
||||
};
|
||||
|
||||
const TwitterPicker = React.lazy(
|
||||
const TwitterPicker = lazyWithRetry(
|
||||
() => import("react-color/lib/components/twitter/Twitter")
|
||||
);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function MarkdownIcon({
|
||||
<path
|
||||
d="M19.2692 7H3.86538C3.38745 7 3 7.38476 3 7.85938V16.2812C3 16.7559 3.38745 17.1406 3.86538 17.1406H19.2692C19.7472 17.1406 20.1346 16.7559 20.1346 16.2812V7.85938C20.1346 7.38476 19.7472 7 19.2692 7Z"
|
||||
stroke={color}
|
||||
stroke-width="2"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M5.16345 14.9922V9.14844H6.89422L8.62499 11.2969L10.3558 9.14844H12.0865V14.9922H10.3558V11.6406L8.62499 13.7891L6.89422 11.6406V14.9922H5.16345ZM15.9808 14.9922L13.3846 12.1562H15.1154V9.14844H16.8461V12.1562H18.5769L15.9808 14.9922Z"
|
||||
|
||||
@@ -30,6 +30,8 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
|
||||
color: ${s("text")};
|
||||
height: 30px;
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
@@ -175,6 +177,8 @@ function Input(
|
||||
labelHidden,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onRequestSubmit,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@@ -211,6 +215,7 @@ function Input(
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</Outline>
|
||||
</label>
|
||||
{error && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
import Input, { Props as InputProps } from "./Input";
|
||||
@@ -15,7 +16,7 @@ type Props = Omit<InputProps, "onChange"> & {
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }) => {
|
||||
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
@@ -26,7 +27,7 @@ const InputColor: React.FC<Props> = ({ value, onChange, ...rest }) => {
|
||||
<Relative>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onChange={(event) => onChange(event.target.value.replace(/^#?/, "#"))}
|
||||
placeholder="#"
|
||||
maxLength={7}
|
||||
{...rest}
|
||||
@@ -68,7 +69,7 @@ const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>`
|
||||
right: 6px;
|
||||
`;
|
||||
|
||||
const ColorPicker = React.lazy(
|
||||
const ColorPicker = lazyWithRetry(
|
||||
() => import("react-color/lib/components/chrome/Chrome")
|
||||
);
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ function InputSearchPage({
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
history.push(
|
||||
|
||||
@@ -85,11 +85,11 @@ const InputSelect = (props: Props) => {
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const minWidth = buttonRef.current?.offsetWidth || 0;
|
||||
const margin = 8;
|
||||
const menuMaxHeight = useMenuHeight(
|
||||
select.visible,
|
||||
select.unstable_disclosureRef,
|
||||
margin
|
||||
);
|
||||
const menuMaxHeight = useMenuHeight({
|
||||
visible: select.visible,
|
||||
elementRef: select.unstable_disclosureRef,
|
||||
margin,
|
||||
});
|
||||
const maxHeight = Math.min(
|
||||
menuMaxHeight ?? 0,
|
||||
window.innerHeight -
|
||||
@@ -108,11 +108,7 @@ const InputSelect = (props: Props) => {
|
||||
}
|
||||
previousValue.current = select.selectedValue;
|
||||
|
||||
async function load() {
|
||||
await onChange?.(select.selectedValue);
|
||||
}
|
||||
|
||||
load();
|
||||
onChange?.(select.selectedValue);
|
||||
}, [onChange, select.selectedValue]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
|
||||
@@ -5,10 +5,11 @@ import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
label: React.ReactNode | string;
|
||||
};
|
||||
|
||||
const Labeled: React.FC<Props> = ({ label, children, ...props }) => (
|
||||
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
|
||||
<Flex column {...props}>
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
|
||||
@@ -21,14 +21,14 @@ function Icon({ className }: { className?: string }) {
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M21 18H16L14 16V6C14 4.89543 14.8954 4 16 4H28C29.1046 4 30 4.89543 30 6V16C30 17.1046 29.1046 18 28 18H27L25.4142 19.5858C24.6332 20.3668 23.3668 20.3668 22.5858 19.5858L21 18ZM16 15.1716V6H28V16H27H26.1716L25.5858 16.5858L24 18.1716L22.4142 16.5858L21.8284 16H21H16.8284L16 15.1716Z"
|
||||
fill="#2B2F35"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M16 13H4C2.89543 13 2 13.8954 2 15V25C2 26.1046 2.89543 27 4 27H5L6.58579 28.5858C7.36684 29.3668 8.63316 29.3668 9.41421 28.5858L11 27H16C17.1046 27 18 26.1046 18 25V15C18 13.8954 17.1046 13 16 13ZM9 17L6 16.9681C6 16.9681 5 17.016 5 18C5 18.984 6 19 6 19H8.5H10C10 19 9.57627 20.1885 8.38983 21.0831C7.20339 21.9777 5.7197 23 5.7197 23C5.7197 23 4.99153 23.6054 5.5 24.5C6.00847 25.3946 7 24.8403 7 24.8403L9.74576 22.8722L11.9492 24.6614C11.9492 24.6614 12.6271 25.3771 13.3051 24.4825C13.9831 23.5879 13.3051 23.0512 13.3051 23.0512L11.1017 21.262C11.1017 21.262 11.5 21 12 20L12.5 19H14C14 19 15 19.0319 15 18C15 16.9681 14 16.9681 14 16.9681L11 17V16C11 16 11.0169 15 10 15C8.98305 15 9 16 9 16V17Z"
|
||||
fill="#2B2F35"
|
||||
/>
|
||||
@@ -73,11 +73,11 @@ export default function LanguagePrompt() {
|
||||
</Trans>
|
||||
<br />
|
||||
<Link
|
||||
onClick={() => {
|
||||
auth.updateUser({
|
||||
onClick={async () => {
|
||||
ui.setLanguagePromptDismissed();
|
||||
await auth.updateUser({
|
||||
language,
|
||||
});
|
||||
ui.setLanguagePromptDismissed();
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
|
||||
@@ -16,6 +16,7 @@ import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
title?: string;
|
||||
sidebar?: React.ReactNode;
|
||||
sidebarRight?: React.ReactNode;
|
||||
@@ -26,7 +27,7 @@ const Layout: React.FC<Props> = ({
|
||||
children,
|
||||
sidebar,
|
||||
sidebarRight,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const { ui } = useStores();
|
||||
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
|
||||
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import * as React from "react";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { loadPolyfills } from "~/utils/polyfills";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Asyncronously load required polyfills. Should wrap the React tree.
|
||||
*/
|
||||
export const LazyPolyfill: React.FC = ({ children }) => {
|
||||
export const LazyPolyfill: React.FC = ({ children }: Props) => {
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadPolyfills().then(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
loadPolyfills()
|
||||
.then(() => {
|
||||
setIsLoaded(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error("Polyfills failed to load", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!isLoaded) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { format as formatDate, formatDistanceToNow } from "date-fns";
|
||||
import { format as formatDate } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { dateLocale, locales } from "~/utils/i18n";
|
||||
|
||||
let callbacks: (() => void)[] = [];
|
||||
|
||||
@@ -21,6 +21,7 @@ function eachMinute(fn: () => void) {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
dateTime: string;
|
||||
tooltipDelay?: number;
|
||||
addSuffix?: boolean;
|
||||
@@ -37,7 +38,7 @@ const LocaleTime: React.FC<Props> = ({
|
||||
format,
|
||||
relative,
|
||||
tooltipDelay,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const userLocale: string = useUserLocale() || "";
|
||||
const dateFormatLong = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
@@ -59,26 +60,21 @@ const LocaleTime: React.FC<Props> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const date = new Date(Date.parse(dateTime));
|
||||
const locale = dateLocale(userLocale);
|
||||
let relativeContent = formatDistanceToNow(Date.parse(dateTime), {
|
||||
const relativeContent = dateToRelative(date, {
|
||||
addSuffix,
|
||||
locale,
|
||||
shorten,
|
||||
});
|
||||
|
||||
if (shorten) {
|
||||
relativeContent = relativeContent
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
const tooltipContent = formatDate(Date.parse(dateTime), formatLocaleLong, {
|
||||
const tooltipContent = formatDate(date, formatLocaleLong, {
|
||||
locale,
|
||||
});
|
||||
const content =
|
||||
relative !== false
|
||||
? relativeContent
|
||||
: formatDate(Date.parse(dateTime), formatLocale, {
|
||||
: formatDate(date, formatLocale, {
|
||||
locale,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ import Desktop from "~/utils/Desktop";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
|
||||
let openModals = 0;
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
isCentered?: boolean;
|
||||
title?: React.ReactNode;
|
||||
@@ -32,7 +34,7 @@ const Modal: React.FC<Props> = ({
|
||||
isCentered,
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({
|
||||
animated: 250,
|
||||
});
|
||||
|
||||
@@ -5,11 +5,12 @@ import Flex from "./Flex";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
icon?: JSX.Element;
|
||||
description?: JSX.Element;
|
||||
};
|
||||
|
||||
const Notice: React.FC<Props> = ({ children, icon, description }) => (
|
||||
const Notice: React.FC<Props> = ({ children, icon, description }: Props) => (
|
||||
<Container>
|
||||
<Flex as="span" gap={8}>
|
||||
{icon}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SubscribeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } 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} />
|
||||
{count > 0 && <Badge />}
|
||||
</Relative>
|
||||
);
|
||||
};
|
||||
|
||||
const Badge = styled.div`
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${s("accent")};
|
||||
top: 0;
|
||||
right: 0;
|
||||
`;
|
||||
|
||||
export default observer(NotificationIcon);
|
||||
@@ -0,0 +1,110 @@
|
||||
import { toJS } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import CommentEditor from "~/scenes/Document/components/CommentEditor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover, truncateMultiline } from "~/styles";
|
||||
import Avatar from "../Avatar";
|
||||
import { AvatarSize } from "../Avatar/Avatar";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
|
||||
type Props = {
|
||||
notification: Notification;
|
||||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const collectionId = notification.document?.collectionId;
|
||||
const collection = collectionId ? collections.get(collectionId) : undefined;
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLAnchorElement> = (event) => {
|
||||
if (event.altKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void notification.toggleRead();
|
||||
return;
|
||||
}
|
||||
|
||||
void notification.markAsRead();
|
||||
|
||||
onNavigate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={notification.path} onClick={handleClick}>
|
||||
<Container gap={8} $unread={!notification.viewedAt}>
|
||||
<StyledAvatar model={notification.actor} size={AvatarSize.Large} />
|
||||
<Flex column>
|
||||
<Text as="div" size="small">
|
||||
<Text as="span" weight="bold">
|
||||
{notification.actor?.name ?? t("Unknown")}
|
||||
</Text>{" "}
|
||||
{notification.eventText(t)}{" "}
|
||||
<Text as="span" weight="bold">
|
||||
{notification.subject}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text as="span" type="tertiary" size="xsmall">
|
||||
<Time
|
||||
dateTime={notification.createdAt}
|
||||
tooltipDelay={1000}
|
||||
addSuffix
|
||||
/>{" "}
|
||||
{collection && <>· {collection.name}</>}
|
||||
</Text>
|
||||
{notification.comment && (
|
||||
<StyledCommentEditor
|
||||
defaultValue={toJS(notification.comment.data)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{notification.viewedAt ? null : <Unread />}
|
||||
</Container>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
font-size: 0.9em;
|
||||
margin-top: 4px;
|
||||
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ $unread: boolean }>`
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
margin: 0 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:${hover},
|
||||
&:active {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
`;
|
||||
|
||||
const Unread = styled.div`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: ${s("accent")};
|
||||
border-radius: 8px;
|
||||
align-self: center;
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
`;
|
||||
|
||||
export default observer(NotificationListItem);
|
||||
@@ -0,0 +1,131 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { MarkAsReadIcon, SettingsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import { navigateToNotificationSettings } from "~/actions/definitions/navigation";
|
||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Empty from "../Empty";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
import Flex from "../Flex";
|
||||
import NudeButton from "../NudeButton";
|
||||
import PaginatedList from "../PaginatedList";
|
||||
import Scrollable from "../Scrollable";
|
||||
import Text from "../Text";
|
||||
import Tooltip from "../Tooltip";
|
||||
import NotificationListItem from "./NotificationListItem";
|
||||
|
||||
type Props = {
|
||||
/* Callback when the notification panel wants to close. */
|
||||
onRequestClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A panel containing a list of notifications and controls to manage them.
|
||||
*/
|
||||
function Notifications(
|
||||
{ onRequestClose }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const context = useActionContext();
|
||||
const { notifications } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const isEmpty = notifications.orderedData.length === 0;
|
||||
|
||||
// Update the notification count in the dock icon, if possible.
|
||||
React.useEffect(() => {
|
||||
// Account for old versions of the desktop app that don't have the
|
||||
// setNotificationCount method on the bridge.
|
||||
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
|
||||
void Desktop.bridge.setNotificationCount(
|
||||
notifications.approximateUnreadCount
|
||||
);
|
||||
}
|
||||
}, [notifications.approximateUnreadCount]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Flex style={{ width: "100%" }} column>
|
||||
<Header justify="space-between">
|
||||
<Text weight="bold" as="span">
|
||||
{t("Notifications")}
|
||||
</Text>
|
||||
<Text color="textSecondary" as={Flex} gap={8}>
|
||||
{notifications.approximateUnreadCount > 0 && (
|
||||
<Tooltip delay={500} tooltip={t("Mark all as read")}>
|
||||
<Button action={markNotificationsAsRead} context={context}>
|
||||
<MarkAsReadIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip delay={500} tooltip={t("Settings")}>
|
||||
<Button action={navigateToNotificationSettings} context={context}>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
</Header>
|
||||
<React.Suspense fallback={null}>
|
||||
<Scrollable ref={ref} flex topShadow>
|
||||
<PaginatedList
|
||||
fetch={notifications.fetchPage}
|
||||
items={notifications.orderedData}
|
||||
renderItem={(item: Notification) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
notification={item}
|
||||
onNavigate={onRequestClose}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Scrollable>
|
||||
</React.Suspense>
|
||||
{isEmpty && (
|
||||
<EmptyNotifications>{t("You're all caught up")}.</EmptyNotifications>
|
||||
)}
|
||||
</Flex>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const EmptyNotifications = styled(Empty)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
`;
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
&:${hover},
|
||||
&:active {
|
||||
color: ${s("text")};
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled(Flex)`
|
||||
padding: 8px 12px 12px;
|
||||
height: 44px;
|
||||
|
||||
${Button} {
|
||||
opacity: 0.75;
|
||||
transition: opacity 250ms ease-in-out;
|
||||
}
|
||||
|
||||
&:${hover},
|
||||
&:focus-within {
|
||||
${Button} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(Notifications));
|
||||
@@ -0,0 +1,53 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import Popover from "~/components/Popover";
|
||||
import Notifications from "./Notifications";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const scrollableRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
placement: "top-start",
|
||||
unstable_fixed: true,
|
||||
});
|
||||
|
||||
// Reset scroll position to the top when popover is opened
|
||||
React.useEffect(() => {
|
||||
if (popover.visible && scrollableRef.current) {
|
||||
scrollableRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [popover.visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>{children}</PopoverDisclosure>
|
||||
<StyledPopover
|
||||
{...popover}
|
||||
scrollable={false}
|
||||
mobilePosition="bottom"
|
||||
aria-label={t("Notifications")}
|
||||
unstable_initialFocusRef={scrollableRef}
|
||||
shrink
|
||||
flex
|
||||
>
|
||||
<Notifications onRequestClose={popover.hide} ref={scrollableRef} />
|
||||
</StyledPopover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
z-index: ${depths.menu};
|
||||
`;
|
||||
|
||||
export default observer(NotificationsPopover);
|
||||
@@ -1,35 +1,34 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { cdnPath } from "@shared/utils/urls";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTeamContext } from "./TeamContext";
|
||||
|
||||
type Props = {
|
||||
title: React.ReactNode;
|
||||
favicon?: string;
|
||||
};
|
||||
|
||||
const originalShortcutHref = document
|
||||
.querySelector('link[rel="shortcut icon"]')
|
||||
?.getAttribute("href") as string;
|
||||
|
||||
const PageTitle = ({ title, favicon }: Props) => {
|
||||
const { auth } = useStores();
|
||||
const { team } = auth;
|
||||
const team = useTeamContext() ?? auth.team;
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
<title>
|
||||
{team?.name ? `${title} - ${team.name}` : `${title} - ${env.APP_NAME}`}
|
||||
</title>
|
||||
{favicon ? (
|
||||
<link rel="shortcut icon" href={favicon} key={favicon} />
|
||||
) : (
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/png"
|
||||
key="favicon"
|
||||
href={cdnPath("/images/favicon-32.png")}
|
||||
sizes="32x32"
|
||||
/>
|
||||
)}
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/png"
|
||||
href={favicon ?? originalShortcutHref}
|
||||
key={favicon ?? originalShortcutHref}
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
@@ -70,7 +70,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
allowLoadMore = true;
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchResults();
|
||||
void this.fetchResults();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props<T>) {
|
||||
@@ -79,7 +79,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
!isEqual(prevProps.options, this.props.options)
|
||||
) {
|
||||
this.reset();
|
||||
this.fetchResults();
|
||||
void this.fetchResults();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import Pin from "~/models/Pin";
|
||||
import DocumentCard from "~/components/DocumentCard";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ResizingHeightContainer } from "./ResizingHeightContainer";
|
||||
|
||||
type Props = {
|
||||
/** Pins to display */
|
||||
@@ -98,27 +99,36 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={items} strategy={rectSortingStrategy}>
|
||||
<List>
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((documentId) => {
|
||||
const document = documents.get(documentId);
|
||||
const pin = pins.find((pin) => pin.documentId === documentId);
|
||||
<ResizingHeightContainer
|
||||
config={{
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SortableContext items={items} strategy={rectSortingStrategy}>
|
||||
<List>
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((documentId) => {
|
||||
const document = documents.get(documentId);
|
||||
const pin = pins.find((pin) => pin.documentId === documentId);
|
||||
|
||||
return document ? (
|
||||
<DocumentCard
|
||||
key={documentId}
|
||||
document={document}
|
||||
canUpdatePin={canUpdate}
|
||||
isDraggable={items.length > 1}
|
||||
pin={pin}
|
||||
{...rest}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</List>
|
||||
</SortableContext>
|
||||
return document ? (
|
||||
<DocumentCard
|
||||
key={documentId}
|
||||
document={document}
|
||||
canUpdatePin={canUpdate}
|
||||
isDraggable={items.length > 1}
|
||||
pin={pin}
|
||||
{...rest}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</List>
|
||||
</SortableContext>
|
||||
</ResizingHeightContainer>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { Dialog } from "reakit/Dialog";
|
||||
import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -11,52 +11,87 @@ type Props = PopoverProps & {
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
shrink?: boolean;
|
||||
flex?: boolean;
|
||||
tabIndex?: number;
|
||||
scrollable?: boolean;
|
||||
mobilePosition?: "top" | "bottom";
|
||||
};
|
||||
|
||||
const Popover: React.FC<Props> = ({
|
||||
children,
|
||||
shrink,
|
||||
width = 380,
|
||||
scrollable = true,
|
||||
flex,
|
||||
mobilePosition,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const isMobile = useMobile();
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Dialog {...rest} modal>
|
||||
<Contents $shrink={shrink}>{children}</Contents>
|
||||
<Contents
|
||||
$shrink={shrink}
|
||||
$scrollable={scrollable}
|
||||
$flex={flex}
|
||||
$mobilePosition={mobilePosition}
|
||||
>
|
||||
{children}
|
||||
</Contents>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReakitPopover {...rest}>
|
||||
<Contents $shrink={shrink} $width={width}>
|
||||
<Contents
|
||||
$shrink={shrink}
|
||||
$width={width}
|
||||
$scrollable={scrollable}
|
||||
$flex={flex}
|
||||
>
|
||||
{children}
|
||||
</Contents>
|
||||
</ReakitPopover>
|
||||
);
|
||||
};
|
||||
|
||||
const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
|
||||
type ContentsProps = {
|
||||
$shrink?: boolean;
|
||||
$width?: number;
|
||||
$flex?: boolean;
|
||||
$scrollable: boolean;
|
||||
$mobilePosition?: "top" | "bottom";
|
||||
};
|
||||
|
||||
const Contents = styled.div<ContentsProps>`
|
||||
display: ${(props) => (props.$flex ? "flex" : "block")};
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
|
||||
max-height: 75vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
width: ${(props) => props.$width}px;
|
||||
|
||||
${(props) =>
|
||||
props.$scrollable &&
|
||||
css`
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
`}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed;
|
||||
z-index: ${depths.menu};
|
||||
|
||||
// 50 is a magic number that positions us nicely under the top bar
|
||||
top: 50px;
|
||||
top: ${(props: ContentsProps) =>
|
||||
props.$mobilePosition === "bottom" ? "auto" : "50px"};
|
||||
bottom: ${(props: ContentsProps) =>
|
||||
props.$mobilePosition === "bottom" ? "0" : "auto"};
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
|
||||
@@ -11,6 +11,7 @@ type Props = {
|
||||
left?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
centered?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Scene: React.FC<Props> = ({
|
||||
@@ -21,7 +22,7 @@ const Scene: React.FC<Props> = ({
|
||||
left,
|
||||
children,
|
||||
centered,
|
||||
}) => (
|
||||
}: Props) => (
|
||||
<FillWidth>
|
||||
<PageTitle title={textTitle || title} />
|
||||
<Header
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function SearchActions() {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!searches.isLoaded) {
|
||||
searches.fetchPage({});
|
||||
void searches.fetchPage({});
|
||||
}
|
||||
}, [searches]);
|
||||
|
||||
@@ -20,7 +20,8 @@ export default function SearchActions() {
|
||||
}));
|
||||
|
||||
useCommandBarActions(
|
||||
searchQuery ? [searchDocumentsForQuery(searchQuery)] : []
|
||||
searchQuery ? [searchDocumentsForQuery(searchQuery)] : [],
|
||||
[searchQuery]
|
||||
);
|
||||
|
||||
useCommandBarActions(searches.recent.map(navigateToRecentSearchQuery));
|
||||
|
||||
@@ -17,7 +17,7 @@ import useStores from "~/hooks/useStores";
|
||||
import { SearchResult } from "~/types";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
|
||||
type Props = { shareId: string };
|
||||
type Props = React.HTMLAttributes<HTMLInputElement> & { shareId: string };
|
||||
|
||||
function SearchPopover({ shareId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -32,6 +32,7 @@ function SearchPopover({ shareId }: Props) {
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
const searchResults = documents.searchResults(query);
|
||||
const { show, hide } = popover;
|
||||
|
||||
const [cachedQuery, setCachedQuery] = React.useState(query);
|
||||
const [cachedSearchResults, setCachedSearchResults] = React.useState<
|
||||
@@ -42,9 +43,9 @@ function SearchPopover({ shareId }: Props) {
|
||||
if (searchResults) {
|
||||
setCachedQuery(query);
|
||||
setCachedSearchResults(searchResults);
|
||||
popover.show();
|
||||
show();
|
||||
}
|
||||
}, [searchResults, query, popover.show]);
|
||||
}, [searchResults, query, show]);
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query, ...options }) => {
|
||||
@@ -76,9 +77,8 @@ function SearchPopover({ shareId }: Props) {
|
||||
[popover, cachedQuery]
|
||||
);
|
||||
|
||||
const searchInputRef = popover.unstable_referenceRef as React.RefObject<
|
||||
HTMLInputElement
|
||||
>;
|
||||
const searchInputRef =
|
||||
popover.unstable_referenceRef as React.RefObject<HTMLInputElement>;
|
||||
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
@@ -89,10 +89,14 @@ function SearchPopover({ shareId }: Props) {
|
||||
|
||||
const handleSearchInputFocus = React.useCallback(() => {
|
||||
focusRef.current = searchInputRef.current;
|
||||
}, []);
|
||||
}, [searchInputRef]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Enter") {
|
||||
if (searchResults) {
|
||||
popover.show();
|
||||
@@ -138,12 +142,12 @@ function SearchPopover({ shareId }: Props) {
|
||||
);
|
||||
|
||||
const handleSearchItemClick = React.useCallback(() => {
|
||||
popover.hide();
|
||||
hide();
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.value = "";
|
||||
focusRef.current = document.getElementById(bodyContentId);
|
||||
}
|
||||
}, [popover.hide]);
|
||||
}, [searchInputRef, hide]);
|
||||
|
||||
useKeyDown("/", (ev) => {
|
||||
if (
|
||||
|
||||
@@ -43,8 +43,8 @@ function AppSidebar() {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!user.isViewer) {
|
||||
documents.fetchDrafts();
|
||||
documents.fetchTemplates();
|
||||
void documents.fetchDrafts();
|
||||
void documents.fetchTemplates();
|
||||
}
|
||||
}, [documents, user.isViewer]);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import SearchPopover from "~/components/SearchPopover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { IAvatar } from "../Avatar/Avatar";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
import HeaderButton from "./components/HeaderButton";
|
||||
@@ -16,12 +16,12 @@ import Section from "./components/Section";
|
||||
import DocumentLink from "./components/SharedDocumentLink";
|
||||
|
||||
type Props = {
|
||||
team?: IAvatar & { name: string };
|
||||
rootNode: NavigationNode;
|
||||
shareId: string;
|
||||
};
|
||||
|
||||
function SharedSidebar({ rootNode, team, shareId }: Props) {
|
||||
function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
const team = useTeamContext();
|
||||
const { ui, documents, auth } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
+174
-165
@@ -15,6 +15,8 @@ import { draggableOnDesktop, 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";
|
||||
@@ -25,191 +27,198 @@ type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
({ children }: 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);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const { user } = auth;
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
{ children }: 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);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const { user } = auth;
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
// this is simple because the sidebar is always against the left edge
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
// this is simple because the sidebar is always against the left edge
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setWidth(theme.sidebarCollapsedWidth);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
},
|
||||
[theme, offset, minWidth, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
if (isSmallerThanMinimum) {
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setAnimating(false);
|
||||
setCollapsing(true);
|
||||
ui.collapseSidebar();
|
||||
} else {
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
}
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setWidth(theme.sidebarCollapsedWidth);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
},
|
||||
[theme, offset, minWidth, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
const handleMouseDown = React.useCallback(
|
||||
(event) => {
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
if (isSmallerThanMinimum) {
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setAnimating(false);
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), ANIMATION_MS);
|
||||
setCollapsing(true);
|
||||
ui.collapseSidebar();
|
||||
} else {
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
}
|
||||
}, [isAnimating]);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isCollapsing) {
|
||||
setTimeout(() => {
|
||||
setWidth(minWidth);
|
||||
setCollapsing(false);
|
||||
}, ANIMATION_MS);
|
||||
}
|
||||
}, [setWidth, minWidth, isCollapsing]);
|
||||
const handleMouseDown = React.useCallback(
|
||||
(event) => {
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), ANIMATION_MS);
|
||||
}
|
||||
}, [isAnimating]);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
React.useEffect(() => {
|
||||
if (isCollapsing) {
|
||||
setTimeout(() => {
|
||||
setWidth(minWidth);
|
||||
setCollapsing(false);
|
||||
}, ANIMATION_MS);
|
||||
}
|
||||
}, [setWidth, minWidth, isCollapsing]);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
ui.setSidebarResizing(isResizing);
|
||||
}, [ui, isResizing]);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location, previousLocation]);
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
}),
|
||||
[width]
|
||||
);
|
||||
React.useEffect(() => {
|
||||
ui.setSidebarResizing(isResizing);
|
||||
}, [ui, isResizing]);
|
||||
|
||||
const toggleStyle = React.useMemo(
|
||||
() => ({
|
||||
right: "auto",
|
||||
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
|
||||
}),
|
||||
[width, theme.sidebarCollapsedWidth, collapsed]
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location, previousLocation]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
ref={ref}
|
||||
style={style}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
column
|
||||
>
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Backdrop onClick={ui.toggleMobileSidebar} />
|
||||
</Portal>
|
||||
)}
|
||||
{children}
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
}),
|
||||
[width]
|
||||
);
|
||||
|
||||
{user && (
|
||||
<AccountMenu>
|
||||
{(props: HeaderButtonProps) => (
|
||||
<HeaderButton
|
||||
{...props}
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
image={
|
||||
<StyledAvatar
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AccountMenu>
|
||||
)}
|
||||
<ResizeBorder
|
||||
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 toggleStyle = React.useMemo(
|
||||
() => ({
|
||||
right: "auto",
|
||||
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
|
||||
}),
|
||||
[width, theme.sidebarCollapsedWidth, collapsed]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
ref={ref}
|
||||
style={style}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
column
|
||||
>
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Backdrop onClick={ui.toggleMobileSidebar} />
|
||||
</Portal>
|
||||
)}
|
||||
{children}
|
||||
|
||||
{user && (
|
||||
<AccountMenu>
|
||||
{(props: HeaderButtonProps) => (
|
||||
<HeaderButton
|
||||
{...props}
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
image={
|
||||
<StyledAvatar
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
{(rest: HeaderButtonProps) => (
|
||||
<HeaderButton {...rest} image={<NotificationIcon />} />
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
</HeaderButton>
|
||||
)}
|
||||
</AccountMenu>
|
||||
)}
|
||||
<ResizeBorder
|
||||
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;
|
||||
|
||||
@@ -37,14 +37,14 @@ const CollectionLink: React.FC<Props> = ({
|
||||
expanded,
|
||||
onDisclosureClick,
|
||||
isDraggingAnyCollection,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const itemRef = React.useRef<
|
||||
NavigationNode & { depth: number; active: boolean; collectionId: string }
|
||||
>();
|
||||
const { dialogs, documents, collections } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const canUpdate = usePolicy(collection).update;
|
||||
const can = usePolicy(collection);
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const inStarredSection = useStarredContext();
|
||||
@@ -105,7 +105,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: () => canUpdate,
|
||||
canDrop: () => can.createDocument,
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver({
|
||||
shallow: true,
|
||||
@@ -118,6 +118,10 @@ const CollectionLink: React.FC<Props> = ({
|
||||
setIsEditing(isEditing);
|
||||
}, []);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void collection.fetchDocuments();
|
||||
}, [collection]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeCollectionId: collection.id,
|
||||
inStarredSection,
|
||||
@@ -134,6 +138,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
}}
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
@@ -147,7 +152,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
title={collection.name}
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={handleTitleEditing}
|
||||
canUpdate={canUpdate}
|
||||
canUpdate={can.update}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentsLoader from "~/components/DocumentsLoader";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
@@ -11,6 +14,7 @@ import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import EmptyCollectionPlaceholder from "./EmptyCollectionPlaceholder";
|
||||
import Folder from "./Folder";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
import useCollectionDocuments from "./useCollectionDocuments";
|
||||
|
||||
@@ -53,7 +57,7 @@ function CollectionLinkChildren({
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
documents.move(item.id, collection.id, undefined, 0);
|
||||
void documents.move(item.id, collection.id, undefined, 0);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
@@ -63,28 +67,40 @@ function CollectionLinkChildren({
|
||||
|
||||
return (
|
||||
<Folder expanded={expanded}>
|
||||
{isDraggingAnyDocument && can.update && manualSort && (
|
||||
{isDraggingAnyDocument && can.createDocument && manualSort && (
|
||||
<DropCursor
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{childDocuments.length === 0 && <EmptyCollectionPlaceholder />}
|
||||
<DocumentsLoader collection={collection} enabled={expanded}>
|
||||
{!childDocuments && (
|
||||
<ResizingHeightContainer hideOverflow>
|
||||
<Loading />
|
||||
</ResizingHeightContainer>
|
||||
)}
|
||||
{childDocuments?.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{childDocuments?.length === 0 && <EmptyCollectionPlaceholder />}
|
||||
</DocumentsLoader>
|
||||
</Folder>
|
||||
);
|
||||
}
|
||||
|
||||
const Loading = styled(PlaceholderCollections)`
|
||||
margin-left: 44px;
|
||||
min-height: 90px;
|
||||
`;
|
||||
|
||||
export default observer(CollectionLinkChildren);
|
||||
|
||||
@@ -23,13 +23,20 @@ function Collections() {
|
||||
const { t } = useTranslation();
|
||||
const orderedCollections = collections.orderedData;
|
||||
|
||||
const params = React.useMemo(
|
||||
() => ({
|
||||
limit: 100,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const [
|
||||
{ isCollectionDropping, isDraggingAnyCollection },
|
||||
dropToReorderCollection,
|
||||
] = useDrop({
|
||||
accept: "collection",
|
||||
drop: async (item: DragObject) => {
|
||||
collections.move(
|
||||
void collections.move(
|
||||
item.id,
|
||||
fractionalIndex(null, orderedCollections[0].index)
|
||||
);
|
||||
@@ -47,7 +54,7 @@ function Collections() {
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
fetch={collections.fetchPage}
|
||||
options={{ limit: 25 }}
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.orderedData}
|
||||
loading={<PlaceholderCollections />}
|
||||
|
||||
@@ -67,9 +67,9 @@ function InnerDocumentLink(
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActiveDocument && hasChildDocuments) {
|
||||
fetchChildDocuments(node.id);
|
||||
void fetchChildDocuments(node.id);
|
||||
}
|
||||
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);
|
||||
}, [fetchChildDocuments, node.id, hasChildDocuments, isActiveDocument]);
|
||||
|
||||
const pathToNode = React.useMemo(
|
||||
() => collection?.pathToDocument(node.id).map((entry) => entry.id),
|
||||
@@ -114,8 +114,8 @@ function InnerDocumentLink(
|
||||
[expanded]
|
||||
);
|
||||
|
||||
const handleMouseEnter = React.useCallback(() => {
|
||||
prefetchDocument?.(node.id);
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void prefetchDocument?.(node.id);
|
||||
}, [prefetchDocument, node]);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
@@ -125,7 +125,6 @@ function InnerDocumentLink(
|
||||
}
|
||||
await documents.update({
|
||||
id: document.id,
|
||||
text: document.text,
|
||||
title,
|
||||
});
|
||||
},
|
||||
@@ -242,11 +241,11 @@ function InnerDocumentLink(
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
documents.move(item.id, collection.id, node.id, 0);
|
||||
void documents.move(item.id, collection.id, node.id, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
documents.move(item.id, collection.id, parentId, index + 1);
|
||||
void documents.move(item.id, collection.id, parentId, index + 1);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: monitor.isOver(),
|
||||
@@ -317,7 +316,7 @@ function InnerDocumentLink(
|
||||
<SidebarLink
|
||||
expanded={hasChildren ? isExpanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onClickIntent={handlePrefetch}
|
||||
to={{
|
||||
pathname: node.url,
|
||||
state: {
|
||||
@@ -335,7 +334,9 @@ function InnerDocumentLink(
|
||||
/>
|
||||
}
|
||||
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||
!!match && location.state?.starred === inStarredSection
|
||||
((document && location.pathname.endsWith(document.urlId)) ||
|
||||
!!match) &&
|
||||
location.state?.starred === inStarredSection
|
||||
}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
|
||||
@@ -51,7 +51,7 @@ function DraggableCollectionLink({
|
||||
] = useDrop({
|
||||
accept: "collection",
|
||||
drop: (item: DragObject) => {
|
||||
collections.move(
|
||||
void collections.move(
|
||||
item.id,
|
||||
fractionalIndex(collection.index, belowCollectionIndex)
|
||||
);
|
||||
|
||||
@@ -26,10 +26,14 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
collectionId,
|
||||
documentId
|
||||
);
|
||||
const targetId = collectionId || documentId;
|
||||
invariant(targetId, "Must provide either collectionId or documentId");
|
||||
invariant(
|
||||
collectionId || documentId,
|
||||
"Must provide either collectionId or documentId"
|
||||
);
|
||||
|
||||
const canCollection = usePolicy(collectionId);
|
||||
const canDocument = usePolicy(documentId);
|
||||
|
||||
const can = usePolicy(targetId);
|
||||
const handleRejection = React.useCallback(() => {
|
||||
showToast(
|
||||
t("Document not supported – try Markdown, Plain text, HTML, or Word"),
|
||||
@@ -39,7 +43,11 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
);
|
||||
}, [t, showToast]);
|
||||
|
||||
if (disabled || !can.update) {
|
||||
if (
|
||||
disabled ||
|
||||
(collectionId && !canCollection.createDocument) ||
|
||||
(documentId && !canDocument.createChildDocument)
|
||||
) {
|
||||
return children;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ const Input = styled.input`
|
||||
border: 0;
|
||||
padding: 5px 6px;
|
||||
margin: -4px;
|
||||
height: 32px;
|
||||
height: 30px;
|
||||
|
||||
&:focus {
|
||||
outline-color: ${s("accent")};
|
||||
|
||||
@@ -3,9 +3,10 @@ import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
expanded: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Folder: React.FC<Props> = ({ expanded, children }) => {
|
||||
const Folder: React.FC<Props> = ({ expanded, children }: Props) => {
|
||||
const [openedOnce, setOpenedOnce] = React.useState(expanded);
|
||||
|
||||
// allows us to avoid rendering all children when the folder hasn't been opened
|
||||
|
||||
@@ -9,12 +9,13 @@ type Props = {
|
||||
/** Unique header id – if passed the header will become toggleable */
|
||||
id?: string;
|
||||
title: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggleable sidebar header
|
||||
*/
|
||||
export const Header: React.FC<Props> = ({ id, title, children }) => {
|
||||
export const Header: React.FC<Props> = ({ id, title, children }: Props) => {
|
||||
const [firstRender, setFirstRender] = React.useState(true);
|
||||
const [expanded, setExpanded] = usePersistedState<boolean>(
|
||||
`sidebar-header-${id}`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user