Compare commits

..

5 Commits

Author SHA1 Message Date
Tom Moor 5c6475f776 0.68.1 2023-02-19 12:27:59 -05:00
Tom Moor 0383b8749d 0.68.0 2023-02-19 12:26:03 -05:00
Tom Moor 670bd862f2 test 2023-02-19 12:25:13 -05:00
Tom Moor 41a18a4e85 fix: Cursor position changes on new token with line numbers enabled (#4896)
Move line numbers to psuedo element
2023-02-19 12:25:05 -05:00
Tom Moor 6c069f658b fix: Do not show authentication provider plugins that aren't enabled 2023-02-19 12:24:58 -05:00
940 changed files with 26291 additions and 40114 deletions
+1
View File
@@ -14,6 +14,7 @@
]
],
"plugins": [
"lodash",
"styled-components",
[
"@babel/plugin-proposal-decorators",
+3 -9
View File
@@ -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
View File
@@ -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=
-16
View File
@@ -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",
{
+2 -2
View File
@@ -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
-4
View File
@@ -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
View File
@@ -11,4 +11,4 @@ fakes3/*
.idea
*.pem
*.key
*.cert
*.cert
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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:
+4 -4
View File
@@ -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&amp;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
+9 -3
View File
@@ -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
View File
@@ -1,7 +1,6 @@
{
"extends": [
"../.eslintrc",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
],
"plugins": [
+7 -44
View File
@@ -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 -17
View File
@@ -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];
+31 -71
View File
@@ -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,
];
+6 -22
View File
@@ -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,
];
-16
View File
@@ -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];
+18 -10
View File
@@ -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
)}`;
+2 -3
View File
@@ -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],
+19 -16
View File
@@ -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();
-28
View File
@@ -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];
+4 -2
View File
@@ -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
View File
@@ -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,
-2
View File
@@ -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");
+4 -8
View File
@@ -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} />;
}
+3 -4
View File
@@ -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);
+9 -24
View File
@@ -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);
}, []);
+9 -3
View File
@@ -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>
);
}
-4
View File
@@ -20,10 +20,6 @@ function ArrowKeyNavigation(
const handleKeyDown = React.useCallback(
(ev) => {
if (onEscape) {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "Escape") {
onEscape(ev);
}
+26
View File
@@ -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;
+43
View File
@@ -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;
+49
View File
@@ -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;
+9 -7
View File
@@ -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="/" />;
};
+24 -31
View File
@@ -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>
);
+21 -17
View File
@@ -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;
+1 -2
View File
@@ -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};
}
`}
`;
+2 -6
View File
@@ -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
+4 -4
View File
@@ -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")`
+5 -4
View File
@@ -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")};
+29 -9
View File
@@ -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>
);
-15
View File
@@ -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;
+7 -6
View File
@@ -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;
+2 -2
View File
@@ -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 -4
View File
@@ -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;
+6 -3
View File
@@ -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
+1 -1
View File
@@ -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,
}}
+9 -10
View File
@@ -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,
+35 -13
View File
@@ -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;
+20 -32
View File
@@ -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;
+16 -26
View File
@@ -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;
`;
-53
View File
@@ -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("Im 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);
+2 -11
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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;
`;
+39 -41
View File
@@ -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 />}
&nbsp;
</>
)}
{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};
}
`}
+2 -6
View File
@@ -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>
+29 -32
View File
@@ -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} />}
+15 -22
View File
@@ -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>
+1 -1
View File
@@ -30,7 +30,7 @@ export default function DesktopEventHandler() {
action: {
text: "Install now",
onClick: () => {
void Desktop.bridge?.restartAndInstall();
Desktop.bridge?.restartAndInstall();
},
},
});
+1 -1
View File
@@ -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 -2
View File
@@ -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;
`;
+13 -25
View File
@@ -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;
`;
+21 -23
View File
@@ -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;
`;
+49 -60
View File
@@ -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}>
+5 -4
View File
@@ -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};
+10 -9
View File
@@ -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;
+6 -21
View File
@@ -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 }>`
@@ -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) && (
<>
&nbsp;&nbsp;
<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>
&nbsp;&nbsp;
<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);
+1 -1
View File
@@ -44,7 +44,7 @@ function DocumentTasks({ document }: Props) {
<>
{completed === total ? (
<Done
color={theme.accent}
color={theme.primary}
size={20}
$animated={done && previousDone === false}
/>
+2 -2
View File
@@ -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",
});
+5 -6
View File
@@ -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()
),
});
-21
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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;
`;
+18 -35
View File
@@ -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 -2
View File
@@ -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();
+33 -33
View File
@@ -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")} &middot; {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;
}
}
+10 -34
View File
@@ -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>
);
}
+2 -3
View File
@@ -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 -2
View File
@@ -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`
+1 -4
View File
@@ -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;
+7 -6
View File
@@ -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);
}
+5 -6
View File
@@ -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;
+4 -7
View File
@@ -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);
+1 -2
View File
@@ -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;
`;
+243
View File
@@ -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;
-108
View File
@@ -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;
-3
View File
@@ -1,3 +0,0 @@
import HoverPreview from "./HoverPreview";
export default HoverPreview;
+61
View File
@@ -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);
+35 -34
View File
@@ -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