mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
437 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a78622327 | |||
| c8f37707d3 | |||
| 278177fac2 | |||
| 3238752421 | |||
| d4ada01b19 | |||
| 01abda82a6 | |||
| e7c2a8f65d | |||
| e03b08a964 | |||
| 34a1fe3f2d | |||
| be4babe70a | |||
| 460834eb49 | |||
| d0a6e54bc8 | |||
| 5a88492766 | |||
| 2df413f7f0 | |||
| 931305b84d | |||
| db0ec6faa7 | |||
| 084f15e734 | |||
| d55d664f08 | |||
| b425669734 | |||
| 24cfe60d9f | |||
| b9e2a7b0ec | |||
| 6f2c5982f5 | |||
| 0cecb3f929 | |||
| bbeac9fa31 | |||
| a2a2628073 | |||
| 43800acf0d | |||
| c75c61ca4b | |||
| 81f655f402 | |||
| bb1fe1a25f | |||
| b9ffe8aaa3 | |||
| 97775e14df | |||
| d8478d1847 | |||
| d12c6063cd | |||
| 0ded55bf21 | |||
| 706e13eddc | |||
| eeb393d634 | |||
| 78e9dbb336 | |||
| 926402b820 | |||
| 0b6c9d1838 | |||
| 239e9e294d | |||
| 9b002abae3 | |||
| 534eeacc97 | |||
| 8b28d6f6e0 | |||
| 59b02154b9 | |||
| 0d6651b0da | |||
| a1cefa9771 | |||
| 1caa51f58e | |||
| a2e07e9593 | |||
| 8f166ca775 | |||
| d70aefe9fa | |||
| 4bc441cc9f | |||
| f39487d25b | |||
| 65a4874301 | |||
| d1268167c8 | |||
| 80a8f5b7e2 | |||
| 5473b698a4 | |||
| 2e6c960ae9 | |||
| d02d3cb55d | |||
| 85ca25371c | |||
| 8011bdb482 | |||
| 549881b297 | |||
| f0d9bb4898 | |||
| b04929db76 | |||
| 4de780c339 | |||
| 075555a867 | |||
| aac495fa58 | |||
| 7dbc419bbf | |||
| 0c572ac2c4 | |||
| 6d45566be3 | |||
| d5eabd7771 | |||
| b5876dc844 | |||
| 0272ea03bd | |||
| 440fb32868 | |||
| f24ef965cf | |||
| 9ceb68920a | |||
| ad902af52c | |||
| 120132b01f | |||
| aa6d3a2081 | |||
| 9841e6d243 | |||
| cc14c212b6 | |||
| 9ea606a734 | |||
| 784631baf4 | |||
| 3974674871 | |||
| 245d91998f | |||
| 10516e8d25 | |||
| 6ab428a498 | |||
| 88a1f72b59 | |||
| 2021f192bd | |||
| 1b6496dff4 | |||
| 6c5dadff8c | |||
| 6b286d82b8 | |||
| fd89ad5155 | |||
| 96af0f779f | |||
| 005bab23cf | |||
| 0e3e699c82 | |||
| b008898aad | |||
| be916380fa | |||
| 83c97a1be7 | |||
| 50a7e600c7 | |||
| 31f596685a | |||
| cfb089dec9 | |||
| d9779890fe | |||
| 834c46a095 | |||
| 5d743ff018 | |||
| 59e6f3f22f | |||
| da4a0189dc | |||
| 312e11e7c1 | |||
| d3dbf53d0b | |||
| 5b561e98f7 | |||
| aa88bb2a7b | |||
| f83b0ab5e3 | |||
| 095028541d | |||
| c1aa4c8dde | |||
| 049f49ebe8 | |||
| f1406577b7 | |||
| 1da6847e68 | |||
| cb475abc69 | |||
| 20b8bb6a31 | |||
| 82dbe8b6b7 | |||
| 68e30b4974 | |||
| 0960f8d07b | |||
| 22008db2ac | |||
| c23ca5d3b9 | |||
| bd472ab5b0 | |||
| a41f35d289 | |||
| 50973b7408 | |||
| fb15ab90a1 | |||
| bb20523266 | |||
| 7b6d1e1c81 | |||
| 1d71ee1109 | |||
| 4337dd8bff | |||
| 594e8f72f5 | |||
| 4e352b36a1 | |||
| 21ce145f67 | |||
| 0a4c7091b5 | |||
| f0f574812d | |||
| 830763a9eb | |||
| 55f2989a3d | |||
| 788450136e | |||
| 0c269081d9 | |||
| 31f743eb4c | |||
| 4ca0fc32c1 | |||
| 0bed01a062 | |||
| a25372c186 | |||
| 51baba8fa8 | |||
| 0cccd7141d | |||
| 6b438e3467 | |||
| 0d53e5a7ba | |||
| e3db7455b3 | |||
| d20f379943 | |||
| e347404502 | |||
| 17a8dbb3f0 | |||
| 3be170ddb8 | |||
| 4a97b35d5a | |||
| f98cac5705 | |||
| 860b6a7c20 | |||
| 02e5ed76ee | |||
| 49473795ae | |||
| 785b9888dd | |||
| d5158f0a34 | |||
| 64b72bcf82 | |||
| 6be7409d85 | |||
| fddcbbd7af | |||
| 3e7f823e17 | |||
| 31c47ab37b | |||
| cbfb7d2c23 | |||
| 9617a15ae8 | |||
| 53414ec3ba | |||
| a333f48102 | |||
| d4da33424b | |||
| e67ac1215a | |||
| 9f825b9adf | |||
| 4b47bffcf5 | |||
| cbd9971bc7 | |||
| c80aec5eb2 | |||
| 5b60f1ab00 | |||
| ec2da746dc | |||
| a065a8426f | |||
| b6141442b7 | |||
| 445d19f43e | |||
| f655288f67 | |||
| fc6bb3caef | |||
| f4461573de | |||
| b5e5b84cca | |||
| e130882549 | |||
| 8bd0878144 | |||
| bc204ef15e | |||
| db21c4d41d | |||
| 569009168c | |||
| bb568d2e62 | |||
| eb50c9e1f1 | |||
| a2e183627c | |||
| 1c9eee2134 | |||
| 64d8f3091a | |||
| 0d920e02b1 | |||
| 6efcf1c1a8 | |||
| 435969cf4b | |||
| 28a54113e1 | |||
| 712ff8265e | |||
| b6234848fb | |||
| e4880daadf | |||
| 97b0fd465d | |||
| 423f961ca1 | |||
| 4ccff8cb29 | |||
| 8c54f6330f | |||
| 846a1f8eab | |||
| 205f7d2a7e | |||
| 2494ca39c1 | |||
| 8e4270c321 | |||
| dc795604a4 | |||
| 05a4f050bb | |||
| ad9525bfa3 | |||
| 575f70a9e2 | |||
| e70f1ed84c | |||
| 16958560e6 | |||
| cdbc6df485 | |||
| c6fb764631 | |||
| 1e036ebd0e | |||
| 7a1e6a1b73 | |||
| 1328162921 | |||
| 2e36ad9d1f | |||
| c6c06ac4ce | |||
| b29a9fbeee | |||
| 0f489d54c3 | |||
| 7c47ab560e | |||
| 997d796eb7 | |||
| 18b69fad99 | |||
| 318e1df13b | |||
| f3469d25fe | |||
| 1b8dd9399c | |||
| ee37ba9355 | |||
| 68ad7607b0 | |||
| 393d9c4a72 | |||
| c41bd9592e | |||
| b8f748be52 | |||
| 82c565f1d4 | |||
| 504693c68d | |||
| d261aa4d32 | |||
| 09c3ee50ba | |||
| 0cb439857d | |||
| 1a69cb057c | |||
| 0fa583e492 | |||
| 67ec5a1a33 | |||
| 9618d514e1 | |||
| 4b66a52a52 | |||
| bf21863dbf | |||
| acf74b83a8 | |||
| f8ba393f7c | |||
| 1995a3fb19 | |||
| 6f57767b7c | |||
| a9683f4d53 | |||
| 600b3e4b3e | |||
| 02b352a382 | |||
| 79829a3129 | |||
| b9dd060736 | |||
| 301fde26b6 | |||
| 662012da08 | |||
| cab8b69e8d | |||
| 91155295fe | |||
| 80780eedda | |||
| e4da92a359 | |||
| 0f19c550f9 | |||
| 7e22526cc7 | |||
| 5c842087a5 | |||
| 053d10d893 | |||
| 4f67437b81 | |||
| 7db2284564 | |||
| 92ab7c1700 | |||
| 239db70374 | |||
| a650e92979 | |||
| 5f121ff268 | |||
| ea63023fca | |||
| 549b7ab030 | |||
| 4ce8ea8cd6 | |||
| 98d79e1e8b | |||
| b0b7c7d647 | |||
| 481382ee9f | |||
| 3d6da26ad6 | |||
| 0a68266365 | |||
| 908ca36de2 | |||
| 435a7ab26b | |||
| 8513200900 | |||
| 1fd3f3137a | |||
| d16133fda8 | |||
| cd29cd3aec | |||
| 13db16283a | |||
| d6d1eb4485 | |||
| 0f31d5b45f | |||
| 08a471f230 | |||
| e15ad530de | |||
| 6354acca85 | |||
| e32f2c2257 | |||
| 6903a1c481 | |||
| 571f812771 | |||
| 4d1bbf3f80 | |||
| 0a0498d139 | |||
| 0f19a82488 | |||
| fc9d685ef5 | |||
| b305154715 | |||
| d09a3de800 | |||
| 83b687a632 | |||
| 648424fe2c | |||
| 63cef45284 | |||
| bc299a00f5 | |||
| b40bb71adf | |||
| 59d9859a64 | |||
| fbeaa2ec9f | |||
| 53669a4be6 | |||
| 201c3e1f05 | |||
| 572ffe44aa | |||
| 8ca5d66204 | |||
| a5e2ac6570 | |||
| b5570a7587 | |||
| d09c583c72 | |||
| cc333637dd | |||
| ea9680c3d7 | |||
| d22b44dcff | |||
| fa8685d241 | |||
| cb1b8e9764 | |||
| 957e9ba0ff | |||
| 5a42f70b65 | |||
| fd9625b57e | |||
| 18535d949e | |||
| a8936039e5 | |||
| a6125be6f1 | |||
| 100d05035b | |||
| 5b00741b1a | |||
| 95f2c69f81 | |||
| 0794450596 | |||
| 09f5462068 | |||
| 4cb1652005 | |||
| 7dbf098d68 | |||
| 32e47d86a5 | |||
| e76e547d8a | |||
| e605961e23 | |||
| 088ef81133 | |||
| 6e36ffb706 | |||
| 1f49bd167d | |||
| c27987569b | |||
| ae6855f3df | |||
| 924b554281 | |||
| 552c0ecf01 | |||
| 19d33a7658 | |||
| f43f253286 | |||
| 01a482552a | |||
| e6ef5a16cc | |||
| 4c8138ad4a | |||
| 4047ec73bb | |||
| 1e723be556 | |||
| 1f5171053e | |||
| 829214d9a3 | |||
| 441055a05e | |||
| 24a1eac804 | |||
| b60c66316a | |||
| 3880a956a3 | |||
| 762341a4ec | |||
| 622f464b9f | |||
| cafe4ed848 | |||
| cff67f4ca7 | |||
| 6788005115 | |||
| 26946853da | |||
| d0827e21c1 | |||
| eee0abe415 | |||
| e7af0ce6de | |||
| 369ac487b1 | |||
| 587f062677 | |||
| 920b58c006 | |||
| d16d94a0f6 | |||
| 4d4cd42740 | |||
| b55ba473d1 | |||
| c859d2dd84 | |||
| fd799e00a7 | |||
| 5f4d67e2f9 | |||
| 9936f42882 | |||
| bac1c9dc14 | |||
| 2df4b352a1 | |||
| f2fb5dd1e5 | |||
| c2de6b70bc | |||
| 88188a0a59 | |||
| 5e17b24869 | |||
| 6f8d01df21 | |||
| 881ea34dfe | |||
| 3cb0b88f0f | |||
| d4cac4983c | |||
| ab6c5c2e78 | |||
| ade26139e6 | |||
| 17977064aa | |||
| 5b55f7ab1c | |||
| 1e62d25861 | |||
| 86aa531fad | |||
| f6f90ff406 | |||
| 79cbe304da | |||
| 19e26ba402 | |||
| c916d4f594 | |||
| 51b3371bf5 | |||
| 39d8eb8a3a | |||
| c1fb8c74ff | |||
| ca255d9210 | |||
| fe3e8d3830 | |||
| 808eb540a7 | |||
| a89d30c735 | |||
| 6b74d43380 | |||
| 249f340b21 | |||
| f63bf336f1 | |||
| c3c1de09ab | |||
| df46d3754a | |||
| 434bb989cc | |||
| b5b349be29 | |||
| 87761e9bf2 | |||
| 708f9a3fd6 | |||
| 8d47a05591 | |||
| fabdcd03e2 | |||
| 44ce377c38 | |||
| d43423fc39 | |||
| e00e3e232a | |||
| 1f1dd23e18 | |||
| 8ba911b56d | |||
| e714e934cb | |||
| 60f6a1f1c6 | |||
| 9af22017fe | |||
| f6ae32deef | |||
| c0a86753bd | |||
| f277d08982 | |||
| 49d53ccfc2 | |||
| c108a91195 | |||
| 6caa61f4a5 | |||
| a814543aaf | |||
| 167ade0d59 | |||
| b8b0d927f2 | |||
| 6072d3320a | |||
| 1a88fd5515 | |||
| 3f3c05c800 | |||
| bb21fa725c | |||
| 98f997387c | |||
| 7a9c75b9f1 | |||
| 98e44f528f | |||
| 0e79795856 |
@@ -98,8 +98,8 @@ jobs:
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: build-webpack
|
||||
command: yarn build:webpack
|
||||
name: build-vite
|
||||
command: yarn vite:build
|
||||
build-image:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
|
||||
@@ -167,9 +167,6 @@ SMTP_REPLY_EMAIL=
|
||||
SMTP_TLS_CIPHERS=
|
||||
SMTP_SECURE=true
|
||||
|
||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
|
||||
@@ -24,8 +24,13 @@ on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "00 20 * * 0"
|
||||
permissions: {}
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write # to comment on pull request
|
||||
|
||||
name: calibreapp/image-actions
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on main repo on and PRs that match the main repo.
|
||||
|
||||
+3
-1
@@ -11,4 +11,6 @@ fakes3/*
|
||||
.idea
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
*.cert
|
||||
/public/sw.js
|
||||
/public/registerSW.js
|
||||
|
||||
+11
-37
@@ -2,10 +2,7 @@
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/server"
|
||||
],
|
||||
"roots": ["<rootDir>/server"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
@@ -14,33 +11,22 @@
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"<rootDir>/server/test/env.ts"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/server/test/setup.ts"
|
||||
],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"testEnvironment": "node",
|
||||
"runner": "@getoutline/jest-runner-serial"
|
||||
},
|
||||
{
|
||||
"displayName": "app",
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"roots": ["<rootDir>/app"],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"modulePaths": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/app/test/setup.ts"
|
||||
],
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/app/test/setup.ts"],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost"
|
||||
@@ -48,37 +34,25 @@
|
||||
},
|
||||
{
|
||||
"displayName": "shared-node",
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/shared/test/setup.ts"
|
||||
],
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
{
|
||||
"displayName": "shared-jsdom",
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost"
|
||||
|
||||
@@ -199,10 +199,6 @@
|
||||
"description": "A sentry tunnel URL for bypassing ad blockers in the UI (optional)",
|
||||
"required": false
|
||||
},
|
||||
"TEAM_LOGO": {
|
||||
"description": "A logo that will be displayed on the signed out home page",
|
||||
"required": false
|
||||
},
|
||||
"DEFAULT_LANGUAGE": {
|
||||
"value": "en_US",
|
||||
"description": "The default interface language. See translate.getoutline.com for a list of available language codes and their rough percentage translated.",
|
||||
|
||||
@@ -12,7 +12,7 @@ import Collection from "~/models/Collection";
|
||||
import CollectionEdit from "~/scenes/CollectionEdit";
|
||||
import CollectionNew from "~/scenes/CollectionNew";
|
||||
import CollectionPermissions from "~/scenes/CollectionPermissions";
|
||||
import DynamicCollectionIcon from "~/components/CollectionIcon";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
|
||||
@@ -18,17 +18,30 @@ import {
|
||||
CrossIcon,
|
||||
ArchiveIcon,
|
||||
ShuffleIcon,
|
||||
HistoryIcon,
|
||||
LightBulbIcon,
|
||||
UnpublishIcon,
|
||||
PublishIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { ExportContentType } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, newDocumentPath, searchPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
documentInsightsUrl,
|
||||
documentHistoryUrl,
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
@@ -59,11 +72,9 @@ export const createDocument = createAction({
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
perform: ({ activeCollectionId, inStarredSection }) =>
|
||||
activeCollectionId &&
|
||||
history.push(newDocumentPath(activeCollectionId), {
|
||||
starred: inStarredSection,
|
||||
}),
|
||||
@@ -118,6 +129,71 @@ export const unstarDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const publishDocument = createAction({
|
||||
name: ({ t }) => t("Publish"),
|
||||
section: DocumentSection,
|
||||
icon: <PublishIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!document?.isDraft && stores.policies.abilities(activeDocumentId).update
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (document?.publishedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document?.collectionId) {
|
||||
await document.save({
|
||||
publish: true,
|
||||
});
|
||||
stores.toasts.showToast(t("Document published"), {
|
||||
type: "success",
|
||||
});
|
||||
} else if (document) {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Publish document"),
|
||||
isCentered: true,
|
||||
content: <DocumentPublish document={document} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const unpublishDocument = createAction({
|
||||
name: ({ t }) => t("Unpublish"),
|
||||
section: DocumentSection,
|
||||
icon: <UnpublishIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeDocumentId).unpublish;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
document?.unpublish();
|
||||
|
||||
stores.toasts.showToast(t("Document unpublished"), {
|
||||
type: "success",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeDocument = createAction({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
section: DocumentSection,
|
||||
@@ -194,7 +270,34 @@ export const downloadDocumentAsHTML = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download("text/html");
|
||||
document?.download(ExportContentType.Html);
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsPDF = createAction({
|
||||
name: ({ t }) => t("PDF"),
|
||||
section: DocumentSection,
|
||||
keywords: "export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED,
|
||||
perform: ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = stores.toasts.showToast(`${t("Exporting")}…`, {
|
||||
type: "loading",
|
||||
timeout: 30 * 1000,
|
||||
});
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document
|
||||
?.download(ExportContentType.Pdf)
|
||||
.finally(() => id && stores.toasts.hideToast(id));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -212,7 +315,7 @@ export const downloadDocumentAsMarkdown = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download("text/markdown");
|
||||
document?.download(ExportContentType.Markdown);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -222,7 +325,11 @@ export const downloadDocument = createAction({
|
||||
section: DocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
children: [downloadDocumentAsHTML, downloadDocumentAsMarkdown],
|
||||
children: [
|
||||
downloadDocumentAsHTML,
|
||||
downloadDocumentAsPDF,
|
||||
downloadDocumentAsMarkdown,
|
||||
],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
@@ -254,7 +361,17 @@ export const duplicateDocument = createAction({
|
||||
* of the collection for all collection members to see.
|
||||
*/
|
||||
export const pinDocumentToCollection = createAction({
|
||||
name: ({ t }) => t("Pin to collection"),
|
||||
name: ({ activeDocumentId = "", t, stores }) => {
|
||||
const selectedDocument = stores.documents.get(activeDocumentId);
|
||||
const collectionName = selectedDocument
|
||||
? stores.documents.getCollectionForDocument(selectedDocument)?.name
|
||||
: t("collection");
|
||||
|
||||
return t("Pin to {{collectionName}}", {
|
||||
collectionName,
|
||||
});
|
||||
},
|
||||
|
||||
section: DocumentSection,
|
||||
icon: <PinIcon />,
|
||||
iconInContextMenu: false,
|
||||
@@ -331,7 +448,7 @@ export const printDocument = createAction({
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
section: DocumentSection,
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId,
|
||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||
perform: async () => {
|
||||
window.print();
|
||||
},
|
||||
@@ -464,15 +581,11 @@ export const moveDocument = createAction({
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
title: t("Move {{ documentType }}", {
|
||||
documentType: document.noun,
|
||||
}),
|
||||
content: (
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
isCentered: true,
|
||||
content: <DocumentMove document={document} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -571,6 +684,46 @@ export const permanentlyDeleteDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentHistory = createAction({
|
||||
name: ({ t }) => t("History"),
|
||||
section: DocumentSection,
|
||||
icon: <HistoryIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return !!activeDocumentId && can.read && !can.restore;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
history.push(documentHistoryUrl(document));
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentInsights = createAction({
|
||||
name: ({ t }) => t("Insights"),
|
||||
section: DocumentSection,
|
||||
icon: <LightBulbIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return !!activeDocumentId && can.read;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
history.push(documentInsightsUrl(document));
|
||||
},
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
@@ -581,6 +734,8 @@ export const rootDocumentActions = [
|
||||
downloadDocument,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
duplicateDocument,
|
||||
@@ -590,4 +745,6 @@ export const rootDocumentActions = [
|
||||
printDocument,
|
||||
pinDocumentToCollection,
|
||||
pinDocumentToHome,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
EmailIcon,
|
||||
LogoutIcon,
|
||||
ProfileIcon,
|
||||
BrowserIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import {
|
||||
@@ -24,7 +25,10 @@ import SearchQuery from "~/models/SearchQuery";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { isMac } from "~/utils/browser";
|
||||
import history from "~/utils/history";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
profileSettingsPath,
|
||||
@@ -157,6 +161,20 @@ export const openKeyboardShortcuts = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadApp = createAction({
|
||||
name: ({ t }) =>
|
||||
t("Download {{ platform }} app", {
|
||||
platform: isMac() ? "macOS" : "Windows",
|
||||
}),
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <BrowserIcon />,
|
||||
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
|
||||
perform: () => {
|
||||
window.open("https://desktop.getoutline.com");
|
||||
},
|
||||
});
|
||||
|
||||
export const logout = createAction({
|
||||
name: ({ t }) => t("Log out"),
|
||||
section: NavigationSection,
|
||||
@@ -170,6 +188,7 @@ export const rootNavigationActions = [
|
||||
navigateToTemplates,
|
||||
navigateToArchive,
|
||||
navigateToTrash,
|
||||
downloadApp,
|
||||
openAPIDocumentation,
|
||||
openFeedbackUrl,
|
||||
openBugReportUrl,
|
||||
|
||||
@@ -1,30 +1,48 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import TeamNew from "~/scenes/TeamNew";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import { createAction } from "~/actions";
|
||||
import { loadSessionsFromCookie } from "~/hooks/useSessions";
|
||||
import { ActionContext } from "~/types";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const switchTeamList = getSessions().map((session) => {
|
||||
return createAction({
|
||||
name: session.name,
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: () => <Logo alt={session.name} src={session.logoUrl} />,
|
||||
visible: ({ currentTeamId }) => currentTeamId !== session.teamId,
|
||||
perform: () => (window.location.href = session.url),
|
||||
});
|
||||
});
|
||||
export const createTeamsList = ({ stores }: { stores: RootStore }) => {
|
||||
return (
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
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}
|
||||
/>
|
||||
),
|
||||
visible: ({ currentTeamId }: ActionContext) =>
|
||||
currentTeamId !== session.id,
|
||||
perform: () => (window.location.href = session.url),
|
||||
})) ?? []
|
||||
);
|
||||
};
|
||||
|
||||
const switchTeam = createAction({
|
||||
export const switchTeam = createAction({
|
||||
name: ({ t }) => t("Switch workspace"),
|
||||
placeholder: ({ t }) => t("Select a workspace"),
|
||||
keywords: "change switch workspace organization team",
|
||||
section: TeamSection,
|
||||
visible: ({ currentTeamId }) =>
|
||||
getSessions({ exclude: currentTeamId }).length > 0,
|
||||
children: switchTeamList,
|
||||
visible: ({ stores }) =>
|
||||
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
|
||||
children: createTeamsList,
|
||||
});
|
||||
|
||||
export const createTeam = createAction({
|
||||
@@ -47,18 +65,9 @@ export const createTeam = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
function getSessions(params?: { exclude?: string }) {
|
||||
const sessions = loadSessionsFromCookie();
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== params?.exclude
|
||||
);
|
||||
return otherSessions;
|
||||
}
|
||||
|
||||
const Logo = styled("img")`
|
||||
const StyledTeamLogo = styled(TeamLogo)`
|
||||
border-radius: 2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
`;
|
||||
|
||||
export const rootTeamActions = [switchTeam, createTeam];
|
||||
|
||||
+11
-3
@@ -57,8 +57,16 @@ export function actionToMenuItem(
|
||||
icon,
|
||||
visible,
|
||||
dangerous: action.dangerous,
|
||||
onClick: () => action.perform && action.perform(context),
|
||||
selected: action.selected ? action.selected(context) : undefined,
|
||||
onClick: () => {
|
||||
try {
|
||||
action.perform?.(context);
|
||||
} catch (err) {
|
||||
context.stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
selected: action.selected?.(context),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,7 +78,7 @@ export function actionToKBar(
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
|
||||
const resolvedIcon = resolve<React.ReactElement>(action.icon, context);
|
||||
const resolvedChildren = resolve<Action[]>(action.children, context);
|
||||
const resolvedSection = resolve<string>(action.section, context);
|
||||
const resolvedName = resolve<string>(action.name, context);
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { Action, ActionContext } from "~/types";
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
export type Props = React.ComponentPropsWithoutRef<"button"> & {
|
||||
/** Show the button in a disabled state */
|
||||
disabled?: boolean;
|
||||
/** Hide the button entirely if action is not applicable */
|
||||
@@ -20,40 +20,47 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
*/
|
||||
const ActionButton = React.forwardRef(
|
||||
(
|
||||
{
|
||||
action,
|
||||
context,
|
||||
tooltip,
|
||||
hideOnActionDisabled,
|
||||
...rest
|
||||
}: Props & React.HTMLAttributes<HTMLButtonElement>,
|
||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
const [executing, setExecuting] = React.useState(false);
|
||||
const disabled = rest.disabled;
|
||||
|
||||
if (!context || !action) {
|
||||
return <button {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
if (action?.visible && !action.visible(context) && hideOnActionDisabled) {
|
||||
const actionContext = { ...context, isButton: true };
|
||||
|
||||
if (
|
||||
action?.visible &&
|
||||
!action.visible(actionContext) &&
|
||||
hideOnActionDisabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label =
|
||||
typeof action.name === "function" ? action.name(context) : action.name;
|
||||
typeof action.name === "function"
|
||||
? action.name(actionContext)
|
||||
: action.name;
|
||||
|
||||
const button = (
|
||||
<button
|
||||
{...rest}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
disabled={disabled || executing}
|
||||
ref={ref}
|
||||
onClick={
|
||||
action?.perform && context
|
||||
action?.perform && actionContext
|
||||
? (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
action.perform?.(context);
|
||||
const response = action.perform?.(actionContext);
|
||||
if (response?.finally) {
|
||||
setExecuting(true);
|
||||
response.finally(() => setExecuting(false));
|
||||
}
|
||||
}
|
||||
: rest.onClick
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/* eslint-disable prefer-rest-params */
|
||||
/* global ga */
|
||||
import { escape } from "lodash";
|
||||
import * as React from "react";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
const Analytics: React.FC = ({ children }) => {
|
||||
// Google Analytics 3
|
||||
React.useEffect(() => {
|
||||
if (!env.GOOGLE_ANALYTICS_ID) {
|
||||
return;
|
||||
@@ -17,11 +21,9 @@ const Analytics: React.FC = ({ children }) => {
|
||||
|
||||
ga.l = +new Date();
|
||||
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
|
||||
ga("set", {
|
||||
dimension1: "true",
|
||||
});
|
||||
ga("send", "pageview");
|
||||
const script = document.createElement("script");
|
||||
script.type = "text/javascript";
|
||||
script.src = "https://www.google-analytics.com/analytics.js";
|
||||
script.async = true;
|
||||
|
||||
@@ -30,9 +32,32 @@ const Analytics: React.FC = ({ children }) => {
|
||||
ga("send", "event", "pwa", "install");
|
||||
});
|
||||
|
||||
if (document.body) {
|
||||
document.body.appendChild(script);
|
||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||
}, []);
|
||||
|
||||
// Google Analytics 4
|
||||
React.useEffect(() => {
|
||||
if (env.analytics.service !== IntegrationService.GoogleAnalytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measurementId = escape(env.analytics.settings?.measurementId);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.type = "text/javascript";
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
|
||||
script.async = true;
|
||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -7,28 +7,29 @@ import SlackLogo from "./SlackLogo";
|
||||
type Props = {
|
||||
providerName: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
function AuthLogo({ providerName, size = 16 }: Props) {
|
||||
function AuthLogo({ providerName, color, size = 16 }: Props) {
|
||||
switch (providerName) {
|
||||
case "slack":
|
||||
return (
|
||||
<Logo>
|
||||
<SlackLogo size={size} />
|
||||
<SlackLogo size={size} fill={color} />
|
||||
</Logo>
|
||||
);
|
||||
|
||||
case "google":
|
||||
return (
|
||||
<Logo>
|
||||
<GoogleLogo size={size} />
|
||||
<GoogleLogo size={size} fill={color} />
|
||||
</Logo>
|
||||
);
|
||||
|
||||
case "azure":
|
||||
return (
|
||||
<Logo>
|
||||
<MicrosoftLogo size={size} />
|
||||
<MicrosoftLogo size={size} fill={color} />
|
||||
</Logo>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { observer, useLocalStore } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import DocumentContext from "~/components/DocumentContext";
|
||||
import type { DocumentContextValue } from "~/components/DocumentContext";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
@@ -16,29 +20,29 @@ import {
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentInsights,
|
||||
} from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
|
||||
const DocumentHistory = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "document-history" */
|
||||
"~/components/DocumentHistory"
|
||||
)
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
const CommandBar = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "command-bar" */
|
||||
"~/components/CommandBar"
|
||||
)
|
||||
const DocumentInsights = React.lazy(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
);
|
||||
const CommandBar = React.lazy(() => import("~/components/CommandBar"));
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const can = usePolicy(ui.activeCollectionId);
|
||||
const { user, team } = auth;
|
||||
const documentContext = useLocalStore<DocumentContextValue>(() => ({
|
||||
editor: null,
|
||||
setEditor: (editor: TEditor) => {
|
||||
documentContext.editor = editor;
|
||||
},
|
||||
}));
|
||||
|
||||
const goToSearch = (ev: KeyboardEvent) => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
@@ -74,37 +78,38 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
</Fade>
|
||||
) : undefined;
|
||||
|
||||
const showHistory = !!matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
});
|
||||
const showInsights = !!matchPath(location.pathname, {
|
||||
path: matchDocumentInsights,
|
||||
});
|
||||
|
||||
const sidebarRight = (
|
||||
<React.Suspense fallback={null}>
|
||||
<AnimatePresence key={ui.activeDocumentId}>
|
||||
<Switch
|
||||
location={location}
|
||||
key={
|
||||
matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
})
|
||||
? "history"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<Route
|
||||
key="document-history"
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={DocumentHistory}
|
||||
/>
|
||||
</Switch>
|
||||
</AnimatePresence>
|
||||
</React.Suspense>
|
||||
<AnimatePresence key={ui.activeDocumentId}>
|
||||
{(showHistory || showInsights) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<SidebarRight>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showInsights && <DocumentInsights />}
|
||||
</React.Suspense>
|
||||
</SidebarRight>
|
||||
</Route>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
</Layout>
|
||||
<DocumentContext.Provider value={documentContext}>
|
||||
<Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
</Layout>
|
||||
</DocumentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,52 +1,59 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import User from "~/models/User";
|
||||
import placeholder from "./placeholder.png";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import Initials from "./Initials";
|
||||
|
||||
export interface IAvatar {
|
||||
avatarUrl: string | null;
|
||||
color: string;
|
||||
initial: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
size: number;
|
||||
src?: string;
|
||||
icon?: React.ReactNode;
|
||||
user?: User;
|
||||
model?: IAvatar;
|
||||
alt?: string;
|
||||
showBorder?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@observer
|
||||
class Avatar extends React.Component<Props> {
|
||||
@observable
|
||||
error: boolean;
|
||||
function Avatar(props: Props) {
|
||||
const { icon, showBorder, model, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
static defaultProps = {
|
||||
size: 24,
|
||||
};
|
||||
|
||||
handleError = () => {
|
||||
this.error = true;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { src, icon, showBorder, ...rest } = this.props;
|
||||
return (
|
||||
<AvatarWrapper>
|
||||
return (
|
||||
<Relative>
|
||||
{src && !error ? (
|
||||
<CircleImg
|
||||
onError={this.handleError}
|
||||
src={this.error ? placeholder : src}
|
||||
onError={handleError}
|
||||
src={src}
|
||||
$showBorder={showBorder}
|
||||
{...rest}
|
||||
/>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
</AvatarWrapper>
|
||||
);
|
||||
}
|
||||
) : model ? (
|
||||
<Initials color={model.color} $showBorder={showBorder} {...rest}>
|
||||
{model.initial}
|
||||
</Initials>
|
||||
) : (
|
||||
<Initials $showBorder={showBorder} {...rest} />
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
</Relative>
|
||||
);
|
||||
}
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
Avatar.defaultProps = {
|
||||
size: 24,
|
||||
};
|
||||
|
||||
const Relative = styled.div`
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
@@ -66,10 +73,12 @@ const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid
|
||||
${(props) =>
|
||||
props.$showBorder === false ? "transparent" : props.theme.background};
|
||||
border: ${(props) =>
|
||||
props.$showBorder === false
|
||||
? "none"
|
||||
: `2px solid ${props.theme.background}`};
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export default Avatar;
|
||||
|
||||
@@ -51,7 +51,7 @@ function AvatarWithPresence({
|
||||
$isObserving={isObserving}
|
||||
$color={user.color}
|
||||
>
|
||||
<Avatar src={user.avatarUrl} onClick={onClick} size={32} />
|
||||
<Avatar model={user} onClick={onClick} size={32} />
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
const Initials = styled(Flex)<{
|
||||
color?: string;
|
||||
size: number;
|
||||
$showBorder?: boolean;
|
||||
}>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #fff;
|
||||
background-color: ${(props) => props.color};
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid
|
||||
${(props) =>
|
||||
props.$showBorder === false ? "transparent" : props.theme.background};
|
||||
flex-shrink: 0;
|
||||
font-size: ${(props) => props.size / 2}px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
export default Initials;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 564 B |
@@ -3,7 +3,7 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
import env from "~/env";
|
||||
import OutlineLogo from "./OutlineLogo";
|
||||
import OutlineIcon from "./Icons/OutlineIcon";
|
||||
|
||||
type Props = {
|
||||
href?: string;
|
||||
@@ -12,8 +12,8 @@ type Props = {
|
||||
function Branding({ href = env.URL }: Props) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<OutlineLogo size={16} />
|
||||
Outline
|
||||
<OutlineIcon size={20} />
|
||||
{env.APP_NAME}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
+38
-18
@@ -6,18 +6,19 @@ import styled from "styled-components";
|
||||
import ActionButton, {
|
||||
Props as ActionButtonProps,
|
||||
} from "~/components/ActionButton";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
type RealProps = {
|
||||
fullwidth?: boolean;
|
||||
borderOnHover?: boolean;
|
||||
$fullwidth?: boolean;
|
||||
$borderOnHover?: boolean;
|
||||
$neutral?: boolean;
|
||||
danger?: boolean;
|
||||
iconColor?: string;
|
||||
$danger?: boolean;
|
||||
$iconColor?: string;
|
||||
};
|
||||
|
||||
const RealButton = styled(ActionButton)<RealProps>`
|
||||
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
|
||||
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
|
||||
display: ${(props) => (props.$fullwidth ? "block" : "inline-block")};
|
||||
width: ${(props) => (props.$fullwidth ? "100%" : "auto")};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -33,12 +34,13 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
cursor: var(--pointer);
|
||||
user-select: none;
|
||||
appearance: none !important;
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
${(props) =>
|
||||
!props.borderOnHover &&
|
||||
!props.$borderOnHover &&
|
||||
`
|
||||
svg {
|
||||
fill: ${props.iconColor || "currentColor"};
|
||||
fill: ${props.$iconColor || "currentColor"};
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -69,16 +71,16 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
color: ${props.theme.buttonNeutralText};
|
||||
box-shadow: ${
|
||||
props.borderOnHover
|
||||
props.$borderOnHover
|
||||
? "none"
|
||||
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
|
||||
};
|
||||
|
||||
${
|
||||
props.borderOnHover
|
||||
props.$borderOnHover
|
||||
? ""
|
||||
: `svg {
|
||||
fill: ${props.iconColor || "currentColor"};
|
||||
fill: ${props.$iconColor || "currentColor"};
|
||||
}`
|
||||
}
|
||||
|
||||
@@ -86,7 +88,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${
|
||||
props.borderOnHover
|
||||
props.$borderOnHover
|
||||
? props.theme.buttonNeutralBackground
|
||||
: darken(0.05, props.theme.buttonNeutralBackground)
|
||||
};
|
||||
@@ -106,7 +108,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.danger &&
|
||||
props.$danger &&
|
||||
`
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
@@ -158,11 +160,11 @@ export type Props<T> = ActionButtonProps & {
|
||||
disclosure?: boolean;
|
||||
neutral?: boolean;
|
||||
danger?: boolean;
|
||||
primary?: boolean;
|
||||
fullwidth?: boolean;
|
||||
as?: T;
|
||||
to?: LocationDescriptor;
|
||||
borderOnHover?: boolean;
|
||||
hideIcon?: boolean;
|
||||
href?: string;
|
||||
"data-on"?: string;
|
||||
"data-event-category"?: string;
|
||||
@@ -173,10 +175,24 @@ const Button = <T extends React.ElementType = "button">(
|
||||
props: Props<T> & React.ComponentPropsWithoutRef<T>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
const { type, children, value, disclosure, neutral, action, ...rest } = props;
|
||||
const {
|
||||
type,
|
||||
children,
|
||||
value,
|
||||
disclosure,
|
||||
neutral,
|
||||
action,
|
||||
icon,
|
||||
iconColor,
|
||||
borderOnHover,
|
||||
hideIcon,
|
||||
fullwidth,
|
||||
danger,
|
||||
...rest
|
||||
} = props;
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const icon = action?.icon ?? rest.icon;
|
||||
const hasIcon = icon !== undefined;
|
||||
const ic = hideIcon ? undefined : action?.icon ?? icon;
|
||||
const hasIcon = ic !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton
|
||||
@@ -184,10 +200,14 @@ const Button = <T extends React.ElementType = "button">(
|
||||
ref={ref}
|
||||
$neutral={neutral}
|
||||
action={action}
|
||||
$danger={danger}
|
||||
$fullwidth={fullwidth}
|
||||
$borderOnHover={borderOnHover}
|
||||
$iconColor={iconColor}
|
||||
{...rest}
|
||||
>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasIcon && ic}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon color="currentColor" />}
|
||||
</Inner>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const ClickablePadding = styled.div<{ grow?: boolean }>`
|
||||
min-height: 10em;
|
||||
cursor: ${({ onClick }) => (onClick ? "text" : "default")};
|
||||
${({ grow }) => grow && `flex-grow: 100;`};
|
||||
const ClickablePadding = styled.div<{ grow?: boolean; minHeight?: string }>`
|
||||
min-height: ${(props) => props.minHeight || "50vh"};
|
||||
flex-grow: 100;
|
||||
cursor: text;
|
||||
`;
|
||||
|
||||
export default ClickablePadding;
|
||||
|
||||
@@ -11,7 +11,7 @@ import CommandBarResults from "~/components/CommandBarResults";
|
||||
import SearchActions from "~/components/SearchActions";
|
||||
import rootActions from "~/actions/root";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useSettingsActions from "~/hooks/useSettingsAction";
|
||||
import useSettingsActions from "~/hooks/useSettingsActions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { CommandBarAction } from "~/types";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
|
||||
@@ -92,7 +92,9 @@ const Content = styled(Flex)`
|
||||
|
||||
const Item = styled.div<{ active?: boolean }>`
|
||||
font-size: 15px;
|
||||
padding: 10px 16px;
|
||||
padding: 9px 12px;
|
||||
margin: 0 8px;
|
||||
border-radius: 4px;
|
||||
background: ${(props) =>
|
||||
props.active ? props.theme.menuItemSelected : "none"};
|
||||
display: flex;
|
||||
@@ -103,6 +105,7 @@ const Item = styled.div<{ active?: boolean }>`
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
min-width: 0;
|
||||
|
||||
${(props) =>
|
||||
|
||||
@@ -51,7 +51,7 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">{children}</Text>
|
||||
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
|
||||
{isSaving ? savingText : submitText}
|
||||
{isSaving && savingText ? savingText : submitText}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
|
||||
@@ -118,8 +118,8 @@ const ContentEditable = React.forwardRef(
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
|
||||
// Ensure only plain text can be pasted into title when pasting from another
|
||||
// rich text editor
|
||||
// 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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -8,6 +9,7 @@ import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
@@ -18,29 +20,31 @@ type Props = {
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactElement;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const MenuItem: React.FC<Props> = ({
|
||||
onClick,
|
||||
children,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
icon,
|
||||
...rest
|
||||
}) => {
|
||||
const MenuItem = (
|
||||
{
|
||||
onClick,
|
||||
children,
|
||||
active,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
icon,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) => {
|
||||
const handleClick = React.useCallback(
|
||||
(ev) => {
|
||||
if (onClick) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onClick(ev);
|
||||
}
|
||||
|
||||
if (hide) {
|
||||
hide();
|
||||
}
|
||||
hide?.();
|
||||
},
|
||||
[onClick, hide]
|
||||
);
|
||||
@@ -63,10 +67,14 @@ const MenuItem: React.FC<Props> = ({
|
||||
{(props) => (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$toggleable={selected !== undefined}
|
||||
$active={active}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
ref={mergeRefs([
|
||||
ref,
|
||||
props.ref as React.RefObject<HTMLAnchorElement>,
|
||||
])}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
@@ -97,6 +105,7 @@ type MenuAnchorProps = {
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
disclosure?: boolean;
|
||||
$active?: boolean;
|
||||
};
|
||||
|
||||
export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
@@ -104,6 +113,7 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
@@ -127,11 +137,12 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.disabled
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
${(props) => props.disabled && "pointer-events: none;"}
|
||||
|
||||
${(props) =>
|
||||
props.$active === undefined &&
|
||||
!props.disabled &&
|
||||
`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
@@ -146,18 +157,32 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
}
|
||||
}
|
||||
}
|
||||
`};
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.$active &&
|
||||
!props.disabled &&
|
||||
`
|
||||
color: ${props.theme.white};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 12px;
|
||||
padding-right: ${(props: MenuAnchorProps) =>
|
||||
props.disclosure ? 32 : 12}px;
|
||||
font-size: 14px;
|
||||
`};
|
||||
`}
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
${MenuAnchorCSS}
|
||||
`;
|
||||
|
||||
export default MenuItem;
|
||||
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
|
||||
|
||||
@@ -11,5 +11,5 @@ export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
|
||||
}
|
||||
|
||||
const HorizontalRule = styled.hr`
|
||||
margin: 0.5em 12px;
|
||||
margin: 6px 0;
|
||||
`;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useMenuState,
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
MenuStateReturn,
|
||||
} from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -25,7 +26,7 @@ import MouseSafeArea from "./MouseSafeArea";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
|
||||
type Props = {
|
||||
type Props = Omit<MenuStateReturn, "items"> & {
|
||||
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||
context?: Partial<ActionContext>;
|
||||
items?: TMenuItem[];
|
||||
@@ -37,13 +38,15 @@ const Disclosure = styled(ExpandedIcon)`
|
||||
right: 8px;
|
||||
`;
|
||||
|
||||
const Submenu = React.forwardRef(
|
||||
type SubMenuProps = MenuStateReturn & {
|
||||
templateItems: TMenuItem[];
|
||||
parentMenuState: Omit<MenuStateReturn, "items">;
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenu = React.forwardRef(
|
||||
(
|
||||
{
|
||||
templateItems,
|
||||
title,
|
||||
...rest
|
||||
}: { templateItems: TMenuItem[]; title: React.ReactNode },
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -59,7 +62,11 @@ const Submenu = React.forwardRef(
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Submenu")}>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Submenu")}
|
||||
onClick={parentMenuState.hide}
|
||||
>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
@@ -177,8 +184,9 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
return (
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
as={Submenu}
|
||||
as={SubMenu}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={<Title title={item.title} icon={item.icon} />}
|
||||
{...menu}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu } from "reakit/Menu";
|
||||
import { Menu, MenuStateReturn } from "reakit/Menu";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths } from "@shared/styles";
|
||||
@@ -36,21 +36,23 @@ export type Placement =
|
||||
| "left"
|
||||
| "left-start";
|
||||
|
||||
type Props = {
|
||||
type Props = MenuStateReturn & {
|
||||
"aria-label": string;
|
||||
visible?: boolean;
|
||||
placement?: Placement;
|
||||
animating?: boolean;
|
||||
unstable_disclosureRef?: React.RefObject<HTMLElement | null>;
|
||||
/** The parent menu state if this is a submenu. */
|
||||
parentMenuState?: MenuStateReturn;
|
||||
/** Called when the context menu is opened. */
|
||||
onOpen?: () => void;
|
||||
/** Called when the context menu is closed. */
|
||||
onClose?: () => void;
|
||||
hide?: () => void;
|
||||
/** Called when the context menu is clicked. */
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
const ContextMenu: React.FC<Props> = ({
|
||||
children,
|
||||
onOpen,
|
||||
onClose,
|
||||
parentMenuState,
|
||||
...rest
|
||||
}) => {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
@@ -67,19 +69,17 @@ const ContextMenu: React.FC<Props> = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
if (rest["aria-label"] !== t("Submenu")) {
|
||||
onOpen?.();
|
||||
|
||||
if (!parentMenuState) {
|
||||
setIsMenuOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rest.visible && previousVisible) {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
if (rest["aria-label"] !== t("Submenu")) {
|
||||
onClose?.();
|
||||
|
||||
if (!parentMenuState) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
rest.visible,
|
||||
ui.sidebarCollapsed,
|
||||
setIsMenuOpen,
|
||||
rest,
|
||||
parentMenuState,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -205,7 +205,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
max-width: 100%;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
padding: 6px;
|
||||
min-width: 180px;
|
||||
min-height: 44px;
|
||||
max-height: 75vh;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { HomeIcon } from "outline-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Optional } from "utility-types";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { useDesktopTitlebar } from "~/hooks/useDesktopTitlebar";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
export default function DesktopEventHandler() {
|
||||
useDesktopTitlebar();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const { dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
React.useEffect(() => {
|
||||
Desktop.bridge?.redirect((path: string, replace = false) => {
|
||||
if (replace) {
|
||||
history.replace(path);
|
||||
} else {
|
||||
history.push(path);
|
||||
}
|
||||
});
|
||||
|
||||
Desktop.bridge?.updateDownloaded(() => {
|
||||
showToast("An update is ready to install.", {
|
||||
type: "info",
|
||||
timeout: Infinity,
|
||||
action: {
|
||||
text: "Install now",
|
||||
onClick: () => {
|
||||
Desktop.bridge?.restartAndInstall();
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Desktop.bridge?.focus(() => {
|
||||
window.document.body.classList.remove("backgrounded");
|
||||
});
|
||||
|
||||
Desktop.bridge?.blur(() => {
|
||||
window.document.body.classList.add("backgrounded");
|
||||
});
|
||||
|
||||
Desktop.bridge?.openKeyboardShortcuts(() => {
|
||||
dialogs.openGuide({
|
||||
title: t("Keyboard shortcuts"),
|
||||
content: <KeyboardShortcuts />,
|
||||
});
|
||||
});
|
||||
}, [t, history, dialogs, showToast]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -3,11 +3,12 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink, NavigationNode } from "~/types";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { collectionUrl } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -58,7 +59,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
const category = useCategory(document);
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
let collectionNode: MenuInternalLink;
|
||||
let collectionNode: MenuInternalLink | undefined;
|
||||
|
||||
if (collection) {
|
||||
collectionNode = {
|
||||
@@ -67,7 +68,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionUrl(collection.url),
|
||||
};
|
||||
} else {
|
||||
} else if (document.collectionId && !collection) {
|
||||
collectionNode = {
|
||||
type: "route",
|
||||
title: t("Deleted Collection"),
|
||||
@@ -77,8 +78,9 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
const path = React.useMemo(
|
||||
() => collection?.pathToDocument?.(document.id).slice(0, -1) || [],
|
||||
[collection, document]
|
||||
() => collection?.pathToDocument(document.id).slice(0, -1) || [],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[collection, document, document.collectionId, document.parentDocumentId]
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
@@ -88,7 +90,9 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
output.push(collectionNode);
|
||||
if (collectionNode) {
|
||||
output.push(collectionNode);
|
||||
}
|
||||
|
||||
path.forEach((node: NavigationNode) => {
|
||||
output.push({
|
||||
|
||||
@@ -13,8 +13,8 @@ import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionIcon from "./CollectionIcon";
|
||||
import EmojiIcon from "./EmojiIcon";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import EmojiIcon from "./Icons/EmojiIcon";
|
||||
import Squircle from "./Squircle";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { Editor } from "~/editor";
|
||||
|
||||
export type DocumentContextValue = {
|
||||
/** The current editor instance for this document. */
|
||||
editor: Editor | null;
|
||||
/** Set the current editor instance for this document. */
|
||||
setEditor: (editor: Editor) => void;
|
||||
};
|
||||
|
||||
const DocumentContext = React.createContext<DocumentContextValue>({
|
||||
editor: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
setEditor() {},
|
||||
});
|
||||
|
||||
export const useDocumentContext = () => React.useContext(DocumentContext);
|
||||
|
||||
export default DocumentContext;
|
||||
@@ -0,0 +1,408 @@
|
||||
import FuzzySearch from "fuzzy-search";
|
||||
import { includes, difference, concat, filter, map, fill } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { ancestors, descendants } from "~/utils/tree";
|
||||
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
onSubmit: () => void;
|
||||
|
||||
/** A side-effect of item selection */
|
||||
onSelect: (item: NavigationNode | null) => void;
|
||||
|
||||
/** Items to be shown in explorer */
|
||||
items: NavigationNode[];
|
||||
};
|
||||
|
||||
function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const isMobile = useMobile();
|
||||
const { collections, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>();
|
||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
);
|
||||
const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>(
|
||||
0
|
||||
);
|
||||
const [nodes, setNodes] = React.useState<NavigationNode[]>([]);
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
|
||||
const [itemRefs, setItemRefs] = React.useState<
|
||||
React.RefObject<HTMLSpanElement>[]
|
||||
>([]);
|
||||
|
||||
const inputSearchRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(
|
||||
null
|
||||
);
|
||||
const listRef = React.useRef<List<NavigationNode[]>>(null);
|
||||
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const searchIndex = React.useMemo(() => {
|
||||
return new FuzzySearch(items, ["title"], {
|
||||
caseSensitive: false,
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (searchTerm) {
|
||||
selectNode(null);
|
||||
setExpandedNodes([]);
|
||||
}
|
||||
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(
|
||||
fill(Array(items.length), 0),
|
||||
(_, i) => itemRefs[i] || React.createRef()
|
||||
)
|
||||
);
|
||||
}, [items.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
if (itemRefs[node] && itemRefs[node].current) {
|
||||
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
|
||||
behavior: "auto",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
},
|
||||
[itemRefs]
|
||||
);
|
||||
|
||||
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(ev.target.value);
|
||||
};
|
||||
|
||||
const isExpanded = (node: number) => {
|
||||
return includes(expandedNodes, nodes[node].id);
|
||||
};
|
||||
|
||||
const calculateInitialScrollOffset = (itemCount: number) => {
|
||||
if (listRef.current) {
|
||||
const { height, itemSize } = listRef.current.props;
|
||||
const { scrollOffset } = listRef.current.state as {
|
||||
scrollOffset: number;
|
||||
};
|
||||
const itemsHeight = itemCount * itemSize;
|
||||
return itemsHeight < height ? 0 : scrollOffset;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const collapse = (node: number) => {
|
||||
const descendantIds = descendants(nodes[node]).map((des) => des.id);
|
||||
setExpandedNodes(
|
||||
difference(expandedNodes, [...descendantIds, nodes[node].id])
|
||||
);
|
||||
|
||||
// remove children
|
||||
const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
const expand = (node: number) => {
|
||||
setExpandedNodes(concat(expandedNodes, nodes[node].id));
|
||||
|
||||
// add children
|
||||
const newNodes = nodes.slice();
|
||||
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
const isSelected = (node: number) => {
|
||||
if (!selectedNode) {
|
||||
return false;
|
||||
}
|
||||
const selectedNodeId = selectedNode.id;
|
||||
const nodeId = nodes[node].id;
|
||||
|
||||
return selectedNodeId === nodeId;
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) => {
|
||||
return nodes[node].children.length > 0;
|
||||
};
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
return;
|
||||
}
|
||||
if (isExpanded(node)) {
|
||||
collapse(node);
|
||||
} else {
|
||||
expand(node);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (node: number) => {
|
||||
if (isSelected(node)) {
|
||||
selectNode(null);
|
||||
} else {
|
||||
selectNode(nodes[node]);
|
||||
}
|
||||
};
|
||||
|
||||
const ListItem = ({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: NavigationNode[];
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const node = data[index];
|
||||
const isCollection = node.type === "collection";
|
||||
let icon, title, path;
|
||||
|
||||
if (isCollection) {
|
||||
const col = collections.get(node.collectionId as string);
|
||||
icon = col && (
|
||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||
);
|
||||
title = node.title;
|
||||
} else {
|
||||
const doc = documents.get(node.id);
|
||||
const { strippedTitle, emoji } = parseTitle(node.title);
|
||||
title = strippedTitle;
|
||||
|
||||
if (emoji) {
|
||||
icon = <EmojiIcon emoji={emoji} />;
|
||||
} else if (doc?.isStarred) {
|
||||
icon = <StarredIcon color={theme.yellow} />;
|
||||
} else {
|
||||
icon = <DocumentIcon />;
|
||||
}
|
||||
|
||||
path = ancestors(node)
|
||||
.map((a) => parseTitle(a.title).strippedTitle)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
return searchTerm ? (
|
||||
<DocumentExplorerSearchResult
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
path={path}
|
||||
/>
|
||||
) : (
|
||||
<DocumentExplorerNode
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
onDisclosureClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
toggleCollapse(index);
|
||||
}}
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
expanded={isExpanded(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
depth={node.depth as number}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const focusSearchInput = () => {
|
||||
inputSearchRef.current?.focus();
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
return Math.min(activeNode + 1, nodes.length - 1);
|
||||
};
|
||||
|
||||
const prev = () => {
|
||||
return Math.max(activeNode - 1, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown": {
|
||||
ev.preventDefault();
|
||||
setActiveNode(next());
|
||||
scrollNodeIntoView(next());
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
ev.preventDefault();
|
||||
if (activeNode === 0) {
|
||||
focusSearchInput();
|
||||
} else {
|
||||
setActiveNode(prev());
|
||||
scrollNodeIntoView(prev());
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ArrowLeft": {
|
||||
if (!searchTerm && isExpanded(activeNode)) {
|
||||
toggleCollapse(activeNode);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ArrowRight": {
|
||||
if (!searchTerm) {
|
||||
toggleCollapse(activeNode);
|
||||
// let the nodes re-render first and then scroll
|
||||
setImmediate(() => scrollNodeIntoView(activeNode));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Enter": {
|
||||
if (isModKey(ev)) {
|
||||
onSubmit();
|
||||
} else {
|
||||
toggleSelect(activeNode);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
|
||||
<ListSearch
|
||||
ref={inputSearchRef}
|
||||
onChange={handleSearch}
|
||||
placeholder={`${t("Search collections & documents")}…`}
|
||||
autoFocus
|
||||
/>
|
||||
<ListContainer>
|
||||
{nodes.length ? (
|
||||
<AutoSizer>
|
||||
{({ width, height }: { width: number; height: number }) => (
|
||||
<Flex role="listbox" column>
|
||||
<List
|
||||
ref={listRef}
|
||||
key={nodes.length}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={nodes}
|
||||
itemCount={nodes.length}
|
||||
itemSize={isMobile ? 48 : 32}
|
||||
innerElementType={innerElementType}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemKey={(index, results) => results[index].id}
|
||||
>
|
||||
{ListItem}
|
||||
</List>
|
||||
</Flex>
|
||||
)}
|
||||
</AutoSizer>
|
||||
) : (
|
||||
<FlexContainer>
|
||||
<Text type="secondary">{t("No results found")}.</Text>
|
||||
</FlexContainer>
|
||||
)}
|
||||
</ListContainer>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const Container = styled.div``;
|
||||
|
||||
const FlexContainer = styled(Flex)`
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const ListSearch = styled(InputSearch)`
|
||||
${Outline} {
|
||||
border-radius: 16px;
|
||||
}
|
||||
margin-bottom: 4px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
`;
|
||||
|
||||
const ListContainer = styled.div`
|
||||
height: 65vh;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
height: 40vh;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(DocumentExplorer);
|
||||
@@ -0,0 +1,134 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "~/components/Flex";
|
||||
import Disclosure from "~/components/Sidebar/components/Disclosure";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
type Props = {
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
style: React.CSSProperties;
|
||||
expanded: boolean;
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
depth: number;
|
||||
hasChildren: boolean;
|
||||
|
||||
onDisclosureClick: (ev: React.MouseEvent) => void;
|
||||
onPointerMove: (ev: React.MouseEvent) => void;
|
||||
onClick: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
function DocumentExplorerNode(
|
||||
{
|
||||
selected,
|
||||
active,
|
||||
style,
|
||||
expanded,
|
||||
icon,
|
||||
title,
|
||||
depth,
|
||||
hasChildren,
|
||||
onDisclosureClick,
|
||||
onPointerMove,
|
||||
onClick,
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLSpanElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const OFFSET = 12;
|
||||
const ICON_SIZE = 24;
|
||||
|
||||
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
|
||||
|
||||
return (
|
||||
<Node
|
||||
ref={ref}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
>
|
||||
<Spacer width={width}>
|
||||
{hasChildren && (
|
||||
<StyledDisclosure
|
||||
expanded={expanded}
|
||||
onClick={onDisclosureClick}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
</Spacer>
|
||||
{icon}
|
||||
<Title>{title || t("Untitled")}</Title>
|
||||
</Node>
|
||||
);
|
||||
}
|
||||
|
||||
const Title = styled(Text)`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0 4px 0 4px;
|
||||
color: inherit;
|
||||
`;
|
||||
|
||||
const StyledDisclosure = styled(Disclosure)`
|
||||
position: relative;
|
||||
left: auto;
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
const Spacer = styled(Flex)<{ width: number }>`
|
||||
flex-direction: row-reverse;
|
||||
flex-shrink: 0;
|
||||
width: ${(props) => props.width}px;
|
||||
`;
|
||||
|
||||
export const Node = styled.span<{
|
||||
active: boolean;
|
||||
selected: boolean;
|
||||
style: React.CSSProperties;
|
||||
}>`
|
||||
display: flex;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
width: ${(props) => props.style.width};
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: var(--pointer);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: ${(props) =>
|
||||
!props.selected && props.active && props.theme.listItemHoverBackground};
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.selected &&
|
||||
`
|
||||
background: ${props.theme.primary};
|
||||
color: ${props.theme.white};
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px;
|
||||
font-size: 15px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(DocumentExplorerNode));
|
||||
@@ -0,0 +1,85 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
type Props = {
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
style: React.CSSProperties;
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
path?: string;
|
||||
|
||||
onPointerMove: (ev: React.MouseEvent) => void;
|
||||
onClick: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
function DocumentExplorerSearchResult({
|
||||
selected,
|
||||
active,
|
||||
style,
|
||||
icon,
|
||||
title,
|
||||
path,
|
||||
onPointerMove,
|
||||
onClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ref = React.useCallback(
|
||||
(node: HTMLSpanElement | null) => {
|
||||
if (active && node) {
|
||||
scrollIntoView(node, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
},
|
||||
[active]
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchResult
|
||||
ref={ref}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
>
|
||||
{icon}
|
||||
<Flex>
|
||||
<Title>{title || t("Untitled")}</Title>
|
||||
<Path $selected={selected} size="xsmall">
|
||||
{path}
|
||||
</Path>
|
||||
</Flex>
|
||||
</SearchResult>
|
||||
);
|
||||
}
|
||||
|
||||
const Title = styled(Text)`
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
margin: 0 4px 0 4px;
|
||||
color: inherit;
|
||||
`;
|
||||
|
||||
const Path = styled(Text)<{ $selected: boolean }>`
|
||||
padding-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0 4px 0 8px;
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white50 : props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
export default observer(DocumentExplorerSearchResult);
|
||||
@@ -1,158 +0,0 @@
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Event from "~/models/Event";
|
||||
import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import PaginatedEventList from "~/components/PaginatedEventList";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentUrl } from "~/utils/routeHelpers";
|
||||
|
||||
const EMPTY_ARRAY: Event[] = [];
|
||||
|
||||
function DocumentHistory() {
|
||||
const { events, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const theme = useTheme();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
|
||||
const eventsInDocument = document
|
||||
? events.inDocument(document.id)
|
||||
: EMPTY_ARRAY;
|
||||
|
||||
const onCloseHistory = () => {
|
||||
if (document) {
|
||||
history.push(documentUrl(document));
|
||||
} else {
|
||||
history.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (
|
||||
eventsInDocument[0] &&
|
||||
document &&
|
||||
eventsInDocument[0].createdAt !== document.updatedAt
|
||||
) {
|
||||
eventsInDocument.unshift(
|
||||
new Event(
|
||||
{
|
||||
id: "live",
|
||||
name: "documents.live_editing",
|
||||
documentId: document.id,
|
||||
createdAt: document.updatedAt,
|
||||
actor: document.updatedBy,
|
||||
},
|
||||
events
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return eventsInDocument;
|
||||
}, [eventsInDocument, events, document]);
|
||||
|
||||
useKeyDown("Escape", onCloseHistory);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
initial={{
|
||||
width: 0,
|
||||
}}
|
||||
animate={{
|
||||
transition: {
|
||||
type: "spring",
|
||||
bounce: 0.2,
|
||||
duration: 0.6,
|
||||
},
|
||||
width: theme.sidebarWidth,
|
||||
}}
|
||||
exit={{
|
||||
width: 0,
|
||||
}}
|
||||
>
|
||||
{document ? (
|
||||
<Position column>
|
||||
<Header>
|
||||
<Title>{t("History")}</Title>
|
||||
<Button
|
||||
icon={<CloseIcon />}
|
||||
onClick={onCloseHistory}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Header>
|
||||
<Scrollable topShadow>
|
||||
<PaginatedEventList
|
||||
aria-label={t("History")}
|
||||
fetch={events.fetchPage}
|
||||
events={items}
|
||||
options={{
|
||||
documentId: document.id,
|
||||
}}
|
||||
document={document}
|
||||
empty={<EmptyHistory>{t("No history yet")}</EmptyHistory>}
|
||||
/>
|
||||
</Scrollable>
|
||||
</Position>
|
||||
) : null}
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const EmptyHistory = styled(Empty)`
|
||||
padding: 0 12px;
|
||||
`;
|
||||
|
||||
const Position = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: ${(props) => props.theme.sidebarWidth}px;
|
||||
`;
|
||||
|
||||
const Sidebar = styled(m.div)`
|
||||
display: none;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background: ${(props) => props.theme.background};
|
||||
width: ${(props) => props.theme.sidebarWidth}px;
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled(Flex)`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Header = styled(Flex)`
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 16px 12px;
|
||||
color: ${(props) => props.theme.text};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default observer(DocumentHistory);
|
||||
@@ -2,13 +2,13 @@ import { LocationDescriptor } from "history";
|
||||
import { observer, useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import { Link, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Popover from "~/components/Popover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentUrl, documentInsightsUrl } from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -20,38 +20,32 @@ type Props = {
|
||||
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
const { views } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||
const totalViewers = documentViews.length;
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
||||
const viewsLoadedOnMount = React.useRef(totalViewers > 0);
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 8,
|
||||
placement: "bottom",
|
||||
modal: true,
|
||||
});
|
||||
const insightsUrl = documentInsightsUrl(document);
|
||||
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
|
||||
|
||||
return (
|
||||
<Meta document={document} to={to} replace {...rest}>
|
||||
{totalViewers && !isDraft ? (
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<>
|
||||
•
|
||||
<a {...props}>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
? t("only you")
|
||||
: `${totalViewers} ${
|
||||
totalViewers === 1 ? t("person") : t("people")
|
||||
}`}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Wrapper>
|
||||
•
|
||||
<Link
|
||||
to={match.url === insightsUrl ? documentUrl(document) : insightsUrl}
|
||||
>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
? t("only you")
|
||||
: `${totalViewers} ${
|
||||
totalViewers === 1 ? t("person") : t("people")
|
||||
}`}
|
||||
</Link>
|
||||
</Wrapper>
|
||||
) : null}
|
||||
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
|
||||
<DocumentViews document={document} isOpen={popover.visible} />
|
||||
</Popover>
|
||||
</Meta>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, TFunction } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import CircularProgressBar from "~/components/CircularProgressBar";
|
||||
|
||||
@@ -43,10 +43,10 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
<PaginatedList
|
||||
aria-label={t("Viewers")}
|
||||
items={users}
|
||||
renderItem={(item: User) => {
|
||||
const view = documentViews.find((v) => v.user.id === item.id);
|
||||
const isPresent = presentIds.includes(item.id);
|
||||
const isEditing = editingIds.includes(item.id);
|
||||
renderItem={(model: User) => {
|
||||
const view = documentViews.find((v) => v.user.id === model.id);
|
||||
const isPresent = presentIds.includes(model.id);
|
||||
const isEditing = editingIds.includes(model.id);
|
||||
const subtitle = isPresent
|
||||
? isEditing
|
||||
? t("Currently editing")
|
||||
@@ -58,10 +58,10 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
});
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
title={item.name}
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
subtitle={subtitle}
|
||||
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
|
||||
image={<Avatar key={model.id} model={model} size={32} />}
|
||||
border={false}
|
||||
small
|
||||
/>
|
||||
|
||||
+27
-29
@@ -8,6 +8,7 @@ import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
@@ -29,13 +30,7 @@ import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
|
||||
const LazyLoadedEditor = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "preload-shared-editor" */
|
||||
"~/editor"
|
||||
)
|
||||
);
|
||||
const LazyLoadedEditor = React.lazy(() => import("~/editor"));
|
||||
|
||||
export type Props = Optional<
|
||||
EditorProps,
|
||||
@@ -49,31 +44,33 @@ export type Props = Optional<
|
||||
> & {
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
grow?: boolean;
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => any;
|
||||
bottomPadding?: string;
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { id, shareId, onChange, onHeadingsChange } = props;
|
||||
const { documents } = useStores();
|
||||
const { documents, auth } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const [
|
||||
activeLinkEvent,
|
||||
setActiveLinkEvent,
|
||||
] = React.useState<MouseEvent | null>(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 handleLinkActive = React.useCallback((event: MouseEvent) => {
|
||||
setActiveLinkEvent(event);
|
||||
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
|
||||
setActiveLink(element);
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const handleLinkInactive = React.useCallback(() => {
|
||||
setActiveLinkEvent(null);
|
||||
setActiveLink(null);
|
||||
}, []);
|
||||
|
||||
const handleSearchLink = React.useCallback(
|
||||
@@ -132,6 +129,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
documentId: id,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
});
|
||||
return result.url;
|
||||
},
|
||||
@@ -175,8 +173,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
);
|
||||
|
||||
const focusAtEnd = React.useCallback(() => {
|
||||
ref?.current?.focusAtEnd();
|
||||
}, [ref]);
|
||||
localRef?.current?.focusAtEnd();
|
||||
}, [localRef]);
|
||||
|
||||
const handleDrop = React.useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -184,7 +182,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
event.stopPropagation();
|
||||
const files = getDataTransferFiles(event);
|
||||
|
||||
const view = ref?.current?.view;
|
||||
const view = localRef?.current?.view;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
@@ -228,7 +226,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
});
|
||||
},
|
||||
[
|
||||
ref,
|
||||
localRef,
|
||||
props.onFileUploadStart,
|
||||
props.onFileUploadStop,
|
||||
dictionary,
|
||||
@@ -249,7 +247,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
// Calculate if headings have changed and trigger callback if so
|
||||
const updateHeadings = React.useCallback(() => {
|
||||
if (onHeadingsChange) {
|
||||
const headings = ref?.current?.getHeadings();
|
||||
const headings = localRef?.current?.getHeadings();
|
||||
if (
|
||||
headings &&
|
||||
headings.map((h) => h.level + h.title).join("") !==
|
||||
@@ -259,7 +257,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
onHeadingsChange(headings);
|
||||
}
|
||||
}
|
||||
}, [ref, onHeadingsChange]);
|
||||
}, [localRef, onHeadingsChange]);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event) => {
|
||||
@@ -271,7 +269,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
const handleRefChanged = React.useCallback(
|
||||
(node: SharedEditor | null) => {
|
||||
if (node && !previousHeadings.current) {
|
||||
if (node) {
|
||||
updateHeadings();
|
||||
}
|
||||
},
|
||||
@@ -282,10 +280,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
ref={mergeRefs([ref, handleRefChanged])}
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={onUploadFile}
|
||||
onShowToast={showToast}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onHoverLink={handleLinkActive}
|
||||
@@ -295,18 +294,17 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.grow && !props.readOnly && (
|
||||
{props.bottomPadding && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={focusAtEnd}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
grow
|
||||
minHeight={props.bottomPadding}
|
||||
/>
|
||||
)}
|
||||
{activeLinkEvent && !shareId && (
|
||||
{activeLinkElement && !shareId && (
|
||||
<HoverPreview
|
||||
node={activeLinkEvent.target as HTMLAnchorElement}
|
||||
event={activeLinkEvent}
|
||||
element={activeLinkElement}
|
||||
onClose={handleLinkInactive}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -142,7 +142,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
onClick={handleTimeClick}
|
||||
/>
|
||||
}
|
||||
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
|
||||
image={<Avatar model={event.actor} size={32} />}
|
||||
subtitle={
|
||||
<Subtitle>
|
||||
{icon}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { FileOperationFormat } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
collection?: Collection;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function ExportDialog({ collection, onSubmit }: Props) {
|
||||
const [format, setFormat] = React.useState<FileOperationFormat>(
|
||||
FileOperationFormat.MarkdownZip
|
||||
);
|
||||
const { showToast } = useToasts();
|
||||
const { collections, notificationSettings } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
React.useEffect(() => {
|
||||
notificationSettings.fetchPage({});
|
||||
}, [notificationSettings]);
|
||||
|
||||
const handleFormatChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormat(ev.target.value as FileOperationFormat);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (collection) {
|
||||
await collection.export(format);
|
||||
} else {
|
||||
await collections.export(format);
|
||||
}
|
||||
onSubmit();
|
||||
showToast(t("Export started"), { type: "success" });
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Markdown",
|
||||
description: t(
|
||||
"A ZIP file containing the images, and documents in the Markdown format."
|
||||
),
|
||||
value: FileOperationFormat.MarkdownZip,
|
||||
},
|
||||
{
|
||||
title: "HTML",
|
||||
description: t(
|
||||
"A ZIP file containing the images, and documents as HTML files."
|
||||
),
|
||||
value: FileOperationFormat.HTMLZip,
|
||||
},
|
||||
{
|
||||
title: "JSON",
|
||||
description: t(
|
||||
"Structured data that can be used to transfer data to another compatible {{ appName }} instance.",
|
||||
{
|
||||
appName,
|
||||
}
|
||||
),
|
||||
value: FileOperationFormat.JSON,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Export")}>
|
||||
{collection && (
|
||||
<Text>
|
||||
<Trans
|
||||
defaults="Exporting the collection <em>{{collectionName}}</em> may take some time."
|
||||
values={{
|
||||
collectionName: collection.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>{" "}
|
||||
{notificationSettings.getByEvent("emails.export_completed") &&
|
||||
t("You will receive an email when it's complete.")}
|
||||
</Text>
|
||||
)}
|
||||
<Flex gap={12} column>
|
||||
{items.map((item) => (
|
||||
<Option>
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value={item.value}
|
||||
checked={format === item.value}
|
||||
onChange={handleFormatChange}
|
||||
/>
|
||||
<div>
|
||||
<Text size="small" weight="bold">
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="small">{item.description}</Text>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Flex>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const Option = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(ExportDialog);
|
||||
@@ -39,7 +39,7 @@ function Facepile({
|
||||
}
|
||||
|
||||
function DefaultAvatar(user: User) {
|
||||
return <Avatar user={user} src={user.avatarUrl} size={32} />;
|
||||
return <Avatar model={user} size={32} />;
|
||||
}
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
|
||||
@@ -10,6 +10,7 @@ type TFilterOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
note?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -57,6 +58,7 @@ const FilterOptions = ({
|
||||
selected={option.key === activeKey}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon && <Icon>{option.icon}</Icon>}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
@@ -106,6 +108,12 @@ const StyledButton = styled(Button)`
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled.div`
|
||||
margin-right: 8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function GithubLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 36 36"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fill="#191717"
|
||||
d="M18,1.4C9,1.4,1.7,8.7,1.7,17.7c0,7.2,4.7,13.3,11.1,15.5 c0.8,0.1,1.1-0.4,1.1-0.8c0-0.4,0-1.4,0-2.8c-4.5,1-5.5-2.2-5.5-2.2c-0.7-1.9-1.8-2.4-1.8-2.4c-1.5-1,0.1-1,0.1-1 c1.6,0.1,2.5,1.7,2.5,1.7c1.5,2.5,3.8,1.8,4.7,1.4c0.1-1.1,0.6-1.8,1-2.2c-3.6-0.4-7.4-1.8-7.4-8.1c0-1.8,0.6-3.2,1.7-4.4 c-0.2-0.4-0.7-2.1,0.2-4.3c0,0,1.4-0.4,4.5,1.7c1.3-0.4,2.7-0.5,4.1-0.5c1.4,0,2.8,0.2,4.1,0.5c3.1-2.1,4.5-1.7,4.5-1.7 c0.9,2.2,0.3,3.9,0.2,4.3c1,1.1,1.7,2.6,1.7,4.4c0,6.3-3.8,7.6-7.4,8c0.6,0.5,1.1,1.5,1.1,3c0,2.2,0,3.9,0,4.5 c0,0.4,0.3,0.9,1.1,0.8c6.5-2.2,11.1-8.3,11.1-15.5C34.3,8.7,27,1.4,18,1.4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default GithubLogo;
|
||||
@@ -1,10 +1,9 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembers from "~/scenes/GroupMembers";
|
||||
@@ -12,10 +11,11 @@ import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import withStores from "~/components/withStores";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = RootStore & {
|
||||
type Props = {
|
||||
group: Group;
|
||||
membership?: CollectionGroupMembership;
|
||||
showFacepile?: boolean;
|
||||
@@ -23,71 +23,57 @@ type Props = RootStore & {
|
||||
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
@observer
|
||||
class GroupListItem extends React.Component<Props> {
|
||||
@observable
|
||||
membersModalOpen = false;
|
||||
function GroupListItem({ group, showFacepile, renderActions }: Props) {
|
||||
const { groupMemberships } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [
|
||||
membersModalOpen,
|
||||
setMembersModalOpen,
|
||||
setMembersModalClosed,
|
||||
] = useBoolean();
|
||||
const memberCount = group.memberCount;
|
||||
const membershipsInGroup = groupMemberships.inGroup(group.id);
|
||||
const users = membershipsInGroup
|
||||
.slice(0, MAX_AVATAR_DISPLAY)
|
||||
.map((gm) => gm.user);
|
||||
const overflow = memberCount - users.length;
|
||||
|
||||
handleMembersModalOpen = () => {
|
||||
this.membersModalOpen = true;
|
||||
};
|
||||
|
||||
handleMembersModalClose = () => {
|
||||
this.membersModalOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { group, groupMemberships, showFacepile, renderActions } = this.props;
|
||||
const memberCount = group.memberCount;
|
||||
const membershipsInGroup = groupMemberships.inGroup(group.id);
|
||||
const users = membershipsInGroup
|
||||
.slice(0, MAX_AVATAR_DISPLAY)
|
||||
.map((gm) => gm.user);
|
||||
const overflow = memberCount - users.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Image>
|
||||
<GroupIcon size={24} />
|
||||
</Image>
|
||||
}
|
||||
title={
|
||||
<Title onClick={this.handleMembersModalOpen}>{group.name}</Title>
|
||||
}
|
||||
subtitle={
|
||||
<>
|
||||
{memberCount} member{memberCount === 1 ? "" : "s"}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{showFacepile && (
|
||||
<NudeButton
|
||||
width="auto"
|
||||
height="auto"
|
||||
onClick={this.handleMembersModalOpen}
|
||||
>
|
||||
<Facepile users={users} overflow={overflow} />
|
||||
</NudeButton>
|
||||
)}
|
||||
{renderActions({
|
||||
openMembersModal: this.handleMembersModalOpen,
|
||||
})}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title="Group members"
|
||||
onRequestClose={this.handleMembersModalClose}
|
||||
isOpen={this.membersModalOpen}
|
||||
>
|
||||
<GroupMembers group={group} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Image>
|
||||
<GroupIcon size={24} />
|
||||
</Image>
|
||||
}
|
||||
title={<Title onClick={setMembersModalOpen}>{group.name}</Title>}
|
||||
subtitle={t("{{ count }} member", { count: memberCount })}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{showFacepile && (
|
||||
<NudeButton
|
||||
width="auto"
|
||||
height="auto"
|
||||
onClick={setMembersModalOpen}
|
||||
>
|
||||
<Facepile users={users} overflow={overflow} />
|
||||
</NudeButton>
|
||||
)}
|
||||
{renderActions({
|
||||
openMembersModal: setMembersModalOpen,
|
||||
})}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Group members")}
|
||||
onRequestClose={setMembersModalClosed}
|
||||
isOpen={membersModalOpen}
|
||||
>
|
||||
<GroupMembers group={group} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Image = styled(Flex)`
|
||||
@@ -106,4 +92,4 @@ const Title = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
export default withStores(GroupListItem);
|
||||
export default observer(GroupListItem);
|
||||
|
||||
@@ -12,6 +12,8 @@ import Flex from "~/components/Flex";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { supportsPassiveListener } from "~/utils/browser";
|
||||
|
||||
type Props = {
|
||||
@@ -24,7 +26,6 @@ type Props = {
|
||||
function Header({ left, title, actions, hasSidebar }: Props) {
|
||||
const { ui } = useStores();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const hasMobileSidebar = hasSidebar && isMobile;
|
||||
|
||||
const passThrough = !actions && !left && !title;
|
||||
@@ -50,7 +51,12 @@ function Header({ left, title, actions, hasSidebar }: Props) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper align="center" shrink={false} $passThrough={passThrough}>
|
||||
<Wrapper
|
||||
align="center"
|
||||
shrink={false}
|
||||
$passThrough={passThrough}
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
{left || hasMobileSidebar ? (
|
||||
<Breadcrumbs>
|
||||
{hasMobileSidebar && (
|
||||
@@ -98,7 +104,12 @@ const Actions = styled(Flex)`
|
||||
`};
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
|
||||
type WrapperProps = {
|
||||
$passThrough?: boolean;
|
||||
$insetTitleAdjust?: boolean;
|
||||
};
|
||||
|
||||
const Wrapper = styled(Flex)<WrapperProps>`
|
||||
top: 0;
|
||||
z-index: ${depths.header};
|
||||
position: sticky;
|
||||
@@ -120,6 +131,8 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
|
||||
transform: translate3d(0, 0, 0);
|
||||
min-height: 64px;
|
||||
justify-content: flex-start;
|
||||
${draggableOnDesktop()}
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
backdrop-filter: blur(20px);
|
||||
@@ -133,7 +146,8 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
|
||||
${breakpoint("tablet")`
|
||||
padding: 16px;
|
||||
justify-content: center;
|
||||
`};
|
||||
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled("div")`
|
||||
|
||||
@@ -14,14 +14,13 @@ const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 300;
|
||||
|
||||
type Props = {
|
||||
node: HTMLAnchorElement;
|
||||
event: MouseEvent;
|
||||
element: HTMLAnchorElement;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function HoverPreviewInternal({ node, onClose }: Props) {
|
||||
function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const { documents } = useStores();
|
||||
const slug = parseDocumentSlug(node.href);
|
||||
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>>();
|
||||
@@ -68,13 +67,13 @@ function HoverPreviewInternal({ node, onClose }: Props) {
|
||||
cardRef.current.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
node.addEventListener("mouseout", startCloseTimer);
|
||||
node.addEventListener("mouseover", stopCloseTimer);
|
||||
node.addEventListener("mouseover", startOpenTimer);
|
||||
element.addEventListener("mouseout", startCloseTimer);
|
||||
element.addEventListener("mouseover", stopCloseTimer);
|
||||
element.addEventListener("mouseover", startOpenTimer);
|
||||
return () => {
|
||||
node.removeEventListener("mouseout", startCloseTimer);
|
||||
node.removeEventListener("mouseover", stopCloseTimer);
|
||||
node.removeEventListener("mouseover", startOpenTimer);
|
||||
element.removeEventListener("mouseout", startCloseTimer);
|
||||
element.removeEventListener("mouseover", stopCloseTimer);
|
||||
element.removeEventListener("mouseover", startOpenTimer);
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseenter", stopCloseTimer);
|
||||
@@ -88,9 +87,9 @@ function HoverPreviewInternal({ node, onClose }: Props) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
}, [node, slug]);
|
||||
}, [element, slug]);
|
||||
|
||||
const anchorBounds = node.getBoundingClientRect();
|
||||
const anchorBounds = element.getBoundingClientRect();
|
||||
const cardBounds = cardRef.current?.getBoundingClientRect();
|
||||
const left = cardBounds
|
||||
? Math.min(anchorBounds.left, window.innerWidth - 16 - 350)
|
||||
@@ -105,7 +104,7 @@ function HoverPreviewInternal({ node, onClose }: Props) {
|
||||
aria-hidden
|
||||
>
|
||||
<div ref={cardRef}>
|
||||
<HoverPreviewDocument url={node.href}>
|
||||
<HoverPreviewDocument url={element.href}>
|
||||
{(content: React.ReactNode) =>
|
||||
isVisible ? (
|
||||
<Animate>
|
||||
@@ -124,18 +123,18 @@ function HoverPreviewInternal({ node, onClose }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ node, ...rest }: Props) {
|
||||
function HoverPreview({ element, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// previews only work for internal doc links for now
|
||||
if (isExternalUrl(node.href)) {
|
||||
if (isExternalUrl(element.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HoverPreviewInternal {...rest} node={node} />;
|
||||
return <HoverPreviewInternal {...rest} element={element} />;
|
||||
}
|
||||
|
||||
const Animate = styled.div`
|
||||
|
||||
@@ -51,12 +51,9 @@ const style = {
|
||||
width: 30,
|
||||
height: 30,
|
||||
};
|
||||
|
||||
const TwitterPicker = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "twitter-picker" */
|
||||
"react-color/lib/components/twitter/Twitter"
|
||||
)
|
||||
() => import("react-color/lib/components/twitter/Twitter")
|
||||
);
|
||||
|
||||
export const icons = {
|
||||
@@ -241,7 +238,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
aria-label={t("Choose icon")}
|
||||
>
|
||||
<Icons>
|
||||
{Object.keys(icons).map((name) => {
|
||||
{Object.keys(icons).map((name, index) => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={name}
|
||||
@@ -249,7 +246,15 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
{...menu}
|
||||
>
|
||||
{(props) => (
|
||||
<IconButton style={style} {...props}>
|
||||
<IconButton
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
"--delay": `${index * 8}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<Icon as={icons[name].component} color={color} size={30} />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -257,7 +262,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
);
|
||||
})}
|
||||
</Icons>
|
||||
<Flex>
|
||||
<Colors>
|
||||
<React.Suspense fallback={<Loading>{t("Loading")}…</Loading>}>
|
||||
<ColorPicker
|
||||
color={color}
|
||||
@@ -266,6 +271,10 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
triangle="hide"
|
||||
styles={{
|
||||
default: {
|
||||
body: {
|
||||
padding: 0,
|
||||
marginRight: -8,
|
||||
},
|
||||
hash: {
|
||||
color: theme.text,
|
||||
background: theme.inputBorder,
|
||||
@@ -279,7 +288,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Flex>
|
||||
</Colors>
|
||||
</ContextMenu>
|
||||
</Wrapper>
|
||||
);
|
||||
@@ -287,6 +296,11 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
|
||||
const Icon = styled.svg`
|
||||
transition: fill 150ms ease-in-out;
|
||||
transition-delay: var(--delay);
|
||||
`;
|
||||
|
||||
const Colors = styled(Flex)`
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
@@ -294,7 +308,7 @@ const Label = styled.label`
|
||||
`;
|
||||
|
||||
const Icons = styled.div`
|
||||
padding: 16px 8px 0 16px;
|
||||
padding: 8px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: 276px;
|
||||
@@ -321,11 +335,7 @@ const Loading = styled(Text)`
|
||||
const ColorPicker = styled(TwitterPicker)`
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
width: auto !important;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: 276px;
|
||||
`};
|
||||
width: 100% !important;
|
||||
`;
|
||||
|
||||
const Wrapper = styled("div")`
|
||||
|
||||
@@ -8,9 +8,13 @@ import useStores from "~/hooks/useStores";
|
||||
import Logger from "~/utils/Logger";
|
||||
|
||||
type Props = {
|
||||
/** The collection to show an icon for */
|
||||
collection: Collection;
|
||||
/** Whether the icon should be the "expanded" graphic when displaying the default collection icon */
|
||||
expanded?: boolean;
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the collection color */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ const Span = styled.span<{ $size: number }>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
width: ${(props) => props.$size}px;
|
||||
height: ${(props) => props.$size}px;
|
||||
text-indent: -0.15em;
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export default function GoogleIcon({
|
||||
size = 24,
|
||||
color = "currentColor",
|
||||
}: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={color}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="-8 -8 48 48"
|
||||
version="1.1"
|
||||
>
|
||||
<path d="M32.6162791,13.9090909 L16.8837209,13.9090909 L16.8837209,20.4772727 L25.9395349,20.4772727 C25.0953488,24.65 21.5651163,27.0454545 16.8837209,27.0454545 C11.3581395,27.0454545 6.90697674,22.5636364 6.90697674,17 C6.90697674,11.4363636 11.3581395,6.95454545 16.8837209,6.95454545 C19.2627907,6.95454545 21.4116279,7.80454545 23.1,9.19545455 L28.0116279,4.25 C25.0186047,1.62272727 21.1813953,0 16.8837209,0 C7.52093023,0 0,7.57272727 0,17 C0,26.4272727 7.52093023,34 16.8837209,34 C25.3255814,34 33,27.8181818 33,17 C33,15.9954545 32.8465116,14.9136364 32.6162791,13.9090909 Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export default function MarkdownIcon({
|
||||
size = 24,
|
||||
color = "currentColor",
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...rest}
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
/* Whether the safe area should be removed and have graphic across full size */
|
||||
cover?: boolean;
|
||||
};
|
||||
|
||||
export default function OutlineIcon({
|
||||
size = 24,
|
||||
cover,
|
||||
color = "currentColor",
|
||||
}: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={color}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={cover ? "2 2 20 20" : "0 0 24 24"}
|
||||
version="1.1"
|
||||
>
|
||||
<path d="M14.6667 20.2155V20.7163C14.6667 21.4253 14.0697 22 13.3333 22C13.1044 22 12.8792 21.9432 12.6797 21.8351L4.67965 17.5028C4.25982 17.2754 4 16.8478 4 16.384V7.61623C4 7.15248 4.25982 6.72478 4.67965 6.49742L12.6797 2.16508C13.3215 1.81751 14.1344 2.03666 14.4954 2.65456C14.6077 2.8467 14.6667 3.06343 14.6667 3.28388V3.78471L15.6169 3.51027C16.3222 3.30655 17.0655 3.69189 17.2771 4.37093C17.3144 4.49059 17.3333 4.61486 17.3333 4.73979V5.26091L18.5013 5.12036C19.232 5.03242 19.8984 5.53141 19.9897 6.23488C19.9966 6.2877 20 6.34088 20 6.3941V17.6061C20 18.3151 19.403 18.8898 18.6667 18.8898C18.6114 18.8898 18.5561 18.8865 18.5013 18.8799L17.3333 18.7393V19.2604C17.3333 19.9694 16.7364 20.5441 16 20.5441C15.8702 20.5441 15.7412 20.5259 15.6169 20.49L14.6667 20.2155ZM14.6667 18.8753L16 19.2604V4.73979L14.6667 5.12488V18.8753ZM17.3333 6.55456V17.4457L18.6667 17.6061V6.3941L17.3333 6.55456ZM5.33333 7.61623V16.384L13.3333 20.7163V3.28388L5.33333 7.61623ZM6.66667 8.47006L8 7.82823V16.172L6.66667 15.5302V8.47006Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export default function SlackIcon({ color = "#4E5C6E" }: Props) {
|
||||
export default function SlackIcon({
|
||||
size = 24,
|
||||
color = "currentColor",
|
||||
}: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={color}
|
||||
width="24px"
|
||||
height="24px"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
>
|
||||
@@ -1,15 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export default function ZapierIcon({ color = "#4E5C6E" }: Props) {
|
||||
export default function ZapierIcon({
|
||||
size = 24,
|
||||
color = "currentColor",
|
||||
}: Props) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color}
|
||||
width="24px"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
>
|
||||
+85
-72
@@ -1,10 +1,10 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
|
||||
border: 0;
|
||||
@@ -32,6 +32,7 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
@@ -98,6 +99,9 @@ export const Outline = styled(Flex)<{
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
background: ${(props) => props.theme.background};
|
||||
|
||||
/* Prevents an issue where input placeholder appears in a selected style when double clicking title bar */
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export const LabelText = styled.div`
|
||||
@@ -115,92 +119,101 @@ export type Props = React.InputHTMLAttributes<
|
||||
flex?: boolean;
|
||||
short?: boolean;
|
||||
margin?: string | number;
|
||||
error?: string;
|
||||
icon?: React.ReactNode;
|
||||
innerRef?: React.Ref<any>;
|
||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
||||
};
|
||||
|
||||
@observer
|
||||
class Input extends React.Component<Props> {
|
||||
input = this.props.innerRef;
|
||||
function Input(
|
||||
props: Props,
|
||||
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
|
||||
) {
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
@observable
|
||||
focused = false;
|
||||
const handleBlur = (ev: React.SyntheticEvent) => {
|
||||
setFocused(false);
|
||||
|
||||
handleBlur = (ev: React.SyntheticEvent) => {
|
||||
this.focused = false;
|
||||
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur(ev);
|
||||
if (props.onBlur) {
|
||||
props.onBlur(ev);
|
||||
}
|
||||
};
|
||||
|
||||
handleFocus = (ev: React.SyntheticEvent) => {
|
||||
this.focused = true;
|
||||
const handleFocus = (ev: React.SyntheticEvent) => {
|
||||
setFocused(true);
|
||||
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(ev);
|
||||
if (props.onFocus) {
|
||||
props.onFocus(ev);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
type = "text",
|
||||
icon,
|
||||
label,
|
||||
margin,
|
||||
className,
|
||||
short,
|
||||
flex,
|
||||
labelHidden,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...rest
|
||||
} = this.props;
|
||||
const {
|
||||
type = "text",
|
||||
icon,
|
||||
label,
|
||||
margin,
|
||||
error,
|
||||
className,
|
||||
short,
|
||||
flex,
|
||||
labelHidden,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<Wrapper className={className} short={short} flex={flex}>
|
||||
<label>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={this.focused} margin={margin}>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{type === "textarea" ? (
|
||||
<RealTextarea
|
||||
ref={this.props.innerRef}
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<RealInput
|
||||
ref={this.props.innerRef}
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
type={type}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</Outline>
|
||||
</label>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Wrapper className={className} short={short} flex={flex}>
|
||||
<label>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={focused} margin={margin}>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{type === "textarea" ? (
|
||||
<RealTextarea
|
||||
ref={ref as React.RefObject<HTMLTextAreaElement>}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
hasIcon={!!icon}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<RealInput
|
||||
ref={ref as React.RefObject<HTMLInputElement>}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
hasIcon={!!icon}
|
||||
type={type}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</Outline>
|
||||
</label>
|
||||
{error && (
|
||||
<TextWrapper>
|
||||
<StyledText type="danger" size="xsmall">
|
||||
{error}
|
||||
</StyledText>
|
||||
</TextWrapper>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const ReactHookWrappedInput = React.forwardRef(
|
||||
(props: Omit<Props, "innerRef">, ref: React.Ref<any>) => {
|
||||
return <Input {...{ ...props, innerRef: ref }} />;
|
||||
}
|
||||
);
|
||||
export const TextWrapper = styled.span`
|
||||
min-height: 16px;
|
||||
display: block;
|
||||
margin-top: -16px;
|
||||
`;
|
||||
|
||||
export default Input;
|
||||
export const StyledText = styled(Text)`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
export default React.forwardRef(Input);
|
||||
|
||||
@@ -42,7 +42,7 @@ function InputSearch(
|
||||
onBlur={handleBlur}
|
||||
margin={0}
|
||||
labelHidden
|
||||
innerRef={ref}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ function InputSearchPage({
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
innerRef={inputRef}
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
placeholder={placeholder || `${t("Search")}…`}
|
||||
value={value}
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import {
|
||||
Position,
|
||||
@@ -42,7 +42,7 @@ export type Props = {
|
||||
icon?: React.ReactNode;
|
||||
options: Option[];
|
||||
note?: React.ReactNode;
|
||||
onChange: (value: string | null) => void;
|
||||
onChange?: (value: string | null) => void;
|
||||
};
|
||||
|
||||
const getOptionFromValue = (options: Option[], value: string | null) => {
|
||||
@@ -78,16 +78,25 @@ const InputSelect = (props: Props) => {
|
||||
disabled,
|
||||
});
|
||||
|
||||
const isMobile = useMobile();
|
||||
const previousValue = React.useRef<string | null>(value);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const selectedRef = React.useRef<HTMLDivElement>(null);
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const minWidth = buttonRef.current?.offsetWidth || 0;
|
||||
const maxHeight = useMenuHeight(
|
||||
const margin = 8;
|
||||
const menuMaxHeight = useMenuHeight(
|
||||
select.visible,
|
||||
select.unstable_disclosureRef
|
||||
select.unstable_disclosureRef,
|
||||
margin
|
||||
);
|
||||
const maxHeight = Math.min(
|
||||
menuMaxHeight ?? 0,
|
||||
window.innerHeight -
|
||||
(buttonRef.current?.getBoundingClientRect().bottom ?? 0) -
|
||||
margin
|
||||
);
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
const selectedValueIndex = options.findIndex(
|
||||
(option) => option.value === select.selectedValue
|
||||
@@ -100,32 +109,21 @@ const InputSelect = (props: Props) => {
|
||||
previousValue.current = select.selectedValue;
|
||||
|
||||
async function load() {
|
||||
await onChange(select.selectedValue);
|
||||
await onChange?.(select.selectedValue);
|
||||
}
|
||||
|
||||
load();
|
||||
}, [onChange, select.selectedValue]);
|
||||
|
||||
// Ensure selected option is visible when opening the input
|
||||
React.useEffect(() => {
|
||||
if (!select.animating && selectedRef.current) {
|
||||
scrollIntoView(selectedRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}, [select.animating]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (select.visible) {
|
||||
const offset = Math.round(
|
||||
(selectedRef.current?.getBoundingClientRect().top || 0) -
|
||||
(contentRef.current?.getBoundingClientRect().top || 0)
|
||||
);
|
||||
setOffset(offset);
|
||||
requestAnimationFrame(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTop = selectedValueIndex * 32;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [select.visible]);
|
||||
}, [select.visible, selectedValueIndex]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -158,17 +156,9 @@ const InputSelect = (props: Props) => {
|
||||
placement: Placement;
|
||||
}
|
||||
) => {
|
||||
if (!props.style) {
|
||||
props.style = {};
|
||||
}
|
||||
const topAnchor = props.style.top === "0";
|
||||
const topAnchor = props.style?.top === "0";
|
||||
const rightAnchor = props.placement === "bottom-end";
|
||||
|
||||
// offset top of select to place selected item under the cursor
|
||||
if (selectedValueIndex !== -1) {
|
||||
props.style.top = `-${offset + 32}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Positioner {...props}>
|
||||
<Background
|
||||
@@ -218,7 +208,7 @@ const InputSelect = (props: Props) => {
|
||||
{note}
|
||||
</Text>
|
||||
)}
|
||||
{select.visible && <Backdrop />}
|
||||
{select.visible && isMobile && <Backdrop />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function InputSelectPermission(
|
||||
value = "";
|
||||
}
|
||||
|
||||
onChange(value);
|
||||
onChange?.(value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
@@ -12,13 +12,12 @@ const Key = styled.kbd<Props>`
|
||||
font-family: ${(props) =>
|
||||
props.symbol ? props.theme.fontFamily : props.theme.fontFamilyMono};
|
||||
line-height: 10px;
|
||||
color: ${(props) => props.theme.almostBlack};
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
vertical-align: middle;
|
||||
background-color: ${(props) => props.theme.smokeLight};
|
||||
border: solid 1px ${(props) => props.theme.slateLight};
|
||||
border-bottom-color: ${(props) => props.theme.slate};
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 -1px 0 ${(props) => props.theme.slate};
|
||||
`;
|
||||
|
||||
export default Key;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { languages, languageOptions } from "@shared/i18n";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import Flex from "~/components/Flex";
|
||||
import NoticeTip from "~/components/NoticeTip";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { detectLanguage } from "~/utils/language";
|
||||
@@ -57,6 +58,7 @@ export default function LanguagePrompt() {
|
||||
|
||||
const option = find(languageOptions, (o) => o.value === language);
|
||||
const optionLabel = option ? option.label : "";
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
return (
|
||||
<NoticeTip>
|
||||
@@ -64,7 +66,7 @@ export default function LanguagePrompt() {
|
||||
<LanguageIcon />
|
||||
<span>
|
||||
<Trans>
|
||||
Outline is available in your language{" "}
|
||||
{{ appName }} is available in your language{" "}
|
||||
{{
|
||||
optionLabel,
|
||||
}}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
import SkipNavContent from "~/components/SkipNavContent";
|
||||
import SkipNavLink from "~/components/SkipNavLink";
|
||||
import env from "~/env";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { MenuProvider } from "~/hooks/useMenuContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -25,7 +26,7 @@ const Layout: React.FC<Props> = ({
|
||||
sidebarRight,
|
||||
}) => {
|
||||
const { ui } = useStores();
|
||||
const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed;
|
||||
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
|
||||
|
||||
useKeyDown(".", (event) => {
|
||||
if (isModKey(event)) {
|
||||
@@ -36,7 +37,7 @@ const Layout: React.FC<Props> = ({
|
||||
return (
|
||||
<Container column auto>
|
||||
<Helmet>
|
||||
<title>{title ? title : "Outline"}</title>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Helmet>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
let openModals = 0;
|
||||
type Props = {
|
||||
@@ -222,7 +223,7 @@ const Back = styled(NudeButton)`
|
||||
position: absolute;
|
||||
display: none;
|
||||
align-items: center;
|
||||
top: 2rem;
|
||||
top: ${Desktop.hasInsetTitlebar() ? "3rem" : "2rem"};
|
||||
left: 2rem;
|
||||
opacity: 0.75;
|
||||
color: ${(props) => props.theme.text};
|
||||
@@ -251,8 +252,9 @@ const Small = styled.div`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
margin: auto auto;
|
||||
width: 30vw;
|
||||
min-width: 350px;
|
||||
max-width: 30vw;
|
||||
max-width: 500px;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -2,6 +2,7 @@ import styled from "styled-components";
|
||||
import ActionButton, {
|
||||
Props as ActionButtonProps,
|
||||
} from "~/components/ActionButton";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
type Props = ActionButtonProps & {
|
||||
width?: number | string;
|
||||
@@ -30,6 +31,7 @@ const NudeButton = styled(ActionButton).attrs((props: Props) => ({
|
||||
cursor: var(--pointer);
|
||||
user-select: none;
|
||||
color: inherit;
|
||||
${undraggableOnDesktop()}
|
||||
`;
|
||||
|
||||
export default NudeButton;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function OutlineLogo({ size = 32, fill = "#333", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M32,57.6 L32,59.1606101 C32,61.3697491 30.209139,63.1606101 28,63.1606101 C27.3130526,63.1606101 26.6376816,62.9836959 26.038955,62.6469122 L2.03895504,49.1469122 C0.779447116,48.438439 -4.3614532e-15,47.1057033 -7.10542736e-15,45.6606101 L-7.10542736e-15,18.3393899 C-7.28240024e-15,16.8942967 0.779447116,15.561561 2.03895504,14.8530878 L26.038955,1.35308779 C27.9643866,0.270032565 30.4032469,0.952913469 31.4863021,2.87834498 C31.8230858,3.47707155 32,4.15244252 32,4.83938994 L32,6.4 L34.8506085,5.54481746 C36.9665799,4.91002604 39.1965137,6.11075966 39.8313051,8.22673106 C39.9431692,8.59961116 40,8.98682435 40,9.3761226 L40,11 L43.5038611,10.5620174 C45.6959408,10.2880074 47.6951015,11.8429102 47.9691115,14.0349899 C47.9896839,14.1995692 48,14.3652688 48,14.5311289 L48,49.4688711 C48,51.6780101 46.209139,53.4688711 44,53.4688711 C43.8341399,53.4688711 43.6684404,53.458555 43.5038611,53.4379826 L40,53 L40,54.6238774 C40,56.8330164 38.209139,58.6238774 36,58.6238774 C35.6107017,58.6238774 35.2234886,58.5670466 34.8506085,58.4551825 L32,57.6 Z M32,53.4238774 L36,54.6238774 L36,9.3761226 L32,10.5761226 L32,53.4238774 Z M40,15.0311289 L40,48.9688711 L44,49.4688711 L44,14.5311289 L40,15.0311289 Z M5.32907052e-15,44.4688711 L5.32907052e-15,19.5311289 L3.55271368e-15,44.4688711 Z M4,18.3393899 L4,45.6606101 L28,59.1606101 L28,4.83938994 L4,18.3393899 Z M8,21 L12,19 L12,45 L8,43 L8,21 Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default OutlineLogo;
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { cdnPath } from "@shared/utils/urls";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
@@ -16,15 +17,16 @@ const PageTitle = ({ title, favicon }: Props) => {
|
||||
return (
|
||||
<Helmet>
|
||||
<title>
|
||||
{team?.name ? `${title} - ${team.name}` : `${title} - Outline`}
|
||||
{team?.name ? `${title} - ${team.name}` : `${title} - ${env.APP_NAME}`}
|
||||
</title>
|
||||
{favicon ? (
|
||||
<link rel="shortcut icon" href={favicon} />
|
||||
<link rel="shortcut icon" href={favicon} key={favicon} />
|
||||
) : (
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/png"
|
||||
href={cdnPath("/favicon-32.png")}
|
||||
key="favicon"
|
||||
href={cdnPath("/images/favicon-32.png")}
|
||||
sizes="32x32"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -46,11 +46,11 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
});
|
||||
|
||||
const StyledPaginatedList = styled(PaginatedList)`
|
||||
padding: 0 8px;
|
||||
padding: 0 12px;
|
||||
`;
|
||||
|
||||
const Heading = styled("h3")`
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
padding: 0 4px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "../stores";
|
||||
import { shallow } from "enzyme";
|
||||
import { TFunction } from "i18next";
|
||||
import * as React from "react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
|
||||
@@ -16,7 +17,7 @@ describe("PaginatedList", () => {
|
||||
const props = {
|
||||
i18n,
|
||||
tReady: true,
|
||||
t: (key: string) => key,
|
||||
t: ((key: string) => key) as TFunction,
|
||||
logout: () => {
|
||||
//
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import withStores from "~/components/withStores";
|
||||
import { dateToHeading } from "~/utils/dates";
|
||||
import { dateToHeading } from "~/utils/date";
|
||||
|
||||
export interface PaginatedItem {
|
||||
id: string;
|
||||
@@ -54,11 +54,14 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
@observable
|
||||
isFetching = false;
|
||||
|
||||
@observable
|
||||
isFetchingInitial = !this.props.items?.length;
|
||||
|
||||
@observable
|
||||
fetchCounter = 0;
|
||||
|
||||
@observable
|
||||
renderCount: number = DEFAULT_PAGINATION_LIMIT;
|
||||
renderCount = 15;
|
||||
|
||||
@observable
|
||||
offset = 0;
|
||||
@@ -85,6 +88,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
this.allowLoadMore = true;
|
||||
this.renderCount = DEFAULT_PAGINATION_LIMIT;
|
||||
this.isFetching = false;
|
||||
this.isFetchingInitial = false;
|
||||
this.isFetchingMore = false;
|
||||
};
|
||||
|
||||
@@ -95,7 +99,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
}
|
||||
this.isFetching = true;
|
||||
const counter = ++this.fetchCounter;
|
||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||
const limit = this.props.options?.limit ?? DEFAULT_PAGINATION_LIMIT;
|
||||
this.error = undefined;
|
||||
|
||||
try {
|
||||
@@ -112,6 +116,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
}
|
||||
|
||||
this.renderCount += limit;
|
||||
this.isFetchingInitial = false;
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
} finally {
|
||||
@@ -159,7 +164,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
const showLoading =
|
||||
this.isFetching &&
|
||||
!this.isFetchingMore &&
|
||||
(!items?.length || this.fetchCounter === 0);
|
||||
(!items?.length || (this.fetchCounter <= 1 && this.isFetchingInitial));
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { GoToIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { DocumentPath } from "~/stores/CollectionsStore";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
type Props = {
|
||||
result: DocumentPath;
|
||||
document?: Document | null | undefined;
|
||||
collection: Collection | null | undefined;
|
||||
onSuccess?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
ref?: (element: React.ElementRef<"div"> | null | undefined) => void;
|
||||
};
|
||||
|
||||
@observer
|
||||
class PathToDocument extends React.Component<Props> {
|
||||
handleClick = async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
const { document, result, onSuccess } = this.props;
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.type === "document") {
|
||||
await document.move(result.collectionId, result.id);
|
||||
} else {
|
||||
await document.move(result.collectionId);
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { result, collection, document, ref, style } = this.props;
|
||||
const Component = document ? ResultWrapperLink : ResultWrapper;
|
||||
if (!result) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
// @ts-expect-error ts-migrate(2604) FIXME: JSX element type 'Component' does not have any con... Remove this comment to see the full error message
|
||||
<Component
|
||||
ref={ref}
|
||||
onClick={this.handleClick}
|
||||
href=""
|
||||
style={style}
|
||||
role="option"
|
||||
selectable
|
||||
>
|
||||
{collection && <CollectionIcon collection={collection} />}
|
||||
|
||||
{result.path
|
||||
.map((doc) => <Title key={doc.id}>{doc.title}</Title>)
|
||||
// @ts-expect-error ts-migrate(2739) FIXME: Type 'Element[]' is missing the following properti... Remove this comment to see the full error message
|
||||
.reduce((prev, curr) => [prev, <StyledGoToIcon />, curr])}
|
||||
{document && (
|
||||
<DocumentTitle>
|
||||
{" "}
|
||||
<StyledGoToIcon /> <Title>{document.title}</Title>
|
||||
</DocumentTitle>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const DocumentTitle = styled(Flex)``;
|
||||
|
||||
const Title = styled.span`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const StyledGoToIcon = styled(GoToIcon)`
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const ResultWrapper = styled.div`
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
user-select: none;
|
||||
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: default;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
|
||||
padding: 8px 4px;
|
||||
|
||||
${DocumentTitle} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
|
||||
${DocumentTitle} {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default PathToDocument;
|
||||
@@ -12,23 +12,11 @@ export type Props = {
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
class PlaceholderText extends React.Component<Props> {
|
||||
width = randomInteger(this.props.minWidth || 75, this.props.maxWidth || 100);
|
||||
function PlaceholderText({ minWidth, maxWidth, ...restProps }: Props) {
|
||||
// We only want to compute the width once so we are storing it inside ref
|
||||
const widthRef = React.useRef(randomInteger(minWidth || 75, maxWidth || 100));
|
||||
|
||||
shouldComponentUpdate() {
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Mask
|
||||
width={this.width}
|
||||
height={this.props.height}
|
||||
delay={this.props.delay}
|
||||
header={this.props.header}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Mask width={widthRef.current} {...restProps} />;
|
||||
}
|
||||
|
||||
const Mask = styled(Flex)<{
|
||||
@@ -51,4 +39,6 @@ const Mask = styled(Flex)<{
|
||||
}
|
||||
`;
|
||||
|
||||
export default PlaceholderText;
|
||||
// We don't want the component to re-render on any props change
|
||||
// So returning true from the custom comparison function to avoid re-render
|
||||
export default React.memo(PlaceholderText, () => true);
|
||||
|
||||
@@ -45,7 +45,8 @@ const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
|
||||
max-height: 50vh;
|
||||
max-height: 75vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
width: ${(props) => props.$width}px;
|
||||
|
||||
@@ -14,6 +14,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import {
|
||||
homePath,
|
||||
draftsPath,
|
||||
@@ -24,9 +25,11 @@ import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ArchiveLink from "./components/ArchiveLink";
|
||||
import Collections from "./components/Collections";
|
||||
import DragPlaceholder from "./components/DragPlaceholder";
|
||||
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
|
||||
import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
import SidebarAction from "./components/SidebarAction";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import Starred from "./components/Starred";
|
||||
import TrashLink from "./components/TrashLink";
|
||||
@@ -56,21 +59,27 @@ function AppSidebar() {
|
||||
|
||||
return (
|
||||
<Sidebar ref={handleSidebarRef}>
|
||||
<HistoryNavigation />
|
||||
{dndArea && (
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
<DragPlaceholder />
|
||||
|
||||
<OrganizationMenu>
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{(props: HeaderButtonProps) => (
|
||||
<HeaderButton
|
||||
{...props}
|
||||
title={team.name}
|
||||
image={
|
||||
<StyledTeamLogo
|
||||
src={team.avatarUrl}
|
||||
width={32}
|
||||
height={32}
|
||||
<TeamLogo
|
||||
model={team}
|
||||
size={Desktop.hasInsetTitlebar() ? 24 : 32}
|
||||
alt={t("Logo")}
|
||||
/>
|
||||
}
|
||||
style={
|
||||
// Move the logo over to align with smaller size
|
||||
Desktop.hasInsetTitlebar() ? { paddingLeft: 8 } : undefined
|
||||
}
|
||||
showDisclosure
|
||||
/>
|
||||
)}
|
||||
@@ -139,11 +148,6 @@ function AppSidebar() {
|
||||
);
|
||||
}
|
||||
|
||||
const StyledTeamLogo = styled(TeamLogo)`
|
||||
margin-right: 4px;
|
||||
background: white;
|
||||
`;
|
||||
|
||||
const Drafts = styled(Text)`
|
||||
margin: 0 4px;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "~/components/Flex";
|
||||
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
children: React.ReactNode;
|
||||
border?: boolean;
|
||||
};
|
||||
|
||||
function Right({ children, border, className }: Props) {
|
||||
const theme = useTheme();
|
||||
const [width, setWidth] = usePersistedState(
|
||||
"rightSidebarWidth",
|
||||
theme.sidebarWidth
|
||||
);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
const width = Math.max(
|
||||
Math.min(window.innerWidth - event.pageX, maxWidth),
|
||||
minWidth
|
||||
);
|
||||
setWidth(width);
|
||||
},
|
||||
[minWidth, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
setWidth(theme.sidebarWidth);
|
||||
}, [setWidth, theme.sidebarWidth]);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (document.activeElement) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = React.useCallback(() => {
|
||||
setResizing(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
}),
|
||||
[width]
|
||||
);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
initial={{
|
||||
width: 0,
|
||||
}}
|
||||
animate={{
|
||||
transition: isResizing
|
||||
? { duration: 0 }
|
||||
: {
|
||||
type: "spring",
|
||||
bounce: 0.2,
|
||||
duration: 0.6,
|
||||
},
|
||||
width,
|
||||
}}
|
||||
exit={{
|
||||
width: 0,
|
||||
}}
|
||||
$border={border}
|
||||
className={className}
|
||||
>
|
||||
<Position style={style} column>
|
||||
{children}
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleReset}
|
||||
dir="right"
|
||||
/>
|
||||
</Position>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const Position = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
`;
|
||||
|
||||
const Sidebar = styled(m.div)<{ $border?: boolean }>`
|
||||
display: none;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background: ${(props) => props.theme.background};
|
||||
width: ${(props) => props.theme.sidebarWidth}px;
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
transition: border-left 100ms ease-in-out;
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default observer(Right);
|
||||
@@ -7,19 +7,21 @@ import { useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import HeaderButton from "./components/HeaderButton";
|
||||
import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import Version from "./components/Version";
|
||||
|
||||
function SettingsSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const configs = useAuthorizedSettingsConfig();
|
||||
const configs = useSettingsConfig();
|
||||
const groupedConfig = groupBy(configs, "group");
|
||||
|
||||
const returnToApp = React.useCallback(() => {
|
||||
@@ -28,11 +30,12 @@ function SettingsSidebar() {
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarButton
|
||||
<HistoryNavigation />
|
||||
<HeaderButton
|
||||
title={t("Return to App")}
|
||||
image={<StyledBackIcon color="currentColor" />}
|
||||
onClick={returnToApp}
|
||||
minHeight={48}
|
||||
minHeight={Desktop.hasInsetTitlebar() ? undefined : 48}
|
||||
/>
|
||||
|
||||
<Flex auto column>
|
||||
|
||||
@@ -1,33 +1,52 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Team from "~/models/Team";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { NavigationNode } from "~/types";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
import HeaderButton from "./components/HeaderButton";
|
||||
import Section from "./components/Section";
|
||||
import DocumentLink from "./components/SharedDocumentLink";
|
||||
|
||||
type Props = {
|
||||
team?: Team;
|
||||
rootNode: NavigationNode;
|
||||
shareId: string;
|
||||
};
|
||||
|
||||
function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
const { ui, documents } = useStores();
|
||||
function SharedSidebar({ rootNode, team, shareId }: Props) {
|
||||
const { ui, documents, auth } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<ScrollContainer flex>
|
||||
{team && (
|
||||
<HeaderButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
|
||||
onClick={() =>
|
||||
history.push(
|
||||
auth.user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ScrollContainer topShadow flex>
|
||||
<TopSection>
|
||||
<SearchPopover shareId={shareId} />
|
||||
</TopSection>
|
||||
<Section>
|
||||
<DocumentLink
|
||||
index={0}
|
||||
depth={0}
|
||||
shareId={shareId}
|
||||
depth={1}
|
||||
node={rootNode}
|
||||
activeDocumentId={ui.activeDocumentId}
|
||||
activeDocument={documents.active}
|
||||
@@ -44,8 +63,11 @@ const ScrollContainer = styled(Scrollable)`
|
||||
|
||||
const TopSection = styled(Section)`
|
||||
// this weird looking && increases the specificity of the style rule
|
||||
&& {
|
||||
&&:first-child {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&& {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -11,10 +11,12 @@ import useMenuContext from "~/hooks/useMenuContext";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import AccountMenu from "~/menus/AccountMenu";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Avatar from "../Avatar";
|
||||
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
|
||||
|
||||
const ANIMATION_MS = 250;
|
||||
@@ -35,7 +37,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const { user } = auth;
|
||||
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = (ui.isEditing || ui.sidebarCollapsed) && !isMenuOpen;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
@@ -170,15 +172,15 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
|
||||
{user && (
|
||||
<AccountMenu>
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{(props: HeaderButtonProps) => (
|
||||
<HeaderButton
|
||||
{...props}
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
image={
|
||||
<StyledAvatar
|
||||
alt={user.name}
|
||||
src={user.avatarUrl}
|
||||
model={user}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
/>
|
||||
@@ -189,9 +191,9 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
)}
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
{ui.sidebarCollapsed && !ui.isEditing && (
|
||||
{ui.sidebarIsClosed && (
|
||||
<Toggle
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={"right"}
|
||||
@@ -199,14 +201,12 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
{!ui.isEditing && (
|
||||
<Toggle
|
||||
style={toggleStyle}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={ui.sidebarCollapsed ? "right" : "left"}
|
||||
aria-label={ui.sidebarCollapsed ? t("Expand") : t("Collapse")}
|
||||
/>
|
||||
)}
|
||||
<Toggle
|
||||
style={toggleStyle}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={ui.sidebarIsClosed ? "right" : "left"}
|
||||
aria-label={ui.sidebarIsClosed ? t("Expand") : t("Collapse")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -251,6 +251,9 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
z-index: ${depths.sidebar};
|
||||
max-width: 70%;
|
||||
min-width: 280px;
|
||||
padding-top: ${Desktop.hasInsetTitlebar() ? 36 : 0}px;
|
||||
${draggableOnDesktop()}
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
${Positioner} {
|
||||
display: none;
|
||||
@@ -265,7 +268,9 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
transform: translateX(${(props: ContainerProps) =>
|
||||
props.$collapsed ? "calc(-100% + 16px)" : 0});
|
||||
props.$collapsed
|
||||
? `calc(-100% + ${Desktop.hasInsetTitlebar() ? 8 : 16}px)`
|
||||
: 0});
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
|
||||
@@ -5,11 +5,12 @@ import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentReparent from "~/scenes/DocumentReparent";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { createDocument } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
@@ -17,7 +18,6 @@ import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import { NavigationNode } from "~/types";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import Relative from "./Relative";
|
||||
@@ -27,7 +27,7 @@ import { useStarredContext } from "./StarredContext";
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
expanded?: boolean;
|
||||
onDisclosureClick: (ev: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
activeDocument: Document | undefined;
|
||||
isDraggingAnyCollection?: boolean;
|
||||
};
|
||||
@@ -62,7 +62,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
// Drop to re-parent document
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject, monitor) => {
|
||||
drop: async (item: DragObject, monitor) => {
|
||||
const { id, collectionId } = item;
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
@@ -81,7 +81,8 @@ const CollectionLink: React.FC<Props> = ({
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission === null &&
|
||||
prevCollection.permission !== collection.permission
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
itemRef.current = item;
|
||||
|
||||
@@ -97,7 +98,11 @@ const CollectionLink: React.FC<Props> = ({
|
||||
),
|
||||
});
|
||||
} else {
|
||||
documents.move(id, collection.id);
|
||||
await documents.move(id, collection.id);
|
||||
|
||||
if (!expanded) {
|
||||
onDisclosureClick();
|
||||
}
|
||||
}
|
||||
},
|
||||
canDrop: () => canUpdate,
|
||||
|
||||
@@ -63,9 +63,8 @@ function CollectionLinkChildren({
|
||||
|
||||
return (
|
||||
<Folder expanded={expanded}>
|
||||
{isDraggingAnyDocument && can.update && (
|
||||
{isDraggingAnyDocument && can.update && manualSort && (
|
||||
<DropCursor
|
||||
disabled={!manualSort}
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
|
||||
@@ -43,15 +43,13 @@ function Collections() {
|
||||
}),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
collections.fetchPage({ limit: 100 });
|
||||
}, [collections]);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
fetch={collections.fetchPage}
|
||||
options={{ limit: 25 }}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.orderedData}
|
||||
loading={<PlaceholderCollections />}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
@@ -18,7 +20,6 @@ import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
@@ -105,8 +106,7 @@ function InnerDocumentLink(
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ev?.preventDefault();
|
||||
setExpanded(!expanded);
|
||||
},
|
||||
[expanded]
|
||||
@@ -137,9 +137,10 @@ function InnerDocumentLink(
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const isMoving = documents.movingDocumentId === node.id;
|
||||
const manualSort = collection?.sort.field === "index";
|
||||
const can = policies.abilities(node.id);
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
const [{ isDragging }, drag, preview] = useDrag({
|
||||
type: "document",
|
||||
item: () => ({
|
||||
...node,
|
||||
@@ -150,16 +151,13 @@ function InnerDocumentLink(
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => {
|
||||
return (
|
||||
!isDraft &&
|
||||
(policies.abilities(node.id).move ||
|
||||
policies.abilities(node.id).archive ||
|
||||
policies.abilities(node.id).delete)
|
||||
);
|
||||
},
|
||||
canDrag: () => can.move || can.archive || can.delete,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
preview(getEmptyImage(), { captureDraggingState: true });
|
||||
}, [preview]);
|
||||
|
||||
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
@@ -174,19 +172,21 @@ function InnerDocumentLink(
|
||||
// Drop to re-parent
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject, monitor) => {
|
||||
drop: async (item: DragObject, monitor) => {
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
documents.move(item.id, collection.id, node.id);
|
||||
await documents.move(item.id, collection.id, node.id);
|
||||
setExpanded(true);
|
||||
},
|
||||
canDrop: (_item, monitor) =>
|
||||
canDrop: (item, monitor) =>
|
||||
!isDraft &&
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem<DragObject>().id),
|
||||
!pathToNode.includes(monitor.getItem<DragObject>().id) &&
|
||||
item.id !== node.id,
|
||||
hover: (_item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
@@ -287,7 +287,6 @@ function InnerDocumentLink(
|
||||
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
|
||||
t("Untitled");
|
||||
|
||||
const can = policies.abilities(node.id);
|
||||
const isExpanded = expanded && !isDragging;
|
||||
const hasChildren = nodeChildren.length > 0;
|
||||
|
||||
@@ -380,12 +379,8 @@ function InnerDocumentLink(
|
||||
</DropToImport>
|
||||
</div>
|
||||
</Draggable>
|
||||
{isDraggingAnyDocument && (
|
||||
<DropCursor
|
||||
disabled={!manualSort}
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
/>
|
||||
{isDraggingAnyDocument && manualSort && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</Relative>
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
@@ -408,7 +403,8 @@ function InnerDocumentLink(
|
||||
}
|
||||
|
||||
const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>`
|
||||
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
|
||||
transition: opacity 250ms ease;
|
||||
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.1 : 1)};
|
||||
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as React from "react";
|
||||
import { useDragLayer, XYCoord } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
const layerStyles: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
pointerEvents: "none",
|
||||
zIndex: 100,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
};
|
||||
|
||||
function getItemStyles(
|
||||
initialOffset: XYCoord | null,
|
||||
currentOffset: XYCoord | null,
|
||||
sidebarWidth: number
|
||||
) {
|
||||
if (!initialOffset || !currentOffset) {
|
||||
return {
|
||||
display: "none",
|
||||
};
|
||||
}
|
||||
const { y } = currentOffset;
|
||||
const x = Math.max(
|
||||
initialOffset.x,
|
||||
Math.min(initialOffset.x + sidebarWidth / 4, currentOffset.x)
|
||||
);
|
||||
|
||||
const transform = `translate(${x}px, ${y}px)`;
|
||||
return {
|
||||
width: sidebarWidth - 24,
|
||||
transform,
|
||||
WebkitTransform: transform,
|
||||
};
|
||||
}
|
||||
|
||||
const DragPlaceholder = () => {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
|
||||
const { isDragging, item, initialOffset, currentOffset } = useDragLayer(
|
||||
(monitor) => ({
|
||||
item: monitor.getItem(),
|
||||
itemType: monitor.getItemType(),
|
||||
initialOffset: monitor.getInitialSourceClientOffset(),
|
||||
currentOffset: monitor.getSourceClientOffset(),
|
||||
isDragging: monitor.isDragging(),
|
||||
})
|
||||
);
|
||||
|
||||
if (!isDragging || !currentOffset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={layerStyles}>
|
||||
<div style={getItemStyles(initialOffset, currentOffset, ui.sidebarWidth)}>
|
||||
<GhostLink
|
||||
icon={item.icon}
|
||||
label={item.title || t("Untitled")}
|
||||
isDraft={item.isDraft}
|
||||
depth={item.depth}
|
||||
active
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GhostLink = styled(SidebarLink)`
|
||||
transition: box-shadow 250ms ease-in-out;
|
||||
box-shadow: rgb(0 0 0 / 30%) 0px 4px 15px;
|
||||
opacity: 0.95;
|
||||
`;
|
||||
|
||||
export default DragPlaceholder;
|
||||
@@ -2,10 +2,12 @@ import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
@@ -62,26 +64,28 @@ function DraggableCollectionLink({
|
||||
},
|
||||
collect: (monitor: DropTargetMonitor<Collection, Collection>) => ({
|
||||
isCollectionDropping: monitor.isOver(),
|
||||
isDraggingAnyCollection: monitor.getItemType() === "collection",
|
||||
isDraggingAnyCollection: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Drag to reorder collection
|
||||
const [{ isCollectionDragging }, dragToReorderCollection] = useDrag({
|
||||
const [{ isDragging }, dragToReorderCollection, preview] = useDrag({
|
||||
type: "collection",
|
||||
item: () => {
|
||||
return {
|
||||
id: collection.id,
|
||||
};
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isCollectionDragging: monitor.isDragging(),
|
||||
item: () => ({
|
||||
id: collection.id,
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
}),
|
||||
canDrag: () => {
|
||||
return can.move;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => can.move,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
preview(getEmptyImage(), { captureDraggingState: false });
|
||||
}, [preview]);
|
||||
|
||||
// If the current collection is active and relevant to the sidebar section we
|
||||
// are in then expand it automatically
|
||||
React.useEffect(() => {
|
||||
@@ -91,18 +95,18 @@ function DraggableCollectionLink({
|
||||
}, [collection.id, ui.activeCollectionId, locationStateStarred]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
const displayChildDocuments = expanded && !isCollectionDragging;
|
||||
const displayChildDocuments = expanded && !isDragging;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Draggable
|
||||
key={collection.id}
|
||||
ref={dragToReorderCollection}
|
||||
$isDragging={isCollectionDragging}
|
||||
$isDragging={isDragging}
|
||||
>
|
||||
<CollectionLink
|
||||
collection={collection}
|
||||
@@ -130,7 +134,8 @@ function DraggableCollectionLink({
|
||||
}
|
||||
|
||||
const Draggable = styled("div")<{ $isDragging: boolean }>`
|
||||
opacity: ${(props) => (props.$isDragging ? 0.5 : 1)};
|
||||
transition: opacity 250ms ease;
|
||||
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
|
||||
pointer-events: ${(props) => (props.$isDragging ? "none" : "auto")};
|
||||
`;
|
||||
|
||||
|
||||
@@ -2,27 +2,18 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
isActiveDrop: boolean;
|
||||
innerRef: React.Ref<HTMLDivElement>;
|
||||
position?: "top";
|
||||
};
|
||||
|
||||
function DropCursor({ isActiveDrop, innerRef, position, disabled }: Props) {
|
||||
return (
|
||||
<Cursor
|
||||
isOver={isActiveDrop}
|
||||
disabled={disabled}
|
||||
ref={innerRef}
|
||||
position={position}
|
||||
/>
|
||||
);
|
||||
function DropCursor({ isActiveDrop, innerRef, position }: Props) {
|
||||
return <Cursor isOver={isActiveDrop} ref={innerRef} position={position} />;
|
||||
}
|
||||
|
||||
// transparent hover zone with a thin visible band vertically centered
|
||||
const Cursor = styled.div<{
|
||||
isOver?: boolean;
|
||||
disabled?: boolean;
|
||||
position?: "top";
|
||||
}>`
|
||||
opacity: ${(props) => (props.isOver ? 1 : 0)};
|
||||
@@ -36,10 +27,7 @@ const Cursor = styled.div<{
|
||||
${(props) => (props.position === "top" ? "top: -7px;" : "bottom: -7px;")}
|
||||
|
||||
::after {
|
||||
background: ${(props) =>
|
||||
props.disabled
|
||||
? props.theme.sidebarActiveBackground
|
||||
: props.theme.slateDark};
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
content: "";
|
||||
|
||||
@@ -46,6 +46,15 @@ function EditableTitle({
|
||||
[originalValue]
|
||||
);
|
||||
|
||||
const stopPropagation = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleFocus = React.useCallback((event) => {
|
||||
event.target.select();
|
||||
}, []);
|
||||
|
||||
const handleSave = React.useCallback(
|
||||
async (ev) => {
|
||||
ev.preventDefault();
|
||||
@@ -85,9 +94,11 @@ function EditableTitle({
|
||||
dir="auto"
|
||||
type="text"
|
||||
value={value}
|
||||
onClick={stopPropagation}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onBlur={handleSave}
|
||||
onFocus={handleFocus}
|
||||
autoFocus
|
||||
{...rest}
|
||||
/>
|
||||
@@ -102,11 +113,11 @@ function EditableTitle({
|
||||
}
|
||||
|
||||
const Input = styled.input`
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.background};
|
||||
width: calc(100% + 12px);
|
||||
border-radius: 3px;
|
||||
border: 1px solid ${(props) => props.theme.inputBorderFocused};
|
||||
border: 0;
|
||||
padding: 5px 6px;
|
||||
margin: -4px;
|
||||
height: 32px;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
/** Unique header id – if passed the header will become toggleable */
|
||||
@@ -76,6 +77,7 @@ const Button = styled.button`
|
||||
border-radius: 4px;
|
||||
-webkit-appearance: none;
|
||||
transition: all 100ms ease;
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&:not(:disabled):hover,
|
||||
&:not(:disabled):active {
|
||||
|
||||
+8
-5
@@ -2,8 +2,9 @@ import { ExpandedIcon, MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
export type SidebarButtonProps = {
|
||||
export type HeaderButtonProps = React.ComponentProps<typeof Wrapper> & {
|
||||
title: React.ReactNode;
|
||||
image: React.ReactNode;
|
||||
minHeight?: number;
|
||||
@@ -13,7 +14,7 @@ export type SidebarButtonProps = {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
|
||||
const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
|
||||
(
|
||||
{
|
||||
showDisclosure,
|
||||
@@ -22,10 +23,11 @@ const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
|
||||
title,
|
||||
minHeight = 0,
|
||||
...rest
|
||||
}: SidebarButtonProps,
|
||||
}: HeaderButtonProps,
|
||||
ref
|
||||
) => (
|
||||
<Wrapper
|
||||
role="button"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
as="button"
|
||||
@@ -33,7 +35,7 @@ const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
|
||||
{...rest}
|
||||
ref={ref}
|
||||
>
|
||||
<Title gap={4} align="center">
|
||||
<Title gap={6} align="center">
|
||||
{image}
|
||||
{title}
|
||||
</Title>
|
||||
@@ -69,6 +71,7 @@ const Wrapper = styled(Flex)<{ minHeight: number }>`
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
cursor: var(--pointer);
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
@@ -79,4 +82,4 @@ const Wrapper = styled(Flex)<{ minHeight: number }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default SidebarButton;
|
||||
export default HeaderButton;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ArrowIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { isMac } from "~/utils/browser";
|
||||
|
||||
function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
|
||||
const { t } = useTranslation();
|
||||
const [back, setBack] = React.useState(false);
|
||||
const [forward, setForward] = React.useState(false);
|
||||
|
||||
useKeyDown(
|
||||
(event) =>
|
||||
isMac()
|
||||
? event.metaKey && event.key === "["
|
||||
: event.altKey && event.key === "ArrowLeft",
|
||||
() => {
|
||||
setBack(true);
|
||||
setTimeout(() => setBack(false), 100);
|
||||
}
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(event) =>
|
||||
isMac()
|
||||
? event.metaKey && event.key === "]"
|
||||
: event.altKey && event.key === "ArrowRight",
|
||||
() => {
|
||||
setForward(true);
|
||||
setTimeout(() => setForward(false), 100);
|
||||
}
|
||||
);
|
||||
|
||||
if (!Desktop.isMacApp()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Navigation gap={4} {...props}>
|
||||
<Tooltip tooltip={t("Go back")} delay={500}>
|
||||
<NudeButton onClick={() => Desktop.bridge.goBack()}>
|
||||
<Back color="currentColor" $active={back} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
<Tooltip tooltip={t("Go forward")} delay={500}>
|
||||
<NudeButton onClick={() => Desktop.bridge.goForward()}>
|
||||
<Forward color="currentColor" $active={forward} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Navigation>
|
||||
);
|
||||
}
|
||||
|
||||
const Navigation = styled(Flex)`
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 14px;
|
||||
`;
|
||||
|
||||
const Forward = styled(ArrowIcon)<{ $active: boolean }>`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
opacity: ${(props) => (props.$active ? 1 : 0.5)};
|
||||
transition: color 100ms ease-in-out;
|
||||
|
||||
&:active,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Back = styled(Forward)`
|
||||
transform: rotate(180deg);
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default HistoryNavigation;
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import history from "~/utils/history";
|
||||
|
||||
const resolveToLocation = (
|
||||
to: LocationDescriptor | ((location: Location) => LocationDescriptor),
|
||||
@@ -35,14 +36,16 @@ export type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
activeStyle?: React.CSSProperties;
|
||||
scrollIntoViewIfNeeded?: boolean;
|
||||
exact?: boolean;
|
||||
replace?: boolean;
|
||||
isActive?: (match: match | null, location: Location) => boolean;
|
||||
location?: Location;
|
||||
strict?: boolean;
|
||||
to: LocationDescriptor;
|
||||
onBeforeClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A <Link> wrapper that knows if it's "active" or not.
|
||||
* A <Link> wrapper that clicks extra fast and knows if it's "active" or not.
|
||||
*/
|
||||
const NavLink = ({
|
||||
"aria-current": ariaCurrent = "page",
|
||||
@@ -53,13 +56,19 @@ const NavLink = ({
|
||||
isActive: isActiveProp,
|
||||
location: locationProp,
|
||||
strict,
|
||||
replace,
|
||||
style: styleProp,
|
||||
scrollIntoViewIfNeeded,
|
||||
onClick,
|
||||
onBeforeClick,
|
||||
to,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const linkRef = React.useRef(null);
|
||||
const context = React.useContext(RouterContext);
|
||||
const [preActive, setPreActive] = React.useState<boolean | undefined>(
|
||||
undefined
|
||||
);
|
||||
const currentLocation = locationProp || context.location;
|
||||
const toLocation = normalizeToLocation(
|
||||
resolveToLocation(to, currentLocation),
|
||||
@@ -67,25 +76,24 @@ const NavLink = ({
|
||||
);
|
||||
const { pathname: path } = toLocation;
|
||||
|
||||
// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
|
||||
const escapedPath = path?.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
|
||||
const match = escapedPath
|
||||
const match = path
|
||||
? matchPath(currentLocation.pathname, {
|
||||
path: escapedPath,
|
||||
// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
|
||||
path: path.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1"),
|
||||
exact,
|
||||
strict,
|
||||
})
|
||||
: null;
|
||||
|
||||
const isActive = !!(isActiveProp
|
||||
? isActiveProp(match, currentLocation)
|
||||
: match);
|
||||
const isActive =
|
||||
preActive ??
|
||||
!!(isActiveProp ? isActiveProp(match, currentLocation) : match);
|
||||
const className = isActive
|
||||
? joinClassnames(classNameProp, activeClassName)
|
||||
: classNameProp;
|
||||
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
|
||||
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) {
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
@@ -94,15 +102,73 @@ const NavLink = ({
|
||||
}
|
||||
}, [linkRef, scrollIntoViewIfNeeded, isActive]);
|
||||
|
||||
const props = {
|
||||
"aria-current": (isActive && ariaCurrent) || undefined,
|
||||
className,
|
||||
style,
|
||||
to: toLocation,
|
||||
...rest,
|
||||
};
|
||||
const shouldFastClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>): boolean => {
|
||||
return (
|
||||
event.button === 0 && // Only intercept left clicks
|
||||
!event.defaultPrevented &&
|
||||
!rest.target &&
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey
|
||||
);
|
||||
},
|
||||
[rest.target]
|
||||
);
|
||||
|
||||
return <Link ref={linkRef} {...props} />;
|
||||
const navigateTo = React.useCallback(() => {
|
||||
if (replace) {
|
||||
history.replace(to);
|
||||
} else {
|
||||
history.push(to);
|
||||
}
|
||||
}, [to, replace]);
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(event);
|
||||
|
||||
if (shouldFastClick(event)) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
|
||||
setPreActive(true);
|
||||
|
||||
// Wait a frame until following the link
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(navigateTo);
|
||||
event.currentTarget?.blur();
|
||||
});
|
||||
}
|
||||
},
|
||||
[onClick, navigateTo, shouldFastClick]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPreActive(undefined);
|
||||
}, [currentLocation]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={isActive ? "active" : "inactive"}
|
||||
ref={linkRef}
|
||||
//onMouseDown={handleClick}
|
||||
onKeyDown={(event) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
}}
|
||||
onClick={handleClick}
|
||||
aria-current={(isActive && ariaCurrent) || undefined}
|
||||
className={className}
|
||||
style={style}
|
||||
to={toLocation}
|
||||
replace={replace}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavLink;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import styled from "styled-components";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
const ResizeBorder = styled.div`
|
||||
const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -1px;
|
||||
right: ${(props) => (props.dir !== "right" ? "-1px" : "auto")};
|
||||
left: ${(props) => (props.dir === "right" ? "-1px" : "auto")};
|
||||
width: 2px;
|
||||
cursor: col-resize;
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&:hover {
|
||||
transition-delay: 500ms;
|
||||
@@ -21,6 +24,7 @@ const ResizeBorder = styled.div`
|
||||
bottom: 0;
|
||||
right: -4px;
|
||||
width: 10px;
|
||||
${undraggableOnDesktop()}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import Disclosure from "./Disclosure";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
@@ -100,7 +100,7 @@ function DocumentLink(
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && (
|
||||
{hasChildDocuments && depth !== 0 && (
|
||||
<Disclosure expanded={expanded} onClick={handleDisclosureClick} />
|
||||
)}
|
||||
{title}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme, css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import EventBoundary from "~/components/EventBoundary";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import Disclosure from "./Disclosure";
|
||||
import NavLink, { Props as NavLinkProps } from "./NavLink";
|
||||
|
||||
@@ -181,6 +182,7 @@ const Link = styled(NavLink)<{
|
||||
font-size: 16px;
|
||||
cursor: var(--pointer);
|
||||
overflow: hidden;
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
${(props) =>
|
||||
props.$disabled &&
|
||||
@@ -192,8 +194,17 @@ const Link = styled(NavLink)<{
|
||||
${(props) =>
|
||||
props.$isDraft &&
|
||||
css`
|
||||
padding: 4px 14px;
|
||||
border: 1px dashed ${props.theme.sidebarDraftBorder};
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
border: 1.5px dashed ${props.theme.sidebarDraftBorder};
|
||||
}
|
||||
`}
|
||||
|
||||
svg {
|
||||
|
||||
@@ -45,7 +45,7 @@ function Starred() {
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchResults();
|
||||
}, [fetchResults]);
|
||||
}, []);
|
||||
|
||||
const handleShowMore = async () => {
|
||||
await fetchResults(displayedStarsCount);
|
||||
@@ -55,8 +55,10 @@ function Starred() {
|
||||
// Drop to reorder document
|
||||
const [{ isOverReorder, isDraggingAnyStar }, dropToReorder] = useDrop({
|
||||
accept: "star",
|
||||
drop: async (item: Star) => {
|
||||
item?.save({ index: fractionalIndex(null, stars.orderedData[0].index) });
|
||||
drop: async (item: { star: Star }) => {
|
||||
item.star.save({
|
||||
index: fractionalIndex(null, stars.orderedData[0].index),
|
||||
});
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
|
||||
@@ -5,12 +5,14 @@ import { StarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import Star from "~/models/Star";
|
||||
import EmojiIcon from "~/components/EmojiIcon";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
@@ -33,8 +35,45 @@ function useLocationStateStarred() {
|
||||
return location.state?.starred;
|
||||
}
|
||||
|
||||
function StarredLink({ star }: Props) {
|
||||
function useLabelAndIcon({ documentId, collectionId }: Star) {
|
||||
const { collections, documents } = useStores();
|
||||
const theme = useTheme();
|
||||
|
||||
if (documentId) {
|
||||
const document = documents.get(documentId);
|
||||
if (document) {
|
||||
const { emoji } = parseTitle(document?.title);
|
||||
|
||||
return {
|
||||
label: emoji
|
||||
? document.title.replace(emoji, "")
|
||||
: document.titleWithDefault,
|
||||
icon: emoji ? (
|
||||
<EmojiIcon emoji={emoji} />
|
||||
) : (
|
||||
<StarredIcon color={theme.yellow} />
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
const collection = collections.get(collectionId);
|
||||
if (collection) {
|
||||
return {
|
||||
label: collection.name,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: "",
|
||||
icon: <StarredIcon color={theme.yellow} />,
|
||||
};
|
||||
}
|
||||
|
||||
function StarredLink({ star }: Props) {
|
||||
const { ui, collections, documents } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId, collectionId } = star;
|
||||
@@ -69,23 +108,33 @@ function StarredLink({ star }: Props) {
|
||||
[]
|
||||
);
|
||||
|
||||
const { label, icon } = useLabelAndIcon(star);
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
const [{ isDragging }, drag, preview] = useDrag({
|
||||
type: "star",
|
||||
item: () => star,
|
||||
item: () => ({
|
||||
star,
|
||||
title: label,
|
||||
icon,
|
||||
}),
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => true,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
preview(getEmptyImage(), { captureDraggingState: true });
|
||||
}, [preview]);
|
||||
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder, isDraggingAny }, dropToReorder] = useDrop({
|
||||
accept: "star",
|
||||
drop: (item: Star) => {
|
||||
drop: (item: { star: Star }) => {
|
||||
const next = star?.next();
|
||||
|
||||
item?.save({
|
||||
item.star.save({
|
||||
index: fractionalIndex(star?.index || null, next?.index || null),
|
||||
});
|
||||
},
|
||||
@@ -104,10 +153,6 @@ function StarredLink({ star }: Props) {
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const { emoji } = parseTitle(document.title);
|
||||
const label = emoji
|
||||
? document.title.replace(emoji, "")
|
||||
: document.titleWithDefault;
|
||||
const childDocuments = collection
|
||||
? collection.getDocumentChildren(documentId)
|
||||
: [];
|
||||
@@ -124,13 +169,7 @@ function StarredLink({ star }: Props) {
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
icon={
|
||||
emoji ? (
|
||||
<EmojiIcon emoji={emoji} />
|
||||
) : (
|
||||
<StarredIcon color={theme.yellow} />
|
||||
)
|
||||
}
|
||||
icon={icon}
|
||||
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||
!!match && location.state?.starred === true
|
||||
}
|
||||
@@ -202,7 +241,8 @@ function StarredLink({ star }: Props) {
|
||||
|
||||
const Draggable = styled.div<{ $isDragging?: boolean }>`
|
||||
position: relative;
|
||||
opacity: ${(props) => (props.$isDragging ? 0.5 : 1)};
|
||||
transition: opacity 250ms ease;
|
||||
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
|
||||
`;
|
||||
|
||||
export default observer(StarredLink);
|
||||
|
||||
@@ -17,10 +17,19 @@ export default function Version() {
|
||||
const releases = await res.json();
|
||||
|
||||
if (Array.isArray(releases)) {
|
||||
const computedReleasesBehind = releases
|
||||
const everyNewRelease = releases
|
||||
.map((release) => release.tag_name)
|
||||
.findIndex((tagName) => tagName === `v${version}`);
|
||||
|
||||
const onlyFullNewRelease = releases
|
||||
.filter((release) => !release.prerelease)
|
||||
.map((release) => release.tag_name)
|
||||
.findIndex((tagName) => tagName === `v${version}`);
|
||||
|
||||
const computedReleasesBehind = version.includes("pre")
|
||||
? everyNewRelease
|
||||
: onlyFullNewRelease;
|
||||
|
||||
if (computedReleasesBehind >= 0) {
|
||||
setReleasesBehind(computedReleasesBehind);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user