Compare commits

..

12 Commits

Author SHA1 Message Date
Tom Moor bfaed5d9cb test 2022-09-05 15:38:00 +02:00
Tom Moor 5923281edb Allow arbitrary revisions to be compared 2022-09-05 13:55:07 +02:00
Tom Moor 01bfe2bde7 Add revisions.diff endpoint, first version 2022-09-05 13:35:27 +02:00
Tom Moor 9c6780adab Allow DocumentHelper to be used with Revisions 2022-09-05 12:58:40 +02:00
Tom Moor 93f1d4cfc7 div>article for easier programatic content extraction 2022-09-05 11:52:12 +02:00
Tom Moor 21a43dfc5e Refactor to allow for styling of HTML export 2022-09-04 22:57:54 +02:00
Tom Moor 47fafb5d69 fix nodes that required document to render 2022-09-04 17:29:02 +02:00
Tom Moor 32d76eeb9e docs 2022-09-04 17:07:26 +02:00
Tom Moor 99bef2c02b Add HTML download option to UI 2022-09-04 16:30:35 +02:00
Tom Moor 1125412972 fix: Add compatability for documents without collab state 2022-09-04 16:10:03 +02:00
Tom Moor 2e0d160fcc Add title to HTML export 2022-09-04 15:58:59 +02:00
Tom Moor 21e31be517 tidy 2022-09-04 15:31:42 +02:00
1059 changed files with 31370 additions and 55154 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:
+9 -13
View File
@@ -21,7 +21,7 @@ DATABASE_CONNECTION_POOL_MAX=
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://localhost:6379
# or alternatively, if you would like to provide additional connection options,
# or alternatively, if you would like to provide addtional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
# Example: Use Redis Sentinel for high availability
@@ -38,7 +38,7 @@ PORT=3000
COLLABORATION_URL=
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundancy
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
@@ -131,7 +131,7 @@ ENABLE_UPDATES=true
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# Override the maximum size of document imports, could be required if you have
# Override the maxium size of document imports, could be required if you have
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
@@ -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
@@ -154,11 +150,8 @@ SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
@@ -166,11 +159,14 @@ 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
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
-7
View File
@@ -25,17 +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-unused-vars": [
"error",
{
-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"
@@ -24,13 +24,8 @@ on:
workflow_dispatch:
schedule:
- cron: "00 20 * * 0"
permissions: {}
jobs:
build:
permissions:
contents: write
pull-requests: write # to comment on pull request
name: calibreapp/image-actions
runs-on: ubuntu-latest
# Only run on main repo on and PRs that match the main repo.
+37 -12
View File
@@ -1,9 +1,11 @@
{
"workerIdleMemoryLimit": "0.75",
"projects": [
{
"displayName": "server",
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
"verbose": false,
"roots": [
"<rootDir>/server"
],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
@@ -12,22 +14,33 @@
"<rootDir>/__mocks__/console.js",
"<rootDir>/server/test/env.ts"
],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"setupFilesAfterEnv": [
"<rootDir>/server/test/setup.ts"
],
"testEnvironment": "node",
"runner": "@getoutline/jest-runner-serial"
},
{
"displayName": "app",
"roots": ["<rootDir>/app"],
"verbose": false,
"roots": [
"<rootDir>/app"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"setupFilesAfterEnv": ["<rootDir>/app/test/setup.ts"],
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"<rootDir>/app/test/setup.ts"
],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
@@ -35,25 +48,37 @@
},
{
"displayName": "shared-node",
"roots": ["<rootDir>/shared"],
"verbose": false,
"roots": [
"<rootDir>/shared"
],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
"setupFiles": [
"<rootDir>/__mocks__/console.js"
],
"setupFilesAfterEnv": [
"<rootDir>/shared/test/setup.ts"
],
"testEnvironment": "node"
},
{
"displayName": "shared-jsdom",
"roots": ["<rootDir>/shared"],
"verbose": false,
"roots": [
"<rootDir>/shared"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
+1 -1
View File
@@ -5,7 +5,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:18-alpine AS runner
FROM node:16.14.2-alpine3.15 AS runner
ARG APP_PATH
WORKDIR $APP_PATH
-3
View File
@@ -1,12 +1,9 @@
ARG APP_PATH=/opt/outline
FROM node:16.14.2-alpine3.15 AS deps
RUN apk --no-cache add curl
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
View File
@@ -1,6 +1,7 @@
up:
docker-compose up -d redis postgres s3
yarn install --pure-lockfile
yarn sequelize db:migrate
yarn dev:watch
build:
+3 -3
View File
@@ -14,19 +14,19 @@
<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
+2 -2
View File
@@ -195,8 +195,8 @@
"description": "An API key for Sentry if you wish to collect error reporting (optional)",
"required": false
},
"SENTRY_TUNNEL": {
"description": "A sentry tunnel URL for bypassing ad blockers in the UI (optional)",
"TEAM_LOGO": {
"description": "A logo that will be displayed on the signed out home page",
"required": false
},
"DEFAULT_LANGUAGE": {
+5 -34
View File
@@ -1,7 +1,6 @@
import {
CollectionIcon,
EditIcon,
PadlockIcon,
PlusIcon,
StarredIcon,
UnstarredIcon,
@@ -11,19 +10,17 @@ import stores from "~/stores";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionNew from "~/scenes/CollectionNew";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import DynamicCollectionIcon from "~/components/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"),
analyticsName: "Open collection",
section: CollectionSection,
shortcut: ["o", "c"],
icon: <CollectionIcon />,
@@ -43,7 +40,6 @@ export const openCollection = createAction({
export const createCollection = createAction({
name: ({ t }) => t("New collection"),
analyticsName: "New collection",
section: CollectionSection,
icon: <PlusIcon />,
keywords: "create",
@@ -60,9 +56,7 @@ export const createCollection = createAction({
});
export const editCollection = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
analyticsName: "Edit collection",
name: ({ t }) => t("Edit collection"),
section: CollectionSection,
icon: <EditIcon />,
visible: ({ stores, activeCollectionId }) =>
@@ -85,30 +79,8 @@ export const editCollection = createAction({
},
});
export const editCollectionPermissions = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
section: CollectionSection,
icon: <PadlockIcon />,
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
stores.dialogs.openModal({
title: t("Collection permissions"),
content: <CollectionPermissions collectionId={activeCollectionId} />,
});
},
});
export const starCollection = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star collection",
section: CollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
@@ -134,7 +106,6 @@ export const starCollection = createAction({
export const unstarCollection = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar collection",
section: CollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
+20 -248
View File
@@ -17,36 +17,20 @@ import {
TrashIcon,
CrossIcon,
ArchiveIcon,
ShuffleIcon,
HistoryIcon,
LightBulbIcon,
UnpublishIcon,
PublishIcon,
CommentIcon,
} from "outline-icons";
import * as React from "react";
import { ExportContentType, TeamPreference } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import env from "~/env";
import history from "~/utils/history";
import {
documentInsightsPath,
documentHistoryPath,
homePath,
newDocumentPath,
searchPath,
} from "~/utils/routeHelpers";
import { homePath, newDocumentPath, searchPath } from "~/utils/routeHelpers";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
analyticsName: "Open document",
section: DocumentSection,
shortcut: ["o", "d"],
keywords: "go to",
@@ -71,13 +55,14 @@ export const openDocument = createAction({
export const createDocument = createAction({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ activeCollectionId, inStarredSection }) =>
activeCollectionId &&
history.push(newDocumentPath(activeCollectionId), {
starred: inStarredSection,
}),
@@ -85,7 +70,6 @@ export const createDocument = createAction({
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
section: DocumentSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
@@ -110,7 +94,6 @@ export const starDocument = createAction({
export const unstarDocument = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar document",
section: DocumentSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
@@ -134,76 +117,8 @@ export const unstarDocument = createAction({
},
});
export const publishDocument = createAction({
name: ({ t }) => t("Publish"),
analyticsName: "Publish document",
section: DocumentSection,
icon: <PublishIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isDraft && stores.policies.abilities(activeDocumentId).update
);
},
perform: async ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (document?.publishedAt) {
return;
}
if (document?.collectionId) {
await document.save({
publish: true,
});
stores.toasts.showToast(t("Document published"), {
type: "success",
});
} else if (document) {
stores.dialogs.openModal({
title: t("Publish document"),
isCentered: true,
content: <DocumentPublish document={document} />,
});
}
},
});
export const unpublishDocument = createAction({
name: ({ t }) => t("Unpublish"),
analyticsName: "Unpublish document",
section: DocumentSection,
icon: <UnpublishIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return stores.policies.abilities(activeDocumentId).unpublish;
},
perform: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.unpublish();
stores.toasts.showToast(t("Document unpublished"), {
type: "success",
});
},
});
export const subscribeDocument = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to document",
section: DocumentSection,
icon: <SubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
@@ -235,7 +150,6 @@ export const subscribeDocument = createAction({
export const unsubscribeDocument = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from document",
section: DocumentSection,
icon: <UnsubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
@@ -267,7 +181,6 @@ export const unsubscribeDocument = createAction({
export const downloadDocumentAsHTML = createAction({
name: ({ t }) => t("HTML"),
analyticsName: "Download document as HTML",
section: DocumentSection,
keywords: "html export",
icon: <DownloadIcon />,
@@ -280,41 +193,12 @@ export const downloadDocumentAsHTML = createAction({
}
const document = stores.documents.get(activeDocumentId);
document?.download(ExportContentType.Html);
},
});
export const downloadDocumentAsPDF = createAction({
name: ({ t }) => t("PDF"),
analyticsName: "Download document as PDF",
section: DocumentSection,
keywords: "export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId &&
stores.policies.abilities(activeDocumentId).download &&
env.PDF_EXPORT_ENABLED,
perform: ({ activeDocumentId, t, stores }) => {
if (!activeDocumentId) {
return;
}
const id = stores.toasts.showToast(`${t("Exporting")}`, {
type: "loading",
timeout: 30 * 1000,
});
const document = stores.documents.get(activeDocumentId);
document
?.download(ExportContentType.Pdf)
.finally(() => id && stores.toasts.hideToast(id));
document?.download("text/html");
},
});
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Markdown"),
analyticsName: "Download document as Markdown",
section: DocumentSection,
keywords: "md markdown export",
icon: <DownloadIcon />,
@@ -327,28 +211,22 @@ export const downloadDocumentAsMarkdown = createAction({
}
const document = stores.documents.get(activeDocumentId);
document?.download(ExportContentType.Markdown);
document?.download("text/markdown");
},
});
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
analyticsName: "Download document",
section: DocumentSection,
icon: <DownloadIcon />,
keywords: "export",
children: [
downloadDocumentAsHTML,
downloadDocumentAsPDF,
downloadDocumentAsMarkdown,
],
children: [downloadDocumentAsHTML, downloadDocumentAsMarkdown],
});
export const duplicateDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
analyticsName: "Duplicate document",
section: DocumentSection,
icon: <DuplicateIcon />,
keywords: "copy",
@@ -375,17 +253,7 @@ export const duplicateDocument = createAction({
* of the collection for all collection members to see.
*/
export const pinDocumentToCollection = createAction({
name: ({ activeDocumentId = "", t, stores }) => {
const selectedDocument = stores.documents.get(activeDocumentId);
const collectionName = selectedDocument
? stores.documents.getCollectionForDocument(selectedDocument)?.name
: t("collection");
return t("Pin to {{collectionName}}", {
collectionName,
});
},
analyticsName: "Pin document to collection",
name: ({ t }) => t("Pin to collection"),
section: DocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
@@ -421,7 +289,6 @@ export const pinDocumentToCollection = createAction({
*/
export const pinDocumentToHome = createAction({
name: ({ t }) => t("Pin to home"),
analyticsName: "Pin document to home",
section: DocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
@@ -453,7 +320,6 @@ export const pinDocumentToHome = createAction({
export const pinDocument = createAction({
name: ({ t }) => t("Pin"),
analyticsName: "Pin document",
section: DocumentSection,
icon: <PinIcon />,
children: [pinDocumentToCollection, pinDocumentToHome],
@@ -462,18 +328,16 @@ export const pinDocument = createAction({
export const printDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
analyticsName: "Print document",
section: DocumentSection,
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: async () => {
queueMicrotask(window.print);
window.print();
},
});
export const importDocument = createAction({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
section: DocumentSection,
icon: <ImportIcon />,
keywords: "upload",
@@ -522,7 +386,6 @@ export const importDocument = createAction({
export const createTemplate = createAction({
name: ({ t }) => t("Templatize"),
analyticsName: "Templatize document",
section: DocumentSection,
icon: <ShapesIcon />,
keywords: "new create template",
@@ -553,32 +416,12 @@ export const createTemplate = createAction({
},
});
export const openRandomDocument = createAction({
id: "random",
name: ({ t }) => t(`Open random document`),
analyticsName: "Open random document",
section: DocumentSection,
icon: <ShuffleIcon />,
perform: ({ stores, activeDocumentId }) => {
const documentPaths = stores.collections.pathsToDocuments.filter(
(path) => path.type === "document" && path.id !== activeDocumentId
);
const documentPath =
documentPaths[Math.round(Math.random() * documentPaths.length)];
if (documentPath) {
history.push(documentPath.url);
}
},
});
export const searchDocumentsForQuery = (searchQuery: string) =>
createAction({
id: "search",
section: DocumentSection,
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
analyticsName: "Search documents",
section: DocumentSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery)),
visible: ({ location }) => location.pathname !== searchPath(),
@@ -586,7 +429,6 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
@@ -603,11 +445,15 @@ export const moveDocument = createAction({
}
stores.dialogs.openModal({
title: t("Move {{ documentType }}", {
documentType: document.noun,
title: t("Move {{ documentName }}", {
documentName: document.noun,
}),
isCentered: true,
content: <DocumentMove document={document} />,
content: (
<DocumentMove
document={document}
onRequestClose={stores.dialogs.closeAllModals}
/>
),
});
}
},
@@ -615,7 +461,6 @@ export const moveDocument = createAction({
export const archiveDocument = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Archive document",
section: DocumentSection,
icon: <ArchiveIcon />,
visible: ({ activeDocumentId, stores }) => {
@@ -641,7 +486,6 @@ export const archiveDocument = createAction({
export const deleteDocument = createAction({
name: ({ t }) => t("Delete"),
analyticsName: "Delete document",
section: DocumentSection,
icon: <TrashIcon />,
dangerous: true,
@@ -676,7 +520,6 @@ export const deleteDocument = createAction({
export const permanentlyDeleteDocument = createAction({
name: ({ t }) => t("Permanently delete"),
analyticsName: "Permanently delete document",
section: DocumentSection,
icon: <CrossIcon />,
dangerous: true,
@@ -709,71 +552,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",
section: DocumentSection,
icon: <HistoryIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
return !!activeDocumentId && can.read && !can.restore;
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
history.push(documentHistoryPath(document));
},
});
export const openDocumentInsights = createAction({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: DocumentSection,
icon: <LightBulbIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
return !!activeDocumentId && can.read;
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
history.push(documentInsightsPath(document));
},
});
export const rootDocumentActions = [
openDocument,
archiveDocument,
@@ -784,18 +562,12 @@ export const rootDocumentActions = [
downloadDocument,
starDocument,
unstarDocument,
publishDocument,
unpublishDocument,
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
moveDocument,
openRandomDocument,
permanentlyDeleteDocument,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
];
-44
View File
@@ -11,7 +11,6 @@ import {
EmailIcon,
LogoutIcon,
ProfileIcon,
BrowserIcon,
} from "outline-icons";
import * as React from "react";
import {
@@ -25,14 +24,10 @@ import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import Desktop from "~/utils/Desktop";
import { isMac } from "~/utils/browser";
import history from "~/utils/history";
import isCloudHosted from "~/utils/isCloudHosted";
import {
organizationSettingsPath,
profileSettingsPath,
accountPreferencesPath,
homePath,
searchPath,
draftsPath,
@@ -43,7 +38,6 @@ import {
export const navigateToHome = createAction({
name: ({ t }) => t("Home"),
analyticsName: "Navigate to home",
section: NavigationSection,
shortcut: ["d"],
icon: <HomeIcon />,
@@ -55,14 +49,12 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
createAction({
section: RecentSearchesSection,
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery.query)),
});
export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"),
analyticsName: "Navigate to drafts",
section: NavigationSection,
icon: <EditIcon />,
perform: () => history.push(draftsPath()),
@@ -71,7 +63,6 @@ export const navigateToDrafts = createAction({
export const navigateToTemplates = createAction({
name: ({ t }) => t("Templates"),
analyticsName: "Navigate to templates",
section: NavigationSection,
icon: <ShapesIcon />,
perform: () => history.push(templatesPath()),
@@ -80,7 +71,6 @@ export const navigateToTemplates = createAction({
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
section: NavigationSection,
shortcut: ["g", "a"],
icon: <ArchiveIcon />,
@@ -90,7 +80,6 @@ export const navigateToArchive = createAction({
export const navigateToTrash = createAction({
name: ({ t }) => t("Trash"),
analyticsName: "Navigate to trash",
section: NavigationSection,
icon: <TrashIcon />,
perform: () => history.push(trashPath()),
@@ -99,7 +88,6 @@ export const navigateToTrash = createAction({
export const navigateToSettings = createAction({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to settings",
section: NavigationSection,
shortcut: ["g", "s"],
icon: <SettingsIcon />,
@@ -110,25 +98,14 @@ export const navigateToSettings = createAction({
export const navigateToProfileSettings = createAction({
name: ({ t }) => t("Profile"),
analyticsName: "Navigate to profile settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <ProfileIcon />,
perform: () => history.push(profileSettingsPath()),
});
export const navigateToAccountPreferences = createAction({
name: ({ t }) => t("Preferences"),
analyticsName: "Navigate to account preferences",
section: NavigationSection,
iconInContextMenu: false,
icon: <SettingsIcon />,
perform: () => history.push(accountPreferencesPath()),
});
export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"),
analyticsName: "Open API documentation",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
@@ -137,7 +114,6 @@ export const openAPIDocumentation = createAction({
export const openFeedbackUrl = createAction({
name: ({ t }) => t("Send us feedback"),
analyticsName: "Open feedback",
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
@@ -146,14 +122,12 @@ export const openFeedbackUrl = createAction({
export const openBugReportUrl = createAction({
name: ({ t }) => t("Report a bug"),
analyticsName: "Open bug report",
section: NavigationSection,
perform: () => window.open(githubIssuesUrl()),
});
export const openChangelog = createAction({
name: ({ t }) => t("Changelog"),
analyticsName: "Open changelog",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
@@ -162,7 +136,6 @@ export const openChangelog = createAction({
export const openKeyboardShortcuts = createAction({
name: ({ t }) => t("Keyboard shortcuts"),
analyticsName: "Open keyboard shortcuts",
section: NavigationSection,
shortcut: ["?"],
iconInContextMenu: false,
@@ -175,24 +148,8 @@ export const openKeyboardShortcuts = createAction({
},
});
export const downloadApp = createAction({
name: ({ t }) =>
t("Download {{ platform }} app", {
platform: isMac() ? "macOS" : "Windows",
}),
analyticsName: "Download app",
section: NavigationSection,
iconInContextMenu: false,
icon: <BrowserIcon />,
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
perform: () => {
window.open("https://desktop.getoutline.com");
},
});
export const logout = createAction({
name: ({ t }) => t("Log out"),
analyticsName: "Log out",
section: NavigationSection,
icon: <LogoutIcon />,
perform: () => stores.auth.logout(),
@@ -204,7 +161,6 @@ export const rootNavigationActions = [
navigateToTemplates,
navigateToArchive,
navigateToTrash,
downloadApp,
openAPIDocumentation,
openFeedbackUrl,
openBugReportUrl,
-79
View File
@@ -1,79 +0,0 @@
import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon } from "outline-icons";
import * as React from "react";
import { matchPath } from "react-router-dom";
import stores from "~/stores";
import { createAction } from "~/actions";
import { RevisionSection } from "~/actions/sections";
import history from "~/utils/history";
import {
documentHistoryPath,
matchDocumentHistory,
} from "~/utils/routeHelpers";
export const restoreRevision = createAction({
name: ({ t }) => t("Restore revision"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
section: RevisionSection,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
history.push(document.url, {
restore: true,
revisionId,
});
},
});
export const copyLinkToRevision = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
section: RevisionSection,
perform: async ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
const url = `${window.location.origin}${documentHistoryPath(
document,
revisionId
)}`;
copy(url, {
format: "text/plain",
onCopy: () => {
stores.toasts.showToast(t("Link copied"), {
type: "info",
});
},
});
},
});
export const rootRevisionActions = [];
-4
View File
@@ -7,7 +7,6 @@ import { SettingsSection } from "~/actions/sections";
export const changeToDarkTheme = createAction({
name: ({ t }) => t("Dark"),
analyticsName: "Change to dark theme",
icon: <MoonIcon />,
iconInContextMenu: false,
keywords: "theme dark night",
@@ -18,7 +17,6 @@ export const changeToDarkTheme = createAction({
export const changeToLightTheme = createAction({
name: ({ t }) => t("Light"),
analyticsName: "Change to light theme",
icon: <SunIcon />,
iconInContextMenu: false,
keywords: "theme light day",
@@ -29,7 +27,6 @@ export const changeToLightTheme = createAction({
export const changeToSystemTheme = createAction({
name: ({ t }) => t("System"),
analyticsName: "Change to system theme",
icon: <BrowserIcon />,
iconInContextMenu: false,
keywords: "theme system default",
@@ -41,7 +38,6 @@ export const changeToSystemTheme = createAction({
export const changeTheme = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Appearance") : t("Change theme"),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
icon: () =>
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
+29 -60
View File
@@ -1,71 +1,40 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { stringToColor } from "@shared/utils/color";
import RootStore from "~/stores/RootStore";
import TeamNew from "~/scenes/TeamNew";
import TeamLogo from "~/components/TeamLogo";
import { createAction } from "~/actions";
import { ActionContext } from "~/types";
import { TeamSection } from "../sections";
import { loadSessionsFromCookie } from "~/hooks/useSessions";
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: () => (
<StyledTeamLogo
alt={session.name}
model={{
initial: session.name[0],
avatarUrl: session.avatarUrl,
id: session.id,
color: stringToColor(session.id),
}}
size={24}
/>
),
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
perform: () => (window.location.href = session.url),
})) ?? [];
export const changeTeam = createAction({
name: ({ t }) => t("Switch team"),
placeholder: ({ t }) => t("Select a team"),
keywords: "change workspace organization",
section: "Account",
visible: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.length > 0;
},
children: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
export const switchTeam = createAction({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
analyticsName: "Switch workspace",
keywords: "change switch workspace organization team",
section: TeamSection,
visible: ({ stores }) =>
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
children: createTeamsList,
});
export const createTeam = createAction({
name: ({ t }) => `${t("New workspace")}`,
analyticsName: "New workspace",
keywords: "create change switch workspace organization team",
section: TeamSection,
icon: <PlusIcon />,
visible: ({ stores, currentTeamId }) =>
stores.policies.abilities(currentTeamId ?? "").createTeam,
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
const { user } = stores.auth;
user &&
stores.dialogs.openModal({
title: t("Create a workspace"),
content: <TeamNew user={user} />,
});
return otherSessions.map((session) => ({
id: session.url,
name: session.name,
section: "Account",
icon: <Logo alt={session.name} src={session.logoUrl} />,
perform: () => (window.location.href = session.url),
}));
},
});
const StyledTeamLogo = styled(TeamLogo)`
const Logo = styled("img")`
border-radius: 2px;
border: 0;
width: 24px;
height: 24px;
`;
export const rootTeamActions = [switchTeam, createTeam];
export const rootTeamActions = [changeTeam];
+1 -2
View File
@@ -7,9 +7,8 @@ import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,
analyticsName: "Invite people",
icon: <PlusIcon />,
keywords: "team member workspace user",
keywords: "team member user",
section: UserSection,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
+7 -32
View File
@@ -9,7 +9,6 @@ import {
MenuItemButton,
MenuItemWithChildren,
} from "~/types";
import Analytics from "~/utils/Analytics";
function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
@@ -18,23 +17,6 @@ function resolve<T>(value: any, context: ActionContext): T {
export function createAction(definition: Optional<Action, "id">): Action {
return {
...definition,
perform: definition.perform
? (context) => {
// We muse use the specific analytics name here as the action name is
// translated and potentially contains user strings.
if (definition.analyticsName) {
Analytics.track("perform_action", definition.analyticsName, {
context: context.isButton
? "button"
: context.isCommandBar
? "commandbar"
: "contextmenu",
});
}
return definition.perform?.(context);
}
: undefined,
id: uuidv4(),
};
}
@@ -49,7 +31,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) {
@@ -73,16 +57,8 @@ export function actionToMenuItem(
icon,
visible,
dangerous: action.dangerous,
onClick: () => {
try {
action.perform?.(context);
} catch (err) {
context.stores.toasts.showToast(err.message, {
type: "error",
});
}
},
selected: action.selected?.(context),
onClick: () => action.perform && action.perform(context),
selected: action.selected ? action.selected(context) : undefined,
};
}
@@ -94,7 +70,7 @@ export function actionToKBar(
return [];
}
const resolvedIcon = resolve<React.ReactElement>(action.icon, context);
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const resolvedSection = resolve<string>(action.section, context);
const resolvedName = resolve<string>(action.name, context);
@@ -109,13 +85,12 @@ export function actionToKBar(
{
id: action.id,
name: resolvedName,
analyticsName: action.analyticsName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: action.keywords ?? "",
shortcut: action.shortcut || [],
icon: resolvedIcon,
perform: action.perform ? () => action.perform?.(context) : undefined,
perform: action.perform ? () => action?.perform?.(context) : undefined,
},
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
].concat(children.map((child) => ({ ...child, parent: action.id })));
-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 { rootRevisionActions } from "./definitions/revisions";
import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams";
import { rootUserActions } from "./definitions/users";
@@ -12,7 +11,6 @@ export default [
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
...rootRevisionActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
-4
View File
@@ -6,15 +6,11 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const RevisionSection = ({ t }: ActionContext) => t("Revision");
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
+13 -20
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { Action, ActionContext } from "~/types";
export type Props = React.ComponentPropsWithoutRef<"button"> & {
export type Props = {
/** Show the button in a disabled state */
disabled?: boolean;
/** Hide the button entirely if action is not applicable */
@@ -20,47 +20,40 @@ export type Props = React.ComponentPropsWithoutRef<"button"> & {
*/
const ActionButton = React.forwardRef(
(
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
{
action,
context,
tooltip,
hideOnActionDisabled,
...rest
}: Props & React.HTMLAttributes<HTMLButtonElement>,
ref: React.Ref<HTMLButtonElement>
) => {
const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled;
if (!context || !action) {
return <button {...rest} ref={ref} />;
}
const actionContext = { ...context, isButton: true };
if (
action?.visible &&
!action.visible(actionContext) &&
hideOnActionDisabled
) {
if (action?.visible && !action.visible(context) && hideOnActionDisabled) {
return null;
}
const label =
typeof action.name === "function"
? action.name(actionContext)
: action.name;
typeof action.name === "function" ? action.name(context) : action.name;
const button = (
<button
{...rest}
aria-label={label}
disabled={disabled || executing}
disabled={disabled}
ref={ref}
onClick={
action?.perform && actionContext
action?.perform && context
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
const response = action.perform?.(actionContext);
if (response?.finally) {
setExecuting(true);
response.finally(() => setExecuting(false));
}
action.perform?.(context);
}
: rest.onClick
}
+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);
+41
View File
@@ -0,0 +1,41 @@
/* global ga */
import * as React from "react";
import env from "~/env";
export default class Analytics extends React.Component {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) {
return;
}
// standard Google Analytics script
window.ga =
window.ga ||
function (...args) {
(ga.q = ga.q || []).push(args);
};
ga.l = +new Date();
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
ga("set", {
dimension1: "true",
});
ga("send", "pageview");
const script = document.createElement("script");
script.src = "https://www.google-analytics.com/analytics.js";
script.async = true;
// Track PWA install event
window.addEventListener("appinstalled", () => {
ga("send", "event", "pwa", "install");
});
if (document.body) {
document.body.appendChild(script);
}
}
render() {
return this.props.children || null;
}
}
-66
View File
@@ -1,66 +0,0 @@
/* eslint-disable prefer-rest-params */
/* global ga */
import { escape } from "lodash";
import * as React from "react";
import { IntegrationService } from "@shared/types";
import env from "~/env";
const Analytics: React.FC = ({ children }) => {
// Google Analytics 3
React.useEffect(() => {
if (!env.GOOGLE_ANALYTICS_ID) {
return;
}
// standard Google Analytics script
window.ga =
window.ga ||
function (...args) {
(ga.q = ga.q || []).push(args);
};
ga.l = +new Date();
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
ga("send", "pageview");
const script = document.createElement("script");
script.type = "text/javascript";
script.src = "https://www.google-analytics.com/analytics.js";
script.async = true;
// Track PWA install event
window.addEventListener("appinstalled", () => {
ga("send", "event", "pwa", "install");
});
document.getElementsByTagName("head")[0]?.appendChild(script);
}, []);
// Google Analytics 4
React.useEffect(() => {
if (env.analytics.service !== IntegrationService.GoogleAnalytics) {
return;
}
const measurementId = escape(env.analytics.settings?.measurementId);
window.dataLayer = window.dataLayer || [];
window.gtag = function () {
window.dataLayer.push(arguments);
};
window.gtag("js", new Date());
window.gtag("config", measurementId, {
allow_google_signals: false,
restricted_data_processing: true,
});
const script = document.createElement("script");
script.type = "text/javascript";
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
script.async = true;
document.getElementsByTagName("head")[0]?.appendChild(script);
}, []);
return <>{children}</>;
};
export default Analytics;
+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>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import {
CompositeStateReturn,
} from "reakit/Composite";
type Props = React.HTMLAttributes<HTMLDivElement> & {
type Props = {
children: (composite: CompositeStateReturn) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
+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;
+48
View File
@@ -0,0 +1,48 @@
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;
};
function AuthLogo({ providerName, size = 16 }: Props) {
switch (providerName) {
case "slack":
return (
<Logo>
<SlackLogo size={size} />
</Logo>
);
case "google":
return (
<Logo>
<GoogleLogo size={size} />
</Logo>
);
case "azure":
return (
<Logo>
<MicrosoftLogo size={size} />
</Logo>
);
default:
return null;
}
}
const Logo = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
`;
export default AuthLogo;
+70 -87
View File
@@ -1,54 +1,49 @@
import { AnimatePresence } from "framer-motion";
import { observer, useLocalStore } from "mobx-react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { TeamPreference } from "@shared/types";
import { withTranslation, WithTranslation } from "react-i18next";
import { Switch, Route } from "react-router-dom";
import RootStore from "~/stores/RootStore";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import DocumentContext from "~/components/DocumentContext";
import type { DocumentContextValue } from "~/components/DocumentContext";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import type { Editor as TEditor } from "~/editor";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import {
searchPath,
matchDocumentSlug as slug,
newDocumentPath,
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
matchDocumentInsights,
} from "~/utils/routeHelpers";
import Fade from "./Fade";
import withStores from "./withStores";
const DocumentComments = React.lazy(
() => import("~/scenes/Document/components/Comments")
);
const DocumentHistory = React.lazy(
() => import("~/scenes/Document/components/History")
() =>
import(
/* webpackChunkName: "document-history" */
"~/components/DocumentHistory"
)
);
const DocumentInsights = React.lazy(
() => import("~/scenes/Document/components/Insights")
const CommandBar = React.lazy(
() =>
import(
/* webpackChunkName: "command-bar" */
"~/components/CommandBar"
)
);
const CommandBar = React.lazy(() => import("~/components/CommandBar"));
const AuthenticatedLayout: React.FC = ({ children }) => {
const { ui, auth } = useStores();
const location = useLocation();
const can = usePolicy(ui.activeCollectionId);
const { user, team } = auth;
const documentContext = useLocalStore<DocumentContextValue>(() => ({
editor: null,
setEditor: (editor: TEditor) => {
documentContext.editor = editor;
},
}));
type Props = WithTranslation & RootStore;
const goToSearch = (ev: KeyboardEvent) => {
@observer
class AuthenticatedLayout extends React.Component<Props> {
scrollable: HTMLDivElement | null | undefined;
@observable
keyboardShortcutsOpen = false;
goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
ev.stopPropagation();
@@ -56,72 +51,60 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
}
};
const goToNewDocument = (event: KeyboardEvent) => {
goToNewDocument = (event: KeyboardEvent) => {
if (event.metaKey || event.altKey) {
return;
}
const { activeCollectionId } = ui;
if (!activeCollectionId || !can.update) {
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) {
return;
}
const can = this.props.policies.abilities(activeCollectionId);
if (!can.update) {
return;
}
history.push(newDocumentPath(activeCollectionId));
};
if (auth.isSuspended) {
return <ErrorSuspended />;
}
render() {
const { auth } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const showSidebar = auth.authenticated && user && team;
const sidebar = showSidebar ? (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
) : undefined;
const sidebar = showSidebar ? (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
) : undefined;
const rightRail = (
<React.Suspense fallback={null}>
<Switch>
<Route
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</React.Suspense>
);
const showHistory = !!matchPath(location.pathname, {
path: matchDocumentHistory,
});
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>
{(showHistory || showInsights || showComments) && (
<Route path={`/doc/${slug}`}>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showInsights && <DocumentInsights />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
</Route>
)}
</AnimatePresence>
);
return (
<DocumentContext.Provider value={documentContext}>
<Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
return (
<Layout title={team?.name} sidebar={sidebar} rightRail={rightRail}>
<RegisterKeyDown trigger="n" handler={this.goToNewDocument} />
<RegisterKeyDown trigger="t" handler={this.goToSearch} />
<RegisterKeyDown trigger="/" handler={this.goToSearch} />
{this.props.children}
<CommandBar />
</Layout>
</DocumentContext.Provider>
);
};
);
}
}
export default observer(AuthenticatedLayout);
export default withTranslation()(withStores(AuthenticatedLayout));
+34 -46
View File
@@ -1,62 +1,52 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import useBoolean from "~/hooks/useBoolean";
import Initials from "./Initials";
export interface IAvatar {
avatarUrl: string | null;
color?: string;
initial?: string;
id?: string;
}
import User from "~/models/User";
import placeholder from "./placeholder.png";
type Props = {
src: string;
size: number;
src?: string;
icon?: React.ReactNode;
model?: IAvatar;
user?: User;
alt?: string;
showBorder?: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
className?: string;
style?: React.CSSProperties;
};
function Avatar(props: Props) {
const { icon, showBorder, model, style, ...rest } = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
@observer
class Avatar extends React.Component<Props> {
@observable
error: boolean;
return (
<Relative style={style}>
{src && !error ? (
static defaultProps = {
size: 24,
};
handleError = () => {
this.error = true;
};
render() {
const { src, icon, showBorder, ...rest } = this.props;
return (
<AvatarWrapper>
<CircleImg
onError={handleError}
src={src}
onError={this.handleError}
src={this.error ? placeholder : src}
$showBorder={showBorder}
{...rest}
/>
) : model ? (
<Initials color={model.color} $showBorder={showBorder} {...rest}>
{model.initial}
</Initials>
) : (
<Initials $showBorder={showBorder} {...rest} />
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
</Relative>
);
{icon && <IconWrapper>{icon}</IconWrapper>}
</AvatarWrapper>
);
}
}
Avatar.defaultProps = {
size: 24,
};
const Relative = styled.div`
const AvatarWrapper = styled.div`
position: relative;
user-select: none;
flex-shrink: 0;
`;
const IconWrapper = styled.div`
@@ -64,8 +54,8 @@ const IconWrapper = styled.div`
position: absolute;
bottom: -2px;
right: -2px;
background: ${s("accent")};
border: 2px solid ${s("background")};
background: ${(props) => props.theme.primary};
border: 2px solid ${(props) => props.theme.background};
border-radius: 100%;
width: 20px;
height: 20px;
@@ -76,12 +66,10 @@ const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: ${(props) =>
props.$showBorder === false
? "none"
: `2px solid ${props.theme.background}`};
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
flex-shrink: 0;
overflow: hidden;
`;
export default Avatar;
+105 -79
View File
@@ -1,115 +1,141 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { WithTranslation, withTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import UserProfile from "~/scenes/UserProfile";
import Avatar from "~/components/Avatar";
import Tooltip from "~/components/Tooltip";
type Props = {
type Props = WithTranslation & {
user: User;
isPresent: boolean;
isEditing: boolean;
isObserving: boolean;
isCurrentUser: boolean;
profileOnClick: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
};
function AvatarWithPresence({
onClick,
user,
isPresent,
isEditing,
isObserving,
isCurrentUser,
}: Props) {
const { t } = useTranslation();
const status = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
@observer
class AvatarWithPresence extends React.Component<Props> {
@observable
isOpen = false;
return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && (
<>
<br />
{status}
</>
)}
</Centered>
}
placement="bottom"
>
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
handleOpenProfile = () => {
this.isOpen = true;
};
handleCloseProfile = () => {
this.isOpen = false;
};
render() {
const {
onClick,
user,
isPresent,
isEditing,
isObserving,
isCurrentUser,
t,
} = this.props;
const status = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && (
<>
<br />
{status}
</>
)}
</Centered>
}
placement="bottom"
>
<Avatar model={user} onClick={onClick} size={32} />
</AvatarWrapper>
</Tooltip>
</>
);
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
>
<Avatar
src={user.avatarUrl}
onClick={
this.props.profileOnClick === false
? onClick
: this.handleOpenProfile
}
size={32}
/>
</AvatarWrapper>
</Tooltip>
{this.props.profileOnClick && (
<UserProfile
user={user}
isOpen={this.isOpen}
onRequestClose={this.handleCloseProfile}
/>
)}
</>
);
}
}
const Centered = styled.div`
text-align: center;
`;
type AvatarWrapperProps = {
const AvatarWrapper = styled.div<{
$isPresent: boolean;
$isObserving: boolean;
$color: string;
};
const AvatarWrapper = styled.div<AvatarWrapperProps>`
}>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
position: relative;
${(props) =>
props.$isPresent &&
css<AvatarWrapperProps>`
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
transition: border-color 100ms ease-in-out;
border: 2px solid transparent;
pointer-events: none;
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
transition: border-color 100ms ease-in-out;
border: 2px solid transparent;
pointer-events: none;
${(props) =>
props.$isObserving &&
css`
border: 2px solid ${props.$color};
box-shadow: inset 0 0 0 2px ${props.theme.background};
${(props) =>
props.$isObserving &&
css`
border: 2px solid ${props.$color};
box-shadow: inset 0 0 0 2px ${props.theme.background};
&:hover {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
`}
}
&:hover {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
`}
}
&:hover:after {
border: 2px solid ${(props) => props.$color};
box-shadow: inset 0 0 0 2px ${s("background")};
}
`}
&:hover:after {
border: 2px solid ${(props) => props.$color};
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
}
`;
export default observer(AvatarWithPresence);
export default withTranslation()(AvatarWithPresence);
-27
View File
@@ -1,27 +0,0 @@
import styled from "styled-components";
import Flex from "~/components/Flex";
const Initials = styled(Flex)<{
color?: string;
size: number;
$showBorder?: boolean;
}>`
align-items: center;
justify-content: center;
border-radius: 50%;
width: 100%;
height: 100%;
color: #fff;
background-color: ${(props) => props.color};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
flex-shrink: 0;
font-size: ${(props) => props.size / 2}px;
font-weight: 500;
`;
export default Initials;
Binary file not shown.

After

Width:  |  Height:  |  Size: 564 B

+1 -1
View File
@@ -5,7 +5,7 @@ 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.white : yellow ? theme.almostBlack : theme.textTertiary};
border: 1px solid
+7 -7
View File
@@ -1,9 +1,9 @@
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";
import OutlineLogo from "./OutlineLogo";
type Props = {
href?: string;
@@ -12,8 +12,8 @@ type Props = {
function Branding({ href = env.URL }: Props) {
return (
<Link href={href}>
<OutlineIcon size={20} />
&nbsp;{env.APP_NAME}
<OutlineLogo size={16} />
&nbsp;Outline
</Link>
);
}
@@ -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 -5
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,19 @@ 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")};
+43 -55
View File
@@ -1,29 +1,23 @@
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";
import { undraggableOnDesktop } from "~/styles";
type RealProps = {
$fullwidth?: boolean;
$borderOnHover?: boolean;
const RealButton = styled.button<{
fullwidth?: boolean;
borderOnHover?: boolean;
$neutral?: boolean;
$danger?: boolean;
};
const RealButton = styled(ActionButton)<RealProps>`
display: ${(props) => (props.$fullwidth ? "block" : "inline-block")};
width: ${(props) => (props.$fullwidth ? "100%" : "auto")};
danger?: boolean;
iconColor?: string;
}>`
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
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;
@@ -31,10 +25,17 @@ const RealButton = styled(ActionButton)<RealProps>`
height: 32px;
text-decoration: none;
flex-shrink: 0;
cursor: var(--pointer);
cursor: pointer;
user-select: none;
appearance: none !important;
${undraggableOnDesktop()}
${(props) =>
!props.borderOnHover &&
`
svg {
fill: ${props.iconColor || "currentColor"};
}
`}
&::-moz-focus-inner {
padding: 0;
@@ -43,14 +44,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};
@@ -63,15 +64,24 @@ const RealButton = styled(ActionButton)<RealProps>`
background: ${props.theme.buttonNeutralBackground};
color: ${props.theme.buttonNeutralText};
box-shadow: ${
props.$borderOnHover
props.borderOnHover
? "none"
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
};
${
props.borderOnHover
? ""
: `svg {
fill: ${props.iconColor || "currentColor"};
}`
}
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${
props.$borderOnHover
props.borderOnHover
? props.theme.buttonNeutralBackground
: darken(0.05, props.theme.buttonNeutralBackground)
};
@@ -91,7 +101,7 @@ const RealButton = styled(ActionButton)<RealProps>`
`}
${(props) =>
props.$danger &&
props.danger &&
`
background: ${props.theme.danger};
color: ${props.theme.white};
@@ -136,17 +146,18 @@ export const Inner = styled.span<{
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
export type Props<T> = ActionButtonProps & {
export type Props<T> = {
icon?: React.ReactNode;
iconColor?: string;
children?: React.ReactNode;
disclosure?: boolean;
neutral?: boolean;
danger?: boolean;
primary?: boolean;
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
borderOnHover?: boolean;
hideIcon?: boolean;
href?: string;
"data-on"?: string;
"data-event-category"?: string;
@@ -157,39 +168,16 @@ const Button = <T extends React.ElementType = "button">(
props: Props<T> & React.ComponentPropsWithoutRef<T>,
ref: React.Ref<HTMLButtonElement>
) => {
const {
type,
children,
value,
disclosure,
neutral,
action,
icon,
borderOnHover,
hideIcon,
fullwidth,
danger,
...rest
} = props;
const { type, icon, children, value, disclosure, neutral, ...rest } = props;
const hasText = children !== undefined || value !== undefined;
const ic = hideIcon ? undefined : action?.icon ?? icon;
const hasIcon = ic !== undefined;
const hasIcon = icon !== undefined;
return (
<RealButton
type={type || "button"}
ref={ref}
$neutral={neutral}
action={action}
$danger={danger}
$fullwidth={fullwidth}
$borderOnHover={borderOnHover}
{...rest}
>
<RealButton type={type || "button"} ref={ref} $neutral={neutral} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && ic}
{hasIcon && icon}
{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 -5
View File
@@ -26,10 +26,12 @@ const Content = styled.div`
`};
`;
const CenteredContent: React.FC<Props> = ({ children, ...rest }) => (
<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}
/>
+4 -7
View File
@@ -1,12 +1,9 @@
import styled from "styled-components";
const ClickablePadding = styled.div<{
grow?: boolean;
minHeight?: React.CSSProperties["paddingBottom"];
}>`
min-height: ${(props) => props.minHeight || "50vh"};
flex-grow: 100;
cursor: text;
const ClickablePadding = styled.div<{ grow?: boolean }>`
min-height: 10em;
cursor: ${({ onClick }) => (onClick ? "text" : "default")};
${({ grow }) => grow && `flex-grow: 100;`};
`;
export default ClickablePadding;
+6 -2
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();
@@ -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
@@ -87,6 +90,7 @@ function Collaborators(props: Props) {
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
profileOnClick={false}
onClick={
isObservable
? (ev) => {
+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,
}}
+6 -7
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";
@@ -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,
@@ -8,13 +8,9 @@ import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
type Props = {
/** The collection to show an icon for */
collection: Collection;
/** Whether the icon should be the "expanded" graphic when displaying the default collection icon */
expanded?: boolean;
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the collection color */
color?: string;
};
+34 -8
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 useSettingsActions from "~/hooks/useSettingsAction";
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],
@@ -33,10 +38,10 @@ function CommandBar() {
return (
<>
<SearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchActions />
<SearchInput
placeholder={`${
rootAction?.placeholder ||
@@ -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>
@@ -64,6 +80,16 @@ const KBarPortal: React.FC = ({ children }) => {
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};
`;
@@ -73,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};
}
`;
@@ -86,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;
+12 -12
View File
@@ -2,7 +2,6 @@ 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";
@@ -39,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>
@@ -76,33 +76,33 @@ const Icon = styled(Flex)`
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;
padding: 9px 12px;
margin: 0 8px;
border-radius: 4px;
font-size: 15px;
padding: 10px 16px;
background: ${(props) =>
props.active ? props.theme.menuItemSelected : "none"};
display: flex;
align-items: center;
justify-content: space-between;
cursor: var(--pointer);
cursor: pointer;
${ellipsis()}
user-select: none;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
min-width: 0;
${(props) =>
+1 -2
View File
@@ -1,7 +1,6 @@
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() {
@@ -31,6 +30,6 @@ const Header = styled.h3`
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 -10
View File
@@ -15,8 +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;
};
const ConfirmationDialog: React.FC<Props> = ({
@@ -25,7 +23,6 @@ const ConfirmationDialog: React.FC<Props> = ({
submitText,
savingText,
danger,
disabled = false,
}) => {
const [isSaving, setIsSaving] = React.useState(false);
const { dialogs } = useStores();
@@ -53,13 +50,8 @@ const ConfirmationDialog: React.FC<Props> = ({
<Flex column>
<form onSubmit={handleSubmit}>
<Text type="secondary">{children}</Text>
<Button
type="submit"
disabled={isSaving || disabled}
danger={danger}
autoFocus
>
{isSaving && savingText ? savingText : submitText}
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
{isSaving ? savingText : submitText}
</Button>
</form>
</Flex>
+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;
+9 -16
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"> & {
@@ -57,13 +56,7 @@ const ContentEditable = React.forwardRef(
React.useImperativeHandle(ref, () => ({
focus: () => {
if (contentRef.current) {
contentRef.current.focus();
// looks unnecessary but required because of https://github.com/outline/outline/issues/5198
if (!contentRef.current.innerText) {
placeCaret(contentRef.current, true);
}
}
contentRef.current?.focus();
},
focusAtStart: () => {
if (contentRef.current) {
@@ -125,8 +118,8 @@ const ContentEditable = React.forwardRef(
}
}, [value, contentRef]);
// Ensure only plain text can be pasted into input when pasting from another
// rich text source
// Ensure only plain text can be pasted into title when pasting from another
// rich text editor
const handlePaste = React.useCallback(
(event: React.ClipboardEvent<HTMLSpanElement>) => {
event.preventDefault();
@@ -173,10 +166,10 @@ 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;
@@ -187,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;
`;
+32 -53
View File
@@ -1,7 +1,6 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -9,7 +8,6 @@ import MenuIconWrapper from "../MenuIconWrapper";
type Props = {
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
@@ -20,31 +18,29 @@ type Props = {
hide?: () => void;
level?: number;
icon?: React.ReactElement;
children?: React.ReactNode;
};
const MenuItem = (
{
onClick,
children,
active,
selected,
disabled,
as,
hide,
icon,
...rest
}: Props,
ref: React.Ref<HTMLAnchorElement>
) => {
const MenuItem: React.FC<Props> = ({
onClick,
children,
selected,
disabled,
as,
hide,
icon,
...rest
}) => {
const handleClick = React.useCallback(
(ev) => {
if (onClick) {
ev.preventDefault();
ev.stopPropagation();
onClick(ev);
}
hide?.();
if (hide) {
hide();
}
},
[onClick, hide]
);
@@ -67,22 +63,22 @@ const MenuItem = (
{(props) => (
<MenuAnchor
{...props}
$active={active}
$toggleable={selected !== undefined}
as={onClick ? "button" : as}
onClick={handleClick}
onMouseDown={handleMouseDown}
ref={mergeRefs([
ref,
props.ref as React.RefObject<HTMLAnchorElement>,
])}
>
{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>
)}
@@ -101,7 +97,6 @@ type MenuAnchorProps = {
disabled?: boolean;
dangerous?: boolean;
disclosure?: boolean;
$active?: boolean;
};
export const MenuAnchorCSS = css<MenuAnchorProps>`
@@ -109,7 +104,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
margin: 0;
border: 0;
padding: 12px;
border-radius: 4px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
width: 100%;
min-height: 32px;
@@ -133,52 +127,37 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
${(props) => props.disabled && "pointer-events: none;"}
${(props) =>
props.$active === undefined &&
!props.disabled &&
`
props.disabled
? "pointer-events: none;"
: `
@media (hover: hover) {
&: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);
cursor: pointer;
svg {
fill: ${props.theme.accentText};
fill: ${props.theme.white};
}
}
}
`}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
box-shadow: none;
cursor: var(--pointer);
svg {
fill: ${props.theme.accentText};
}
`}
`};
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`}
`};
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
export default MenuItem;
@@ -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>
+1 -1
View File
@@ -11,5 +11,5 @@ export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
}
const HorizontalRule = styled.hr`
margin: 6px 0;
margin: 0.5em 12px;
`;
+9 -17
View File
@@ -6,7 +6,6 @@ import {
useMenuState,
MenuButton,
MenuItem as BaseMenuItem,
MenuStateReturn,
} from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import Flex from "~/components/Flex";
@@ -26,7 +25,7 @@ import MouseSafeArea from "./MouseSafeArea";
import Separator from "./Separator";
import ContextMenu from ".";
type Props = Omit<MenuStateReturn, "items"> & {
type Props = {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>;
items?: TMenuItem[];
@@ -38,15 +37,13 @@ const Disclosure = styled(ExpandedIcon)`
right: 8px;
`;
type SubMenuProps = MenuStateReturn & {
templateItems: TMenuItem[];
parentMenuState: Omit<MenuStateReturn, "items">;
title: React.ReactNode;
};
const SubMenu = React.forwardRef(
const Submenu = React.forwardRef(
(
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
{
templateItems,
title,
...rest
}: { templateItems: TMenuItem[]; title: React.ReactNode },
ref: React.LegacyRef<HTMLButtonElement>
) => {
const { t } = useTranslation();
@@ -62,11 +59,7 @@ const SubMenu = React.forwardRef(
</MenuAnchor>
)}
</MenuButton>
<ContextMenu
{...menu}
aria-label={t("Submenu")}
onClick={parentMenuState.hide}
>
<ContextMenu {...menu} aria-label={t("Submenu")}>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
@@ -184,9 +177,8 @@ function Template({ items, actions, context, ...menu }: Props) {
return (
<BaseMenuItem
key={index}
as={SubMenu}
as={Submenu}
templateItems={item.items}
parentMenuState={menu}
title={<Title title={item.title} icon={item.icon} />}
{...menu}
/>
+50 -53
View File
@@ -1,14 +1,14 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuStateReturn } from "reakit/Menu";
import { Portal } from "react-portal";
import { Menu } 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";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
@@ -36,23 +36,21 @@ export type Placement =
| "left"
| "left-start";
type Props = MenuStateReturn & {
"aria-label"?: string;
/** The parent menu state if this is a submenu. */
parentMenuState?: MenuStateReturn;
/** Called when the context menu is opened. */
type Props = {
"aria-label": string;
visible?: boolean;
placement?: Placement;
animating?: boolean;
unstable_disclosureRef?: React.RefObject<HTMLElement | null>;
onOpen?: () => void;
/** Called when the context menu is closed. */
onClose?: () => void;
/** Called when the context menu is clicked. */
onClick?: (ev: React.MouseEvent) => void;
hide?: () => void;
};
const ContextMenu: React.FC<Props> = ({
children,
onOpen,
onClose,
parentMenuState,
...rest
}) => {
const previousVisible = usePrevious(rest.visible);
@@ -61,7 +59,6 @@ const ContextMenu: React.FC<Props> = ({
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
const isMobile = useMobile();
useUnmount(() => {
setIsMenuOpen(false);
@@ -69,17 +66,19 @@ const ContextMenu: React.FC<Props> = ({
React.useEffect(() => {
if (rest.visible && !previousVisible) {
onOpen?.();
if (!parentMenuState) {
if (onOpen) {
onOpen();
}
if (rest["aria-label"] !== t("Submenu")) {
setIsMenuOpen(true);
}
}
if (!rest.visible && previousVisible) {
onClose?.();
if (!parentMenuState) {
if (onClose) {
onClose();
}
if (rest["aria-label"] !== t("Submenu")) {
setIsMenuOpen(false);
}
}
@@ -90,7 +89,7 @@ const ContextMenu: React.FC<Props> = ({
rest.visible,
ui.sidebarCollapsed,
setIsMenuOpen,
parentMenuState,
rest,
t,
]);
@@ -116,7 +115,7 @@ const ContextMenu: React.FC<Props> = ({
// trigger and the bottom of the window
return (
<>
<Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...rest}>
<Menu hideOnClickOutside preventBodyScroll={false} {...rest}>
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
@@ -126,38 +125,32 @@ const ContextMenu: React.FC<Props> = ({
const rightAnchor = props.placement === "bottom-end";
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
rest.hide?.();
}}
/>
)}
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
</>
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
);
}}
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
<Backdrop onClick={rest.hide} />
</Portal>
)}
</>
);
};
@@ -171,8 +164,12 @@ export const Backdrop = styled.div`
left: 0;
right: 0;
bottom: 0;
background: ${s("backdrop")};
background: ${(props) => props.theme.backdrop};
z-index: ${depths.menu - 1};
${breakpoint("tablet")`
display: none;
`};
`;
export const Position = styled.div`
@@ -203,9 +200,9 @@ 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;
padding: 6px 0;
min-width: 180px;
min-height: 44px;
max-height: 75vh;
@@ -2,8 +2,8 @@ import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Optional } from "utility-types";
import CollectionIcon from "~/components/CollectionIcon";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSelect from "~/components/InputSelect";
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import useStores from "~/hooks/useStores";
@@ -73,7 +73,7 @@ const DefaultCollectionInputSelect = ({
label: (
<Flex align="center">
<IconWrapper>
<HomeIcon />
<HomeIcon color="currentColor" />
</IconWrapper>
{t("Home")}
</Flex>
-56
View File
@@ -1,56 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { useDesktopTitlebar } from "~/hooks/useDesktopTitlebar";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import Desktop from "~/utils/Desktop";
export default function DesktopEventHandler() {
useDesktopTitlebar();
const { t } = useTranslation();
const history = useHistory();
const { dialogs } = useStores();
const { showToast } = useToasts();
React.useEffect(() => {
Desktop.bridge?.redirect((path: string, replace = false) => {
if (replace) {
history.replace(path);
} else {
history.push(path);
}
});
Desktop.bridge?.updateDownloaded(() => {
showToast("An update is ready to install.", {
type: "info",
timeout: Infinity,
action: {
text: "Install now",
onClick: () => {
Desktop.bridge?.restartAndInstall();
},
},
});
});
Desktop.bridge?.focus(() => {
window.document.body.classList.remove("backgrounded");
});
Desktop.bridge?.blur(() => {
window.document.body.classList.add("backgrounded");
});
Desktop.bridge?.openKeyboardShortcuts(() => {
dialogs.openGuide({
title: t("Keyboard shortcuts"),
content: <KeyboardShortcuts />,
});
});
}, [t, history, dialogs, showToast]);
return null;
}
+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;
`;
+18 -29
View File
@@ -3,18 +3,12 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import CollectionIcon from "~/components/CollectionIcon";
import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types";
import {
archivePath,
collectionPath,
templatesPath,
trashPath,
} from "~/utils/routeHelpers";
import { MenuInternalLink, NavigationNode } from "~/types";
import { collectionUrl } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -27,27 +21,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",
};
}
@@ -62,32 +56,29 @@ const DocumentBreadcrumb: React.FC<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;
let collectionNode: MenuInternalLink;
if (collection) {
collectionNode = {
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.url),
to: collectionUrl(collection.url),
};
} else if (document.collectionId && !collection) {
} else {
collectionNode = {
type: "route",
title: t("Deleted Collection"),
icon: undefined,
to: collectionPath("deleted-collection"),
to: collectionUrl("deleted-collection"),
};
}
const path = React.useMemo(
() => collection?.pathToDocument(document.id).slice(0, -1) || [],
// eslint-disable-next-line react-hooks/exhaustive-deps
[collection, document, document.collectionId, document.parentDocumentId]
() => collection?.pathToDocument?.(document.id).slice(0, -1) || [],
[collection, document]
);
const items = React.useMemo(() => {
@@ -97,9 +88,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
output.push(category);
}
if (collectionNode) {
output.push(collectionNode);
}
output.push(collectionNode);
path.forEach((node: NavigationNode) => {
output.push({
@@ -138,7 +127,7 @@ const SmallSlash = styled(GoToIcon)`
vertical-align: middle;
flex-shrink: 0;
fill: ${(props) => props.theme.textTertiary};
fill: ${(props) => props.theme.slate};
opacity: 0.5;
`;
+105 -123
View File
@@ -3,20 +3,18 @@ import { CSS } from "@dnd-kit/utilities";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { getLuminance, transparentize } from "polished";
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 styled, { css } from "styled-components";
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 CollectionIcon from "./Icons/CollectionIcon";
import EmojiIcon from "./Icons/EmojiIcon";
import Squircle from "./Squircle";
import CollectionIcon from "./CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -34,11 +32,8 @@ type Props = {
function DocumentCard(props: Props) {
const { t } = useTranslation();
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,
@@ -46,24 +41,16 @@ function DocumentCard(props: Props) {
transform,
transition,
isDragging,
} = useSortable({
id: props.document.id,
disabled: !isDraggable || !canUpdatePin,
});
} = useSortable({ id: props.document.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const handleUnpin = React.useCallback(
(ev) => {
ev.preventDefault();
ev.stopPropagation();
pin?.delete();
},
[pin]
);
const handleUnpin = React.useCallback(() => {
pin?.delete();
}, [pin]);
return (
<Reorderable
@@ -71,8 +58,6 @@ function DocumentCard(props: Props) {
style={style}
$isDragging={isDragging}
{...attributes}
{...listeners}
tabIndex={-1}
>
<AnimatePresence
initial={{ opacity: 0, scale: 0.95 }}
@@ -88,6 +73,12 @@ function DocumentCard(props: Props) {
>
<DocumentLink
dir={document.dir}
style={{
background:
collection?.color && getLuminance(collection.color) < 0.6
? collection.color
: undefined,
}}
$isDragging={isDragging}
to={{
pathname: document.url,
@@ -97,117 +88,89 @@ function DocumentCard(props: Props) {
}}
>
<Content justify="space-between" column>
<Fold
width="20"
height="21"
viewBox="0 0 20 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19.5 20.5H6C2.96243 20.5 0.5 18.0376 0.5 15V0.5H0.792893L19.5 19.2071V20.5Z" />
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
</Fold>
{document.emoji ? (
<Squircle color={theme.slateLight}>
<EmojiIcon emoji={document.emoji} size={26} />
</Squircle>
{collection?.icon &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
) : (
<Squircle color={collection?.color}>
{collection?.icon &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
) : (
<DocumentIcon color="white" />
)}
</Squircle>
<DocumentIcon color="white" />
)}
<div>
<Heading dir={document.dir}>
{document.emoji
? document.titleWithDefault.replace(document.emoji, "")
: document.titleWithDefault}
</Heading>
<Heading dir={document.dir}>{document.titleWithDefault}</Heading>
<DocumentMeta size="xsmall">
<Clock size={18} />
<Time
dateTime={document.updatedAt}
tooltipDelay={500}
addSuffix
shorten
/>
<ClockIcon color="currentColor" size={18} />{" "}
<Time dateTime={document.updatedAt} addSuffix shorten />
</DocumentMeta>
</div>
</Content>
{canUpdatePin && (
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<CloseIcon />
</PinButton>
</Tooltip>
)}
</Actions>
)}
</DocumentLink>
{canUpdatePin && (
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<CloseIcon color="currentColor" />
</PinButton>
</Tooltip>
)}
{isDraggable && (
<DragHandle $isDragging={isDragging} {...listeners}>
:::
</DragHandle>
)}
</Actions>
)}
</AnimatePresence>
</Reorderable>
);
}
const Clock = styled(ClockIcon)`
flex-shrink: 0;
`;
const AnimatePresence = styled(m.div)`
width: 100%;
height: 100%;
`;
const Fold = styled.svg`
fill: ${s("background")};
stroke: ${s("inputBorder")};
background: ${s("background")};
position: absolute;
top: -1px;
right: -2px;
`;
const PinButton = styled(NudeButton)`
color: ${s("textTertiary")};
color: ${(props) => props.theme.white75};
&:hover,
&:active {
color: ${s("text")};
color: ${(props) => props.theme.white};
}
`;
const Actions = styled(Flex)`
position: absolute;
top: 4px;
right: ${(props) => (props.dir === "rtl" ? "auto" : "4px")};
left: ${(props) => (props.dir === "rtl" ? "4px" : "auto")};
top: 12px;
right: ${(props) => (props.dir === "rtl" ? "auto" : "12px")};
left: ${(props) => (props.dir === "rtl" ? "12px" : "auto")};
opacity: 0;
color: ${s("textTertiary")};
transition: opacity 100ms ease-in-out;
// move actions above content
z-index: 2;
`;
const DragHandle = styled.div<{ $isDragging: boolean }>`
cursor: ${(props) => (props.$isDragging ? "grabbing" : "grab")};
padding: 0 4px;
font-weight: bold;
color: ${(props) => props.theme.white75};
line-height: 1.35;
&:hover,
&:active {
color: ${(props) => props.theme.white};
}
`;
const AnimatePresence = m.div;
const Reorderable = styled.div<{ $isDragging: boolean }>`
position: relative;
user-select: none;
touch-action: none;
width: 170px;
height: 180px;
transition: box-shadow 200ms ease;
border-radius: 8px;
// move above other cards when dragging
z-index: ${(props) => (props.$isDragging ? 1 : "inherit")};
pointer-events: ${(props) => (props.$isDragging ? "none" : "inherit")};
transform: scale(${(props) => (props.$isDragging ? "1.025" : "1")});
box-shadow: ${(props) =>
props.$isDragging ? "0 0 20px rgba(0,0,0,0.3);" : "0 0 0 rgba(0,0,0,0)"};
&:hover ${Actions} {
opacity: 1;
@@ -217,32 +180,45 @@ const Reorderable = styled.div<{ $isDragging: boolean }>`
const Content = styled(Flex)`
min-width: 0;
height: 100%;
// move content above ::after
position: relative;
z-index: 1;
`;
const DocumentMeta = styled(Text)`
${ellipsis()}
display: flex;
align-items: center;
gap: 2px;
color: ${s("textTertiary")};
margin: 0 0 0 -2px;
color: ${(props) => transparentize(0.25, props.theme.white)};
margin: 0;
`;
const DocumentLink = styled(Link)<{
$menuOpen?: boolean;
$isDragging?: boolean;
}>`
position: relative;
display: block;
padding: 12px;
width: 100%;
height: 100%;
border-radius: 8px;
cursor: var(--pointer);
background: ${s("background")};
height: 160px;
background: ${(props) => props.theme.slate};
color: ${(props) => props.theme.white};
transition: transform 50ms ease-in-out;
border: 1px solid ${s("inputBorder")};
border-bottom-width: 2px;
border-right-width: 2px;
&:after {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.1));
border-radius: 8px;
pointer-events: none;
}
${Actions} {
opacity: 0;
@@ -252,22 +228,28 @@ const DocumentLink = styled(Link)<{
&:active,
&:focus,
&:focus-within {
transform: ${(props) => (props.$isDragging ? "scale(1.1)" : "scale(1.08)")}
rotate(-2deg);
box-shadow: ${(props) =>
props.$isDragging
? "0 0 20px rgba(0,0,0,0.2);"
: "0 0 10px rgba(0,0,0,0.1)"};
z-index: 1;
${Fold} {
display: none;
}
${Actions} {
opacity: 1;
}
${(props) =>
!props.$isDragging &&
css`
&:after {
background: rgba(0, 0, 0, 0.1);
}
`}
}
${(props) =>
props.$menuOpen &&
css`
background: ${(props) => props.theme.listItemHoverBackground};
${Actions} {
opacity: 1;
}
`}
`;
const Heading = styled.h3`
@@ -277,7 +259,7 @@ const Heading = styled.h3`
max-height: 66px; // 3*line-height
overflow: hidden;
color: ${s("text")};
color: ${(props) => props.theme.white};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
-19
View File
@@ -1,19 +0,0 @@
import * as React from "react";
import { Editor } from "~/editor";
export type DocumentContextValue = {
/** The current editor instance for this document. */
editor: Editor | null;
/** Set the current editor instance for this document. */
setEditor: (editor: Editor) => void;
};
const DocumentContext = React.createContext<DocumentContextValue>({
editor: null,
// eslint-disable-next-line @typescript-eslint/no-empty-function
setEditor() {},
});
export const useDocumentContext = () => React.useContext(DocumentContext);
export default DocumentContext;
-402
View File
@@ -1,402 +0,0 @@
import FuzzySearch from "fuzzy-search";
import { includes, difference, concat, filter, map, fill } from "lodash";
import { observer } from "mobx-react";
import { StarredIcon, DocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { NavigationNode } from "@shared/types";
import parseTitle from "@shared/utils/parseTitle";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
import { ancestors, descendants } from "~/utils/tree";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
onSubmit: () => void;
/** A side-effect of item selection */
onSelect: (item: NavigationNode | null) => void;
/** Items to be shown in explorer */
items: NavigationNode[];
};
function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
const theme = useTheme();
const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
null
);
const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>(
0
);
const [nodes, setNodes] = React.useState<NavigationNode[]>([]);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
const [itemRefs, setItemRefs] = React.useState<
React.RefObject<HTMLSpanElement>[]
>([]);
const inputSearchRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(
null
);
const listRef = React.useRef<List<NavigationNode[]>>(null);
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const searchIndex = React.useMemo(
() =>
new FuzzySearch(items, ["title"], {
caseSensitive: false,
}),
[items]
);
React.useEffect(() => {
if (searchTerm) {
selectNode(null);
setExpandedNodes([]);
}
setActiveNode(0);
}, [searchTerm]);
React.useEffect(() => {
let results;
if (searchTerm) {
results = searchIndex.search(searchTerm);
} else {
results = items.filter((item) => item.type === "collection");
}
setInitialScrollOffset(0);
setNodes(results);
}, [searchTerm, items, searchIndex]);
React.useEffect(() => {
setItemRefs((itemRefs) =>
map(
fill(Array(items.length), 0),
(_, i) => itemRefs[i] || React.createRef()
)
);
}, [items.length]);
React.useEffect(() => {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
if (itemRefs[node] && itemRefs[node].current) {
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
behavior: "auto",
block: "center",
});
}
},
[itemRefs]
);
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(ev.target.value);
};
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
const calculateInitialScrollOffset = (itemCount: number) => {
if (listRef.current) {
const { height, itemSize } = listRef.current.props;
const { scrollOffset } = listRef.current.state as {
scrollOffset: number;
};
const itemsHeight = itemCount * itemSize;
return itemsHeight < height ? 0 : scrollOffset;
}
return 0;
};
const collapse = (node: number) => {
const descendantIds = descendants(nodes[node]).map((des) => des.id);
setExpandedNodes(
difference(expandedNodes, [...descendantIds, nodes[node].id])
);
// remove children
const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id));
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
setNodes(newNodes);
};
const expand = (node: number) => {
setExpandedNodes(concat(expandedNodes, nodes[node].id));
// add children
const newNodes = nodes.slice();
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
setNodes(newNodes);
};
const isSelected = (node: number) => {
if (!selectedNode) {
return false;
}
const selectedNodeId = selectedNode.id;
const nodeId = nodes[node].id;
return selectedNodeId === nodeId;
};
const hasChildren = (node: number) => nodes[node].children.length > 0;
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
return;
}
if (isExpanded(node)) {
collapse(node);
} else {
expand(node);
}
};
const toggleSelect = (node: number) => {
if (isSelected(node)) {
selectNode(null);
} else {
selectNode(nodes[node]);
}
};
const ListItem = ({
index,
data,
style,
}: {
index: number;
data: NavigationNode[];
style: React.CSSProperties;
}) => {
const node = data[index];
const isCollection = node.type === "collection";
let icon, title, path;
if (isCollection) {
const col = collections.get(node.collectionId as string);
icon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
title = node.title;
} else {
const doc = documents.get(node.id);
const { strippedTitle, emoji } = parseTitle(node.title);
title = strippedTitle;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
} else {
icon = <DocumentIcon />;
}
path = ancestors(node)
.map((a) => parseTitle(a.title).strippedTitle)
.join(" / ");
}
return searchTerm ? (
<DocumentExplorerSearchResult
selected={isSelected(index)}
active={activeNode === index}
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={icon}
title={title}
path={path}
/>
) : (
<DocumentExplorerNode
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
onDisclosureClick={(ev) => {
ev.stopPropagation();
toggleCollapse(index);
}}
selected={isSelected(index)}
active={activeNode === index}
expanded={isExpanded(index)}
icon={icon}
title={title}
depth={node.depth as number}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
);
};
const focusSearchInput = () => {
inputSearchRef.current?.focus();
};
const next = () => Math.min(activeNode + 1, nodes.length - 1);
const prev = () => Math.max(activeNode - 1, 0);
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
switch (ev.key) {
case "ArrowDown": {
ev.preventDefault();
setActiveNode(next());
scrollNodeIntoView(next());
break;
}
case "ArrowUp": {
ev.preventDefault();
if (activeNode === 0) {
focusSearchInput();
} else {
setActiveNode(prev());
scrollNodeIntoView(prev());
}
break;
}
case "ArrowLeft": {
if (!searchTerm && isExpanded(activeNode)) {
toggleCollapse(activeNode);
}
break;
}
case "ArrowRight": {
if (!searchTerm) {
toggleCollapse(activeNode);
// let the nodes re-render first and then scroll
setTimeout(() => scrollNodeIntoView(activeNode), 0);
}
break;
}
case "Enter": {
if (isModKey(ev)) {
onSubmit();
} else {
toggleSelect(activeNode);
}
break;
}
}
};
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ style, ...rest }, ref) => (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
));
return (
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
<ListSearch
ref={inputSearchRef}
onChange={handleSearch}
placeholder={`${t("Search collections & documents")}`}
autoFocus
/>
<ListContainer>
{nodes.length ? (
<AutoSizer>
{({ width, height }: { width: number; height: number }) => (
<Flex role="listbox" column>
<List
ref={listRef}
key={nodes.length}
width={width}
height={height}
itemData={nodes}
itemCount={nodes.length}
itemSize={isMobile ? 48 : 32}
innerElementType={innerElementType}
initialScrollOffset={initialScrollOffset}
itemKey={(index, results) => results[index].id}
>
{ListItem}
</List>
</Flex>
)}
</AutoSizer>
) : (
<FlexContainer>
<Text type="secondary">{t("No results found")}.</Text>
</FlexContainer>
)}
</ListContainer>
</Container>
);
}
const Container = styled.div``;
const FlexContainer = styled(Flex)`
height: 100%;
align-items: center;
justify-content: center;
`;
const ListSearch = styled(InputSearch)`
${Outline} {
border-radius: 16px;
}
margin-bottom: 4px;
padding-left: 24px;
padding-right: 24px;
`;
const ListContainer = styled.div`
height: 65vh;
${breakpoint("tablet")`
height: 40vh;
`}
`;
export default observer(DocumentExplorer);
-133
View File
@@ -1,133 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import Disclosure from "~/components/Sidebar/components/Disclosure";
import Text from "~/components/Text";
type Props = {
selected: boolean;
active: boolean;
style: React.CSSProperties;
expanded: boolean;
icon?: React.ReactNode;
title: string;
depth: number;
hasChildren: boolean;
onDisclosureClick: (ev: React.MouseEvent) => void;
onPointerMove: (ev: React.MouseEvent) => void;
onClick: (ev: React.MouseEvent) => void;
};
function DocumentExplorerNode(
{
selected,
active,
style,
expanded,
icon,
title,
depth,
hasChildren,
onDisclosureClick,
onPointerMove,
onClick,
}: Props,
ref: React.RefObject<HTMLSpanElement>
) {
const { t } = useTranslation();
const OFFSET = 12;
const ICON_SIZE = 24;
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
return (
<Node
ref={ref}
selected={selected}
active={active}
onClick={onClick}
style={style}
onPointerMove={onPointerMove}
role="option"
>
<Spacer width={width}>
{hasChildren && (
<StyledDisclosure
expanded={expanded}
onClick={onDisclosureClick}
tabIndex={-1}
/>
)}
</Spacer>
{icon}
<Title>{title || t("Untitled")}</Title>
</Node>
);
}
const Title = styled(Text)`
${ellipsis()}
margin: 0 4px 0 4px;
color: inherit;
`;
const StyledDisclosure = styled(Disclosure)`
position: relative;
left: auto;
margin-top: 2px;
`;
const Spacer = styled(Flex)<{ width: number }>`
flex-direction: row-reverse;
flex-shrink: 0;
width: ${(props) => props.width}px;
`;
export const Node = styled.span<{
active: boolean;
selected: boolean;
style: React.CSSProperties;
}>`
display: flex;
user-select: none;
overflow: hidden;
font-size: 16px;
width: ${(props) => props.style.width};
color: ${s("text")};
cursor: var(--pointer);
padding: 12px;
border-radius: 6px;
background: ${(props) =>
!props.selected && props.active && props.theme.listItemHoverBackground};
svg {
flex-shrink: 0;
}
&:focus {
outline: none;
}
${(props) =>
props.selected &&
`
background: ${props.theme.accent};
color: ${props.theme.white};
svg {
fill: ${props.theme.white};
}
`}
${breakpoint("tablet")`
padding: 4px;
font-size: 15px;
`}
`;
export default observer(React.forwardRef(DocumentExplorerNode));
@@ -1,84 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
type Props = {
selected: boolean;
active: boolean;
style: React.CSSProperties;
icon?: React.ReactNode;
title: string;
path?: string;
onPointerMove: (ev: React.MouseEvent) => void;
onClick: (ev: React.MouseEvent) => void;
};
function DocumentExplorerSearchResult({
selected,
active,
style,
icon,
title,
path,
onPointerMove,
onClick,
}: Props) {
const { t } = useTranslation();
const ref = React.useCallback(
(node: HTMLSpanElement | null) => {
if (active && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
behavior: "auto",
block: "nearest",
});
}
},
[active]
);
return (
<SearchResult
ref={ref}
selected={selected}
active={active}
onClick={onClick}
style={style}
onPointerMove={onPointerMove}
role="option"
>
{icon}
<Flex>
<Title>{title || t("Untitled")}</Title>
<Path $selected={selected} size="xsmall">
{path}
</Path>
</Flex>
</SearchResult>
);
}
const Title = styled(Text)`
flex-shrink: 0;
white-space: nowrap;
margin: 0 4px 0 4px;
color: inherit;
`;
const Path = styled(Text)<{ $selected: boolean }>`
${ellipsis()}
padding-top: 2px;
margin: 0 4px 0 8px;
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
`;
export default observer(DocumentExplorerSearchResult);
+133
View File
@@ -0,0 +1,133 @@
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "~/models/Event";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import PaginatedEventList from "~/components/PaginatedEventList";
import Scrollable from "~/components/Scrollable";
import useStores from "~/hooks/useStores";
import { documentUrl } from "~/utils/routeHelpers";
const EMPTY_ARRAY: Event[] = [];
function DocumentHistory() {
const { events, documents } = useStores();
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
? events.inDocument(document.id)
: EMPTY_ARRAY;
const onCloseHistory = () => {
if (document) {
history.push(documentUrl(document));
} else {
history.goBack();
}
};
const items = React.useMemo(() => {
if (
eventsInDocument[0] &&
document &&
eventsInDocument[0].createdAt !== document.updatedAt
) {
eventsInDocument.unshift(
new Event(
{
name: "documents.latest_version",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
},
events
)
);
}
return eventsInDocument;
}, [eventsInDocument, events, document]);
return (
<Sidebar>
{document ? (
<Position column>
<Header>
<Title>{t("History")}</Title>
<Button
icon={<CloseIcon />}
onClick={onCloseHistory}
borderOnHover
neutral
/>
</Header>
<Scrollable topShadow>
<PaginatedEventList
aria-label={t("History")}
fetch={events.fetchPage}
events={items}
options={{
documentId: document.id,
}}
document={document}
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
/>
</Scrollable>
</Position>
) : null}
</Sidebar>
);
}
const Position = styled(Flex)`
position: fixed;
top: 0;
bottom: 0;
width: ${(props) => props.theme.sidebarWidth}px;
`;
const Sidebar = styled(Flex)`
display: none;
position: relative;
flex-shrink: 0;
background: ${(props) => props.theme.background};
width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default observer(DocumentHistory);
+5 -8
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,12 +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")};
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
}
@@ -203,7 +201,6 @@ const DocumentLink = styled(Link)<{
border-radius: 8px;
max-height: 50vh;
width: calc(100vw - 8px);
cursor: var(--pointer);
&:focus-visible {
outline: none;
@@ -225,7 +222,7 @@ const DocumentLink = styled(Link)<{
&:active,
&:focus,
&:focus-within {
background: ${s("listItemHoverBackground")};
background: ${(props) => props.theme.listItemHoverBackground};
${Actions} {
opacity: 1;
@@ -243,7 +240,7 @@ const DocumentLink = styled(Link)<{
${(props) =>
props.$menuOpen &&
css`
background: ${s("listItemHoverBackground")};
background: ${(props) => props.theme.listItemHoverBackground};
${Actions} {
opacity: 1;
@@ -264,7 +261,7 @@ const Heading = styled.h3<{ rtl?: boolean }>`
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;
`;
@@ -282,7 +279,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;
+20 -30
View File
@@ -4,7 +4,6 @@ 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 DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import DocumentTasks from "~/components/DocumentTasks";
@@ -13,13 +12,30 @@ import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const Viewed = styled.span`
text-overflow: ellipsis;
overflow: hidden;
`;
const Modified = styled.span<{ highlight?: boolean }>`
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
type Props = {
showCollection?: boolean;
showPublished?: boolean;
showLastViewed?: boolean;
showParentDocuments?: boolean;
document: Document;
replace?: boolean;
to?: LocationDescriptor;
};
@@ -30,7 +46,6 @@ const DocumentMeta: React.FC<Props> = ({
showParentDocuments,
document,
children,
replace,
to,
...rest
}) => {
@@ -57,9 +72,7 @@ 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;
@@ -150,13 +163,7 @@ const DocumentMeta: React.FC<Props> = ({
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? (
<Link to={to} replace={replace}>
{content}
</Link>
) : (
content
)}
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
@@ -185,21 +192,4 @@ const DocumentMeta: React.FC<Props> = ({
);
};
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")};
font-size: 13px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const Viewed = styled.span`
${ellipsis()}
`;
const Modified = styled.span<{ highlight?: boolean }>`
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
export default observer(DocumentMeta);
+87
View File
@@ -0,0 +1,87 @@
import { LocationDescriptor } from "history";
import { observer, useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import Document from "~/models/Document";
import DocumentMeta from "~/components/DocumentMeta";
import DocumentViews from "~/components/DocumentViews";
import Popover from "~/components/Popover";
import useStores from "~/hooks/useStores";
type Props = {
document: Document;
isDraft: boolean;
to?: LocationDescriptor;
rtl?: boolean;
};
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const { views } = useStores();
const { t } = useTranslation();
const documentViews = useObserver(() => views.inDocument(document.id));
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
React.useEffect(() => {
if (!document.isDeleted) {
views.fetchPage({
documentId: document.id,
});
}
}, [views, document.id, document.isDeleted]);
const popover = usePopoverState({
gutter: 8,
placement: "bottom",
modal: true,
});
return (
<Meta document={document} to={to} {...rest}>
{totalViewers && !isDraft ? (
<PopoverDisclosure {...popover}>
{(props) => (
<>
&nbsp;&nbsp;
<a {...props}>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</a>
</>
)}
</PopoverDisclosure>
) : null}
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</Meta>
);
}
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;
z-index: 1;
a {
color: inherit;
&:hover {
text-decoration: underline;
}
}
@media print {
display: none;
}
`;
export default observer(DocumentMetaWithViews);
+2 -3
View File
@@ -1,8 +1,7 @@
import { TFunction } from "i18next";
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTranslation, TFunction } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Document from "~/models/Document";
import CircularProgressBar from "~/components/CircularProgressBar";
@@ -44,7 +43,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",
});
+7 -7
View File
@@ -43,10 +43,10 @@ function DocumentViews({ document, isOpen }: Props) {
<PaginatedList
aria-label={t("Viewers")}
items={users}
renderItem={(model: User) => {
const view = documentViews.find((v) => v.user.id === model.id);
const isPresent = presentIds.includes(model.id);
const isEditing = editingIds.includes(model.id);
renderItem={(item: User) => {
const view = documentViews.find((v) => v.user.id === item.id);
const isPresent = presentIds.includes(item.id);
const isEditing = editingIds.includes(item.id);
const subtitle = isPresent
? isEditing
? t("Currently editing")
@@ -58,10 +58,10 @@ function DocumentViews({ document, isOpen }: Props) {
});
return (
<ListItem
key={model.id}
title={model.name}
key={item.id}
title={item.name}
subtitle={subtitle}
image={<Avatar key={model.id} model={model} size={32} />}
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
border={false}
small
/>
+52 -100
View File
@@ -1,15 +1,13 @@
import { formatDistanceToNow } from "date-fns";
import { deburr, difference, sortBy } from "lodash";
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 { AttachmentPreset } from "@shared/types";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import { Heading } from "@shared/editor/lib/getHeadings";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
@@ -25,12 +23,18 @@ import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
const LazyLoadedEditor = React.lazy(() => import("~/editor"));
const LazyLoadedEditor = React.lazy(
() =>
import(
/* webpackChunkName: "shared-editor" */
"~/editor"
)
);
export type Props = Optional<
EditorProps,
@@ -44,42 +48,31 @@ export type Props = Optional<
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
grow?: boolean;
onHeadingsChange?: (headings: Heading[]) => void;
onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => any;
editorStyle?: React.CSSProperties;
};
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const {
id,
shareId,
onChange,
onHeadingsChange,
onCreateCommentMark,
onDeleteCommentMark,
} = props;
const { auth, comments, documents } = useStores();
const { id, shareId, onChange, onHeadingsChange } = props;
const { documents } = 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[]>();
activeLinkEvent,
setActiveLinkEvent,
] = React.useState<MouseEvent | null>(null);
const previousHeadings = React.useRef<Heading[] | null>(null);
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
setActiveLink(element);
const handleLinkActive = React.useCallback((event: MouseEvent) => {
setActiveLinkEvent(event);
return false;
}, []);
const handleLinkInactive = React.useCallback(() => {
setActiveLink(null);
setActiveLinkEvent(null);
}, []);
const handleSearchLink = React.useCallback(
@@ -116,11 +109,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()
@@ -132,18 +127,17 @@ 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,
preset: AttachmentPreset.DocumentAttachment,
});
return result.url;
},
[id]
);
const handleClickLink = React.useCallback(
const onClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
// on page hash
if (isHash(href)) {
@@ -165,16 +159,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
}
}
// Link to our own API should be opened in a new tab, not in the app
if (navigateTo.startsWith("/api/")) {
window.open(href, "_blank");
return;
}
// If we're navigating to an internal document link then prepend the
// share route to the URL so that the document is loaded in context
if (shareId && navigateTo.includes("/doc/")) {
navigateTo = sharedDocumentPath(shareId, navigateTo);
if (shareId) {
navigateTo = `/share/${shareId}${navigateTo}`;
}
history.push(navigateTo);
@@ -182,12 +168,12 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
window.open(href, "_blank");
}
},
[history, shareId]
[shareId]
);
const focusAtEnd = React.useCallback(() => {
localRef?.current?.focusAtEnd();
}, [localRef]);
ref?.current?.focusAtEnd();
}, [ref]);
const handleDrop = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
@@ -195,7 +181,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
event.stopPropagation();
const files = getDataTransferFiles(event);
const view = localRef?.current?.view;
const view = ref?.current?.view;
if (!view) {
return;
}
@@ -230,7 +216,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,
@@ -239,11 +225,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
});
},
[
localRef,
ref,
props.onFileUploadStart,
props.onFileUploadStop,
dictionary,
handleUploadFile,
onUploadFile,
showToast,
]
);
@@ -260,7 +246,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
// Calculate if headings have changed and trigger callback if so
const updateHeadings = React.useCallback(() => {
if (onHeadingsChange) {
const headings = localRef?.current?.getHeadings();
const headings = ref?.current?.getHeadings();
if (
headings &&
headings.map((h) => h.level + h.title).join("") !==
@@ -270,88 +256,54 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
onHeadingsChange(headings);
}
}
}, [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]);
}, [ref, onHeadingsChange]);
const handleChange = React.useCallback(
(event) => {
onChange?.(event);
updateHeadings();
updateComments();
},
[onChange, updateComments, updateHeadings]
[onChange, updateHeadings]
);
const handleRefChanged = React.useCallback(
(node: SharedEditor | null) => {
if (node) {
if (node && !previousHeadings.current) {
updateHeadings();
updateComments();
}
},
[updateComments, updateHeadings]
[updateHeadings]
);
return (
<ErrorBoundary component="div" reloadOnChunkMissing>
<ErrorBoundary reloadOnChunkMissing>
<>
<LazyLoadedEditor
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
ref={mergeRefs([ref, handleRefChanged])}
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.grow && !props.readOnly && (
<ClickablePadding
onClick={focusAtEnd}
onDrop={handleDrop}
onDragOver={handleDragOver}
minHeight={props.editorStyle.paddingBottom}
grow
/>
)}
{activeLinkElement && !shareId && (
{activeLinkEvent && !shareId && (
<HoverPreview
id={props.id}
element={activeLinkElement}
node={activeLinkEvent.target as HTMLAnchorElement}
event={activeLinkEvent}
onClose={handleLinkInactive}
/>
)}
@@ -25,7 +25,6 @@ const Span = styled.span<{ $size: number }>`
align-items: center;
justify-content: center;
text-align: center;
flex-shrink: 0;
width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px;
text-indent: -0.15em;
+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;
+32 -49
View File
@@ -1,20 +1,17 @@
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
CheckboxIcon,
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";
@@ -23,10 +20,9 @@ import CompositeItem, {
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import usePolicy from "~/hooks/usePolicy";
import RevisionMenu from "~/menus/RevisionMenu";
import Logger from "~/utils/Logger";
import { documentHistoryPath } from "~/utils/routeHelpers";
import { documentHistoryUrl } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -36,48 +32,39 @@ type Props = {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const location = useLocation();
const can = usePolicy(document);
const opts = {
userName: event.actor.name,
};
const isRevision = event.name === "revisions.create";
let meta, icon, to: LocationDescriptor | undefined;
let meta, icon, to;
const ref = React.useRef<HTMLAnchorElement>(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
const handleTimeClick = () => {
const handleTimeClick = React.useCallback(() => {
ref.current?.focus();
};
const prefetchRevision = () => {
if (event.name === "revisions.create" && event.modelId) {
revisions.fetch(event.modelId);
}
};
}, [ref]);
switch (event.name) {
case "revisions.create":
icon = <EditIcon size={16} />;
meta = t("{{userName}} edited", opts);
to = {
pathname: documentHistoryPath(document, event.modelId || ""),
state: { retainScrollPosition: true },
};
break;
case "documents.live_editing":
icon = <LightningIcon size={16} />;
meta = t("Latest");
to = {
pathname: documentHistoryPath(document),
state: { retainScrollPosition: true },
};
break;
case "documents.latest_version": {
if (latest) {
icon = <CheckboxIcon color="currentColor" size={16} checked />;
meta = t("Latest version");
to = documentHistoryUrl(document);
break;
} else {
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = documentHistoryUrl(document, event.modelId || "");
break;
}
}
case "documents.archive":
icon = <ArchiveIcon size={16} />;
icon = <ArchiveIcon color="currentColor" size={16} />;
meta = t("{{userName}} archived", opts);
break;
@@ -86,7 +73,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;
@@ -95,32 +82,29 @@ 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) {
return null;
}
const isActive =
typeof to === "string"
? location.pathname === to
: location.pathname === to?.pathname;
const isActive = location.pathname === to;
if (document.isDeleted) {
to = undefined;
@@ -144,7 +128,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
onClick={handleTimeClick}
/>
}
image={<Avatar model={event.actor} size={32} />}
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
subtitle={
<Subtitle>
{icon}
@@ -152,11 +136,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
</Subtitle>
}
actions={
isRevision && isActive && event.modelId ? (
isRevision && isActive && event.modelId && can.update ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
onMouseEnter={prefetchRevision}
ref={ref}
{...rest}
/>
@@ -183,7 +166,7 @@ const Subtitle = styled.span`
const ItemStyle = css`
border: 0;
position: relative;
margin: 8px 0;
margin: 8px;
padding: 8px;
border-radius: 8px;
@@ -199,7 +182,7 @@ const ItemStyle = css`
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${s("textSecondary")};
background: ${(props) => props.theme.textSecondary};
opacity: 0.25;
}
@@ -234,4 +217,4 @@ const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
export default observer(EventListItem);
export default EventListItem;
-124
View File
@@ -1,124 +0,0 @@
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 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";
type Props = {
collection?: Collection;
onSubmit: () => void;
};
function ExportDialog({ collection, onSubmit }: Props) {
const [format, setFormat] = React.useState<FileOperationFormat>(
FileOperationFormat.MarkdownZip
);
const user = useCurrentUser();
const { showToast } = useToasts();
const { collections } = useStores();
const { t } = useTranslation();
const appName = env.APP_NAME;
const handleFormatChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setFormat(ev.target.value as FileOperationFormat);
},
[]
);
const handleSubmit = async () => {
if (collection) {
await collection.export(format);
} else {
await collections.export(format);
}
onSubmit();
showToast(t("Export started"), { type: "success" });
};
const items = [
{
title: "Markdown",
description: t(
"A ZIP file containing the images, and documents in the Markdown format."
),
value: FileOperationFormat.MarkdownZip,
},
{
title: "HTML",
description: t(
"A ZIP file containing the images, and documents as HTML files."
),
value: FileOperationFormat.HTMLZip,
},
{
title: "JSON",
description: t(
"Structured data that can be used to transfer data to another compatible {{ appName }} instance.",
{
appName,
}
),
value: FileOperationFormat.JSON,
},
];
return (
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Export")}>
{collection && (
<Text>
<Trans
defaults="Exporting the collection <em>{{collectionName}}</em> may take some time."
values={{
collectionName: collection.name,
}}
components={{
em: <strong />,
}}
/>{" "}
{user.subscribedToEventType(NotificationEventType.ExportCompleted) &&
t("You will receive an email when it's complete.")}
</Text>
)}
<Flex gap={12} column>
{items.map((item) => (
<Option>
<input
type="radio"
name="format"
value={item.value}
checked={format === item.value}
onChange={handleFormatChange}
/>
<div>
<Text size="small" weight="bold">
{item.title}
</Text>
<Text size="small">{item.description}</Text>
</div>
</Option>
))}
</Flex>
</ConfirmationDialog>
);
}
const Option = styled.label`
display: flex;
align-items: center;
gap: 16px;
p {
margin: 0;
}
`;
export default observer(ExportDialog);
+7 -12
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";
@@ -10,7 +9,7 @@ type Props = {
users: User[];
size?: number;
overflow?: number;
limit?: number;
onClick?: React.MouseEventHandler<HTMLDivElement>;
renderAvatar?: (user: User) => React.ReactNode;
};
@@ -18,7 +17,6 @@ function Facepile({
users,
overflow = 0,
size = 32,
limit = 8,
renderAvatar = DefaultAvatar,
...rest
}: Props) {
@@ -26,13 +24,10 @@ function Facepile({
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>
{users.length ? "+" : ""}
{overflow}
</span>
<span>+{overflow}</span>
</More>
)}
{users.slice(0, limit).map((user) => (
{users.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
@@ -40,7 +35,7 @@ function Facepile({
}
function DefaultAvatar(user: User) {
return <Avatar model={user} size={32} />;
return <Avatar user={user} src={user.avatarUrl} size={32} />;
}
const AvatarWrapper = styled.div`
@@ -60,8 +55,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;
@@ -70,7 +65,7 @@ const More = styled.div<{ size: number }>`
const Avatars = styled(Flex)`
align-items: center;
flex-direction: row-reverse;
cursor: var(--pointer);
cursor: pointer;
`;
export default observer(Facepile);
+1 -10
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";
@@ -11,7 +10,6 @@ type TFilterOption = {
key: string;
label: string;
note?: string;
icon?: React.ReactNode;
};
type Props = {
@@ -59,7 +57,6 @@ const FilterOptions = ({
selected={option.key === activeKey}
{...menu}
>
{option.icon && <Icon>{option.icon}</Icon>}
{option.note ? (
<LabelWithNote>
{option.label}
@@ -81,7 +78,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`
@@ -109,12 +106,6 @@ const StyledButton = styled(Button)`
}
`;
const Icon = styled.div`
margin-right: 8px;
width: 18px;
height: 18px;
`;
const Wrapper = styled.div`
margin-right: 8px;
`;
-2
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,7 +26,6 @@ const Flex = styled.div<{
: "row"};
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-wrap: ${({ wrap }) => (wrap ? "wrap" : "initial")};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
min-height: 0;
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
type Props = {
size?: number;
fill?: string;
className?: string;
};
function GithubLogo({ size = 34, fill = "#FFF", className }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 36 36"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
fill="#191717"
d="M18,1.4C9,1.4,1.7,8.7,1.7,17.7c0,7.2,4.7,13.3,11.1,15.5 c0.8,0.1,1.1-0.4,1.1-0.8c0-0.4,0-1.4,0-2.8c-4.5,1-5.5-2.2-5.5-2.2c-0.7-1.9-1.8-2.4-1.8-2.4c-1.5-1,0.1-1,0.1-1 c1.6,0.1,2.5,1.7,2.5,1.7c1.5,2.5,3.8,1.8,4.7,1.4c0.1-1.1,0.6-1.8,1-2.2c-3.6-0.4-7.4-1.8-7.4-8.1c0-1.8,0.6-3.2,1.7-4.4 c-0.2-0.4-0.7-2.1,0.2-4.3c0,0,1.4-0.4,4.5,1.7c1.3-0.4,2.7-0.5,4.1-0.5c1.4,0,2.8,0.2,4.1,0.5c3.1-2.1,4.5-1.7,4.5-1.7 c0.9,2.2,0.3,3.9,0.2,4.3c1,1.1,1.7,2.6,1.7,4.4c0,6.3-3.8,7.6-7.4,8c0.6,0.5,1.1,1.5,1.1,3c0,2.2,0,3.9,0,4.5 c0,0.4,0.3,0.9,1.1,0.8c6.5-2.2,11.1-8.3,11.1-15.5C34.3,8.7,27,1.4,18,1.4z"
/>
</svg>
);
}
export default GithubLogo;
+69 -59
View File
@@ -1,10 +1,10 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import { s } from "@shared/styles";
import RootStore from "~/stores/RootStore";
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
import Group from "~/models/Group";
import GroupMembers from "~/scenes/GroupMembers";
@@ -12,11 +12,9 @@ import Facepile from "~/components/Facepile";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import NudeButton from "./NudeButton";
import withStores from "~/components/withStores";
type Props = {
type Props = RootStore & {
group: Group;
membership?: CollectionGroupMembership;
showFacepile?: boolean;
@@ -24,57 +22,69 @@ type Props = {
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
};
function GroupListItem({ group, showFacepile, renderActions }: Props) {
const { groupMemberships } = useStores();
const { t } = useTranslation();
const [
membersModalOpen,
setMembersModalOpen,
setMembersModalClosed,
] = useBoolean();
const memberCount = group.memberCount;
const membershipsInGroup = groupMemberships.inGroup(group.id);
const users = membershipsInGroup
.slice(0, MAX_AVATAR_DISPLAY)
.map((gm) => gm.user);
const overflow = memberCount - users.length;
@observer
class GroupListItem extends React.Component<Props> {
@observable
membersModalOpen = false;
return (
<>
<ListItem
image={
<Image>
<GroupIcon size={24} />
</Image>
}
title={<Title onClick={setMembersModalOpen}>{group.name}</Title>}
subtitle={t("{{ count }} member", { count: memberCount })}
actions={
<Flex align="center" gap={8}>
{showFacepile && (
<NudeButton
width="auto"
height="auto"
onClick={setMembersModalOpen}
>
<Facepile users={users} overflow={overflow} />
</NudeButton>
)}
{renderActions({
openMembersModal: setMembersModalOpen,
})}
</Flex>
}
/>
<Modal
title={t("Group members")}
onRequestClose={setMembersModalClosed}
isOpen={membersModalOpen}
>
<GroupMembers group={group} />
</Modal>
</>
);
handleMembersModalOpen = () => {
this.membersModalOpen = true;
};
handleMembersModalClose = () => {
this.membersModalOpen = false;
};
render() {
const { group, groupMemberships, showFacepile, renderActions } = this.props;
const memberCount = group.memberCount;
const membershipsInGroup = groupMemberships.inGroup(group.id);
const users = membershipsInGroup
.slice(0, MAX_AVATAR_DISPLAY)
.map((gm) => gm.user);
const overflow = memberCount - users.length;
return (
<>
<ListItem
image={
<Image>
<GroupIcon size={24} />
</Image>
}
title={
<Title onClick={this.handleMembersModalOpen}>{group.name}</Title>
}
subtitle={
<>
{memberCount} member{memberCount === 1 ? "" : "s"}
</>
}
actions={
<Flex align="center" gap={8}>
{showFacepile && (
<Facepile
onClick={this.handleMembersModalOpen}
users={users}
overflow={overflow}
/>
)}
{renderActions({
openMembersModal: this.handleMembersModalOpen,
})}
</Flex>
}
/>
<Modal
title="Group members"
onRequestClose={this.handleMembersModalClose}
isOpen={this.membersModalOpen}
>
<GroupMembers group={group} />
</Modal>
</>
);
}
}
const Image = styled(Flex)`
@@ -82,15 +92,15 @@ 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 {
text-decoration: underline;
cursor: var(--pointer);
cursor: pointer;
}
`;
export default observer(GroupListItem);
export default withStores(GroupListItem);
+4 -4
View File
@@ -1,7 +1,7 @@
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";
@@ -71,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;
@@ -92,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;
+13 -30
View File
@@ -5,30 +5,29 @@ 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";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import Desktop from "~/utils/Desktop";
import { supportsPassiveListener } from "~/utils/browser";
type Props = {
left?: React.ReactNode;
breadcrumb?: React.ReactNode;
title: React.ReactNode;
actions?: React.ReactNode;
hasSidebar?: boolean;
};
function Header({ left, title, actions, hasSidebar }: Props) {
function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
const { ui } = useStores();
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
const passThrough = !actions && !left && !title;
const passThrough = !actions && !breadcrumb && !title;
const [isScrolled, setScrolled] = React.useState(false);
const handleScroll = React.useMemo(
@@ -51,22 +50,18 @@ function Header({ left, title, actions, hasSidebar }: Props) {
}, []);
return (
<Wrapper
align="center"
shrink={false}
$passThrough={passThrough}
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
>
{left || hasMobileSidebar ? (
<Wrapper align="center" shrink={false} $passThrough={passThrough}>
{breadcrumb || hasMobileSidebar ? (
<Breadcrumbs>
{hasMobileSidebar && (
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
iconColor="currentColor"
neutral
/>
)}
{left}
{breadcrumb}
</Breadcrumbs>
) : null}
@@ -103,16 +98,11 @@ const Actions = styled(Flex)`
`};
`;
type WrapperProps = {
$passThrough?: boolean;
$insetTitleAdjust?: boolean;
};
const Wrapper = styled(Flex)<WrapperProps>`
const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
top: 0;
z-index: ${depths.header};
position: sticky;
background: ${s("background")};
background: ${(props) => props.theme.background};
${(props) =>
props.$passThrough
@@ -130,12 +120,6 @@ const Wrapper = styled(Flex)<WrapperProps>`
transform: translate3d(0, 0, 0);
min-height: 64px;
justify-content: flex-start;
${draggableOnDesktop()}
button,
[role="button"] {
${fadeOnDesktopBackgrounded()}
}
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
@@ -149,8 +133,7 @@ const Wrapper = styled(Flex)<WrapperProps>`
${breakpoint("tablet")`
padding: 16px;
justify-content: center;
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
`};
`};
`;
const Title = styled("div")`
@@ -160,7 +143,7 @@ const Title = styled("div")`
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: var(--pointer);
cursor: pointer;
min-width: 0;
${breakpoint("tablet")`
+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;
`;
+21 -24
View File
@@ -2,7 +2,7 @@ import { transparentize } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { depths } from "@shared/styles";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isExternalUrl } from "@shared/utils/urls";
import HoverPreviewDocument from "~/components/HoverPreviewDocument";
@@ -14,17 +14,14 @@ const DELAY_OPEN = 300;
const DELAY_CLOSE = 300;
type Props = {
/* The document associated with the editor, if any */
id?: string;
/* The HTML element that is being hovered over */
element: HTMLAnchorElement;
/* A callback on close of the hover preview */
node: HTMLAnchorElement;
event: MouseEvent;
onClose: () => void;
};
function HoverPreviewInternal({ element, id, onClose }: Props) {
function HoverPreviewInternal({ node, onClose }: Props) {
const { documents } = useStores();
const slug = parseDocumentSlug(element.href);
const slug = parseDocumentSlug(node.href);
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
@@ -71,13 +68,13 @@ function HoverPreviewInternal({ element, id, onClose }: Props) {
cardRef.current.addEventListener("mouseleave", startCloseTimer);
}
element.addEventListener("mouseout", startCloseTimer);
element.addEventListener("mouseover", stopCloseTimer);
element.addEventListener("mouseover", startOpenTimer);
node.addEventListener("mouseout", startCloseTimer);
node.addEventListener("mouseover", stopCloseTimer);
node.addEventListener("mouseover", startOpenTimer);
return () => {
element.removeEventListener("mouseout", startCloseTimer);
element.removeEventListener("mouseover", stopCloseTimer);
element.removeEventListener("mouseover", startOpenTimer);
node.removeEventListener("mouseout", startCloseTimer);
node.removeEventListener("mouseover", stopCloseTimer);
node.removeEventListener("mouseover", startOpenTimer);
if (cardRef.current) {
cardRef.current.removeEventListener("mouseenter", stopCloseTimer);
@@ -91,9 +88,9 @@ function HoverPreviewInternal({ element, id, onClose }: Props) {
clearTimeout(timerClose.current);
}
};
}, [element, slug]);
}, [node, slug]);
const anchorBounds = element.getBoundingClientRect();
const anchorBounds = node.getBoundingClientRect();
const cardBounds = cardRef.current?.getBoundingClientRect();
const left = cardBounds
? Math.min(anchorBounds.left, window.innerWidth - 16 - 350)
@@ -108,7 +105,7 @@ function HoverPreviewInternal({ element, id, onClose }: Props) {
aria-hidden
>
<div ref={cardRef}>
<HoverPreviewDocument url={element.href} id={id}>
<HoverPreviewDocument url={node.href}>
{(content: React.ReactNode) =>
isVisible ? (
<Animate>
@@ -127,18 +124,18 @@ function HoverPreviewInternal({ element, id, onClose }: Props) {
);
}
function HoverPreview({ element, ...rest }: Props) {
function HoverPreview({ node, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
}
// previews only work for internal doc links for now
if (isExternalUrl(element.href)) {
if (isExternalUrl(node.href)) {
return null;
}
return <HoverPreviewInternal {...rest} element={element} />;
return <HoverPreviewInternal {...rest} node={node} />;
}
const Animate = styled.div`
@@ -167,7 +164,7 @@ const CardContent = styled.div`
// &:after — gradient mask for overflow text
const Card = styled.div`
backdrop-filter: blur(10px);
background: ${s("background")};
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);
@@ -190,13 +187,13 @@ const Card = styled.div`
90deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${(props) => transparentize(1, props.theme.background)} 75%,
${s("background")} 90%
${(props) => props.theme.background} 90%
);
bottom: 0;
left: 0;
right: 0;
height: 1.7em;
border-bottom: 16px solid ${s("background")};
border-bottom: 16px solid ${(props) => props.theme.background};
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
@@ -240,7 +237,7 @@ const Pointer = styled.div<{ offset: number }>`
&:after {
border: 7px solid transparent;
border-bottom-color: ${s("background")};
border-bottom-color: ${(props) => props.theme.background};
}
`;
+9 -10
View File
@@ -2,21 +2,17 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import DocumentMeta from "~/components/DocumentMeta";
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
import Editor from "~/components/Editor";
import useStores from "~/hooks/useStores";
type Props = {
/* The document associated with the editor, if any */
id?: string;
/* The URL we want a preview for */
url: string;
children: (content: React.ReactNode) => React.ReactNode;
};
function HoverPreviewDocument({ url, id, children }: Props) {
function HoverPreviewDocument({ url, children }: Props) {
const { documents } = useStores();
const slug = parseDocumentSlug(url);
@@ -25,7 +21,7 @@ function HoverPreviewDocument({ url, id, children }: Props) {
}
const document = slug ? documents.getByUrl(slug) : undefined;
if (!document || document.id === id) {
if (!document) {
return null;
}
@@ -34,7 +30,10 @@ function HoverPreviewDocument({ url, id, children }: Props) {
{children(
<Content to={document.url}>
<Heading>{document.titleWithDefault}</Heading>
<DocumentMeta document={document} />
<DocumentMetaWithViews
isDraft={document.isDraft}
document={document}
/>
<React.Suspense fallback={<div />}>
<Editor
@@ -51,12 +50,12 @@ function HoverPreviewDocument({ url, id, children }: Props) {
}
const Content = styled(Link)`
cursor: var(--pointer);
cursor: pointer;
`;
const Heading = styled.h2`
margin: 0 0 0.75em;
color: ${s("text")};
color: ${(props) => props.theme.text};
`;
export default observer(HoverPreviewDocument);
+34 -46
View File
@@ -40,22 +40,23 @@ 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 DelayedMount from "./DelayedMount";
const style = {
width: 30,
height: 30,
};
const TwitterPicker = React.lazy(
() => import("react-color/lib/components/twitter/Twitter")
() =>
import(
/* webpackChunkName: "twitter-picker" */
"react-color/lib/components/twitter/Twitter"
)
);
export const icons = {
@@ -240,36 +241,24 @@ 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) => {
return (
<MenuItem
key={name}
onClick={() => onChange(color, name)}
{...menu}
>
{(props) => (
<IconButton style={style} {...props}>
<Icon as={icons[name].component} color={color} size={30} />
</IconButton>
)}
</MenuItem>
);
})}
</Icons>
<Colors>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<Flex>
<React.Suspense fallback={<Loading>{t("Loading")}</Loading>}>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
@@ -277,10 +266,6 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
triangle="hide"
styles={{
default: {
body: {
padding: 0,
marginRight: -8,
},
hash: {
color: theme.text,
background: theme.inputBorder,
@@ -294,7 +279,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
}}
/>
</React.Suspense>
</Colors>
</Flex>
</ContextMenu>
</Wrapper>
);
@@ -302,11 +287,6 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
const Icon = styled.svg`
transition: fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
const Colors = styled(Flex)`
padding: 8px;
`;
const Label = styled.label`
@@ -314,7 +294,7 @@ const Label = styled.label`
`;
const Icons = styled.div`
padding: 8px;
padding: 16px 8px 0 16px;
${breakpoint("tablet")`
width: 276px;
@@ -322,7 +302,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;
`;
@@ -334,10 +314,18 @@ const IconButton = styled(NudeButton)`
height: 30px;
`;
const Loading = styled(Text)`
padding: 16px;
`;
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
width: 100% !important;
width: auto !important;
${breakpoint("tablet")`
width: 276px;
`};
`;
const Wrapper = styled("div")`
-25
View File
@@ -1,25 +0,0 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
color?: string;
};
export default function GoogleIcon({
size = 24,
color = "currentColor",
}: Props) {
return (
<svg
fill={color}
width={size}
height={size}
viewBox="-8 -8 48 48"
version="1.1"
>
<path d="M32.6162791,13.9090909 L16.8837209,13.9090909 L16.8837209,20.4772727 L25.9395349,20.4772727 C25.0953488,24.65 21.5651163,27.0454545 16.8837209,27.0454545 C11.3581395,27.0454545 6.90697674,22.5636364 6.90697674,17 C6.90697674,11.4363636 11.3581395,6.95454545 16.8837209,6.95454545 C19.2627907,6.95454545 21.4116279,7.80454545 23.1,9.19545455 L28.0116279,4.25 C25.0186047,1.62272727 21.1813953,0 16.8837209,0 C7.52093023,0 0,7.57272727 0,17 C0,26.4272727 7.52093023,34 16.8837209,34 C25.3255814,34 33,27.8181818 33,17 C33,15.9954545 32.8465116,14.9136364 32.6162791,13.9090909 Z" />
</svg>
);
}
-35
View File
@@ -1,35 +0,0 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
color?: string;
};
export default function MarkdownIcon({
size = 24,
color = "currentColor",
...rest
}: Props) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<path
d="M19.2692 7H3.86538C3.38745 7 3 7.38476 3 7.85938V16.2812C3 16.7559 3.38745 17.1406 3.86538 17.1406H19.2692C19.7472 17.1406 20.1346 16.7559 20.1346 16.2812V7.85938C20.1346 7.38476 19.7472 7 19.2692 7Z"
stroke={color}
stroke-width="2"
/>
<path
d="M5.16345 14.9922V9.14844H6.89422L8.62499 11.2969L10.3558 9.14844H12.0865V14.9922H10.3558V11.6406L8.62499 13.7891L6.89422 11.6406V14.9922H5.16345ZM15.9808 14.9922L13.3846 12.1562H15.1154V9.14844H16.8461V12.1562H18.5769L15.9808 14.9922Z"
fill={color}
/>
</svg>
);
}
-28
View File
@@ -1,28 +0,0 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
color?: string;
/* Whether the safe area should be removed and have graphic across full size */
cover?: boolean;
};
export default function OutlineIcon({
size = 24,
cover,
color = "currentColor",
}: Props) {
return (
<svg
fill={color}
width={size}
height={size}
viewBox={cover ? "2 2 20 20" : "0 0 24 24"}
version="1.1"
>
<path d="M14.6667 20.2155V20.7163C14.6667 21.4253 14.0697 22 13.3333 22C13.1044 22 12.8792 21.9432 12.6797 21.8351L4.67965 17.5028C4.25982 17.2754 4 16.8478 4 16.384V7.61623C4 7.15248 4.25982 6.72478 4.67965 6.49742L12.6797 2.16508C13.3215 1.81751 14.1344 2.03666 14.4954 2.65456C14.6077 2.8467 14.6667 3.06343 14.6667 3.28388V3.78471L15.6169 3.51027C16.3222 3.30655 17.0655 3.69189 17.2771 4.37093C17.3144 4.49059 17.3333 4.61486 17.3333 4.73979V5.26091L18.5013 5.12036C19.232 5.03242 19.8984 5.53141 19.9897 6.23488C19.9966 6.2877 20 6.34088 20 6.3941V17.6061C20 18.3151 19.403 18.8898 18.6667 18.8898C18.6114 18.8898 18.5561 18.8865 18.5013 18.8799L17.3333 18.7393V19.2604C17.3333 19.9694 16.7364 20.5441 16 20.5441C15.8702 20.5441 15.7412 20.5259 15.6169 20.49L14.6667 20.2155ZM14.6667 18.8753L16 19.2604V4.73979L14.6667 5.12488V18.8753ZM17.3333 6.55456V17.4457L18.6667 17.6061V6.3941L17.3333 6.55456ZM5.33333 7.61623V16.384L13.3333 20.7163V3.28388L5.33333 7.61623ZM6.66667 8.47006L8 7.82823V16.172L6.66667 15.5302V8.47006Z" />
</svg>
);
}
+82 -115
View File
@@ -1,11 +1,10 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import { undraggableOnDesktop } from "~/styles";
const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
border: 0;
@@ -13,11 +12,11 @@ const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
outline: none;
background: none;
color: ${s("text")};
color: ${(props) => props.theme.text};
&:disabled,
&::placeholder {
color: ${s("placeholder")};
color: ${(props) => props.theme.placeholder};
}
`;
@@ -27,21 +26,23 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
outline: none;
background: none;
color: ${s("text")};
color: ${(props) => props.theme.text};
height: 30px;
min-width: 0;
${ellipsis()}
${undraggableOnDesktop()}
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:disabled,
&::placeholder {
color: ${s("placeholder")};
color: ${(props) => props.theme.placeholder};
}
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0px 1000px ${s("background")} inset;
-webkit-box-shadow: 0 0 0px 1000px ${(props) => props.theme.background}
inset;
}
&::-webkit-search-cancel-button {
@@ -57,13 +58,11 @@ const Wrapper = styled.div<{
flex?: boolean;
short?: boolean;
minHeight?: number;
minWidth?: number;
maxHeight?: number;
}>`
flex: ${(props) => (props.flex ? "1" : "0")};
width: ${(props) => (props.short ? "49%" : "auto")};
max-width: ${(props) => (props.short ? "350px" : "100%")};
min-width: ${({ minWidth }) => (minWidth ? `${minWidth}px` : "initial")};
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "initial")};
`;
@@ -96,10 +95,7 @@ export const Outline = styled(Flex)<{
font-weight: normal;
align-items: center;
overflow: hidden;
background: ${s("background")};
/* Prevents an issue where input placeholder appears in a selected style when double clicking title bar */
user-select: none;
background: ${(props) => props.theme.background};
`;
export const LabelText = styled.div`
@@ -117,121 +113,92 @@ export type Props = React.InputHTMLAttributes<
flex?: boolean;
short?: boolean;
margin?: string | number;
error?: string;
icon?: React.ReactNode;
/* Callback is triggered with the CMD+Enter keyboard combo */
onRequestSubmit?: (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
innerRef?: React.Ref<any>;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
};
function Input(
props: Props,
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
) {
const [focused, setFocused] = React.useState(false);
@observer
class Input extends React.Component<Props> {
input = this.props.innerRef;
const handleBlur = (ev: React.SyntheticEvent) => {
setFocused(false);
@observable
focused = false;
if (props.onBlur) {
props.onBlur(ev);
handleBlur = (ev: React.SyntheticEvent) => {
this.focused = false;
if (this.props.onBlur) {
this.props.onBlur(ev);
}
};
const handleFocus = (ev: React.SyntheticEvent) => {
setFocused(true);
handleFocus = (ev: React.SyntheticEvent) => {
this.focused = true;
if (props.onFocus) {
props.onFocus(ev);
if (this.props.onFocus) {
this.props.onFocus(ev);
}
};
const handleKeyDown = (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
if (ev.key === "Enter" && ev.metaKey) {
if (props.onRequestSubmit) {
props.onRequestSubmit(ev);
}
}
render() {
const {
type = "text",
icon,
label,
margin,
className,
short,
flex,
labelHidden,
onFocus,
onBlur,
...rest
} = this.props;
if (props.onKeyDown) {
props.onKeyDown(ev);
}
};
const wrappedLabel = <LabelText>{label}</LabelText>;
const {
type = "text",
icon,
label,
margin,
error,
className,
short,
flex,
labelHidden,
onFocus,
onBlur,
...rest
} = props;
const wrappedLabel = <LabelText>{label}</LabelText>;
return (
<Wrapper className={className} short={short} flex={flex}>
<label>
{label &&
(labelHidden ? (
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
) : (
wrappedLabel
))}
<Outline focused={focused} margin={margin}>
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
<RealTextarea
ref={ref as React.RefObject<HTMLTextAreaElement>}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
{...rest}
/>
) : (
<RealInput
ref={ref as React.RefObject<HTMLInputElement>}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
type={type}
{...rest}
/>
)}
</Outline>
</label>
{error && (
<TextWrapper>
<StyledText type="danger" size="xsmall">
{error}
</StyledText>
</TextWrapper>
)}
</Wrapper>
);
return (
<Wrapper className={className} short={short} flex={flex}>
<label>
{label &&
(labelHidden ? (
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
) : (
wrappedLabel
))}
<Outline focused={this.focused} margin={margin}>
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
<RealTextarea
ref={this.props.innerRef}
onBlur={this.props.onBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
{...rest}
/>
) : (
<RealInput
ref={this.props.innerRef}
onBlur={this.props.onBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
type={type}
{...rest}
/>
)}
</Outline>
</label>
</Wrapper>
);
}
}
export const TextWrapper = styled.span`
min-height: 16px;
display: block;
margin-top: -16px;
`;
export const ReactHookWrappedInput = React.forwardRef(
(props: Omit<Props, "innerRef">, ref: React.Ref<any>) => {
return <Input {...{ ...props, innerRef: ref }} />;
}
);
export const StyledText = styled(Text)`
margin-bottom: 0;
`;
export default React.forwardRef(Input);
export default Input;
-88
View File
@@ -1,88 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import ContextMenu from "./ContextMenu";
import DelayedMount from "./DelayedMount";
import Input, { Props as InputProps } from "./Input";
import NudeButton from "./NudeButton";
import Relative from "./Sidebar/components/Relative";
import Text from "./Text";
type Props = Omit<InputProps, "onChange"> & {
value: string | undefined;
onChange: (value: string) => void;
};
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }) => {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
return (
<Relative>
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="#"
maxLength={7}
{...rest}
/>
<MenuButton {...menu}>
{(props) => (
<SwatchButton
aria-label={t("Show menu")}
{...props}
$background={value}
/>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Select a color")}>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={value}
onChange={(color) => onChange(color.hex)}
/>
</React.Suspense>
</ContextMenu>
</Relative>
);
};
const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>`
background: ${(props) => props.$background};
border: 1px solid ${s("inputBorder")};
border-radius: 50%;
position: absolute;
bottom: 20px;
right: 6px;
`;
const ColorPicker = React.lazy(
() => import("react-color/lib/components/chrome/Chrome")
);
const StyledColorPicker = styled(ColorPicker)`
background: inherit !important;
box-shadow: none !important;
border: 0 !important;
border-radius: 0 !important;
user-select: none;
input {
user-select: text;
color: ${s("text")} !important;
}
`;
export default InputColor;

Some files were not shown because too many files have changed in this diff Show More