mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c6475f776 | |||
| 0383b8749d | |||
| 670bd862f2 | |||
| 41a18a4e85 | |||
| 6c069f658b |
@@ -14,6 +14,7 @@
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"lodash",
|
||||
"styled-components",
|
||||
[
|
||||
"@babel/plugin-proposal-decorators",
|
||||
|
||||
@@ -3,7 +3,7 @@ version: 2.1
|
||||
defaults: &defaults
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: cimg/node:18.12
|
||||
- image: cimg/node:14.19
|
||||
- image: cimg/redis:5.0
|
||||
- image: cimg/postgres:14.2
|
||||
environment:
|
||||
@@ -20,7 +20,6 @@ defaults: &defaults
|
||||
SMTP_FROM_EMAIL: hello@example.com
|
||||
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
|
||||
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
|
||||
NODE_OPTIONS: --max-old-space-size=8000
|
||||
|
||||
executors:
|
||||
docker-publisher:
|
||||
@@ -94,18 +93,13 @@ jobs:
|
||||
command: yarn test:server --forceExit
|
||||
bundle-size:
|
||||
<<: *defaults
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: build-vite
|
||||
command: yarn vite:build
|
||||
- run:
|
||||
name: Send bundle stats to RelativeCI
|
||||
command: npx relative-ci-agent
|
||||
name: build-webpack
|
||||
command: yarn build:webpack
|
||||
build-image:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
|
||||
+4
-12
@@ -30,7 +30,7 @@ REDIS_URL=redis://localhost:6379
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
URL=https://app.outline.dev:3000
|
||||
URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# See [documentation](docs/SERVICES.md) on running a separate collaboration
|
||||
@@ -99,7 +99,7 @@ OIDC_USERINFO_URI=
|
||||
OIDC_USERNAME_CLAIM=preferred_username
|
||||
|
||||
# Display name for OIDC authentication
|
||||
OIDC_DISPLAY_NAME=OpenID Connect
|
||||
OIDC_DISPLAY_NAME=OpenID
|
||||
|
||||
# Space separated auth scopes.
|
||||
OIDC_SCOPES=openid profile email
|
||||
@@ -139,10 +139,6 @@ MAXIMUM_IMPORT_SIZE=5120000
|
||||
# requests and this ends up being duplicative
|
||||
DEBUG=http
|
||||
|
||||
# Configure lowest severity level for server logs. Should be one of
|
||||
# error, warn, info, http, verbose, debug and silly
|
||||
LOG_LEVEL=info
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed, some more details
|
||||
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
||||
@@ -166,8 +162,8 @@ SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=hello@example.com
|
||||
SMTP_REPLY_EMAIL=hello@example.com
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_REPLY_EMAIL=
|
||||
SMTP_TLS_CIPHERS=
|
||||
SMTP_SECURE=true
|
||||
|
||||
@@ -181,7 +177,3 @@ RATE_LIMITER_ENABLED=true
|
||||
# Configure default throttling parameters for rate limiter
|
||||
RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
# Iframely API config
|
||||
IFRAMELY_URL=
|
||||
IFRAMELY_API_KEY=
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"extraFileExtensions": [".json"],
|
||||
"project": "./tsconfig.json",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
@@ -26,25 +25,10 @@
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
"curly": 2,
|
||||
"no-console": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"spaced-comment": "error",
|
||||
"object-shorthand": "error",
|
||||
"no-mixed-operators": "off",
|
||||
"no-useless-escape": "off",
|
||||
"es/no-regexp-lookbehind-assertions": "error",
|
||||
"react/self-closing-comp": ["error", {
|
||||
"component": true,
|
||||
"html": true
|
||||
}],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{
|
||||
"checksVoidReturn": false
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature request
|
||||
url: https://github.com/outline/outline/discussions/new?category=ideas
|
||||
url: https://github.com/outline/outline/discussions/new
|
||||
about: Request a feature to be added to the project
|
||||
- name: Self hosting questions
|
||||
url: https://github.com/outline/outline/discussions/new?category=self-hosting
|
||||
url: https://github.com/outline/outline/discussions/new
|
||||
about: Ask questions and discuss running Outline with community members
|
||||
|
||||
@@ -7,9 +7,5 @@ version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
open-pull-requests-limit: 5
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ fakes3/*
|
||||
.idea
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
*.cert
|
||||
+1
-2
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"workerIdleMemoryLimit": "0.75",
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
|
||||
"roots": ["<rootDir>/server"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
|
||||
+1
-5
@@ -5,11 +5,7 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
# ---
|
||||
FROM node:18-alpine AS runner
|
||||
|
||||
RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
FROM node:16.14.2-alpine3.15 AS runner
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
+1
-2
@@ -1,10 +1,9 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:18-alpine AS deps
|
||||
FROM node:16.14.2-alpine3.15 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
COPY ./patches ./patches
|
||||
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
up:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn install-local-ssl
|
||||
yarn install --pure-lockfile
|
||||
yarn sequelize db:migrate
|
||||
yarn dev:watch
|
||||
|
||||
build:
|
||||
|
||||
@@ -7,26 +7,26 @@
|
||||
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
|
||||
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
|
||||
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
|
||||
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
|
||||
</p>
|
||||
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com).
|
||||
|
||||
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
|
||||
|
||||
# Installation
|
||||
|
||||
Please see the [documentation](https://docs.getoutline.com/s/hosting/) for running your own copy of Outline in a production configuration.
|
||||
Please see the [documentation](https://app.getoutline.com/share/770a97da-13e5-401e-9f8a-37949c19f97e/) for running your own copy of Outline in a production configuration.
|
||||
|
||||
If you have questions or improvements for the docs please create a thread in [GitHub discussions](https://github.com/outline/outline/discussions).
|
||||
|
||||
# Development
|
||||
|
||||
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
|
||||
There is a short guide for [setting up a development environment](https://app.getoutline.com/share/770a97da-13e5-401e-9f8a-37949c19f97e/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
"description": "Open source wiki and knowledge base for growing teams",
|
||||
"website": "https://www.getoutline.com/",
|
||||
"repository": "https://github.com/outline/outline",
|
||||
"keywords": ["wiki", "team", "node", "markdown", "slack"],
|
||||
"keywords": [
|
||||
"wiki",
|
||||
"team",
|
||||
"node",
|
||||
"markdown",
|
||||
"slack"
|
||||
],
|
||||
"success_url": "/",
|
||||
"formation": {
|
||||
"web": {
|
||||
@@ -88,7 +94,7 @@
|
||||
},
|
||||
"OIDC_DISPLAY_NAME": {
|
||||
"description": "Display name for OIDC authentication",
|
||||
"value": "OpenID Connect",
|
||||
"value": "OpenID",
|
||||
"required": false
|
||||
},
|
||||
"OIDC_SCOPES": {
|
||||
@@ -182,7 +188,7 @@
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ANALYTICS_ID": {
|
||||
"description": "G-xxxx (optional)",
|
||||
"description": "UA-xxxx (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SENTRY_DSN": {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
"plugins": [
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
StarredIcon,
|
||||
TrashIcon,
|
||||
UnstarredIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -13,15 +12,14 @@ import Collection from "~/models/Collection";
|
||||
import CollectionEdit from "~/scenes/CollectionEdit";
|
||||
import CollectionNew from "~/scenes/CollectionNew";
|
||||
import CollectionPermissions from "~/scenes/CollectionPermissions";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
<DynamicCollectionIcon collection={collection} />
|
||||
);
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => {
|
||||
return <DynamicCollectionIcon collection={collection} />;
|
||||
};
|
||||
|
||||
export const openCollection = createAction({
|
||||
name: ({ t }) => t("Open collection"),
|
||||
@@ -124,13 +122,13 @@ export const starCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
await collection?.star();
|
||||
collection?.star();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -150,47 +148,13 @@ export const unstarCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
await collection?.unstar();
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createAction({
|
||||
name: ({ t }) => t("Delete"),
|
||||
analyticsName: "Delete collection",
|
||||
section: CollectionSection,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
perform: ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
isCentered: true,
|
||||
title: t("Delete collection"),
|
||||
content: (
|
||||
<CollectionDeleteDialog
|
||||
collection={collection}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
collection?.unstar();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -199,5 +163,4 @@ export const rootCollectionActions = [
|
||||
createCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
deleteCollection,
|
||||
];
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createAction } from "~/actions";
|
||||
import { DeveloperSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
@@ -36,27 +35,16 @@ export const createTestUsers = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleDebugLogging = createAction({
|
||||
name: ({ t }) => t("Toggle debug logging"),
|
||||
icon: <ToolsIcon />,
|
||||
section: DeveloperSection,
|
||||
perform: async ({ t }) => {
|
||||
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
|
||||
stores.toasts.showToast(
|
||||
Logger.debugLoggingEnabled
|
||||
? t("Debug logging enabled")
|
||||
: t("Debug logging disabled")
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const developer = createAction({
|
||||
name: ({ t }) => t("Development"),
|
||||
name: ({ t }) => t("Developer"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DeveloperSection,
|
||||
children: [clearIndexedDB, toggleDebugLogging, createTestUsers],
|
||||
visible: ({ event }) =>
|
||||
env.ENVIRONMENT === "development" ||
|
||||
(event instanceof KeyboardEvent && event.altKey),
|
||||
children: [clearIndexedDB, createTestUsers],
|
||||
});
|
||||
|
||||
export const rootDeveloperActions = [developer];
|
||||
|
||||
@@ -22,10 +22,9 @@ import {
|
||||
LightBulbIcon,
|
||||
UnpublishIcon,
|
||||
PublishIcon,
|
||||
CommentIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { ExportContentType, TeamPreference } from "@shared/types";
|
||||
import { ExportContentType } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
@@ -37,8 +36,8 @@ import { DocumentSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentInsightsPath,
|
||||
documentHistoryPath,
|
||||
documentInsightsUrl,
|
||||
documentHistoryUrl,
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
searchPath,
|
||||
@@ -61,11 +60,8 @@ export const openDocument = createAction({
|
||||
// cache if the document is renamed
|
||||
id: path.url,
|
||||
name: path.title,
|
||||
icon: function _Icon() {
|
||||
return stores.documents.get(path.id)?.isStarred ? (
|
||||
<StarredIcon />
|
||||
) : null;
|
||||
},
|
||||
icon: () =>
|
||||
stores.documents.get(path.id)?.isStarred ? <StarredIcon /> : null,
|
||||
section: DocumentSection,
|
||||
perform: () => history.push(path.url),
|
||||
}));
|
||||
@@ -101,13 +97,13 @@ export const starDocument = createAction({
|
||||
!document?.isStarred && stores.policies.abilities(activeDocumentId).star
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.star();
|
||||
document?.star();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -127,13 +123,13 @@ export const unstarDocument = createAction({
|
||||
stores.policies.abilities(activeDocumentId).unstar
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.unstar();
|
||||
document?.unstar();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -162,7 +158,7 @@ export const publishDocument = createAction({
|
||||
}
|
||||
|
||||
if (document?.collectionId) {
|
||||
await document.save(undefined, {
|
||||
await document.save({
|
||||
publish: true,
|
||||
});
|
||||
stores.toasts.showToast(t("Document published"), {
|
||||
@@ -189,14 +185,14 @@ export const unpublishDocument = createAction({
|
||||
}
|
||||
return stores.policies.abilities(activeDocumentId).unpublish;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
await document?.unpublish();
|
||||
document?.unpublish();
|
||||
|
||||
stores.toasts.showToast(t("Document unpublished"), {
|
||||
type: "success",
|
||||
@@ -221,14 +217,14 @@ export const subscribeDocument = createAction({
|
||||
stores.policies.abilities(activeDocumentId).subscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
await document?.subscribe();
|
||||
document?.subscribe();
|
||||
|
||||
stores.toasts.showToast(t("Subscribed to document notifications"), {
|
||||
type: "success",
|
||||
@@ -253,14 +249,14 @@ export const unsubscribeDocument = createAction({
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
perform: ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
if (!activeDocumentId || !currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
await document?.unsubscribe(currentUserId);
|
||||
document?.unsubscribe(currentUserId);
|
||||
|
||||
stores.toasts.showToast(t("Unsubscribed from document notifications"), {
|
||||
type: "success",
|
||||
@@ -277,13 +273,13 @@ export const downloadDocumentAsHTML = createAction({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.download(ExportContentType.Html);
|
||||
document?.download(ExportContentType.Html);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -324,13 +320,13 @@ export const downloadDocumentAsMarkdown = createAction({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.download(ExportContentType.Markdown);
|
||||
document?.download(ExportContentType.Markdown);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -407,19 +403,13 @@ export const pinDocumentToCollection = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.pin(document.collectionId);
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.pin(document.collectionId);
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection || !location.pathname.startsWith(collection?.url)) {
|
||||
stores.toasts.showToast(t("Pinned to collection"));
|
||||
}
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
if (!collection || !location.pathname.startsWith(collection?.url)) {
|
||||
stores.toasts.showToast(t("Pinned to collection"));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -452,16 +442,10 @@ export const pinDocumentToHome = createAction({
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
try {
|
||||
await document?.pin();
|
||||
await document?.pin();
|
||||
|
||||
if (location.pathname !== homePath()) {
|
||||
stores.toasts.showToast(t("Pinned to team home"));
|
||||
}
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
if (location.pathname !== homePath()) {
|
||||
stores.toasts.showToast(t("Pinned to team home"));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -482,7 +466,7 @@ export const printDocument = createAction({
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||
perform: async () => {
|
||||
queueMicrotask(window.print);
|
||||
window.print();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -724,29 +708,6 @@ export const permanentlyDeleteDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentComments = createAction({
|
||||
name: ({ t }) => t("Comments"),
|
||||
analyticsName: "Open comments",
|
||||
section: DocumentSection,
|
||||
icon: <CommentIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return (
|
||||
!!activeDocumentId &&
|
||||
can.read &&
|
||||
!can.restore &&
|
||||
!!stores.auth.team?.getPreference(TeamPreference.Commenting)
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.toggleComments(activeDocumentId);
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentHistory = createAction({
|
||||
name: ({ t }) => t("History"),
|
||||
analyticsName: "Open document history",
|
||||
@@ -764,7 +725,7 @@ export const openDocumentHistory = createAction({
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
history.push(documentHistoryPath(document));
|
||||
history.push(documentHistoryUrl(document));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -785,7 +746,7 @@ export const openDocumentInsights = createAction({
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
history.push(documentInsightsPath(document));
|
||||
history.push(documentInsightsUrl(document));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -810,7 +771,6 @@ export const rootDocumentActions = [
|
||||
printDocument,
|
||||
pinDocumentToCollection,
|
||||
pinDocumentToHome,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
];
|
||||
|
||||
@@ -30,13 +30,15 @@ import { isMac } from "~/utils/browser";
|
||||
import history from "~/utils/history";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
profileSettingsPath,
|
||||
accountPreferencesPath,
|
||||
homePath,
|
||||
searchPath,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
archivePath,
|
||||
trashPath,
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const navigateToHome = createAction({
|
||||
@@ -103,7 +105,7 @@ export const navigateToSettings = createAction({
|
||||
icon: <SettingsIcon />,
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
perform: () => history.push(settingsPath("details")),
|
||||
perform: () => history.push(organizationSettingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
@@ -112,16 +114,7 @@ export const navigateToProfileSettings = createAction({
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <ProfileIcon />,
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createAction({
|
||||
name: ({ t }) => t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
perform: () => history.push(settingsPath("notifications")),
|
||||
perform: () => history.push(profileSettingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToAccountPreferences = createAction({
|
||||
@@ -130,7 +123,7 @@ export const navigateToAccountPreferences = createAction({
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <SettingsIcon />,
|
||||
perform: () => history.push(settingsPath("preferences")),
|
||||
perform: () => history.push(accountPreferencesPath()),
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createAction({
|
||||
@@ -142,14 +135,6 @@ export const openAPIDocumentation = createAction({
|
||||
perform: () => window.open(developersUrl()),
|
||||
});
|
||||
|
||||
export const toggleSidebar = createAction({
|
||||
name: ({ t }) => t("Toggle sidebar"),
|
||||
analyticsName: "Toggle sidebar",
|
||||
keywords: "hide show navigation",
|
||||
section: NavigationSection,
|
||||
perform: ({ stores }) => stores.ui.toggleCollapsedSidebar(),
|
||||
});
|
||||
|
||||
export const openFeedbackUrl = createAction({
|
||||
name: ({ t }) => t("Send us feedback"),
|
||||
analyticsName: "Open feedback",
|
||||
@@ -225,6 +210,5 @@ export const rootNavigationActions = [
|
||||
openBugReportUrl,
|
||||
openChangelog,
|
||||
openKeyboardShortcuts,
|
||||
toggleSidebar,
|
||||
logout,
|
||||
];
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { MarkAsReadIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { createAction } from "..";
|
||||
import { NotificationSection } from "../sections";
|
||||
|
||||
export const markNotificationsAsRead = createAction({
|
||||
name: ({ t }) => t("Mark notifications as read"),
|
||||
analyticsName: "Mark notifications as read",
|
||||
section: NotificationSection,
|
||||
icon: <MarkAsReadIcon />,
|
||||
shortcut: ["Shift+Escape"],
|
||||
perform: ({ stores }) => stores.notifications.markAllAsRead(),
|
||||
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
|
||||
});
|
||||
|
||||
export const rootNotificationActions = [markNotificationsAsRead];
|
||||
@@ -6,10 +6,7 @@ import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { RevisionSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentHistoryPath,
|
||||
matchDocumentHistory,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { documentHistoryUrl, matchDocumentHistory } from "~/utils/routeHelpers";
|
||||
|
||||
export const restoreRevision = createAction({
|
||||
name: ({ t }) => t("Restore revision"),
|
||||
@@ -18,7 +15,7 @@ export const restoreRevision = createAction({
|
||||
section: RevisionSection,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
|
||||
perform: async ({ event, location, activeDocumentId }) => {
|
||||
perform: async ({ t, event, location, activeDocumentId }) => {
|
||||
event?.preventDefault();
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
@@ -29,15 +26,26 @@ export const restoreRevision = createAction({
|
||||
});
|
||||
const revisionId = match?.params.revisionId;
|
||||
|
||||
const { team } = stores.auth;
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
history.push(document.url, {
|
||||
restore: true,
|
||||
revisionId,
|
||||
});
|
||||
if (team?.collaborativeEditing) {
|
||||
history.push(document.url, {
|
||||
restore: true,
|
||||
revisionId,
|
||||
});
|
||||
} else {
|
||||
await document.restore({
|
||||
revisionId,
|
||||
});
|
||||
stores.toasts.showToast(t("Document restored"), {
|
||||
type: "success",
|
||||
});
|
||||
history.push(document.url);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,7 +68,7 @@ export const copyLinkToRevision = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${window.location.origin}${documentHistoryPath(
|
||||
const url = `${window.location.origin}${documentHistoryUrl(
|
||||
document,
|
||||
revisionId
|
||||
)}`;
|
||||
|
||||
@@ -43,9 +43,8 @@ export const changeTheme = createAction({
|
||||
isContextMenu ? t("Appearance") : t("Change theme"),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: function _Icon() {
|
||||
return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
|
||||
},
|
||||
icon: () =>
|
||||
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
|
||||
keywords: "appearance display",
|
||||
section: SettingsSection,
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
|
||||
@@ -9,15 +9,15 @@ import { createAction } from "~/actions";
|
||||
import { ActionContext } from "~/types";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: function _Icon() {
|
||||
return (
|
||||
export const createTeamsList = ({ stores }: { stores: RootStore }) => {
|
||||
return (
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: () => (
|
||||
<StyledTeamLogo
|
||||
alt={session.name}
|
||||
model={{
|
||||
@@ -28,11 +28,13 @@ export const createTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
}}
|
||||
size={24}
|
||||
/>
|
||||
);
|
||||
},
|
||||
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
|
||||
perform: () => (window.location.href = session.url),
|
||||
})) ?? [];
|
||||
),
|
||||
visible: ({ currentTeamId }: ActionContext) =>
|
||||
currentTeamId !== session.id,
|
||||
perform: () => (window.location.href = session.url),
|
||||
})) ?? []
|
||||
);
|
||||
};
|
||||
|
||||
export const switchTeam = createAction({
|
||||
name: ({ t }) => t("Switch workspace"),
|
||||
@@ -51,8 +53,9 @@ export const createTeam = createAction({
|
||||
keywords: "create change switch workspace organization team",
|
||||
section: TeamSection,
|
||||
icon: <PlusIcon />,
|
||||
visible: ({ stores, currentTeamId }) =>
|
||||
stores.policies.abilities(currentTeamId ?? "").createTeam,
|
||||
visible: ({ stores, currentTeamId }) => {
|
||||
return stores.policies.abilities(currentTeamId ?? "").createTeam;
|
||||
},
|
||||
perform: ({ t, event, stores }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
@@ -2,7 +2,6 @@ import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import Invite from "~/scenes/Invite";
|
||||
import { UserDeleteDialog } from "~/components/UserDialogs";
|
||||
import { createAction } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
|
||||
@@ -22,31 +21,4 @@ export const inviteUser = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteUserActionFactory = (userId: string) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Delete user")}…`,
|
||||
analyticsName: "Delete user",
|
||||
keywords: "leave",
|
||||
dangerous: true,
|
||||
section: UserSection,
|
||||
visible: ({ stores }) => stores.policies.abilities(userId).delete,
|
||||
perform: ({ t }) => {
|
||||
const user = stores.users.get(userId);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete user"),
|
||||
isCentered: true,
|
||||
content: (
|
||||
<UserDeleteDialog
|
||||
user={user}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const rootUserActions = [inviteUser];
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
|
||||
return definition.perform?.(context);
|
||||
}
|
||||
: undefined,
|
||||
id: definition.id ?? uuidv4(),
|
||||
id: uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ export function actionToMenuItem(
|
||||
const title = resolve<string>(action.name, context);
|
||||
const icon =
|
||||
resolvedIcon && action.iconInContextMenu !== false
|
||||
? resolvedIcon
|
||||
? React.cloneElement(resolvedIcon, {
|
||||
color: "currentColor",
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (resolvedChildren) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { rootCollectionActions } from "./definitions/collections";
|
||||
import { rootDeveloperActions } from "./definitions/developer";
|
||||
import { rootDocumentActions } from "./definitions/documents";
|
||||
import { rootNavigationActions } from "./definitions/navigation";
|
||||
import { rootNotificationActions } from "./definitions/notifications";
|
||||
import { rootRevisionActions } from "./definitions/revisions";
|
||||
import { rootSettingsActions } from "./definitions/settings";
|
||||
import { rootTeamActions } from "./definitions/teams";
|
||||
@@ -13,7 +12,6 @@ export default [
|
||||
...rootDocumentActions,
|
||||
...rootUserActions,
|
||||
...rootNavigationActions,
|
||||
...rootNotificationActions,
|
||||
...rootRevisionActions,
|
||||
...rootSettingsActions,
|
||||
...rootDeveloperActions,
|
||||
|
||||
@@ -12,8 +12,6 @@ export const SettingsSection = ({ t }: ActionContext) => t("Settings");
|
||||
|
||||
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
|
||||
|
||||
export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { Action, ActionContext } from "~/types";
|
||||
|
||||
export type Props = React.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 */
|
||||
@@ -19,17 +18,14 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/**
|
||||
* Button that can be used to trigger an action definition.
|
||||
*/
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
function _ActionButton(
|
||||
const ActionButton = React.forwardRef(
|
||||
(
|
||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
) => {
|
||||
const [executing, setExecuting] = React.useState(false);
|
||||
const disabled = rest.disabled;
|
||||
|
||||
if (action && !context) {
|
||||
throw new Error("Context must be provided with action");
|
||||
}
|
||||
if (!context || !action) {
|
||||
return <button {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
export const Action = styled(Flex)`
|
||||
@@ -21,7 +20,7 @@ export const Separator = styled.div`
|
||||
margin-left: 12px;
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: ${s("divider")};
|
||||
background: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
@@ -30,8 +29,8 @@ const Actions = styled(Flex)`
|
||||
right: 0;
|
||||
left: 0;
|
||||
border-radius: 3px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
|
||||
@@ -5,14 +5,10 @@ import * as React from "react";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Analytics: React.FC = ({ children }: Props) => {
|
||||
const Analytics: React.FC = ({ children }) => {
|
||||
// Google Analytics 3
|
||||
React.useEffect(() => {
|
||||
if (!env.GOOGLE_ANALYTICS_ID?.startsWith("UA-")) {
|
||||
if (!env.GOOGLE_ANALYTICS_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,36 +37,25 @@ const Analytics: React.FC = ({ children }: Props) => {
|
||||
|
||||
// Google Analytics 4
|
||||
React.useEffect(() => {
|
||||
const measurementIds = [];
|
||||
|
||||
if (env.analytics.service === IntegrationService.GoogleAnalytics) {
|
||||
measurementIds.push(escape(env.analytics.settings?.measurementId));
|
||||
}
|
||||
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
|
||||
measurementIds.push(env.GOOGLE_ANALYTICS_ID);
|
||||
}
|
||||
if (measurementIds.length === 0) {
|
||||
if (env.analytics.service !== IntegrationService.GoogleAnalytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = {
|
||||
allow_google_signals: false,
|
||||
restricted_data_processing: true,
|
||||
};
|
||||
const measurementId = escape(env.analytics.settings?.measurementId);
|
||||
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.gtag = function () {
|
||||
window.dataLayer.push(arguments);
|
||||
};
|
||||
window.gtag("js", new Date());
|
||||
|
||||
for (const measurementId of measurementIds) {
|
||||
window.gtag("config", measurementId, params);
|
||||
}
|
||||
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=${measurementIds[0]}`;
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
|
||||
script.async = true;
|
||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||
}, []);
|
||||
|
||||
@@ -6,11 +6,17 @@ export default function Arrow() {
|
||||
width="13"
|
||||
height="30"
|
||||
viewBox="0 0 13 30"
|
||||
fill="currentColor"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z" />
|
||||
<path d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,10 +20,6 @@ function ArrowKeyNavigation(
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev) => {
|
||||
if (onEscape) {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
onEscape(ev);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function GoogleLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 34 34"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<g>
|
||||
<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" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default GoogleLogo;
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function MicrosoftLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 34 34"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M18.0002 1H33.9998C33.9998 5.8172 34.0007 10.6344 33.9988 15.4516C28.6666 15.4508 23.3334 15.4516 18.0012 15.4516C17.9993 10.6344 18.0002 5.8172 18.0002 1Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M18.0009 17.5173C23.3333 17.5155 28.6667 17.5164 34 17.5164V33H18C18.0009 27.8388 17.9991 22.6776 18.0009 17.5173V17.5173Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 1H16L15.9988 15.4516H0V1Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 17.5161C5.3332 17.5179 10.6664 17.5155 15.9996 17.5179C16.0005 22.6789 15.9996 27.839 15.9996 33H0V17.5161Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default MicrosoftLogo;
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import GoogleLogo from "./GoogleLogo";
|
||||
import MicrosoftLogo from "./MicrosoftLogo";
|
||||
import SlackLogo from "./SlackLogo";
|
||||
|
||||
type Props = {
|
||||
providerName: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
function AuthLogo({ providerName, color, size = 16 }: Props) {
|
||||
switch (providerName) {
|
||||
case "slack":
|
||||
return (
|
||||
<Logo>
|
||||
<SlackLogo size={size} fill={color} />
|
||||
</Logo>
|
||||
);
|
||||
|
||||
case "google":
|
||||
return (
|
||||
<Logo>
|
||||
<GoogleLogo size={size} fill={color} />
|
||||
</Logo>
|
||||
);
|
||||
|
||||
case "azure":
|
||||
return (
|
||||
<Logo>
|
||||
<MicrosoftLogo size={size} fill={color} />
|
||||
</Logo>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const Logo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default AuthLogo;
|
||||
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
|
||||
type Props = {
|
||||
children: JSX.Element;
|
||||
@@ -18,18 +18,20 @@ const Authenticated = ({ children }: Props) => {
|
||||
// Watching for language changes here as this is the earliest point we have
|
||||
// the user available and means we can start loading translations faster
|
||||
React.useEffect(() => {
|
||||
void changeLanguage(language, i18n);
|
||||
changeLanguage(language, i18n);
|
||||
}, [i18n, language]);
|
||||
|
||||
if (auth.authenticated) {
|
||||
const { user, team } = auth;
|
||||
|
||||
if (!team || !user) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
if (auth.isFetching) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
void auth.logout(true);
|
||||
auth.logout(true);
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { AnimatePresence } from "framer-motion";
|
||||
import { observer, useLocalStore } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import DocumentContext from "~/components/DocumentContext";
|
||||
import type { DocumentContextValue } from "~/components/DocumentContext";
|
||||
@@ -15,33 +14,39 @@ import type { Editor as TEditor } from "~/editor";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import {
|
||||
searchPath,
|
||||
matchDocumentSlug as slug,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
matchDocumentInsights,
|
||||
} from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
const DocumentHistory = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "document-history" */
|
||||
"~/scenes/Document/components/History"
|
||||
)
|
||||
);
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
const DocumentInsights = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "document-insights" */
|
||||
"~/scenes/Document/components/Insights"
|
||||
)
|
||||
);
|
||||
const DocumentInsights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
const CommandBar = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "command-bar" */
|
||||
"~/components/CommandBar"
|
||||
)
|
||||
);
|
||||
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const can = usePolicy(ui.activeCollectionId);
|
||||
@@ -66,7 +71,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
return;
|
||||
}
|
||||
const { activeCollectionId } = ui;
|
||||
if (!activeCollectionId || !can.createDocument) {
|
||||
if (!activeCollectionId || !can.update) {
|
||||
return;
|
||||
}
|
||||
history.push(newDocumentPath(activeCollectionId));
|
||||
@@ -93,25 +98,15 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const showInsights = !!matchPath(location.pathname, {
|
||||
path: matchDocumentInsights,
|
||||
});
|
||||
const showComments =
|
||||
!showInsights &&
|
||||
!showHistory &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded.includes(ui.activeDocumentId) &&
|
||||
team?.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
key={ui.activeDocumentId ? "active" : "inactive"}
|
||||
>
|
||||
{(showHistory || showInsights || showComments) && (
|
||||
<AnimatePresence key={ui.activeDocumentId}>
|
||||
{(showHistory || showInsights) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<SidebarRight>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showInsights && <DocumentInsights />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</SidebarRight>
|
||||
</Route>
|
||||
@@ -126,9 +121,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<React.Suspense fallback={null}>
|
||||
<CommandBar />
|
||||
</React.Suspense>
|
||||
<CommandBar />
|
||||
</Layout>
|
||||
</DocumentContext.Provider>
|
||||
);
|
||||
|
||||
@@ -3,39 +3,31 @@ import styled from "styled-components";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import Initials from "./Initials";
|
||||
|
||||
export enum AvatarSize {
|
||||
Small = 16,
|
||||
Medium = 24,
|
||||
Large = 32,
|
||||
XLarge = 48,
|
||||
XXLarge = 64,
|
||||
}
|
||||
|
||||
export interface IAvatar {
|
||||
avatarUrl: string | null;
|
||||
color?: string;
|
||||
initial?: string;
|
||||
id?: string;
|
||||
color: string;
|
||||
initial: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
size: AvatarSize;
|
||||
size: number;
|
||||
src?: string;
|
||||
icon?: React.ReactNode;
|
||||
model?: IAvatar;
|
||||
alt?: string;
|
||||
showBorder?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { showBorder, model, style, ...rest } = props;
|
||||
const { icon, showBorder, model, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<Relative style={style}>
|
||||
<Relative>
|
||||
{src && !error ? (
|
||||
<CircleImg
|
||||
onError={handleError}
|
||||
@@ -50,20 +42,32 @@ function Avatar(props: Props) {
|
||||
) : (
|
||||
<Initials $showBorder={showBorder} {...rest} />
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
</Relative>
|
||||
);
|
||||
}
|
||||
|
||||
Avatar.defaultProps = {
|
||||
size: AvatarSize.Medium,
|
||||
size: 24,
|
||||
};
|
||||
|
||||
const Relative = styled.div`
|
||||
position: relative;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
background: ${(props) => props.theme.primary};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
border-radius: 100%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -107,7 +106,7 @@ const AvatarWrapper = styled.div<AvatarWrapperProps>`
|
||||
|
||||
&:hover:after {
|
||||
border: 2px solid ${(props) => props.$color};
|
||||
box-shadow: inset 0 0 0 2px ${s("background")};
|
||||
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -5,13 +5,9 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
|
||||
margin-left: 10px;
|
||||
padding: 1px 5px 2px;
|
||||
background-color: ${({ yellow, primary, theme }) =>
|
||||
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
|
||||
yellow ? theme.yellow : primary ? theme.primary : "transparent"};
|
||||
color: ${({ primary, yellow, theme }) =>
|
||||
primary
|
||||
? theme.accentText
|
||||
: yellow
|
||||
? theme.almostBlack
|
||||
: theme.textTertiary};
|
||||
primary ? theme.white : yellow ? theme.almostBlack : theme.textTertiary};
|
||||
border: 1px solid
|
||||
${({ primary, yellow, theme }) =>
|
||||
primary || yellow
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { depths } from "@shared/styles";
|
||||
import env from "~/env";
|
||||
import OutlineIcon from "./Icons/OutlineIcon";
|
||||
|
||||
@@ -26,16 +26,16 @@ const Link = styled.a`
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
border-top-right-radius: 2px;
|
||||
color: ${s("text")};
|
||||
color: ${(props) => props.theme.text};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
fill: ${s("text")};
|
||||
fill: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${s("sidebarBackground")};
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -2,7 +2,6 @@ import { GoToIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
@@ -61,18 +60,20 @@ function Breadcrumb({
|
||||
|
||||
const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
fill: ${s("divider")};
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
${ellipsis()}
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("text")};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
|
||||
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import { darken, lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import ActionButton, {
|
||||
Props as ActionButtonProps,
|
||||
} from "~/components/ActionButton";
|
||||
@@ -14,6 +13,7 @@ type RealProps = {
|
||||
$borderOnHover?: boolean;
|
||||
$neutral?: boolean;
|
||||
$danger?: boolean;
|
||||
$iconColor?: string;
|
||||
};
|
||||
|
||||
const RealButton = styled(ActionButton)<RealProps>`
|
||||
@@ -22,8 +22,8 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${s("accent")};
|
||||
color: ${s("accentText")};
|
||||
background: ${(props) => props.theme.buttonBackground};
|
||||
color: ${(props) => props.theme.buttonText};
|
||||
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
@@ -36,6 +36,14 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
appearance: none !important;
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
${(props) =>
|
||||
!props.$borderOnHover &&
|
||||
`
|
||||
svg {
|
||||
fill: ${props.$iconColor || "currentColor"};
|
||||
}
|
||||
`}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -43,14 +51,14 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => darken(0.05, props.theme.accent)};
|
||||
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: ${(props) => transparentize(0.5, props.theme.accentText)};
|
||||
background: ${(props) => lighten(0.2, props.theme.accent)};
|
||||
color: ${(props) => props.theme.white50};
|
||||
background: ${(props) => lighten(0.2, props.theme.buttonBackground)};
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.white50};
|
||||
@@ -60,7 +68,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
${(props) =>
|
||||
props.$neutral &&
|
||||
`
|
||||
background: inherit;
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
color: ${props.theme.buttonNeutralText};
|
||||
box-shadow: ${
|
||||
props.$borderOnHover
|
||||
@@ -68,6 +76,15 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
|
||||
};
|
||||
|
||||
${
|
||||
props.$borderOnHover
|
||||
? ""
|
||||
: `svg {
|
||||
fill: ${props.$iconColor || "currentColor"};
|
||||
}`
|
||||
}
|
||||
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${
|
||||
@@ -138,6 +155,7 @@ export const Inner = styled.span<{
|
||||
|
||||
export type Props<T> = ActionButtonProps & {
|
||||
icon?: React.ReactNode;
|
||||
iconColor?: string;
|
||||
children?: React.ReactNode;
|
||||
disclosure?: boolean;
|
||||
neutral?: boolean;
|
||||
@@ -165,6 +183,7 @@ const Button = <T extends React.ElementType = "button">(
|
||||
neutral,
|
||||
action,
|
||||
icon,
|
||||
iconColor,
|
||||
borderOnHover,
|
||||
hideIcon,
|
||||
fullwidth,
|
||||
@@ -184,12 +203,13 @@ const Button = <T extends React.ElementType = "button">(
|
||||
$danger={danger}
|
||||
$fullwidth={fullwidth}
|
||||
$borderOnHover={borderOnHover}
|
||||
$iconColor={iconColor}
|
||||
{...rest}
|
||||
>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && ic}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
{disclosure && <ExpandedIcon color="currentColor" />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import Button, { Inner } from "./Button";
|
||||
|
||||
const ButtonSmall = styled(Button)`
|
||||
font-size: 13px;
|
||||
height: 26px;
|
||||
|
||||
${Inner} {
|
||||
padding: 0 6px;
|
||||
line-height: 26px;
|
||||
min-height: 26px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ButtonSmall;
|
||||
@@ -3,7 +3,6 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
withStickyHeader?: boolean;
|
||||
};
|
||||
|
||||
@@ -27,10 +26,12 @@ const Content = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent: React.FC<Props> = ({ children, ...rest }: Props) => (
|
||||
<Container {...rest}>
|
||||
<Content>{children}</Content>
|
||||
</Container>
|
||||
);
|
||||
const CenteredContent: React.FC<Props> = ({ children, ...rest }) => {
|
||||
return (
|
||||
<Container {...rest}>
|
||||
<Content>{children}</Content>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default CenteredContent;
|
||||
|
||||
@@ -42,7 +42,7 @@ const Circle = ({
|
||||
style={{
|
||||
transition: "stroke-dashoffset 0.6s ease 0s",
|
||||
}}
|
||||
/>
|
||||
></circle>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ const CircularProgressBar = ({
|
||||
<Circle color={theme.progressBarBackground} offset={offset} />
|
||||
{percentage > 0 && (
|
||||
<Circle
|
||||
color={theme.accent}
|
||||
color={theme.primary}
|
||||
percentage={percentage}
|
||||
offset={offset}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const ClickablePadding = styled.div<{
|
||||
grow?: boolean;
|
||||
minHeight?: React.CSSProperties["paddingBottom"];
|
||||
}>`
|
||||
const ClickablePadding = styled.div<{ grow?: boolean; minHeight?: string }>`
|
||||
min-height: ${(props) => props.minHeight || "50vh"};
|
||||
flex-grow: 100;
|
||||
cursor: text;
|
||||
|
||||
@@ -4,11 +4,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "~/models/Document";
|
||||
import AvatarWithPresence from "~/components/Avatar/AvatarWithPresence";
|
||||
import { AvatarWithPresence } from "~/components/Avatar";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Popover from "~/components/Popover";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -19,6 +20,7 @@ type Props = {
|
||||
function Collaborators(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const currentUserId = user?.id;
|
||||
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
|
||||
const { users, presence, ui } = useStores();
|
||||
@@ -57,7 +59,7 @@ function Collaborators(props: Props) {
|
||||
|
||||
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
|
||||
setRequestedUserIds(ids);
|
||||
void users.fetchPage({ ids, limit: 100 });
|
||||
users.fetchPage({ ids, limit: 100 });
|
||||
}
|
||||
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
|
||||
|
||||
@@ -77,7 +79,8 @@ function Collaborators(props: Props) {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
const isEditing = editingIds.includes(collaborator.id);
|
||||
const isObserving = ui.observingUserId === collaborator.id;
|
||||
const isObservable = collaborator.id !== user.id;
|
||||
const isObservable =
|
||||
team.collaborativeEditing && collaborator.id !== user.id;
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
|
||||
@@ -39,7 +39,7 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
||||
<>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash."
|
||||
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash."
|
||||
values={{
|
||||
collectionName: collection.name,
|
||||
}}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Collection from "~/models/Collection";
|
||||
import Arrow from "~/components/Arrow";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
@@ -71,9 +70,9 @@ function CollectionDescription({ collection }: Props) {
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
async (getValue) => {
|
||||
(getValue) => {
|
||||
setDirty(true);
|
||||
await handleSave(getValue);
|
||||
handleSave(getValue);
|
||||
},
|
||||
[handleSave]
|
||||
);
|
||||
@@ -111,7 +110,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
onBlur={handleStopEditing}
|
||||
maxLength={1000}
|
||||
embedsDisabled
|
||||
canUpdate
|
||||
readOnlyWriteCheckboxes
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
@@ -142,7 +141,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
|
||||
const Disclosure = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${s("divider")};
|
||||
color: ${(props) => props.theme.divider};
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 50%;
|
||||
@@ -156,12 +155,12 @@ const Disclosure = styled(NudeButton)`
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: ${s("sidebarText")};
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
}
|
||||
`;
|
||||
|
||||
const Placeholder = styled(ButtonLink)`
|
||||
color: ${s("placeholder")};
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
cursor: text;
|
||||
min-height: 27px;
|
||||
`;
|
||||
@@ -194,7 +193,7 @@ const Input = styled.div`
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: ${s("backgroundTransition")};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
@@ -207,7 +206,7 @@ const Input = styled.div`
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${s("background")} 100%
|
||||
${(props) => props.theme.background} 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,7 +218,7 @@ const Input = styled.div`
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${s("secondaryBackground")};
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
import { QuestionMarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { depths } from "@shared/styles";
|
||||
import CommandBarResults from "~/components/CommandBarResults";
|
||||
import SearchActions from "~/components/SearchActions";
|
||||
import rootActions from "~/actions/root";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useSettingsActions from "~/hooks/useSettingsActions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { CommandBarAction } from "~/types";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import Text from "./Text";
|
||||
|
||||
function CommandBar() {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const settingsActions = useSettingsActions();
|
||||
const commandBarActions = React.useMemo(
|
||||
() => [...rootActions, settingsActions],
|
||||
@@ -25,9 +30,9 @@ function CommandBar() {
|
||||
|
||||
const { rootAction } = useKBar((state) => ({
|
||||
rootAction: state.currentRootActionId
|
||||
? (state.actions[
|
||||
? ((state.actions[
|
||||
state.currentRootActionId
|
||||
] as unknown as CommandBarAction)
|
||||
] as unknown) as CommandBarAction)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
@@ -45,6 +50,17 @@ function CommandBar() {
|
||||
}…`}
|
||||
/>
|
||||
<CommandBarResults />
|
||||
{ui.commandBarOpenedFromSidebar && (
|
||||
<Hint size="small" type="tertiary">
|
||||
<QuestionMarkIcon size={18} color="currentColor" />
|
||||
{t(
|
||||
"Open search from anywhere with the {{ shortcut }} shortcut",
|
||||
{
|
||||
shortcut: `${metaDisplay} + k`,
|
||||
}
|
||||
)}
|
||||
</Hint>
|
||||
)}
|
||||
</Animator>
|
||||
</Positioner>
|
||||
</KBarPortal>
|
||||
@@ -52,11 +68,7 @@ function CommandBar() {
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const KBarPortal: React.FC = ({ children }: Props) => {
|
||||
const KBarPortal: React.FC = ({ children }) => {
|
||||
const { showing } = useKBar((state) => ({
|
||||
showing: state.visualState !== "hidden",
|
||||
}));
|
||||
@@ -68,6 +80,16 @@ const KBarPortal: React.FC = ({ children }: Props) => {
|
||||
return <Portal>{children}</Portal>;
|
||||
};
|
||||
|
||||
const Hint = styled(Text)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-top: 1px solid ${(props) => props.theme.background};
|
||||
margin: 1px 0 0;
|
||||
padding: 6px 16px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Positioner = styled(KBarPositioner)`
|
||||
z-index: ${depths.commandBar};
|
||||
`;
|
||||
@@ -77,12 +99,12 @@ const SearchInput = styled(KBarSearch)`
|
||||
width: 100%;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: ${s("menuBackground")};
|
||||
color: ${s("text")};
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${s("placeholder")};
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -90,8 +112,8 @@ const Animator = styled(KBarAnimator)`
|
||||
max-width: 600px;
|
||||
max-height: 75vh;
|
||||
width: 90vw;
|
||||
background: ${s("menuBackground")};
|
||||
color: ${s("text")};
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
|
||||
|
||||
@@ -2,10 +2,8 @@ import { ActionImpl } from "kbar";
|
||||
import { ArrowIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
action: ActionImpl;
|
||||
@@ -40,9 +38,10 @@ function CommandBarItem(
|
||||
// @ts-expect-error no icon on ActionImpl
|
||||
React.cloneElement(action.icon, {
|
||||
size: 22,
|
||||
color: "currentColor",
|
||||
})
|
||||
) : (
|
||||
<ArrowIcon />
|
||||
<ArrowIcon color="currentColor" />
|
||||
)}
|
||||
</Icon>
|
||||
|
||||
@@ -56,56 +55,43 @@ function CommandBarItem(
|
||||
{action.children?.length ? "…" : ""}
|
||||
</Content>
|
||||
{action.shortcut?.length ? (
|
||||
<Shortcut>
|
||||
{action.shortcut.map((sc: string, index) => (
|
||||
<React.Fragment key={sc}>
|
||||
{index > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
<Text size="xsmall" as="span" type="secondary">
|
||||
then
|
||||
</Text>{" "}
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{sc.split("+").map((s) => (
|
||||
<Key key={s}>{s}</Key>
|
||||
))}
|
||||
</React.Fragment>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridAutoFlow: "column",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
{action.shortcut.map((sc: string) => (
|
||||
<Key key={sc}>{sc}</Key>
|
||||
))}
|
||||
</Shortcut>
|
||||
</div>
|
||||
) : null}
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
const Shortcut = styled.div`
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const Icon = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: ${s("textSecondary")};
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Ancestor = styled.span`
|
||||
color: ${s("textSecondary")};
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
${ellipsis()}
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 1;
|
||||
`;
|
||||
|
||||
const Item = styled.div<{ active?: boolean }>`
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
padding: 9px 12px;
|
||||
margin: 0 8px;
|
||||
border-radius: 4px;
|
||||
@@ -116,7 +102,9 @@ const Item = styled.div<{ active?: boolean }>`
|
||||
justify-content: space-between;
|
||||
cursor: var(--pointer);
|
||||
|
||||
${ellipsis()}
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
min-width: 0;
|
||||
|
||||
|
||||
@@ -1,45 +1,35 @@
|
||||
import { useMatches, KBarResults } from "kbar";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import CommandBarItem from "~/components/CommandBarItem";
|
||||
|
||||
export default function CommandBarResults() {
|
||||
const { results, rootActionId } = useMatches();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<KBarResults
|
||||
items={results}
|
||||
maxHeight={400}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<Header>{item}</Header>
|
||||
) : (
|
||||
<CommandBarItem
|
||||
action={item}
|
||||
active={active}
|
||||
currentRootActionId={rootActionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
<KBarResults
|
||||
items={results}
|
||||
maxHeight={400}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<Header>{item}</Header>
|
||||
) : (
|
||||
<CommandBarItem
|
||||
action={item}
|
||||
active={active}
|
||||
currentRootActionId={rootActionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Cannot style KBarResults unfortunately, so we must wrap and target the inner
|
||||
const Container = styled.div`
|
||||
> div {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0;
|
||||
padding: 16px 0 4px 20px;
|
||||
color: ${s("textTertiary")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
height: 36px;
|
||||
`;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import Comment from "~/models/Comment";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
comment: Comment;
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
function CommentDeleteDialog({ comment, onSubmit }: Props) {
|
||||
const { comments } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const hasChildComments = comments.inThread(comment.id).length > 1;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await comment.delete();
|
||||
onSubmit?.();
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Text type="secondary">
|
||||
{hasChildComments ? (
|
||||
<Trans>
|
||||
Are you sure you want to permanently delete this entire comment
|
||||
thread?
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Are you sure you want to permanently delete this comment?
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CommentDeleteDialog);
|
||||
@@ -15,9 +15,6 @@ type Props = {
|
||||
savingText?: string;
|
||||
/** If true, the submit button will be a dangerous red */
|
||||
danger?: boolean;
|
||||
/** Keep the submit button disabled */
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ConfirmationDialog: React.FC<Props> = ({
|
||||
@@ -26,8 +23,7 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
submitText,
|
||||
savingText,
|
||||
danger,
|
||||
disabled = false,
|
||||
}: Props) => {
|
||||
}) => {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const { dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
@@ -54,12 +50,7 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">{children}</Text>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving || disabled}
|
||||
danger={danger}
|
||||
autoFocus
|
||||
>
|
||||
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
|
||||
{isSaving && savingText ? savingText : submitText}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -39,8 +39,8 @@ const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 32px;
|
||||
margin: 24px;
|
||||
transform: translateX(-32px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
|
||||
+101
-114
@@ -1,7 +1,6 @@
|
||||
import isPrintableKeyEvent from "is-printable-key-event";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import useOnScreen from "~/hooks/useOnScreen";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
|
||||
@@ -30,74 +29,63 @@ export type RefHandle = {
|
||||
* Defines a content editable component with the same interface as a native
|
||||
* HTMLInputElement (or, as close as we can get).
|
||||
*/
|
||||
const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
{
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
maxLength,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
readOnly,
|
||||
dir,
|
||||
onClick,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) {
|
||||
const contentRef = React.useRef<HTMLSpanElement>(null);
|
||||
const [innerValue, setInnerValue] = React.useState<string>(value);
|
||||
const lastValue = React.useRef(value);
|
||||
const ContentEditable = React.forwardRef(
|
||||
(
|
||||
{
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
maxLength,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
readOnly,
|
||||
dir,
|
||||
onClick,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) => {
|
||||
const contentRef = React.useRef<HTMLSpanElement>(null);
|
||||
const [innerValue, setInnerValue] = React.useState<string>(value);
|
||||
const lastValue = React.useRef("");
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
// looks unnecessary but required because of https://github.com/outline/outline/issues/5198
|
||||
if (!contentRef.current.innerText) {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
contentRef.current?.focus();
|
||||
},
|
||||
focusAtStart: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
focusAtStart: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, true);
|
||||
}
|
||||
},
|
||||
focusAtEnd: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, false);
|
||||
}
|
||||
},
|
||||
getComputedDirection: () => {
|
||||
if (contentRef.current) {
|
||||
return window.getComputedStyle(contentRef.current).direction;
|
||||
}
|
||||
return "ltr";
|
||||
},
|
||||
}));
|
||||
},
|
||||
focusAtEnd: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, false);
|
||||
}
|
||||
},
|
||||
getComputedDirection: () => {
|
||||
if (contentRef.current) {
|
||||
return window.getComputedStyle(contentRef.current).direction;
|
||||
}
|
||||
return "ltr";
|
||||
},
|
||||
}));
|
||||
|
||||
const wrappedEvent =
|
||||
(
|
||||
const wrappedEvent = (
|
||||
callback:
|
||||
| React.FocusEventHandler<HTMLSpanElement>
|
||||
| React.FormEventHandler<HTMLSpanElement>
|
||||
| React.KeyboardEventHandler<HTMLSpanElement>
|
||||
| undefined
|
||||
) =>
|
||||
(event: any) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.currentTarget.textContent || "";
|
||||
) => (event: any) => {
|
||||
const text = contentRef.current?.innerText || "";
|
||||
|
||||
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
|
||||
event?.preventDefault();
|
||||
@@ -106,62 +94,62 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
|
||||
if (text !== lastValue.current) {
|
||||
lastValue.current = text;
|
||||
onChange?.(text);
|
||||
onChange && onChange(text);
|
||||
}
|
||||
|
||||
callback?.(event);
|
||||
};
|
||||
|
||||
// This is to account for being within a React.Suspense boundary, in this
|
||||
// case the component may be rendered with display: none. React 18 may solve
|
||||
// this in the future by delaying useEffect hooks:
|
||||
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
|
||||
const isVisible = useOnScreen(contentRef);
|
||||
// This is to account for being within a React.Suspense boundary, in this
|
||||
// case the component may be rendered with display: none. React 18 may solve
|
||||
// this in the future by delaying useEffect hooks:
|
||||
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
|
||||
const isVisible = useOnScreen(contentRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && isVisible && !disabled && !readOnly) {
|
||||
contentRef.current?.focus();
|
||||
}
|
||||
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && isVisible && !disabled && !readOnly) {
|
||||
contentRef.current?.focus();
|
||||
}
|
||||
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current && value !== contentRef.current.textContent) {
|
||||
setInnerValue(value);
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
React.useEffect(() => {
|
||||
if (value !== contentRef.current?.innerText) {
|
||||
setInnerValue(value);
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
|
||||
// Ensure only plain text can be pasted into input when pasting from another
|
||||
// rich text source. Note: If `onPaste` prop is passed then it takes
|
||||
// priority over this behavior.
|
||||
const handlePaste = React.useCallback(
|
||||
(event: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
window.document.execCommand("insertText", false, text);
|
||||
},
|
||||
[]
|
||||
);
|
||||
// Ensure only plain text can be pasted into input when pasting from another
|
||||
// rich text source
|
||||
const handlePaste = React.useCallback(
|
||||
(event: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
window.document.execCommand("insertText", false, text);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
</Content>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
</Content>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function placeCaret(element: HTMLElement, atStart: boolean) {
|
||||
if (
|
||||
@@ -178,14 +166,13 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
|
||||
}
|
||||
|
||||
const Content = styled.span`
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
color: ${s("text")};
|
||||
-webkit-text-fill-color: ${s("text")};
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
color: ${(props) => props.theme.text};
|
||||
-webkit-text-fill-color: ${(props) => props.theme.text};
|
||||
outline: none;
|
||||
resize: none;
|
||||
cursor: text;
|
||||
word-break: anywhere;
|
||||
|
||||
&:empty {
|
||||
display: inline-block;
|
||||
@@ -193,8 +180,8 @@ const Content = styled.span`
|
||||
|
||||
&:empty::before {
|
||||
display: inline-block;
|
||||
color: ${s("placeholder")};
|
||||
-webkit-text-fill-color: ${s("placeholder")};
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
-webkit-text-fill-color: ${(props) => props.theme.placeholder};
|
||||
content: attr(data-placeholder);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${s("sidebarText")};
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
@@ -8,7 +8,6 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
@@ -22,7 +21,6 @@ type Props = {
|
||||
level?: number;
|
||||
icon?: React.ReactElement;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
|
||||
const MenuItem = (
|
||||
@@ -39,26 +37,34 @@ const MenuItem = (
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) => {
|
||||
const content = React.useCallback(
|
||||
(props) => {
|
||||
const handleClick = async (ev: React.MouseEvent) => {
|
||||
hide?.();
|
||||
|
||||
if (onClick) {
|
||||
ev.preventDefault();
|
||||
await onClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const handleMouseDown = (ev: React.MouseEvent) => {
|
||||
const handleClick = React.useCallback(
|
||||
(ev) => {
|
||||
if (onClick) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
onClick(ev);
|
||||
}
|
||||
|
||||
return (
|
||||
hide?.();
|
||||
},
|
||||
[onClick, hide]
|
||||
);
|
||||
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const handleMouseDown = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{(props) => (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$active={active}
|
||||
@@ -72,26 +78,18 @@ const MenuItem = (
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
{selected ? <CheckmarkIcon color="currentColor" /> : <Spacer />}
|
||||
|
||||
</>
|
||||
)}
|
||||
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
|
||||
{icon && (
|
||||
<MenuIconWrapper>
|
||||
{React.cloneElement(icon, { color: "currentColor" })}
|
||||
</MenuIconWrapper>
|
||||
)}
|
||||
{children}
|
||||
</MenuAnchor>
|
||||
);
|
||||
},
|
||||
[active, as, hide, icon, onClick, ref, children, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
)}
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
@@ -149,13 +147,13 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
color: ${props.theme.white};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.accentText};
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,13 +163,13 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
props.$active &&
|
||||
!props.disabled &&
|
||||
`
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
color: ${props.theme.white};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.accentText};
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
`}
|
||||
|
||||
|
||||
@@ -24,12 +24,8 @@ type Positions = {
|
||||
export default function MouseSafeArea(props: {
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const {
|
||||
x = 0,
|
||||
y = 0,
|
||||
height: h = 0,
|
||||
width: w = 0,
|
||||
} = props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const { x = 0, y = 0, height: h = 0, width: w = 0 } =
|
||||
props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const [mouseX, mouseY] = useMousePosition();
|
||||
const positions = { x, y, h, w, mouseX, mouseY };
|
||||
|
||||
|
||||
@@ -5,14 +5,19 @@ import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = React.ComponentProps<typeof MenuButton> & {
|
||||
className?: string;
|
||||
iconColor?: string;
|
||||
};
|
||||
|
||||
export default function OverflowMenuButton({ className, ...rest }: Props) {
|
||||
export default function OverflowMenuButton({
|
||||
iconColor,
|
||||
className,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton className={className} {...props}>
|
||||
<MoreIcon />
|
||||
<MoreIcon color={iconColor} />
|
||||
</NudeButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
|
||||
@@ -44,35 +44,36 @@ type SubMenuProps = MenuStateReturn & {
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenu = React.forwardRef(function _Template(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState();
|
||||
const SubMenu = React.forwardRef(
|
||||
(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor disclosure {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Submenu")}
|
||||
onClick={parentMenuState.hide}
|
||||
parentMenuState={parentMenuState}
|
||||
>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor disclosure {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Submenu")}
|
||||
onClick={parentMenuState.hide}
|
||||
>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
return items
|
||||
@@ -133,7 +134,6 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
return (
|
||||
<MenuItem
|
||||
as={Link}
|
||||
id={`${item.title}-${index}`}
|
||||
to={item.to}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
@@ -149,7 +149,6 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
if (item.type === "link") {
|
||||
return (
|
||||
<MenuItem
|
||||
id={`${item.title}-${index}`}
|
||||
href={item.href}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
@@ -168,7 +167,6 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
return (
|
||||
<MenuItem
|
||||
as="button"
|
||||
id={`${item.title}-${index}`}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
@@ -187,7 +185,6 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
as={SubMenu}
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={<Title title={item.title} icon={item.icon} />}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuStateReturn } from "reakit/Menu";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { depths } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
@@ -37,16 +37,15 @@ export type Placement =
|
||||
| "left-start";
|
||||
|
||||
type Props = MenuStateReturn & {
|
||||
"aria-label"?: string;
|
||||
"aria-label": string;
|
||||
/** The parent menu state if this is a submenu. */
|
||||
parentMenuState?: Omit<MenuStateReturn, "items">;
|
||||
parentMenuState?: MenuStateReturn;
|
||||
/** Called when the context menu is opened. */
|
||||
onOpen?: () => void;
|
||||
/** Called when the context menu is closed. */
|
||||
onClose?: () => void;
|
||||
/** Called when the context menu is clicked. */
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ContextMenu: React.FC<Props> = ({
|
||||
@@ -55,18 +54,14 @@ const ContextMenu: React.FC<Props> = ({
|
||||
onClose,
|
||||
parentMenuState,
|
||||
...rest
|
||||
}: Props) => {
|
||||
}) => {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const maxHeight = useMenuHeight({
|
||||
visible: rest.visible,
|
||||
elementRef: rest.unstable_disclosureRef,
|
||||
});
|
||||
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
|
||||
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { setIsMenuOpen } = useMenuContext();
|
||||
const isMobile = useMobile();
|
||||
const isSubMenu = !!parentMenuState;
|
||||
|
||||
useUnmount(() => {
|
||||
setIsMenuOpen(false);
|
||||
@@ -76,7 +71,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
if (rest.visible && !previousVisible) {
|
||||
onOpen?.();
|
||||
|
||||
if (!isSubMenu) {
|
||||
if (!parentMenuState) {
|
||||
setIsMenuOpen(true);
|
||||
}
|
||||
}
|
||||
@@ -84,7 +79,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
if (!rest.visible && previousVisible) {
|
||||
onClose?.();
|
||||
|
||||
if (!isSubMenu) {
|
||||
if (!parentMenuState) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
@@ -95,7 +90,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
rest.visible,
|
||||
ui.sidebarCollapsed,
|
||||
setIsMenuOpen,
|
||||
isSubMenu,
|
||||
parentMenuState,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -104,15 +99,13 @@ const ContextMenu: React.FC<Props> = ({
|
||||
// https://github.com/ariakit/ariakit/issues/469
|
||||
React.useEffect(() => {
|
||||
const scrollElement = backgroundRef.current;
|
||||
if (rest.visible && scrollElement && !isSubMenu) {
|
||||
disableBodyScroll(scrollElement, {
|
||||
reserveScrollBarGap: true,
|
||||
});
|
||||
if (rest.visible && scrollElement) {
|
||||
disableBodyScroll(scrollElement);
|
||||
}
|
||||
return () => {
|
||||
scrollElement && !isSubMenu && enableBodyScroll(scrollElement);
|
||||
scrollElement && enableBodyScroll(scrollElement);
|
||||
};
|
||||
}, [isSubMenu, rest.visible]);
|
||||
}, [rest.visible]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
@@ -151,7 +144,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={
|
||||
topAnchor
|
||||
maxHeight && topAnchor
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
@@ -178,7 +171,7 @@ export const Backdrop = styled.div`
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${s("backdrop")};
|
||||
background: ${(props) => props.theme.backdrop};
|
||||
z-index: ${depths.menu - 1};
|
||||
`;
|
||||
|
||||
@@ -210,7 +203,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
background: ${s("menuBackground")};
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
min-width: 180px;
|
||||
|
||||
@@ -28,7 +28,7 @@ const DefaultCollectionInputSelect = ({
|
||||
const { showToast } = useToasts();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
async function load() {
|
||||
if (!collections.isLoaded && !fetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
@@ -48,7 +48,7 @@ const DefaultCollectionInputSelect = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
void fetchData();
|
||||
load();
|
||||
}, [showToast, fetchError, t, fetching, collections]);
|
||||
|
||||
const options = React.useMemo(
|
||||
@@ -73,7 +73,7 @@ const DefaultCollectionInputSelect = ({
|
||||
label: (
|
||||
<Flex align="center">
|
||||
<IconWrapper>
|
||||
<HomeIcon />
|
||||
<HomeIcon color="currentColor" />
|
||||
</IconWrapper>
|
||||
{t("Home")}
|
||||
</Flex>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function DesktopEventHandler() {
|
||||
action: {
|
||||
text: "Install now",
|
||||
onClick: () => {
|
||||
void Desktop.bridge?.restartAndInstall();
|
||||
Desktop.bridge?.restartAndInstall();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import * as React from "react";
|
||||
import Guide from "~/components/Guide";
|
||||
import Modal from "~/components/Modal";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Divider = styled.hr`
|
||||
border: 0;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
@@ -9,15 +9,9 @@ import Breadcrumb from "~/components/Breadcrumb";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import {
|
||||
archivePath,
|
||||
collectionPath,
|
||||
templatesPath,
|
||||
trashPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { collectionUrl } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
document: Document;
|
||||
onlyText?: boolean;
|
||||
};
|
||||
@@ -28,27 +22,27 @@ function useCategory(document: Document): MenuInternalLink | null {
|
||||
if (document.isDeleted) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <TrashIcon />,
|
||||
icon: <TrashIcon color="currentColor" />,
|
||||
title: t("Trash"),
|
||||
to: trashPath(),
|
||||
to: "/trash",
|
||||
};
|
||||
}
|
||||
|
||||
if (document.isArchived) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <ArchiveIcon />,
|
||||
icon: <ArchiveIcon color="currentColor" />,
|
||||
title: t("Archive"),
|
||||
to: archivePath(),
|
||||
to: "/archive",
|
||||
};
|
||||
}
|
||||
|
||||
if (document.isTemplate) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <ShapesIcon />,
|
||||
icon: <ShapesIcon color="currentColor" />,
|
||||
title: t("Templates"),
|
||||
to: templatesPath(),
|
||||
to: "/templates",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,13 +53,11 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
document,
|
||||
children,
|
||||
onlyText,
|
||||
}: Props) => {
|
||||
}) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
let collectionNode: MenuInternalLink | undefined;
|
||||
|
||||
@@ -74,14 +66,14 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.url),
|
||||
to: collectionUrl(collection.url),
|
||||
};
|
||||
} else if (document.collectionId && !collection) {
|
||||
collectionNode = {
|
||||
type: "route",
|
||||
title: t("Deleted Collection"),
|
||||
icon: undefined,
|
||||
to: collectionPath("deleted-collection"),
|
||||
to: collectionUrl("deleted-collection"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,11 +122,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb items={items} highlightFirstItem>
|
||||
{children}
|
||||
</Breadcrumb>
|
||||
);
|
||||
return <Breadcrumb items={items} children={children} highlightFirstItem />;
|
||||
};
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
@@ -143,7 +131,7 @@ const SmallSlash = styled(GoToIcon)`
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
|
||||
fill: ${(props) => props.theme.textTertiary};
|
||||
fill: ${(props) => props.theme.slate};
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
|
||||
@@ -7,14 +7,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Pin from "~/models/Pin";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import EmojiIcon from "./Icons/EmojiIcon";
|
||||
import Squircle from "./Squircle";
|
||||
@@ -37,9 +35,7 @@ function DocumentCard(props: Props) {
|
||||
const { collections } = useStores();
|
||||
const theme = useTheme();
|
||||
const { document, pin, canUpdatePin, isDraggable } = props;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const collection = collections.get(document.collectionId);
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -58,10 +54,10 @@ function DocumentCard(props: Props) {
|
||||
};
|
||||
|
||||
const handleUnpin = React.useCallback(
|
||||
async (ev) => {
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
await pin?.delete();
|
||||
pin?.delete();
|
||||
},
|
||||
[pin]
|
||||
);
|
||||
@@ -131,7 +127,7 @@ function DocumentCard(props: Props) {
|
||||
: document.titleWithDefault}
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
<Clock size={18} />
|
||||
<Clock color="currentColor" size={18} />
|
||||
<Time
|
||||
dateTime={document.updatedAt}
|
||||
tooltipDelay={500}
|
||||
@@ -146,7 +142,7 @@ function DocumentCard(props: Props) {
|
||||
{!isDragging && pin && (
|
||||
<Tooltip tooltip={t("Unpin")}>
|
||||
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
|
||||
<CloseIcon />
|
||||
<CloseIcon color="currentColor" />
|
||||
</PinButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -168,9 +164,9 @@ const AnimatePresence = styled(m.div)`
|
||||
`;
|
||||
|
||||
const Fold = styled.svg`
|
||||
fill: ${s("background")};
|
||||
stroke: ${s("inputBorder")};
|
||||
background: ${s("background")};
|
||||
fill: ${(props) => props.theme.background};
|
||||
stroke: ${(props) => props.theme.inputBorder};
|
||||
background: ${(props) => props.theme.background};
|
||||
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
@@ -178,11 +174,11 @@ const Fold = styled.svg`
|
||||
`;
|
||||
|
||||
const PinButton = styled(NudeButton)`
|
||||
color: ${s("textTertiary")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
|
||||
&:${hover},
|
||||
&:hover,
|
||||
&:active {
|
||||
color: ${s("text")};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -192,7 +188,7 @@ const Actions = styled(Flex)`
|
||||
right: ${(props) => (props.dir === "rtl" ? "auto" : "4px")};
|
||||
left: ${(props) => (props.dir === "rtl" ? "4px" : "auto")};
|
||||
opacity: 0;
|
||||
color: ${s("textTertiary")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
|
||||
// move actions above content
|
||||
z-index: 2;
|
||||
@@ -210,7 +206,7 @@ const Reorderable = styled.div<{ $isDragging: boolean }>`
|
||||
z-index: ${(props) => (props.$isDragging ? 1 : "inherit")};
|
||||
pointer-events: ${(props) => (props.$isDragging ? "none" : "inherit")};
|
||||
|
||||
&: ${hover} ${Actions} {
|
||||
&:hover ${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
@@ -221,12 +217,14 @@ const Content = styled(Flex)`
|
||||
`;
|
||||
|
||||
const DocumentMeta = styled(Text)`
|
||||
${ellipsis()}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: ${s("textTertiary")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
margin: 0 0 0 -2px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)<{
|
||||
@@ -239,9 +237,9 @@ const DocumentLink = styled(Link)<{
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
cursor: var(--pointer);
|
||||
background: ${s("background")};
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: transform 50ms ease-in-out;
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
border-bottom-width: 2px;
|
||||
border-right-width: 2px;
|
||||
|
||||
@@ -249,7 +247,7 @@ const DocumentLink = styled(Link)<{
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:${hover},
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
@@ -278,7 +276,7 @@ const Heading = styled.h3`
|
||||
max-height: 66px; // 3*line-height
|
||||
overflow: hidden;
|
||||
|
||||
color: ${s("text")};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
@@ -45,8 +45,10 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
);
|
||||
const [initialScrollOffset, setInitialScrollOffset] =
|
||||
React.useState<number>(0);
|
||||
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<
|
||||
@@ -61,13 +63,11 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const searchIndex = React.useMemo(
|
||||
() =>
|
||||
new FuzzySearch(items, ["title"], {
|
||||
caseSensitive: false,
|
||||
}),
|
||||
[items]
|
||||
);
|
||||
const searchIndex = React.useMemo(() => {
|
||||
return new FuzzySearch(items, ["title"], {
|
||||
caseSensitive: false,
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (searchTerm) {
|
||||
@@ -77,6 +77,19 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
setActiveNode(0);
|
||||
}, [searchTerm]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let results;
|
||||
|
||||
if (searchTerm) {
|
||||
results = searchIndex.search(searchTerm);
|
||||
} else {
|
||||
results = items.filter((item) => item.type === "collection");
|
||||
}
|
||||
|
||||
setInitialScrollOffset(0);
|
||||
setNodes(results);
|
||||
}, [searchTerm, items, searchIndex]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setItemRefs((itemRefs) =>
|
||||
map(
|
||||
@@ -90,22 +103,6 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
function getNodes() {
|
||||
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
||||
return expandedNodes.includes(item.id)
|
||||
? [item, ...descendants(item, 1).flatMap(includeDescendants)]
|
||||
: [item];
|
||||
}
|
||||
|
||||
return searchTerm
|
||||
? searchIndex.search(searchTerm)
|
||||
: items
|
||||
.filter((item) => item.type === "collection")
|
||||
.flatMap(includeDescendants);
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
if (itemRefs[node] && itemRefs[node].current) {
|
||||
@@ -122,7 +119,9 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
setSearchTerm(ev.target.value);
|
||||
};
|
||||
|
||||
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
|
||||
const isExpanded = (node: number) => {
|
||||
return includes(expandedNodes, nodes[node].id);
|
||||
};
|
||||
|
||||
const calculateInitialScrollOffset = (itemCount: number) => {
|
||||
if (listRef.current) {
|
||||
@@ -131,7 +130,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
scrollOffset: number;
|
||||
};
|
||||
const itemsHeight = itemCount * itemSize;
|
||||
return itemsHeight < Number(height) ? 0 : scrollOffset;
|
||||
return itemsHeight < height ? 0 : scrollOffset;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
@@ -146,6 +145,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
const expand = (node: number) => {
|
||||
@@ -156,18 +156,9 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
collections.orderedData
|
||||
.filter(
|
||||
(collection) => expandedNodes.includes(collection.id) || searchTerm
|
||||
)
|
||||
.forEach((collection) => {
|
||||
void collection.fetchDocuments();
|
||||
});
|
||||
}, [collections, expandedNodes, searchTerm]);
|
||||
|
||||
const isSelected = (node: number) => {
|
||||
if (!selectedNode) {
|
||||
return false;
|
||||
@@ -178,8 +169,9 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
return selectedNodeId === nodeId;
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) =>
|
||||
nodes[node].children.length > 0 || nodes[node].type === "collection";
|
||||
const hasChildren = (node: number) => {
|
||||
return nodes[node].children.length > 0;
|
||||
};
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
@@ -229,7 +221,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
} else if (doc?.isStarred) {
|
||||
icon = <StarredIcon color={theme.yellow} />;
|
||||
} else {
|
||||
icon = <DocumentIcon color={theme.textSecondary} />;
|
||||
icon = <DocumentIcon />;
|
||||
}
|
||||
|
||||
path = ancestors(node)
|
||||
@@ -283,22 +275,24 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
inputSearchRef.current?.focus();
|
||||
};
|
||||
|
||||
const next = () => Math.min(activeNode + 1, nodes.length - 1);
|
||||
const next = () => {
|
||||
return Math.min(activeNode + 1, nodes.length - 1);
|
||||
};
|
||||
|
||||
const prev = () => Math.max(activeNode - 1, 0);
|
||||
const prev = () => {
|
||||
return Math.max(activeNode - 1, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown": {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setActiveNode(next());
|
||||
scrollNodeIntoView(next());
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (activeNode === 0) {
|
||||
focusSearchInput();
|
||||
} else {
|
||||
@@ -317,7 +311,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
if (!searchTerm) {
|
||||
toggleCollapse(activeNode);
|
||||
// let the nodes re-render first and then scroll
|
||||
setTimeout(() => scrollNodeIntoView(activeNode), 0);
|
||||
setImmediate(() => scrollNodeIntoView(activeNode));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -335,21 +329,16 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const innerElementType = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(function innerElementType(
|
||||
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
>(({ style, ...rest }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Disclosure from "~/components/Sidebar/components/Disclosure";
|
||||
import Text from "~/components/Text";
|
||||
@@ -71,7 +70,9 @@ function DocumentExplorerNode(
|
||||
}
|
||||
|
||||
const Title = styled(Text)`
|
||||
${ellipsis()}
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0 4px 0 4px;
|
||||
color: inherit;
|
||||
`;
|
||||
@@ -98,7 +99,7 @@ export const Node = styled.span<{
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
width: ${(props) => props.style.width};
|
||||
color: ${s("text")};
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: var(--pointer);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
@@ -116,7 +117,7 @@ export const Node = styled.span<{
|
||||
${(props) =>
|
||||
props.selected &&
|
||||
`
|
||||
background: ${props.theme.accent};
|
||||
background: ${props.theme.primary};
|
||||
color: ${props.theme.white};
|
||||
|
||||
svg {
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 { ellipsis } from "@shared/styles";
|
||||
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
@@ -74,8 +73,10 @@ const Title = styled(Text)`
|
||||
`;
|
||||
|
||||
const Path = styled(Text)<{ $selected: boolean }>`
|
||||
${ellipsis()}
|
||||
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};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Link } from "react-router-dom";
|
||||
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
@@ -178,11 +177,11 @@ const Actions = styled(EventBoundary)`
|
||||
margin: 8px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
${NudeButton} {
|
||||
&: ${hover}, &[aria-expanded= "true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${(props) => props.theme.sidebarControlHoverBackground};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +223,7 @@ const DocumentLink = styled(Link)<{
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
@@ -233,7 +232,7 @@ const DocumentLink = styled(Link)<{
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:${hover} {
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -242,7 +241,7 @@ const DocumentLink = styled(Link)<{
|
||||
${(props) =>
|
||||
props.$menuOpen &&
|
||||
css`
|
||||
background: ${s("listItemHoverBackground")};
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
@@ -258,10 +257,12 @@ const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${s("text")};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
@@ -279,7 +280,7 @@ const Title = styled(Highlight)`
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${s("textTertiary")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
|
||||
@@ -4,9 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import DocumentTasks from "~/components/DocumentTasks";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -15,13 +13,11 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showLastViewed?: boolean;
|
||||
showParentDocuments?: boolean;
|
||||
document: Document;
|
||||
revision?: Revision;
|
||||
replace?: boolean;
|
||||
to?: LocationDescriptor;
|
||||
};
|
||||
@@ -32,12 +28,11 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
showLastViewed,
|
||||
showParentDocuments,
|
||||
document,
|
||||
revision,
|
||||
children,
|
||||
replace,
|
||||
to,
|
||||
...rest
|
||||
}: Props) => {
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const user = useCurrentUser();
|
||||
@@ -61,23 +56,12 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const collection = collections.get(document.collectionId);
|
||||
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
|
||||
const userName = updatedBy.name;
|
||||
let content;
|
||||
|
||||
if (revision) {
|
||||
content = (
|
||||
<span>
|
||||
{revision.createdBy?.id === user.id
|
||||
? t("You updated")
|
||||
: t("{{ userName }} updated", { userName })}{" "}
|
||||
<Time dateTime={revision.createdAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (deletedAt) {
|
||||
if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{lastUpdatedByCurrentUser
|
||||
@@ -200,7 +184,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
|
||||
const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
color: ${s("textTertiary")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -208,7 +192,8 @@ const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
`;
|
||||
|
||||
const Viewed = styled.span`
|
||||
${ellipsis()}
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Modified = styled.span<{ highlight?: boolean }>`
|
||||
|
||||
+9
-46
@@ -1,71 +1,41 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer, useObserver } from "mobx-react";
|
||||
import { CommentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Fade from "~/components/Fade";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
|
||||
import { documentUrl, documentInsightsUrl } from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
|
||||
type Props = {
|
||||
/* The document to display meta data for */
|
||||
document: Document;
|
||||
revision?: Revision;
|
||||
isDraft: boolean;
|
||||
to?: LocationDescriptor;
|
||||
rtl?: boolean;
|
||||
};
|
||||
|
||||
function TitleDocumentMeta({
|
||||
to,
|
||||
isDraft,
|
||||
document,
|
||||
revision,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { auth, views, comments, ui } = useStores();
|
||||
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
const { views } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { team } = auth;
|
||||
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 insightsUrl = documentInsightsUrl(document);
|
||||
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
|
||||
|
||||
const insightsPath = documentInsightsPath(document);
|
||||
const commentsCount = comments.inDocument(document.id).length;
|
||||
|
||||
return (
|
||||
<Meta document={document} revision={revision} to={to} replace {...rest}>
|
||||
{team?.getPreference(TeamPreference.Commenting) && (
|
||||
<>
|
||||
•
|
||||
<CommentLink
|
||||
to={documentPath(document)}
|
||||
onClick={() => ui.toggleComments(document.id)}
|
||||
>
|
||||
<CommentIcon size={18} />
|
||||
{commentsCount
|
||||
? t("{{ count }} comment", { count: commentsCount })
|
||||
: t("Comment")}
|
||||
</CommentLink>
|
||||
</>
|
||||
)}
|
||||
<Meta document={document} to={to} replace {...rest}>
|
||||
{totalViewers && !isDraft ? (
|
||||
<Wrapper>
|
||||
•
|
||||
<Link
|
||||
to={
|
||||
match.url === insightsPath ? documentPath(document) : insightsPath
|
||||
}
|
||||
to={match.url === insightsUrl ? documentUrl(document) : insightsUrl}
|
||||
>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
@@ -80,22 +50,15 @@ function TitleDocumentMeta({
|
||||
);
|
||||
}
|
||||
|
||||
const CommentLink = styled(Link)`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
@@ -107,4 +70,4 @@ export const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(TitleDocumentMeta);
|
||||
export default observer(DocumentMetaWithViews);
|
||||
@@ -44,7 +44,7 @@ function DocumentTasks({ document }: Props) {
|
||||
<>
|
||||
{completed === total ? (
|
||||
<Done
|
||||
color={theme.accent}
|
||||
color={theme.primary}
|
||||
size={20}
|
||||
$animated={done && previousDone === false}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useHistory } from "react-router-dom";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { documentUrl } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
@@ -23,7 +23,7 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const template = await document?.templatize();
|
||||
if (template) {
|
||||
history.push(documentPath(template));
|
||||
history.push(documentUrl(template));
|
||||
showToast(t("Template created, go ahead and customize it"), {
|
||||
type: "info",
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dateToRelative } from "@shared/utils/date";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
@@ -33,10 +33,9 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
documentViews,
|
||||
(view) => !presentIds.includes(view.user.id)
|
||||
);
|
||||
const users = React.useMemo(
|
||||
() => sortedViews.map((v) => v.user),
|
||||
[sortedViews]
|
||||
);
|
||||
const users = React.useMemo(() => sortedViews.map((v) => v.user), [
|
||||
sortedViews,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -53,7 +52,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
? t("Currently editing")
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }} ago", {
|
||||
timeAgo: dateToRelative(
|
||||
timeAgo: formatDistanceToNow(
|
||||
view ? Date.parse(view.lastViewedAt) : new Date()
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
|
||||
type Props = {
|
||||
enabled: boolean;
|
||||
collection: Collection;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function DocumentsLoader({ collection, enabled, children }: Props) {
|
||||
React.useEffect(() => {
|
||||
if (enabled) {
|
||||
void collection.fetchDocuments();
|
||||
}
|
||||
}, [collection, enabled]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default observer(DocumentsLoader);
|
||||
+38
-76
@@ -1,15 +1,14 @@
|
||||
import { deburr, difference, sortBy } from "lodash";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useHistory } from "react-router-dom";
|
||||
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 { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
@@ -23,16 +22,21 @@ import useDictionary from "~/hooks/useDictionary";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import history from "~/utils/history";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
|
||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
const LazyLoadedEditor = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "preload-shared-editor" */
|
||||
"~/editor"
|
||||
)
|
||||
);
|
||||
|
||||
export type Props = Optional<
|
||||
EditorProps,
|
||||
@@ -49,31 +53,22 @@ export type Props = Optional<
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => any;
|
||||
editorStyle?: React.CSSProperties;
|
||||
bottomPadding?: string;
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const {
|
||||
id,
|
||||
shareId,
|
||||
onChange,
|
||||
onHeadingsChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
} = props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
const { auth, comments, documents } = useStores();
|
||||
const { id, shareId, onChange, onHeadingsChange } = props;
|
||||
const { documents, auth } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const history = useHistory();
|
||||
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 previousCommentIds = React.useRef<string[]>();
|
||||
const [
|
||||
activeLinkElement,
|
||||
setActiveLink,
|
||||
] = React.useState<HTMLAnchorElement | null>(null);
|
||||
|
||||
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
|
||||
setActiveLink(element);
|
||||
@@ -95,10 +90,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
try {
|
||||
const document = await documents.fetch(slug);
|
||||
const time = dateToRelative(Date.parse(document.updatedAt), {
|
||||
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
shorten: true,
|
||||
locale,
|
||||
});
|
||||
|
||||
return [
|
||||
@@ -120,11 +113,13 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const results = await documents.searchTitles(term);
|
||||
|
||||
return sortBy(
|
||||
results.map((document: Document) => ({
|
||||
title: document.title,
|
||||
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
||||
url: document.url,
|
||||
})),
|
||||
results.map((document: Document) => {
|
||||
return {
|
||||
title: document.title,
|
||||
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
||||
url: document.url,
|
||||
};
|
||||
}),
|
||||
(document) =>
|
||||
deburr(document.title)
|
||||
.toLowerCase()
|
||||
@@ -136,7 +131,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[documents]
|
||||
);
|
||||
|
||||
const handleUploadFile = React.useCallback(
|
||||
const onUploadFile = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
documentId: id,
|
||||
@@ -147,7 +142,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[id]
|
||||
);
|
||||
|
||||
const handleClickLink = React.useCallback(
|
||||
const onClickLink = React.useCallback(
|
||||
(href: string, event: MouseEvent) => {
|
||||
// on page hash
|
||||
if (isHash(href)) {
|
||||
@@ -186,7 +181,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
[history, shareId]
|
||||
[shareId]
|
||||
);
|
||||
|
||||
const focusAtEnd = React.useCallback(() => {
|
||||
@@ -234,7 +229,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
);
|
||||
|
||||
insertFiles(view, event, pos, files, {
|
||||
uploadFile: handleUploadFile,
|
||||
uploadFile: onUploadFile,
|
||||
onFileUploadStart: props.onFileUploadStart,
|
||||
onFileUploadStop: props.onFileUploadStop,
|
||||
onShowToast: showToast,
|
||||
@@ -247,7 +242,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
props.onFileUploadStart,
|
||||
props.onFileUploadStop,
|
||||
dictionary,
|
||||
handleUploadFile,
|
||||
onUploadFile,
|
||||
showToast,
|
||||
]
|
||||
);
|
||||
@@ -276,80 +271,47 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
}
|
||||
}, [localRef, onHeadingsChange]);
|
||||
|
||||
const updateComments = React.useCallback(() => {
|
||||
if (onCreateCommentMark && onDeleteCommentMark) {
|
||||
const commentMarks = localRef.current?.getComments();
|
||||
const commentIds = comments.orderedData.map((c) => c.id);
|
||||
const commentMarkIds = commentMarks?.map((c) => c.id);
|
||||
const newCommentIds = difference(
|
||||
commentMarkIds,
|
||||
previousCommentIds.current ?? [],
|
||||
commentIds
|
||||
);
|
||||
|
||||
newCommentIds.forEach((commentId) => {
|
||||
const mark = commentMarks?.find((c) => c.id === commentId);
|
||||
if (mark) {
|
||||
onCreateCommentMark(mark.id, mark.userId);
|
||||
}
|
||||
});
|
||||
|
||||
const removedCommentIds = difference(
|
||||
previousCommentIds.current ?? [],
|
||||
commentMarkIds ?? []
|
||||
);
|
||||
|
||||
removedCommentIds.forEach((commentId) => {
|
||||
onDeleteCommentMark(commentId);
|
||||
});
|
||||
|
||||
previousCommentIds.current = commentMarkIds;
|
||||
}
|
||||
}, [onCreateCommentMark, onDeleteCommentMark, comments.orderedData]);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event) => {
|
||||
onChange?.(event);
|
||||
updateHeadings();
|
||||
updateComments();
|
||||
},
|
||||
[onChange, updateComments, updateHeadings]
|
||||
[onChange, updateHeadings]
|
||||
);
|
||||
|
||||
const handleRefChanged = React.useCallback(
|
||||
(node: SharedEditor | null) => {
|
||||
if (node) {
|
||||
updateHeadings();
|
||||
updateComments();
|
||||
}
|
||||
},
|
||||
[updateComments, updateHeadings]
|
||||
[updateHeadings]
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary component="div" reloadOnChunkMissing>
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={handleUploadFile}
|
||||
uploadFile={onUploadFile}
|
||||
onShowToast={showToast}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onHoverLink={handleLinkActive}
|
||||
onClickLink={handleClickLink}
|
||||
onClickLink={onClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.editorStyle?.paddingBottom && !props.readOnly && (
|
||||
{props.bottomPadding && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={focusAtEnd}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
minHeight={props.bottomPadding}
|
||||
/>
|
||||
)}
|
||||
{activeLinkElement && !shareId && (
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Empty = styled.p`
|
||||
color: ${s("textTertiary")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, WithTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { githubIssuesUrl, feedbackUrl } from "@shared/utils/urlHelpers";
|
||||
import { githubIssuesUrl } from "@shared/utils/urlHelpers";
|
||||
import Button from "~/components/Button";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
@@ -14,12 +13,7 @@ import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
/** Whether to reload the page if a chunk fails to load. */
|
||||
reloadOnChunkMissing?: boolean;
|
||||
/** Whether to show a title heading. */
|
||||
showTitle?: boolean;
|
||||
/** The wrapping component to use. */
|
||||
component?: React.ComponentType | string;
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -36,7 +30,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
if (
|
||||
this.props.reloadOnChunkMissing &&
|
||||
error.message &&
|
||||
error.message.match(/dynamically imported module/)
|
||||
error.message.match(/chunk/)
|
||||
) {
|
||||
// If the editor bundle fails to load then reload the entire window. This
|
||||
// can happen if a deploy happens between the user loading the initial JS
|
||||
@@ -57,31 +51,24 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
};
|
||||
|
||||
handleReportBug = () => {
|
||||
window.open(isCloudHosted ? feedbackUrl() : githubIssuesUrl());
|
||||
window.open(githubIssuesUrl());
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, component: Component = CenteredContent, showTitle } = this.props;
|
||||
const { t } = this.props;
|
||||
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!env.SENTRY_DSN && isCloudHosted;
|
||||
const isChunkError = [
|
||||
"module script failed",
|
||||
"dynamically imported module",
|
||||
].some((msg) => this.error?.message?.includes(msg));
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
return (
|
||||
<Component>
|
||||
{showTitle && (
|
||||
<>
|
||||
<PageTitle title={t("Module failed to load")} />
|
||||
<h1>
|
||||
<Trans>Loading Failed</Trans>
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Module failed to load")} />
|
||||
<h1>
|
||||
<Trans>Loading Failed</Trans>
|
||||
</h1>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Sorry, part of the application failed to load. This may be
|
||||
@@ -92,20 +79,16 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>{t("Reload")}</Button>
|
||||
</p>
|
||||
</Component>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Component>
|
||||
{showTitle && (
|
||||
<>
|
||||
<PageTitle title={t("Something Unexpected Happened")} />
|
||||
<h1>
|
||||
<Trans>Something Unexpected Happened</Trans>
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Something Unexpected Happened")} />
|
||||
<h1>
|
||||
<Trans>Something Unexpected Happened</Trans>
|
||||
</h1>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
|
||||
@@ -129,7 +112,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
</Component>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +121,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const Pre = styled.pre`
|
||||
background: ${s("secondaryBackground")};
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const EventBoundary: React.FC<Props> = ({ children, className }: Props) => {
|
||||
const EventBoundary: React.FC<Props> = ({ children, className }) => {
|
||||
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
UnpublishIcon,
|
||||
LightningIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { CompositeStateReturn } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import Avatar from "~/components/Avatar";
|
||||
@@ -24,9 +24,7 @@ import Item, { Actions } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import { hover } from "~/styles";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
import { documentHistoryUrl } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -51,30 +49,33 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
ref.current?.focus();
|
||||
};
|
||||
|
||||
const prefetchRevision = async () => {
|
||||
const prefetchRevision = () => {
|
||||
if (event.name === "revisions.create" && event.modelId) {
|
||||
await revisions.fetch(event.modelId);
|
||||
revisions.fetch(event.modelId);
|
||||
}
|
||||
};
|
||||
|
||||
switch (event.name) {
|
||||
case "revisions.create":
|
||||
icon = <EditIcon size={16} />;
|
||||
meta = latest ? (
|
||||
<>
|
||||
{t("Current version")} · {event.actor.name}
|
||||
</>
|
||||
) : (
|
||||
t("{{userName}} edited", opts)
|
||||
);
|
||||
icon = <EditIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} edited", opts);
|
||||
to = {
|
||||
pathname: documentHistoryPath(document, event.modelId || "latest"),
|
||||
pathname: documentHistoryUrl(document, event.modelId || ""),
|
||||
state: { retainScrollPosition: true },
|
||||
};
|
||||
break;
|
||||
|
||||
case "documents.live_editing":
|
||||
icon = <LightningIcon color="currentColor" size={16} />;
|
||||
meta = t("Latest");
|
||||
to = {
|
||||
pathname: documentHistoryUrl(document),
|
||||
state: { retainScrollPosition: true },
|
||||
};
|
||||
break;
|
||||
|
||||
case "documents.archive":
|
||||
icon = <ArchiveIcon size={16} />;
|
||||
icon = <ArchiveIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} archived", opts);
|
||||
break;
|
||||
|
||||
@@ -83,7 +84,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
break;
|
||||
|
||||
case "documents.delete":
|
||||
icon = <TrashIcon size={16} />;
|
||||
icon = <TrashIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} deleted", opts);
|
||||
break;
|
||||
|
||||
@@ -92,22 +93,22 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
break;
|
||||
|
||||
case "documents.publish":
|
||||
icon = <PublishIcon size={16} />;
|
||||
icon = <PublishIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} published", opts);
|
||||
break;
|
||||
|
||||
case "documents.unpublish":
|
||||
icon = <UnpublishIcon size={16} />;
|
||||
icon = <UnpublishIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} unpublished", opts);
|
||||
break;
|
||||
|
||||
case "documents.move":
|
||||
icon = <MoveIcon size={16} />;
|
||||
icon = <MoveIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} moved", opts);
|
||||
break;
|
||||
|
||||
default:
|
||||
Logger.warn("Unhandled event", { event });
|
||||
console.warn("Unhandled event: ", event.name);
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
@@ -149,7 +150,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
</Subtitle>
|
||||
}
|
||||
actions={
|
||||
isRevision && isActive && event.modelId && !latest ? (
|
||||
isRevision && isActive && event.modelId ? (
|
||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||
) : undefined
|
||||
}
|
||||
@@ -160,16 +161,15 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const BaseItem = React.forwardRef(function _BaseItem(
|
||||
{ to, ...rest }: ItemProps,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
if (to) {
|
||||
return <CompositeListItem to={to} ref={ref} {...rest} />;
|
||||
}
|
||||
const BaseItem = React.forwardRef(
|
||||
({ to, ...rest }: ItemProps, ref?: React.Ref<HTMLAnchorElement>) => {
|
||||
if (to) {
|
||||
return <CompositeListItem to={to} ref={ref} {...rest} />;
|
||||
}
|
||||
|
||||
return <ListItem ref={ref} {...rest} />;
|
||||
});
|
||||
return <ListItem ref={ref} {...rest} />;
|
||||
}
|
||||
);
|
||||
|
||||
const Subtitle = styled.span`
|
||||
svg {
|
||||
@@ -197,7 +197,7 @@ const ItemStyle = css`
|
||||
left: 23px;
|
||||
width: 2px;
|
||||
height: calc(100% + 8px);
|
||||
background: ${s("textSecondary")};
|
||||
background: ${(props) => props.theme.textSecondary};
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ const ItemStyle = css`
|
||||
${Actions} {
|
||||
opacity: 0.5;
|
||||
|
||||
&: ${hover} {
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { FileOperationFormat, NotificationEventType } from "@shared/types";
|
||||
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 useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
@@ -21,14 +20,15 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
const [format, setFormat] = React.useState<FileOperationFormat>(
|
||||
FileOperationFormat.MarkdownZip
|
||||
);
|
||||
const [includeAttachments, setIncludeAttachments] =
|
||||
React.useState<boolean>(true);
|
||||
const user = useCurrentUser();
|
||||
const { showToast } = useToasts();
|
||||
const { collections } = useStores();
|
||||
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);
|
||||
@@ -36,18 +36,11 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleIncludeAttachmentsChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIncludeAttachments(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (collection) {
|
||||
await collection.export(format, includeAttachments);
|
||||
await collection.export(format);
|
||||
} else {
|
||||
await collections.export(format, includeAttachments);
|
||||
await collections.export(format);
|
||||
}
|
||||
onSubmit();
|
||||
showToast(t("Export started"), { type: "success" });
|
||||
@@ -93,13 +86,13 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
em: <strong />,
|
||||
}}
|
||||
/>{" "}
|
||||
{user.subscribedToEventType(NotificationEventType.ExportCompleted) &&
|
||||
{notificationSettings.getByEvent("emails.export_completed") &&
|
||||
t("You will receive an email when it's complete.")}
|
||||
</Text>
|
||||
)}
|
||||
<Flex gap={12} column>
|
||||
{items.map((item) => (
|
||||
<Option key={item.value}>
|
||||
<Option>
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
@@ -116,23 +109,6 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
</Option>
|
||||
))}
|
||||
</Flex>
|
||||
<hr />
|
||||
<Option>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includeAttachments"
|
||||
checked={includeAttachments}
|
||||
onChange={handleIncludeAttachmentsChange}
|
||||
/>
|
||||
<div>
|
||||
<Text size="small" weight="bold">
|
||||
{t("Include attachments")}
|
||||
</Text>
|
||||
<Text size="small">
|
||||
{t("Including uploaded images and files in the exported data")}.
|
||||
</Text>{" "}
|
||||
</div>
|
||||
</Option>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -60,8 +59,8 @@ const More = styled.div<{ size: number }>`
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 100%;
|
||||
background: ${(props) => props.theme.slate};
|
||||
color: ${s("text")};
|
||||
border: 2px solid ${s("background")};
|
||||
color: ${(props) => props.theme.text};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
@@ -81,7 +80,7 @@ const Note = styled(Text)`
|
||||
line-height: 1.2em;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: ${s("textTertiary")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
const LabelWithNote = styled.div`
|
||||
|
||||
@@ -10,7 +10,6 @@ const Flex = styled.div<{
|
||||
column?: boolean;
|
||||
align?: AlignValues;
|
||||
justify?: JustifyValues;
|
||||
wrap?: boolean;
|
||||
shrink?: boolean;
|
||||
reverse?: boolean;
|
||||
gap?: number;
|
||||
@@ -27,9 +26,7 @@ const Flex = styled.div<{
|
||||
: "row"};
|
||||
align-items: ${({ align }) => align};
|
||||
justify-content: ${({ justify }) => justify};
|
||||
flex-wrap: ${({ wrap }) => (wrap ? "wrap" : "initial")};
|
||||
flex-shrink: ${({ shrink }) =>
|
||||
shrink === true ? 1 : shrink === false ? 0 : "initial"};
|
||||
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
|
||||
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembers from "~/scenes/GroupMembers";
|
||||
@@ -14,7 +13,6 @@ import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
@@ -28,8 +26,11 @@ type Props = {
|
||||
function GroupListItem({ group, showFacepile, renderActions }: Props) {
|
||||
const { groupMemberships } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
|
||||
useBoolean();
|
||||
const [
|
||||
membersModalOpen,
|
||||
setMembersModalOpen,
|
||||
setMembersModalClosed,
|
||||
] = useBoolean();
|
||||
const memberCount = group.memberCount;
|
||||
const membershipsInGroup = groupMemberships.inGroup(group.id);
|
||||
const users = membershipsInGroup
|
||||
@@ -80,12 +81,12 @@ const Image = styled(Flex)`
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${s("secondaryBackground")};
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
&: ${hover} {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { depths } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
onRequestClose: () => void;
|
||||
@@ -18,7 +17,7 @@ const Guide: React.FC<Props> = ({
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
...rest
|
||||
}: Props) => {
|
||||
}) => {
|
||||
const dialog = useDialogState({
|
||||
animated: 250,
|
||||
});
|
||||
@@ -72,7 +71,7 @@ const Backdrop = styled.div`
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${s("backdrop")} !important;
|
||||
background-color: ${(props) => props.theme.backdrop} !important;
|
||||
z-index: ${depths.modalOverlay};
|
||||
transition: opacity 200ms ease-in-out;
|
||||
opacity: 0;
|
||||
@@ -93,8 +92,8 @@ const Scene = styled.div`
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 350px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
opacity: 0;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { depths } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -63,6 +63,7 @@ function Header({ left, title, actions, hasSidebar }: Props) {
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
iconColor="currentColor"
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
@@ -112,7 +113,7 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
top: 0;
|
||||
z-index: ${depths.header};
|
||||
position: sticky;
|
||||
background: ${s("background")};
|
||||
background: ${(props) => props.theme.background};
|
||||
|
||||
${(props) =>
|
||||
props.$passThrough
|
||||
@@ -131,11 +132,7 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
min-height: 64px;
|
||||
justify-content: flex-start;
|
||||
${draggableOnDesktop()}
|
||||
|
||||
button,
|
||||
[role="button"] {
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
}
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { escapeRegExp } from "lodash";
|
||||
import * as React from "react";
|
||||
import replace from "string-replace-to-array";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLSpanElement> & {
|
||||
highlight: (string | null | undefined) | RegExp;
|
||||
@@ -44,7 +43,7 @@ function Highlight({
|
||||
}
|
||||
|
||||
export const Mark = styled.mark`
|
||||
background: ${s("searchHighlight")};
|
||||
background: ${(props) => props.theme.searchHighlight};
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isExternalUrl } from "@shared/utils/urls";
|
||||
import HoverPreviewDocument from "~/components/HoverPreviewDocument";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { fadeAndSlideDown } from "~/styles/animations";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 300;
|
||||
|
||||
type Props = {
|
||||
element: HTMLAnchorElement;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const { documents } = useStores();
|
||||
const slug = parseDocumentSlug(element.href);
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const startCloseTimer = () => {
|
||||
stopOpenTimer();
|
||||
timerClose.current = setTimeout(() => {
|
||||
if (isVisible) {
|
||||
setVisible(false);
|
||||
}
|
||||
onClose();
|
||||
}, DELAY_CLOSE);
|
||||
};
|
||||
|
||||
const stopCloseTimer = () => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
|
||||
const startOpenTimer = () => {
|
||||
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
|
||||
};
|
||||
|
||||
const stopOpenTimer = () => {
|
||||
if (timerOpen.current) {
|
||||
clearTimeout(timerOpen.current);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (slug) {
|
||||
documents.prefetchDocument(slug);
|
||||
}
|
||||
|
||||
startOpenTimer();
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseenter", stopCloseTimer);
|
||||
}
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
element.addEventListener("mouseout", startCloseTimer);
|
||||
element.addEventListener("mouseover", stopCloseTimer);
|
||||
element.addEventListener("mouseover", startOpenTimer);
|
||||
return () => {
|
||||
element.removeEventListener("mouseout", startCloseTimer);
|
||||
element.removeEventListener("mouseover", stopCloseTimer);
|
||||
element.removeEventListener("mouseover", startOpenTimer);
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseenter", stopCloseTimer);
|
||||
}
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
}, [element, slug]);
|
||||
|
||||
const anchorBounds = element.getBoundingClientRect();
|
||||
const cardBounds = cardRef.current?.getBoundingClientRect();
|
||||
const left = cardBounds
|
||||
? Math.min(anchorBounds.left, window.innerWidth - 16 - 350)
|
||||
: anchorBounds.left;
|
||||
const leftOffset = anchorBounds.left - left;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position
|
||||
top={anchorBounds.bottom + window.scrollY}
|
||||
left={left}
|
||||
aria-hidden
|
||||
>
|
||||
<div ref={cardRef}>
|
||||
<HoverPreviewDocument url={element.href}>
|
||||
{(content: React.ReactNode) =>
|
||||
isVisible ? (
|
||||
<Animate>
|
||||
<Card>
|
||||
<Margin />
|
||||
<CardContent>{content}</CardContent>
|
||||
</Card>
|
||||
<Pointer offset={leftOffset + anchorBounds.width / 2} />
|
||||
</Animate>
|
||||
) : null
|
||||
}
|
||||
</HoverPreviewDocument>
|
||||
</div>
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ element, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// previews only work for internal doc links for now
|
||||
if (isExternalUrl(element.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HoverPreviewInternal {...rest} element={element} />;
|
||||
}
|
||||
|
||||
const Animate = styled.div`
|
||||
animation: ${fadeAndSlideDown} 150ms ease;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
// fills the gap between the card and pointer to avoid a dead zone
|
||||
const Margin = styled.div`
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 11px;
|
||||
`;
|
||||
|
||||
const CardContent = styled.div`
|
||||
overflow: hidden;
|
||||
max-height: 20em;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// &:after — gradient mask for overflow text
|
||||
const Card = styled.div`
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${(props) => props.theme.background};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
padding: 16px;
|
||||
width: 350px;
|
||||
font-size: 0.9em;
|
||||
position: relative;
|
||||
|
||||
.placeholder,
|
||||
.heading-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${(props) => transparentize(1, props.theme.background)} 75%,
|
||||
${(props) => props.theme.background} 90%
|
||||
);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1.7em;
|
||||
border-bottom: 16px solid ${(props) => props.theme.background};
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${depths.hoverPreview};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div<{ offset: number }>`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
border-bottom-color: ${(props) =>
|
||||
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: ${(props) => props.theme.background};
|
||||
}
|
||||
`;
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -1,108 +0,0 @@
|
||||
import { transparentize } from "polished";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
export const CARD_MARGIN = 16;
|
||||
|
||||
const NUMBER_OF_LINES = 10;
|
||||
|
||||
const sharedVars = css`
|
||||
--line-height: 1.6em;
|
||||
`;
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
export const Preview = styled(Link)`
|
||||
cursor: ${(props: any) =>
|
||||
props.as === "div" ? "default" : "var(--pointer)"};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
min-width: 350px;
|
||||
max-width: 375px;
|
||||
`;
|
||||
|
||||
export const Title = styled.h2`
|
||||
font-size: 1.25em;
|
||||
margin: 0;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
export const Info = styled(StyledText).attrs(() => ({
|
||||
type: "tertiary",
|
||||
size: "xsmall",
|
||||
}))`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const Description = styled(StyledText)`
|
||||
${sharedVars}
|
||||
margin-top: 0.5em;
|
||||
line-height: var(--line-height);
|
||||
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
|
||||
`;
|
||||
|
||||
export const Thumbnail = styled.img`
|
||||
object-fit: cover;
|
||||
height: 200px;
|
||||
background: ${s("menuBackground")};
|
||||
`;
|
||||
|
||||
export const CardContent = styled.div`
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// &:after — gradient mask for overflow text
|
||||
export const Card = styled.div<{ fadeOut?: boolean; $borderRadius?: string }>`
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${s("menuBackground")};
|
||||
padding: 16px;
|
||||
font-size: 0.9em;
|
||||
position: relative;
|
||||
|
||||
.placeholder,
|
||||
.heading-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// fills the gap between the card and pointer to avoid a dead zone
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.fadeOut !== false
|
||||
? `&:after {
|
||||
${sharedVars}
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${transparentize(1, props.theme.menuBackground)} 0%,
|
||||
${transparentize(1, props.theme.menuBackground)} 75%,
|
||||
${props.theme.menuBackground} 90%
|
||||
);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--line-height);
|
||||
border-bottom: 16px solid ${props.theme.menuBackground};
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}`
|
||||
: ""}
|
||||
`;
|
||||
@@ -1,261 +0,0 @@
|
||||
import { m } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { UnfurlType } from "@shared/types";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { CARD_MARGIN } from "./Components";
|
||||
import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 600;
|
||||
|
||||
type Props = {
|
||||
/* The HTML element that is being hovered over */
|
||||
element: HTMLAnchorElement;
|
||||
/* A callback on close of the hover preview */
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const url = element.href || element.dataset.url;
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement>(null);
|
||||
const stores = useStores();
|
||||
const [cardLeft, setCardLeft] = React.useState(0);
|
||||
const [cardTop, setCardTop] = React.useState(0);
|
||||
const [pointerOffset, setPointerOffset] = React.useState(0);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isVisible && cardRef.current) {
|
||||
const elem = element.getBoundingClientRect();
|
||||
const card = cardRef.current.getBoundingClientRect();
|
||||
|
||||
const top = elem.bottom + window.scrollY;
|
||||
setCardTop(top);
|
||||
|
||||
let left = elem.left;
|
||||
let pointerOffset = elem.width / 2;
|
||||
if (left + card.width > window.innerWidth) {
|
||||
// shift card leftwards by the amount it went out of screen
|
||||
let shiftBy = left + card.width - window.innerWidth;
|
||||
// shift a littler further to leave some margin between card and window boundary
|
||||
shiftBy += CARD_MARGIN;
|
||||
left -= shiftBy;
|
||||
|
||||
// shift pointer rightwards by same amount so as to position it back correctly
|
||||
pointerOffset += shiftBy;
|
||||
}
|
||||
setCardLeft(left);
|
||||
|
||||
setPointerOffset(pointerOffset);
|
||||
}
|
||||
}, [isVisible, element]);
|
||||
|
||||
const { data, request, loading } = useRequest(
|
||||
React.useCallback(
|
||||
() =>
|
||||
client.post("/urls.unfurl", {
|
||||
url,
|
||||
documentId: stores.ui.activeDocumentId,
|
||||
}),
|
||||
[url, stores.ui.activeDocumentId]
|
||||
)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (url) {
|
||||
stopOpenTimer();
|
||||
setVisible(false);
|
||||
|
||||
void request();
|
||||
}
|
||||
}, [url, request]);
|
||||
|
||||
const stopOpenTimer = () => {
|
||||
if (timerOpen.current) {
|
||||
clearTimeout(timerOpen.current);
|
||||
timerOpen.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const closePreview = React.useCallback(() => {
|
||||
if (isVisible) {
|
||||
stopOpenTimer();
|
||||
setVisible(false);
|
||||
onClose();
|
||||
}
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
useOnClickOutside(cardRef, closePreview);
|
||||
useKeyDown("Escape", closePreview);
|
||||
useEventListener("scroll", closePreview, window, { capture: true });
|
||||
|
||||
const stopCloseTimer = () => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
timerClose.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const startOpenTimer = () => {
|
||||
if (!timerOpen.current) {
|
||||
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
|
||||
}
|
||||
};
|
||||
|
||||
const startCloseTimer = React.useCallback(() => {
|
||||
stopOpenTimer();
|
||||
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
|
||||
}, [closePreview]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
|
||||
if (data) {
|
||||
startOpenTimer();
|
||||
|
||||
if (card) {
|
||||
card.addEventListener("mouseenter", stopCloseTimer);
|
||||
card.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
element.addEventListener("mouseout", startCloseTimer);
|
||||
element.addEventListener("mouseover", stopCloseTimer);
|
||||
element.addEventListener("mouseover", startOpenTimer);
|
||||
}
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("mouseout", startCloseTimer);
|
||||
element.removeEventListener("mouseover", stopCloseTimer);
|
||||
element.removeEventListener("mouseover", startOpenTimer);
|
||||
|
||||
if (card) {
|
||||
card.removeEventListener("mouseenter", stopCloseTimer);
|
||||
card.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
stopCloseTimer();
|
||||
};
|
||||
}, [element, startCloseTimer, data]);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
animate={{ opacity: 1, y: 0, pointerEvents: "auto" }}
|
||||
>
|
||||
{data.type === UnfurlType.Mention ? (
|
||||
<HoverPreviewMention
|
||||
ref={cardRef}
|
||||
url={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
info={data.meta.info}
|
||||
color={data.meta.color}
|
||||
/>
|
||||
) : data.type === UnfurlType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
id={data.meta.id}
|
||||
url={data.url}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
info={data.meta.info}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
thumbnailUrl={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer offset={pointerOffset} />
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ element, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HoverPreviewInternal {...rest} element={element} />;
|
||||
}
|
||||
|
||||
const Animate = styled(m.div)`
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${depths.hoverPreview};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div<{ offset: number }>`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
border-bottom-color: ${(props) =>
|
||||
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: ${s("menuBackground")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -1,54 +0,0 @@
|
||||
import * as React from "react";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Info,
|
||||
Card,
|
||||
CardContent,
|
||||
Description,
|
||||
} from "./Components";
|
||||
|
||||
type Props = {
|
||||
/** Document id associated with the editor, if any */
|
||||
id?: string;
|
||||
/** Document url */
|
||||
url: string;
|
||||
/** Title for the preview card */
|
||||
title: string;
|
||||
/** Info about last activity on the document */
|
||||
info: string;
|
||||
/** Text preview of document content */
|
||||
description: string;
|
||||
};
|
||||
|
||||
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
|
||||
{ id, url, title, info, description }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview to={url}>
|
||||
<Card ref={ref}>
|
||||
<CardContent>
|
||||
<Flex column gap={2}>
|
||||
<Title>{title}</Title>
|
||||
<Info>{info}</Info>
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={id}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewDocument;
|
||||
@@ -1,44 +0,0 @@
|
||||
import * as React from "react";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Description,
|
||||
Card,
|
||||
CardContent,
|
||||
Thumbnail,
|
||||
} from "./Components";
|
||||
|
||||
type Props = {
|
||||
/** Link url */
|
||||
url: string;
|
||||
/** Title for the preview card */
|
||||
title: string;
|
||||
/** Url for thumbnail served by the link provider */
|
||||
thumbnailUrl: string;
|
||||
/** Some description about the link provider */
|
||||
description: string;
|
||||
};
|
||||
|
||||
const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
|
||||
{ url, thumbnailUrl, title, description }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column>
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
|
||||
<Card ref={ref}>
|
||||
<CardContent>
|
||||
<Flex column>
|
||||
<Title>{title}</Title>
|
||||
<Description>{description}</Description>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewLink;
|
||||
@@ -1,46 +0,0 @@
|
||||
import * as React from "react";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
|
||||
type Props = {
|
||||
/** Resource url, avatar url in case of user mention */
|
||||
url: string;
|
||||
/** Title for the preview card*/
|
||||
title: string;
|
||||
/** Info about mentioned user's recent activity */
|
||||
info: string;
|
||||
/** Used for avatar's background color in absence of avatar url */
|
||||
color: string;
|
||||
};
|
||||
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ url, title, info, color }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview as="div">
|
||||
<Card fadeOut={false} ref={ref}>
|
||||
<CardContent>
|
||||
<Flex gap={12}>
|
||||
<Avatar
|
||||
model={{
|
||||
avatarUrl: url,
|
||||
initial: title ? title[0] : "?",
|
||||
color,
|
||||
}}
|
||||
size={AvatarSize.XLarge}
|
||||
/>
|
||||
<Flex column gap={2} justify="center">
|
||||
<Title>{title}</Title>
|
||||
<Info>{info}</Info>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewMention;
|
||||
@@ -1,3 +0,0 @@
|
||||
import HoverPreview from "./HoverPreview";
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
||||
import Editor from "~/components/Editor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
children: (content: React.ReactNode) => React.ReactNode;
|
||||
};
|
||||
|
||||
function HoverPreviewDocument({ url, children }: Props) {
|
||||
const { documents } = useStores();
|
||||
const slug = parseDocumentSlug(url);
|
||||
|
||||
if (slug) {
|
||||
documents.prefetchDocument(slug);
|
||||
}
|
||||
|
||||
const document = slug ? documents.getByUrl(slug) : undefined;
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(
|
||||
<Content to={document.url}>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<DocumentMetaWithViews
|
||||
isDraft={document.isDraft}
|
||||
document={document}
|
||||
/>
|
||||
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={document.id}
|
||||
defaultValue={document.getSummary()}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Content>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled(Link)`
|
||||
cursor: var(--pointer);
|
||||
`;
|
||||
|
||||
const Heading = styled.h2`
|
||||
margin: 0 0 0.75em;
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export default observer(HoverPreviewDocument);
|
||||
@@ -40,23 +40,24 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LabelText } from "~/components/Input";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
|
||||
const style = {
|
||||
width: 30,
|
||||
height: 30,
|
||||
};
|
||||
|
||||
const TwitterPicker = lazyWithRetry(
|
||||
() => import("react-color/lib/components/twitter/Twitter")
|
||||
const TwitterPicker = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "twitter-picker" */
|
||||
"react-color/lib/components/twitter/Twitter"
|
||||
)
|
||||
);
|
||||
|
||||
export const icons = {
|
||||
@@ -241,36 +242,32 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
aria-label={t("Choose icon")}
|
||||
>
|
||||
<Icons>
|
||||
{Object.keys(icons).map((name, index) => (
|
||||
<MenuItem
|
||||
key={name}
|
||||
onClick={() => onChange(color, name)}
|
||||
{...menu}
|
||||
>
|
||||
{(props) => (
|
||||
<IconButton
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
"--delay": `${index * 8}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<Icon as={icons[name].component} color={color} size={30} />
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
{Object.keys(icons).map((name, index) => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={name}
|
||||
onClick={() => onChange(color, name)}
|
||||
{...menu}
|
||||
>
|
||||
{(props) => (
|
||||
<IconButton
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
"--delay": `${index * 8}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<Icon as={icons[name].component} color={color} size={30} />
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Icons>
|
||||
<Colors>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DelayedMount>
|
||||
<Text>{t("Loading")}…</Text>
|
||||
</DelayedMount>
|
||||
}
|
||||
>
|
||||
<React.Suspense fallback={<Loading>{t("Loading")}…</Loading>}>
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(color) => onChange(color.hex, icon)}
|
||||
@@ -323,7 +320,7 @@ const Icons = styled.div`
|
||||
`;
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
border: 1px solid ${s("inputBorder")};
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
`;
|
||||
@@ -335,6 +332,10 @@ const IconButton = styled(NudeButton)`
|
||||
height: 30px;
|
||||
`;
|
||||
|
||||
const Loading = styled(Text)`
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const ColorPicker = styled(TwitterPicker)`
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user