Compare commits

..

4 Commits

Author SHA1 Message Date
Tom Moor 6863fe3698 Merge branch 'main' into codegen-bot/implement-mention-model-tracking-9268 2025-12-12 19:47:16 -05:00
Copilot b68997f78a Fix MentionsProcessor tests: implement parseMentions and resolve circular dependency (#10866)
* Initial plan

* Fix circular dependency in Mention model by using lazy import for can function

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Implement parseMentions method and fix test markdown format for mentions

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2025-12-12 07:20:41 -05:00
codegen-sh[bot] ac2d3bf3cb Fix TypeScript compilation errors in Mention model
- Replace authorize() with can() function for proper boolean return
- Add missing createdAt property to documents.update events in tests
- Add non-null assertions for required properties
- Fix event type definitions to match DocumentEvent interface
2025-05-31 00:17:15 +00:00
codegen-sh[bot] 94a8326c68 Implement Mention model for tracking user mentions
- Add Mention model with relationships to User and Document
- Create database migration for mentions table
- Add MentionsProcessor to handle document events
- Add comprehensive tests for MentionsProcessor
- Update model relationships in Document and User models

Addresses issue #9268 for tracking user mentions similar to backlinks
2025-05-30 23:53:38 +00:00
1394 changed files with 32778 additions and 81726 deletions
+1 -9
View File
@@ -1,3 +1,4 @@
__mocks__
.git
.vscode
.github
@@ -7,19 +8,10 @@
.eslint*
.oxlintrc*
.log
*.md
Makefile
Procfile
app.json
crowdin.yml
lint-staged.config.mjs
build
docker-compose.yml
node_modules
.yarn
**/*.test.ts
**/*.test.tsx
**/*.test.js
**/*.test.jsx
**/__tests__
**/__mocks__
+2 -21
View File
@@ -61,11 +61,6 @@ DATABASE_CONNECTION_POOL_MAX=
# DOCS: https://docs.getoutline.com/s/hosting/doc/redis-LGM4BFXYp4
REDIS_URL=redis://redis:6379
# To enable horizontal scaling of the collaboration service you must provide a Redis URL, it may
# be the same as above, or a different server.
# DOCS: https://docs.getoutline.com/s/hosting/doc/horizontal-scaling-hkfU5Stao7
REDIS_COLLABORATION_URL=
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––– FILE STORAGE –––––––––––
@@ -119,11 +114,6 @@ SSL_CERT=
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
# When behind a reverse proxy, the header to use for the client IP.
# The default value is "X-Forwarded-For", common values are "X-Real-IP"
# and "X-Client-IP".
# PROXY_IP_HEADER=
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– AUTHENTICATION ––––––––––
@@ -208,7 +198,7 @@ RATE_LIMITER_DURATION_WINDOW=60
# ––––––––––– INTEGRATIONS –––––––––––
# ––––––––––––––––––––––––––––––––––––––
# GitHub integration allows previewing issue and pull request links
# The GitHub integration allows previewing issue and pull request links
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
@@ -217,12 +207,7 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# The GitLab integration allows previewing issue and merge request links
# DOCS:
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# Linear integration allows previewing issue links as rich mentions
# The Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
@@ -233,10 +218,6 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Figma integration allows previewing design files as rich mentions
FIGMA_CLIENT_ID=
FIGMA_CLIENT_SECRET=
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
+4 -11
View File
@@ -1,6 +1,10 @@
NODE_ENV=test
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline-test
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET=test-utils-secret-key
REDIS_URL=redis://localhost:6379
URL=http://localhost:3000
COLLABORATION_URL=http://localhost:3001
SMTP_HOST=smtp.example.com
SMTP_USERNAME=test
@@ -18,9 +22,6 @@ GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
GITHUB_APP_NAME=outline-test;
GITLAB_CLIENT_ID=123
GITLAB_CLIENT_SECRET=123
OIDC_CLIENT_ID=client-id
OIDC_CLIENT_SECRET=client-secret
OIDC_AUTH_URI=http://localhost/authorize
@@ -33,11 +34,3 @@ RATE_LIMITER_ENABLED=false
FILE_STORAGE=local
FILE_STORAGE_LOCAL_ROOT_DIR=/tmp
URL=http://localhost:3000
COLLABORATION_URL=
REDIS_URL=redis://localhost:6379
UTILS_SECRET=test-utils-secret
DEBUG=
LOG_LEVEL=error
+20 -55
View File
@@ -18,61 +18,44 @@ env:
SMTP_USERNAME: localhost
jobs:
setup:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- name: Use Node.js 22.x
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v5
with:
node-version: 22.x
node-version: ${{ matrix.node-version }}
cache: "yarn"
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn install --immutable
run: yarn install --frozen-lockfile --prefer-offline
lint:
needs: setup
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint --quiet
types:
needs: setup
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn tsc
changes:
@@ -102,7 +85,7 @@ jobs:
- 'yarn.lock'
test:
needs: [setup, changes]
needs: [build, changes]
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
runs-on: ubuntu-latest
strategy:
@@ -110,21 +93,15 @@ jobs:
test-group: [app, shared]
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [setup, changes]
needs: [build, changes]
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
runs-on: ubuntu-latest
services:
@@ -148,40 +125,28 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | awk "NR % 4 == (${{ matrix.shard }} - 1)")
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [setup, types, changes]
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- run: yarn install --frozen-lockfile --prefer-offline
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
-8
View File
@@ -14,11 +14,3 @@ data/*
*.pem
*.key
*.cert
# Yarn Berry
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
.yarn/releases
!.yarn/sdks
+1 -4
View File
@@ -10,10 +10,7 @@
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"<rootDir>/server/test/setupMocks.js"
],
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
-1
View File
@@ -86,7 +86,6 @@
"@typescript-eslint/no-require-imports": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"typescript/consistent-type-imports": "error",
"no-unused-vars": [
"error",
{
-3
View File
@@ -1,3 +0,0 @@
nodeLinker: node-modules
npmMinimalAgeGate: 86400
+12 -15
View File
@@ -30,12 +30,10 @@ You're an expert in the following areas:
## General Guidelines
- Critical Do not create new markdown (.md) files.
- Use early returns for readability.
- Emphasize type safety and static analysis.
- Follow consistent Prettier formatting.
- Do not replace smart quotes ("") or ('') with simple quotes ("").
- Do not add translation strings manually; they will be extracted automatically from the codebase.
## Dependencies and Upgrading
@@ -64,8 +62,8 @@ yarn install
2. Public static methods
3. Public variables
4. Public methods
5. Protected variables & methods
6. Private variables & methods
6. Protected variables & methods
8. Private variables & methods
### Exports
@@ -79,7 +77,7 @@ yarn install
- Event handlers should be prefixed with "handle", like "handleClick" for onClick.
- Avoid unnecessary re-renders by using React.memo, useMemo, and useCallback appropriately.
- Use descriptive prop types with TypeScript interfaces.
- Do not import React unless it is used directly.
- You do not need to import React unless it is used directly.
- Use styled-components for component styling.
- Ensure high accessibility (a11y) standards using ARIA roles and semantic HTML.
@@ -143,21 +141,20 @@ yarn sequelize migration:create --name=add-field-to-table
- Run tests with Jest:
```bash
# Run a specific test file (preferred)
yarn test path/to/test.spec.ts
# Run every test (avoid)
# Run all tests
yarn test
# Run test suites (avoid)
yarn test:app # All frontend tests
yarn test:server # All backend tests
yarn test:shared # All shared code tests
# Run specific test suites
yarn test:app # Frontend tests
yarn test:server # Backend tests
yarn test:shared # Shared code tests
# Run specific test file
yarn test path/to/test.spec.ts
```
- Write unit tests for utilities and business logic in a collocated .test.ts file.
- Do not create new test directories
- Mock external dependencies appropriately in **mocks** folder.
- Mock external dependencies appropriately in __mocks__ folder.
- Aim for high code coverage but focus on critical paths.
## Code Quality
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+8 -8
View File
@@ -18,15 +18,15 @@ ENV NODE_ENV=production
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline && \
chown -R nodejs:nodejs $APP_PATH
chown -R nodejs:nodejs /var/lib/outline
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
COPY --from=base --chown=nodejs:nodejs $APP_PATH/server ./server
COPY --from=base --chown=nodejs:nodejs $APP_PATH/public ./public
COPY --from=base --chown=nodejs:nodejs $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base --chown=nodejs:nodejs $APP_PATH/node_modules ./node_modules
COPY --from=base --chown=nodejs:nodejs $APP_PATH/package.json ./package.json
COPY --from=base $APP_PATH/server ./server
COPY --from=base $APP_PATH/public ./public
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base $APP_PATH/node_modules ./node_modules
COPY --from=base $APP_PATH/package.json ./package.json
# Install wget to healthcheck the server
RUN apt-get update \
&& apt-get install -y wget \
@@ -44,4 +44,4 @@ USER nodejs
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
EXPOSE 3000
CMD ["node", "build/server/index.js"]
CMD ["yarn", "start"]
+5 -4
View File
@@ -3,21 +3,22 @@ FROM node:22.21.0 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN apt-get update && apt-get install -y cmake
ENV NODE_OPTIONS="--max-old-space-size=24000"
RUN corepack enable
RUN yarn install --immutable --network-timeout 1000000 && \
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
COPY . .
ARG CDN_URL
RUN yarn build
RUN yarn workspaces focus --production && \
RUN rm -rf node_modules
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
ENV PORT=3000
+3 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.5.0
The Licensed Work is (c) 2026 General Outline, Inc.
Licensed Work: Outline 1.1.0
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
Service.
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2030-02-15
Change Date: 2029-11-16
Change License: Apache License, Version 2.0
+1 -1
View File
@@ -1,7 +1,7 @@
up:
docker compose up -d redis postgres
yarn install-local-ssl
yarn install --immutable
yarn install --pure-lockfile
yarn dev:watch
build:
+1 -5
View File
@@ -1,9 +1,5 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./public/logos/outline-logo-dark.png" height="29">
<source media="(prefers-color-scheme: light)" srcset="./public/logos/outline-logo-light.png" height="29">
<img src="./public/logos/outline-logo-light.png" height="29" alt="Outline" />
</picture>
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
</p>
<p align="center">
<i>A fast, collaborative, knowledge base for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
+3 -23
View File
@@ -21,23 +21,7 @@
}
],
"scripts": {
"postdeploy": "yarn sequelize db:migrate",
"pr-predeploy": "yarn sequelize db:migrate"
},
"environments": {
"review": {
"scripts": {
"postdeploy": "yarn sequelize db:migrate"
},
"addons": [
{
"plan": "heroku-redis:mini"
},
{
"plan": "heroku-postgresql:essential-0"
}
]
}
"postdeploy": "yarn sequelize db:migrate"
},
"env": {
"NODE_ENV": {
@@ -59,12 +43,8 @@
"required": true
},
"URL": {
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to. For review apps, this is auto-generated.",
"required": false
},
"HEROKU_APP_NAME": {
"description": "Automatically set by Heroku for review apps",
"required": false
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
"required": true
},
"GOOGLE_CLIENT_ID": {
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
+1 -1
View File
@@ -1,6 +1,6 @@
import { PlusIcon, TrashIcon } from "outline-icons";
import stores from "~/stores";
import type ApiKey from "~/models/ApiKey";
import ApiKey from "~/models/ApiKey";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
import { createAction } from "..";
+179 -121
View File
@@ -1,12 +1,12 @@
import {
SortAlphabeticalReverseIcon,
SortAlphabeticalIcon,
AlphabeticalReverseSortIcon,
AlphabeticalSortIcon,
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
ImportIcon,
SortManualIcon,
ManualSortIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
@@ -29,8 +29,8 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createInternalLinkAction,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
@@ -96,11 +96,11 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -109,7 +109,7 @@ export const editCollection = createAction({
content: (
<CollectionEdit
onSubmit={stores.dialogs.closeAllModals}
collectionId={collection.id}
collectionId={activeCollectionId}
/>
),
});
@@ -122,16 +122,21 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: t("Share this collection"),
style: { marginBottom: -12 },
content: (
<SharePopover
collection={collection}
@@ -148,16 +153,15 @@ export const importDocument = createAction({
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
perform: ({ t, getActiveModel, stores }) => {
const { documents } = stores;
const collection = getActiveModel(Collection);
if (!collection) {
return;
visible: ({ activeCollectionId, stores }) => {
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
},
perform: ({ activeCollectionId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypesString;
@@ -165,17 +169,19 @@ export const importDocument = createAction({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
const toastId = toast.loading(`${t("Uploading")}`);
try {
const document = await documents.import(file, null, collection.id, {
publish: true,
});
history.push(document.path);
const document = await documents.import(
file,
null,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
};
@@ -186,36 +192,37 @@ export const importDocument = createAction({
export const sortCollection = createActionWithChildren({
name: ({ t }) => t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
icon: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
!!stores.policies.abilities(activeCollectionId).update,
icon: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
const sortAlphabetical = collection?.sort.field === "title";
const sortDir = collection?.sort.direction;
return sortAlphabetical ? (
sortDir === "asc" ? (
<SortAlphabeticalIcon />
<AlphabeticalSortIcon />
) : (
<SortAlphabeticalReverseIcon />
<AlphabeticalReverseSortIcon />
)
) : (
<SortManualIcon />
<ManualSortIcon />
);
},
children: [
createAction({
name: ({ t }) => t("A-Z sort"),
section: ActiveCollectionSection,
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "asc"
);
},
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
@@ -227,15 +234,15 @@ export const sortCollection = createActionWithChildren({
createAction({
name: ({ t }) => t("Z-A sort"),
section: ActiveCollectionSection,
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "desc"
);
},
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
@@ -247,12 +254,12 @@ export const sortCollection = createActionWithChildren({
createAction({
name: ({ t }) => t("Manual sort"),
section: ActiveCollectionSection,
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.sort.field !== "title";
},
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "index",
@@ -269,19 +276,22 @@ export const searchInCollection = createInternalLinkAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection?.isActive) {
return false;
}
return stores.policies.abilities(collection.id).readDocument;
return stores.policies.abilities(activeCollectionId).readDocument;
},
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = searchPath({
collectionId: collection?.id,
collectionId: activeCollectionId,
}).split("?");
return {
@@ -298,22 +308,23 @@ export const starCollection = createAction({
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection.isStarred && stores.policies.abilities(collection.id).star
!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
if (!collection) {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
await collection.star();
const collection = stores.collections.get(activeCollectionId);
await collection?.star();
setPersistedState(getHeaderExpandedKey("starred"), true);
},
});
@@ -324,18 +335,22 @@ export const unstarCollection = createAction({
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection.isStarred && stores.policies.abilities(collection.id).unstar
!!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unstar();
},
});
@@ -345,25 +360,28 @@ export const subscribeCollection = createAction({
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection.isActive &&
!collection.isSubscribed &&
stores.policies.abilities(collection.id).subscribe
!!collection?.isActive &&
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
},
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
await collection.subscribe();
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
@@ -373,25 +391,28 @@ export const unsubscribeCollection = createAction({
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection.isActive &&
!!collection.isSubscribed &&
stores.policies.abilities(collection.id).unsubscribe
!!collection?.isActive &&
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
},
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
return;
}
await collection.unsubscribe();
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
@@ -401,15 +422,23 @@ export const archiveCollection = createAction({
analyticsName: "Archive collection",
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.archive),
perform: async ({ getActiveModel, stores, t }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).archive;
},
perform: async ({ activeCollectionId, stores, t }) => {
const { dialogs, collections } = stores;
if (!activeCollectionId) {
return;
}
const collection = collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({
dialogs.openModal({
title: t("Archive collection"),
content: (
<ConfirmationDialog
@@ -434,10 +463,17 @@ export const restoreCollection = createAction({
analyticsName: "Restore collection",
section: CollectionSection,
icon: <RestoreIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.restore),
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).restore;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
@@ -453,10 +489,18 @@ export const deleteCollection = createAction({
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, t, stores }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
@@ -478,10 +522,18 @@ export const exportCollection = createAction({
analyticsName: "Export collection",
section: ActiveCollectionSection,
icon: <ExportIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.export),
perform: async ({ getActiveModel, stores, t }) => {
const collection = getActiveModel(Collection);
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (!currentTeamId || !activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).export;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
@@ -504,13 +556,13 @@ export const createDocument = createInternalLinkAction({
section: ActiveCollectionSection,
icon: <NewDocumentIcon />,
keywords: "new create document",
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newDocumentPath(collection?.id).split("?");
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
return {
pathname,
@@ -526,13 +578,19 @@ export const createTemplate = createInternalLinkAction({
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createTemplate
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return newTemplatePath(collection?.id);
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
import { toast } from "sonner";
import type Comment from "~/models/Comment";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import { createAction } from "..";
+1 -12
View File
@@ -17,17 +17,7 @@ import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import Logger from "~/utils/Logger";
import { deleteAllDatabases } from "~/utils/developer";
import history from "~/utils/history";
import { homePath, debugPath } from "~/utils/routeHelpers";
export const goToDebug = createAction({
name: "Go to debug screen",
icon: <BeakerIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: () => {
history.push(debugPath());
},
});
import { homePath } from "~/utils/routeHelpers";
export const copyId = createActionWithChildren({
name: ({ t }) => t("Copy ID"),
@@ -232,7 +222,6 @@ export const developer = createActionWithChildren({
iconInContextMenu: false,
section: DeveloperSection,
children: [
goToDebug,
copyId,
toggleDebugLogging,
toggleDebugSafeArea,
+198 -221
View File
@@ -35,19 +35,20 @@ import {
} from "outline-icons";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { ExportContentType, TeamPreference } from "@shared/types";
import {
ExportContentType,
TeamPreference,
NavigationNode,
} from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import { Week } from "@shared/utils/time";
import type UserMembership from "~/models/UserMembership";
import { client } from "~/utils/ApiClient";
import UserMembership from "~/models/UserMembership";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
import { DocumentDownload } from "~/components/DocumentDownload";
import DocumentCopy from "~/components/DocumentCopy";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
@@ -62,6 +63,7 @@ import {
DocumentSection,
TrashSection,
} from "~/actions/sections";
import env from "~/env";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import {
@@ -69,7 +71,6 @@ import {
homePath,
newDocumentPath,
newNestedDocumentPath,
newSiblingDocumentPath,
searchPath,
documentPath,
urlify,
@@ -78,15 +79,8 @@ import {
} from "~/utils/routeHelpers";
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type {
Action,
ActionContext,
ActionGroup,
ActionSeparator,
} from "~/types";
import { Action, ActionGroup, ActionSeparator } from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import env from "~/env";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
@@ -138,13 +132,18 @@ export const editDocument = createInternalLinkAction({
keywords: "edit",
icon: <EditIcon />,
visible: ({ activeDocumentId, stores }) => {
const { auth, policies } = stores;
const { auth, documents, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return !!can?.update && !!auth.user?.separateEditMode;
return (
!!can?.update && !!auth.user?.separateEditMode && !document?.template
);
},
to: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
@@ -201,41 +200,59 @@ export const createDraftDocument = createInternalLinkAction({
}),
});
/**
* Finds the index of a document among its siblings in the collection tree.
*
* @param stores - the root stores.
* @param document - the document to find the index of.
* @returns the index of the document among its siblings, or -1 if not found.
*/
function findDocumentSiblingIndex(
stores: ActionContext["stores"],
document: {
id: string;
collectionId?: string | null;
parentDocumentId?: string;
}
): number {
if (!document.collectionId) {
return -1;
}
const collection = stores.collections.get(document.collectionId);
if (!collection) {
return -1;
}
export const createDocumentFromTemplate = createInternalLinkAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({
currentTeamId,
activeCollectionId,
activeDocumentId,
stores,
}) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
const siblings = document.parentDocumentId
? collection.getChildrenForDocument(document.parentDocumentId)
: collection.sortedDocuments;
if (
!currentTeamId ||
!document?.isTemplate ||
!!document?.isDraft ||
!!document?.isDeleted
) {
return false;
}
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
}
if (activeCollectionId) {
return stores.policies.abilities(activeCollectionId).createDocument;
}
return stores.policies.abilities(currentTeamId).createDocument;
},
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
if (!activeDocumentId || !activeCollectionId) {
return "";
}
const [pathname, search] = newDocumentPath(activeCollectionId, {
templateId: activeDocumentId,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const createNestedDocument = createInternalLinkAction({
name: ({ t }) => t("Nested document"),
name: ({ t }) => t("New nested document"),
analyticsName: "New document",
section: ActiveDocumentSection,
keywords: "create nested",
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
@@ -253,93 +270,6 @@ export const createNestedDocument = createInternalLinkAction({
},
});
const createDocumentBefore = createInternalLinkAction({
name: ({ t }) => t("Before"),
analyticsName: "New document before",
section: ActiveDocumentSection,
keywords: "create before",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collectionId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const index = findDocumentSiblingIndex(stores, document);
const [pathname, search] = newSiblingDocumentPath({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
index: Math.max(0, index),
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
const createDocumentAfter = createInternalLinkAction({
name: ({ t }) => t("After"),
analyticsName: "New document after",
section: ActiveDocumentSection,
keywords: "create after",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collectionId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const index = findDocumentSiblingIndex(stores, document);
const [pathname, search] = newSiblingDocumentPath({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
index: index + 1,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const createNewDocument = createActionWithChildren({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
@@ -416,7 +346,7 @@ export const publishDocument = createAction({
return;
}
if (document?.collectionId) {
if (document?.collectionId || document?.template) {
await document.save(undefined, {
publish: true,
});
@@ -579,6 +509,7 @@ export const shareDocument = createAction({
}
stores.dialogs.openModal({
style: { marginBottom: -12 },
title: t("Share this document"),
content: (
<SharePopover
@@ -591,61 +522,13 @@ export const shareDocument = createAction({
},
});
export const downloadDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
analyticsName: "Download document",
section: ActiveDocumentSection,
icon: <DownloadIcon />,
keywords: "export md markdown html",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, t, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
invariant(document, "Document must exist");
stores.dialogs.openModal({
title: t("Download document"),
content: (
<DocumentDownload
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Download as Markdown"),
analyticsName: "Download document as Markdown",
section: ActiveDocumentSection,
keywords: "md markdown export",
icon: <DownloadIcon />,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.download({
contentType: ExportContentType.Markdown,
includeChildDocuments: false,
});
},
});
export const downloadDocumentAsHTML = createAction({
name: ({ t }) => t("Download as HTML"),
name: ({ t }) => t("HTML"),
analyticsName: "Download document as HTML",
section: ActiveDocumentSection,
keywords: "xml html export",
keywords: "html export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ activeDocumentId, stores }) => {
@@ -654,38 +537,70 @@ export const downloadDocumentAsHTML = createAction({
}
const document = stores.documents.get(activeDocumentId);
await document?.download({
contentType: ExportContentType.Html,
includeChildDocuments: false,
});
await document?.download(ExportContentType.Html);
},
});
export const downloadDocumentAsPDF = createAction({
name: ({ t }) => t("Download as PDF"),
name: ({ t }) => t("PDF"),
analyticsName: "Download document as PDF",
section: ActiveDocumentSection,
keywords: "pdf export",
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 = toast.loading(`${t("Exporting")}`);
const document = stores.documents.get(activeDocumentId);
return document
?.download(ExportContentType.Pdf)
.finally(() => id && toast.dismiss(id));
},
});
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Markdown"),
analyticsName: "Download document as Markdown",
section: ActiveDocumentSection,
keywords: "md markdown export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.download({
contentType: ExportContentType.Pdf,
includeChildDocuments: false,
});
await document?.download(ExportContentType.Markdown);
},
});
export const downloadDocument = createActionWithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
analyticsName: "Download document",
section: ActiveDocumentSection,
icon: <DownloadIcon />,
keywords: "export",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
children: [
downloadDocumentAsHTML,
downloadDocumentAsPDF,
downloadDocumentAsMarkdown,
],
});
export const copyDocumentAsMarkdown = createAction({
name: ({ t }) => t("Copy as Markdown"),
section: ActiveDocumentSection,
@@ -699,11 +614,10 @@ export const copyDocumentAsMarkdown = createAction({
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
const res = await client.post("/documents.export", {
id: document.id,
signedUrls: Week.seconds, // 7 days (AWS S3 max for presigned URLs)
});
copy(res.data);
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toMarkdown(document));
toast.success(t("Markdown copied to clipboard"));
}
},
@@ -722,8 +636,9 @@ export const copyDocumentAsPlainText = createAction({
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
const { ProsemirrorHelper } =
await import("~/models/helpers/ProsemirrorHelper");
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toPlainText(document));
toast.success(t("Text copied to clipboard"));
}
@@ -940,7 +855,7 @@ export const printDocument = createAction({
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
perform: () => {
setTimeout(window.print, 0);
queueMicrotask(window.print);
},
});
@@ -961,7 +876,7 @@ export const importDocument = createAction({
return false;
},
perform: ({ t, activeDocumentId, activeCollectionId, stores }) => {
perform: ({ activeDocumentId, activeCollectionId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
@@ -970,7 +885,6 @@ export const importDocument = createAction({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
const toastId = toast.loading(`${t("Uploading")}`);
try {
const document = await documents.import(
@@ -984,8 +898,6 @@ export const importDocument = createAction({
history.push(document.url);
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
};
@@ -1003,12 +915,12 @@ export const createTemplateFromDocument = createAction({
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document?.isActive) {
if (document?.isTemplate || !document?.isActive) {
return false;
}
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createTemplate
stores.policies.abilities(activeCollectionId).updateDocument
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
@@ -1055,8 +967,46 @@ export const searchDocumentsForQuery = (query: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: async ({ activeDocumentId, stores }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.move({
collectionId: null,
});
}
},
});
export const moveDocumentToCollection = createAction({
name: ({ t }) => t("Move"),
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
}
const document = stores.documents.get(activeDocumentId);
return document?.template && document?.collectionId
? t("Move to collection")
: t("Move");
},
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
@@ -1094,7 +1044,8 @@ export const moveDocument = createAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
// Don't show the button if this is a non-workspace template.
if (!document || (document.template && !document.isWorkspaceTemplate)) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
@@ -1102,6 +1053,25 @@ export const moveDocument = createAction({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the menu if this is not a template (or) a workspace template.
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
@@ -1160,7 +1130,10 @@ export const restoreDocument = createAction({
: undefined;
const can = stores.policies.abilities(document.id);
return !!collection?.isActive && !!(can.restore || can.unarchive);
return (
!!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
},
perform: async ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
@@ -1197,7 +1170,10 @@ export const restoreDocumentToCollection = createActionWithChildren({
? stores.collections.get(document.collectionId)
: undefined;
return !collection?.isActive && !!(can.restore || can.unarchive);
return (
!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
},
children: ({ t, activeDocumentId, stores }) => {
const { collections, documents, policies } = stores;
@@ -1339,7 +1315,7 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.set({ rightSidebar: "comments" });
stores.ui.toggleComments();
},
});
@@ -1374,7 +1350,6 @@ export const openDocumentInsights = createAction({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
shortcut: [`Meta+Shift+I`],
icon: <GraphIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1382,7 +1357,12 @@ export const openDocumentInsights = createAction({
? stores.documents.get(activeDocumentId)
: undefined;
return !!activeDocumentId && can.listViews && !document?.isDeleted;
return (
!!activeDocumentId &&
can.listViews &&
!document?.isTemplate &&
!document?.isDeleted
);
},
perform: ({ activeDocumentId, stores, t }) => {
const document = activeDocumentId
@@ -1461,15 +1441,11 @@ export const rootDocumentActions = [
archiveDocument,
createDocument,
createDraftDocument,
createNewDocument,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
importDocument,
downloadDocument,
downloadDocumentAsMarkdown,
downloadDocumentAsHTML,
downloadDocumentAsPDF,
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
@@ -1483,6 +1459,7 @@ export const rootDocumentActions = [
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
permanentlyDeleteDocument,
+2 -2
View File
@@ -2,9 +2,9 @@ import { TrashIcon } from "outline-icons";
import stores from "~/stores";
import { createAction } from "..";
import { SettingsSection } from "../sections";
import type Integration from "~/models/Integration";
import Integration from "~/models/Integration";
import { DisconnectAnalyticsDialog } from "~/scenes/Settings/components/DisconnectAnalyticsDialog";
import type { IntegrationType } from "@shared/types";
import { IntegrationType } from "@shared/types";
import { settingsPath } from "@shared/utils/routeHelpers";
import history from "~/utils/history";
+3 -3
View File
@@ -17,7 +17,7 @@ import {
import { UrlHelper } from "@shared/utils/UrlHelper";
import { isMac } from "@shared/utils/browser";
import stores from "~/stores";
import type SearchQuery from "~/models/SearchQuery";
import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import {
createAction,
@@ -224,13 +224,13 @@ export const openKeyboardShortcuts = createAction({
export const downloadApp = createExternalLinkAction({
name: ({ t }) =>
t("Download {{ platform }} app", {
platform: isMac ? "macOS" : "Windows",
platform: isMac() ? "macOS" : "Windows",
}),
analyticsName: "Download app",
section: NavigationSection,
iconInContextMenu: false,
icon: <BrowserIcon />,
visible: () => !Desktop.isElectron() && isMac && isCloudHosted,
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
url: "https://desktop.getoutline.com",
target: "_blank",
});
+1 -32
View File
@@ -1,7 +1,6 @@
import { ArchiveIcon, CheckmarkIcon, MarkAsReadIcon } from "outline-icons";
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
import { createAction } from "..";
import { NotificationSection } from "../sections";
import type Notification from "~/models/Notification";
export const markNotificationsAsRead = createAction({
name: ({ t }) => t("Mark notifications as read"),
@@ -23,36 +22,6 @@ export const markNotificationsAsArchived = createAction({
visible: ({ stores }) => stores.notifications.orderedData.length > 0,
});
export const notificationMarkRead = (notification: Notification) =>
createAction({
name: ({ t }) => t("Mark as read"),
analyticsName: "Mark notification read",
section: NotificationSection,
icon: <CheckmarkIcon />,
perform: () => notification.toggleRead(),
visible: () => !notification.viewedAt,
});
export const notificationMarkUnread = (notification: Notification) =>
createAction({
name: ({ t }) => t("Mark as unread"),
analyticsName: "Mark notification unread",
section: NotificationSection,
icon: <CheckmarkIcon />,
perform: () => notification.toggleRead(),
visible: () => !!notification.viewedAt,
});
export const notificationArchive = (notification: Notification) =>
createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Mark notification as archived",
section: NotificationSection,
icon: <ArchiveIcon />,
perform: () => notification.archive(),
visible: () => !notification.archivedAt,
});
export const rootNotificationActions = [
markNotificationsAsRead,
markNotificationsAsArchived,
+31 -102
View File
@@ -1,17 +1,14 @@
import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon, TrashIcon, DownloadIcon } from "outline-icons";
import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import { ExportContentType } from "@shared/types";
import stores from "~/stores";
import { createAction, createActionWithChildren } from "~/actions";
import { createAction } from "~/actions";
import { RevisionSection } from "~/actions/sections";
import env from "~/env";
import history from "~/utils/history";
import {
documentHistoryPath,
matchDocumentHistory,
urlify,
} from "~/utils/routeHelpers";
export const restoreRevision = createAction({
@@ -76,105 +73,37 @@ export const deleteRevision = createAction({
},
});
export const copyLinkToRevision = (revisionId: string) =>
createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
section: RevisionSection,
perform: async ({ activeDocumentId, t }) => {
if (!activeDocumentId) {
return;
}
export const copyLinkToRevision = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
section: RevisionSection,
perform: async ({ activeDocumentId, t }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
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 = urlify(documentHistoryPath(document, revisionId));
const url = `${window.location.origin}${documentHistoryPath(
document,
revisionId
)}`;
copy(url, {
format: "text/plain",
onCopy: () => {
toast.message(t("Link copied"));
},
});
},
});
export const downloadRevisionAsHTML = (revisionId: string) =>
createAction({
name: ({ t }) => t("HTML"),
analyticsName: "Download revision as HTML",
section: RevisionSection,
keywords: "html export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId }) =>
!!activeDocumentId &&
stores.policies.abilities(activeDocumentId).download,
perform: async () => {
const revision = stores.revisions.get(revisionId);
await revision?.download(ExportContentType.Html);
},
});
export const downloadRevisionAsPDF = (revisionId: string) =>
createAction({
name: ({ t }) => t("PDF"),
analyticsName: "Download revision as PDF",
section: RevisionSection,
keywords: "export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId }) =>
!!(
activeDocumentId &&
stores.policies.abilities(activeDocumentId).download &&
env.PDF_EXPORT_ENABLED
),
perform: ({ t }) => {
const id = toast.loading(`${t("Exporting")}`);
const revision = stores.revisions.get(revisionId);
return revision
?.download(ExportContentType.Pdf)
.finally(() => id && toast.dismiss(id));
},
});
export const downloadRevisionAsMarkdown = (revisionId: string) =>
createAction({
name: ({ t }) => t("Markdown"),
analyticsName: "Download revision as Markdown",
section: RevisionSection,
keywords: "md markdown export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId }) =>
!!activeDocumentId &&
stores.policies.abilities(activeDocumentId).download,
perform: async () => {
const revision = stores.revisions.get(revisionId);
await revision?.download(ExportContentType.Markdown);
},
});
export const downloadRevision = (revisionId: string) =>
createActionWithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download revision")),
analyticsName: "Download revision",
section: RevisionSection,
icon: <DownloadIcon />,
keywords: "export",
visible: ({ activeDocumentId }) =>
!!activeDocumentId &&
stores.policies.abilities(activeDocumentId).download,
children: [
downloadRevisionAsHTML(revisionId),
downloadRevisionAsPDF(revisionId),
downloadRevisionAsMarkdown(revisionId),
],
});
copy(url, {
format: "text/plain",
onCopy: () => {
toast.message(t("Link copied"));
},
});
},
});
export const rootRevisionActions = [];
+1 -16
View File
@@ -25,21 +25,6 @@ export const changeToLightTheme = createAction({
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
});
export const toggleTheme = createAction({
name: ({ t }) => t("Toggle theme"),
analyticsName: "Change theme",
iconInContextMenu: false,
icon: ({ stores }) =>
stores.ui.resolvedTheme === "light" ? <MoonIcon /> : <SunIcon />,
keywords: "theme light day",
section: SettingsSection,
shortcut: ["Meta+Shift+l"],
perform: ({ stores }) =>
stores.ui.setTheme(
stores.ui.resolvedTheme === "light" ? Theme.Dark : Theme.Light
),
});
export const changeToSystemTheme = createAction({
name: ({ t }) => t("System"),
analyticsName: "Change to system theme",
@@ -62,4 +47,4 @@ export const changeTheme = createActionWithChildren({
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
});
export const rootSettingsActions = [changeTheme, toggleTheme];
export const rootSettingsActions = [changeTheme];
+1 -1
View File
@@ -1,5 +1,5 @@
import copy from "copy-to-clipboard";
import type Share from "~/models/Share";
import Share from "~/models/Share";
import { createAction, createInternalLinkAction } from "..";
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
import { ShareSection } from "../sections";
+2 -2
View File
@@ -1,7 +1,7 @@
import { ArrowIcon, PlusIcon } from "outline-icons";
import styled from "styled-components";
import { stringToColor } from "@shared/utils/color";
import type RootStore from "~/stores/RootStore";
import RootStore from "~/stores/RootStore";
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
import TeamNew from "~/scenes/TeamNew";
import TeamLogo from "~/components/TeamLogo";
@@ -10,7 +10,7 @@ import {
createActionWithChildren,
createExternalLinkAction,
} from "~/actions";
import type { ActionContext, ExternalLinkAction } from "~/types";
import { ActionContext, ExternalLinkAction } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
-231
View File
@@ -1,231 +0,0 @@
import copy from "copy-to-clipboard";
import {
CaseSensitiveIcon,
CollectionIcon,
CopyIcon,
MoveIcon,
NewDocumentIcon,
PlusIcon,
PrintIcon,
TrashIcon,
} from "outline-icons";
import { Trans } from "react-i18next";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import history from "~/utils/history";
import {
newDocumentPath,
newTemplatePath,
settingsPath,
urlify,
} from "~/utils/routeHelpers";
import { ActiveTemplateSection, TemplateSection } from "../sections";
import Template from "~/models/Template";
import { AvatarSize } from "~/components/Avatar";
import TeamLogo from "~/components/TeamLogo";
export const createTemplate = createInternalLinkAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: TemplateSection,
icon: <PlusIcon />,
keywords: "new create template",
visible: ({ currentTeamId, stores }) =>
!!stores.policies.abilities(currentTeamId!).createTemplate,
to: newTemplatePath(),
});
export const deleteTemplate = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete template",
section: ActiveTemplateSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: t("template"),
}),
content: (
<ConfirmationDialog
onSubmit={async () => {
await template.delete();
history.push(settingsPath("templates"));
toast.success(t("Template deleted"));
}}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
values={{
templateName: template.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
),
});
},
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: ActiveTemplateSection,
icon: ({ stores }) => {
const { team } = stores.auth;
return <TeamLogo model={team} size={AvatarSize.Small} />;
},
visible: ({ getActiveModel }) => {
const template = getActiveModel(Template);
return !!template?.collectionId;
},
perform: async ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
try {
await template.save({ collectionId: null });
toast.success(t("Template moved"));
stores.dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldn't move the template, try again?"));
}
},
});
export const moveTemplateToCollection = createAction({
name: ({ t }) => t("Move to collection"),
analyticsName: "Move template to collection",
section: ActiveTemplateSection,
icon: <CollectionIcon />,
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Move template"),
content: <TemplateMove template={template} />,
});
},
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move template",
section: ActiveTemplateSection,
icon: <MoveIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.move),
children: [moveTemplateToWorkspace, moveTemplateToCollection],
});
export const createDocumentFromTemplate = createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document from template",
section: ActiveTemplateSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, getActiveModel, stores }) => {
const template = getActiveModel(Template);
if (!template || !currentTeamId) {
return false;
}
if (template.collectionId) {
return !!stores.policies.abilities(template.collectionId).createDocument;
}
return !!stores.policies.abilities(currentTeamId).createDocument;
},
to: ({ getActiveModel, activeCollectionId, sidebarContext }) => {
const template = getActiveModel(Template);
if (!template) {
return "";
}
const collectionId = template?.collectionId ?? activeCollectionId;
const [pathname, search] = newDocumentPath(collectionId, {
templateId: template.id,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const copyTemplateLink = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy template link",
section: ActiveTemplateSection,
icon: <CopyIcon />,
iconInContextMenu: false,
perform: ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
copy(urlify(template.path));
toast.success(t("Link copied to clipboard"));
}
},
});
export const copyTemplateAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
analyticsName: "Copy template as text",
section: ActiveTemplateSection,
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
perform: async ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
const { ProsemirrorHelper } =
await import("~/models/helpers/ProsemirrorHelper");
copy(ProsemirrorHelper.toPlainText(template));
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyTemplate = createActionWithChildren({
name: ({ t }) => t("Copy"),
analyticsName: "Copy template",
section: ActiveTemplateSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyTemplateLink, copyTemplateAsPlainText],
});
export const printTemplate = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print template")),
analyticsName: "Print template",
section: ActiveTemplateSection,
icon: <PrintIcon />,
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
perform: () => {
setTimeout(window.print, 0);
},
});
export const rootTemplateActions = [moveTemplate, createDocumentFromTemplate];
+2 -2
View File
@@ -1,8 +1,8 @@
import { PlusIcon } from "outline-icons";
import type { UserRole } from "@shared/types";
import { UserRole } from "@shared/types";
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
import stores from "~/stores";
import type User from "~/models/User";
import User from "~/models/User";
import Invite from "~/scenes/Invite";
import {
UserChangeRoleDialog,
+4 -4
View File
@@ -1,8 +1,8 @@
import type { LocationDescriptor } from "history";
import { LocationDescriptor } from "history";
import { toast } from "sonner";
import type { Optional } from "utility-types";
import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import type {
import {
ActionContext,
Action,
ActionGroup,
@@ -15,7 +15,7 @@ import type {
} from "~/types";
import Analytics from "~/utils/Analytics";
import history from "~/utils/history";
import type { Action as KbarAction } from "kbar";
import { Action as KbarAction } from "kbar";
export function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
+1 -10
View File
@@ -1,4 +1,4 @@
import type { ActionContext } from "~/types";
import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
@@ -24,15 +24,6 @@ export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
ActiveDocumentSection.priority = 0.9;
export const TemplateSection = ({ t }: ActionContext) => t("Template");
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
const activeTemplate = stores.templates.active;
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
};
ActiveTemplateSection.priority = 0.9;
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
RecentSection.priority = 1;
+2 -3
View File
@@ -1,11 +1,10 @@
/* oxlint-disable react/prop-types */
import * as React from "react";
import type { Props as TooltipProps } from "~/components/Tooltip";
import Tooltip from "~/components/Tooltip";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction, resolve } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import useActionContext from "~/hooks/useActionContext";
import type { ActionVariant, ActionWithChildren } from "~/types";
import { ActionVariant, ActionWithChildren } from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
+1 -2
View File
@@ -2,8 +2,7 @@
/* global ga */
import escape from "lodash/escape";
import * as React from "react";
import type { PublicEnv } from "@shared/types";
import { IntegrationService } from "@shared/types";
import { IntegrationService, PublicEnv } from "@shared/types";
import env from "~/env";
type Props = {
+60 -15
View File
@@ -1,10 +1,17 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, Redirect } from "react-router-dom";
import {
Switch,
Route,
useLocation,
matchPath,
Redirect,
} from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import { RightSidebarProvider } from "~/components/RightSidebarContext";
import Sidebar from "~/components/Sidebar";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
@@ -16,13 +23,20 @@ import {
searchPath,
newDocumentPath,
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import NotificationBadge from "./NotificationBadge";
import { PortalContext } from "./Portal";
import CommandBar from "./CommandBar";
const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments")
);
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const SettingsSidebar = lazyWithRetry(
() => import("~/components/Sidebar/Settings")
);
@@ -33,7 +47,9 @@ type Props = {
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores();
const location = useLocation();
const layoutRef = React.useRef<HTMLDivElement>(null);
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
@@ -75,20 +91,49 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
</Fade>
);
const showHistory =
!!matchPath(location.pathname, {
path: matchDocumentHistory,
}) && can.listRevisions;
const showComments =
!showHistory &&
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded &&
!!team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
<AnimatePresence
initial={false}
key={ui.activeDocumentId ? "active" : "inactive"}
>
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
</Route>
)}
</AnimatePresence>
);
return (
<DocumentContextProvider>
<RightSidebarProvider>
<PortalContext.Provider value={layoutRef.current}>
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
</PortalContext.Provider>
</RightSidebarProvider>
<PortalContext.Provider value={layoutRef.current}>
<Layout
title={team.name}
sidebar={sidebar}
sidebarRight={sidebarRight}
ref={layoutRef}
>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
);
};
+1 -2
View File
@@ -1,4 +1,3 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import useBoolean from "~/hooks/useBoolean";
@@ -110,4 +109,4 @@ const Image = styled.img<{ size: number }>`
height: ${(props) => props.size}px;
`;
export default observer(Avatar);
export default Avatar;
+1 -1
View File
@@ -3,7 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import type User from "~/models/User";
import User from "~/models/User";
import Tooltip from "~/components/Tooltip";
import Avatar, { AvatarSize } from "./Avatar";
+1 -1
View File
@@ -1,7 +1,7 @@
import { GroupIcon } from "outline-icons";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import type Group from "~/models/Group";
import Group from "~/models/Group";
import { AvatarSize } from "../Avatar/Avatar";
type Props = {
+1 -2
View File
@@ -1,5 +1,4 @@
import type { IAvatar } from "./Avatar";
import Avatar, { AvatarSize, AvatarVariant } from "./Avatar";
import Avatar, { IAvatar, AvatarSize, AvatarVariant } from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
import { GroupAvatar } from "./GroupAvatar";
+3 -3
View File
@@ -2,8 +2,8 @@ import { transparentize } from "polished";
import styled from "styled-components";
const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
padding: 1.5px 5.5px;
margin: 0 2px;
margin-left: 10px;
padding: 1px 5px 2px;
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
color: ${({ primary, yellow, theme }) =>
@@ -17,7 +17,7 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
primary || yellow
? "transparent"
: transparentize(0.4, theme.textTertiary)};
border-radius: 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
user-select: none;
+2 -3
View File
@@ -1,5 +1,4 @@
import { GoToIcon } from "outline-icons";
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
@@ -7,7 +6,7 @@ import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import type { InternalLinkAction, MenuInternalLink } from "~/types";
import { InternalLinkAction, MenuInternalLink } from "~/types";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useComputed } from "~/hooks/useComputed";
@@ -122,4 +121,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
}
`;
export default observer(React.forwardRef<HTMLDivElement, Props>(Breadcrumb));
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
+4 -3
View File
@@ -1,11 +1,12 @@
import type { LocationDescriptor } from "history";
import { LocationDescriptor } from "history";
import { DisclosureIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { Props as ActionButtonProps } from "~/components/ActionButton";
import ActionButton from "~/components/ActionButton";
import ActionButton, {
Props as ActionButtonProps,
} from "~/components/ActionButton";
import { undraggableOnDesktop } from "~/styles";
type RealProps = {
+5 -3
View File
@@ -1,7 +1,6 @@
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
type Props = {
children?: React.ReactNode;
@@ -23,9 +22,12 @@ const Container = styled.div<Props>`
type ContentProps = { $maxWidth?: string };
const Content = styled.div<ContentProps>`
max-width: ${(props: ContentProps) =>
props.$maxWidth ?? EditorStyleHelper.documentWidth};
max-width: ${(props) => props.$maxWidth ?? "46em"};
margin: 0 auto;
${breakpoint("desktopLarge")`
max-width: ${(props: ContentProps) => props.$maxWidth ?? "52em"};
`};
`;
const CenteredContent: React.FC<Props> = ({
+2 -2
View File
@@ -5,7 +5,7 @@ import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import type Document from "~/models/Document";
import Document from "~/models/Document";
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
key={collaborator.id}
{...rest}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
-112
View File
@@ -1,112 +0,0 @@
import * as RadixCollapsible from "@radix-ui/react-collapsible";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
interface CollapsibleProps {
/** The label displayed on the trigger button. */
label: React.ReactNode;
/** The content to show/hide inside the collapsible panel. */
children: React.ReactNode;
/** Whether the collapsible is open by default. */
defaultOpen?: boolean;
/** Controlled open state. */
open?: boolean;
/** Callback fired when the open state changes. */
onOpenChange?: (open: boolean) => void;
/** Additional class name for the root element. */
className?: string;
}
/**
* An accessible collapsible section built on Radix UI Collapsible.
* Renders a trigger button with a disclosure chevron and animated content panel.
*
* @param props - component props.
* @returns the collapsible component.
*/
export function Collapsible({
label,
children,
defaultOpen = false,
open,
onOpenChange,
className,
}: CollapsibleProps) {
return (
<RadixCollapsible.Root
defaultOpen={defaultOpen}
open={open}
onOpenChange={onOpenChange}
className={className}
>
<StyledTrigger>
<StyledExpandedIcon aria-hidden="true" />
{label}
</StyledTrigger>
<StyledContent>{children}</StyledContent>
</RadixCollapsible.Root>
);
}
const StyledExpandedIcon = styled(ExpandedIcon)`
flex-shrink: 0;
transition: transform 150ms ease-out;
margin-left: -4px;
`;
const StyledTrigger = styled(RadixCollapsible.Trigger)`
display: flex;
align-items: center;
background: none;
border: none;
padding: 0 0 8px 0;
cursor: var(--pointer);
color: ${s("textTertiary")};
font-size: 14pxte
&:hover {
color: ${s("textSecondary")};
}
&[data-state="closed"] {
${StyledExpandedIcon} {
transform: rotate(-90deg);
}
}
`;
const StyledContent = styled(RadixCollapsible.Content)`
overflow: hidden;
&[data-state="open"] {
animation: slideDown 200ms ease-out;
}
&[data-state="closed"] {
animation: slideUp 200ms ease-out;
}
@keyframes slideDown {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes slideUp {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
`;
+1 -2
View File
@@ -2,8 +2,7 @@ import { observer } from "mobx-react";
import { useCallback } from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import type { FormData } from "./CollectionForm";
import { CollectionForm } from "./CollectionForm";
import { CollectionForm, FormData } from "./CollectionForm";
type Props = {
collectionId: string;
+37 -45
View File
@@ -6,14 +6,13 @@ import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import type { CollectionPermission } from "@shared/types";
import { TeamPreference } from "@shared/types";
import { CollectionPermission, TeamPreference } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import type Collection from "~/models/Collection";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import { Collapsible } from "~/components/Collapsible";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
@@ -23,7 +22,6 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
import { HStack } from "../primitives/HStack";
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
@@ -142,10 +140,10 @@ export const CollectionForm = observer(function CollectionForm_({
Collections are used to group documents and choose permissions
</Trans>
</Text>
<HStack>
<Flex gap={8}>
<Input
type="text"
label={t("Name")}
placeholder={t("Name")}
{...register("name", {
required: true,
maxLength: CollectionValidation.maxNameLength,
@@ -166,7 +164,7 @@ export const CollectionForm = observer(function CollectionForm_({
autoFocus
flex
/>
</HStack>
</Flex>
{/* Following controls are available in create flow, but moved elsewhere for edit */}
{!collection && (
@@ -190,47 +188,41 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{(team.sharing || team.getPreference(TeamPreference.Commenting)) && (
<Collapsible label={t("Advanced options")}>
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t(
"Allow commenting on documents within this collection."
)}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
</Collapsible>
/>
)}
<HStack justify="flex-end">
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
<Flex justify="flex-end">
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
@@ -243,7 +235,7 @@ export const CollectionForm = observer(function CollectionForm_({
? `${t("Creating")}`
: t("Create")}
</Button>
</HStack>
</Flex>
</form>
);
});
+1 -2
View File
@@ -4,8 +4,7 @@ import { useCallback } from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import type { FormData } from "./CollectionForm";
import { CollectionForm } from "./CollectionForm";
import { CollectionForm, FormData } from "./CollectionForm";
type Props = {
onSubmit: () => void;
+1 -1
View File
@@ -1,7 +1,7 @@
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type Collection from "~/models/Collection";
import Collection from "~/models/Collection";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { archivePath, collectionPath } from "~/utils/routeHelpers";
import Breadcrumb from "./Breadcrumb";
+1 -1
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import type Collection from "~/models/Collection";
import Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
+3 -4
View File
@@ -1,4 +1,4 @@
import type { ActionImpl } from "kbar";
import { ActionImpl } from "kbar";
import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
@@ -7,7 +7,6 @@ import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
import { HStack } from "../primitives/HStack";
type Props = {
action: ActionImpl;
@@ -36,7 +35,7 @@ function CommandBarItem(
return (
<Item active={active} ref={ref}>
<Content>
<Content align="center" gap={8}>
<Icon>
{action.icon ? (
// @ts-expect-error no icon on ActionImpl
@@ -101,7 +100,7 @@ const Ancestor = styled.span`
color: ${s("textSecondary")};
`;
const Content = styled(HStack)`
const Content = styled(Flex)`
${ellipsis()}
flex-shrink: 1;
`;
@@ -11,15 +11,15 @@ import useStores from "~/hooks/useStores";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
const { templates } = useStores();
const { documents } = useStores();
useEffect(() => {
void templates.fetchAll();
}, [templates]);
void documents.fetchAllTemplates();
}, [documents]);
const actions = useMemo(
() =>
templates.alphabetical.map((template) =>
documents.templatesAlphabetical.map((template) =>
createInternalLinkAction({
name: template.titleWithDefault,
analyticsName: "New document",
@@ -66,7 +66,7 @@ const useTemplatesAction = () => {
},
})
),
[templates.alphabetical]
[documents.templatesAlphabetical]
);
const newFromTemplate = useMemo(
+1 -1
View File
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import type Comment from "~/models/Comment";
import Comment from "~/models/Comment";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
+1 -2
View File
@@ -1,8 +1,7 @@
import { observer } from "mobx-react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import type { NavigationNode } from "@shared/types";
import { CollectionPermission } from "@shared/types";
import { CollectionPermission, NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
+60 -106
View File
@@ -1,17 +1,9 @@
import {
CollectionIcon as CollectionIconComponent,
HomeIcon,
PrivateCollectionIcon,
} from "outline-icons";
import { observer } from "mobx-react";
import { getLuminance } from "polished";
import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import type { Option } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { InputSelect, Option } from "~/components/InputSelect";
import useStores from "~/hooks/useStores";
type DefaultCollectionInputSelectProps = {
@@ -19,112 +11,74 @@ type DefaultCollectionInputSelectProps = {
defaultCollectionId: string | null;
};
const DefaultCollectionInputSelect = observer(
({
onSelectCollection,
defaultCollectionId,
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections, ui } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
React.useEffect(() => {
async function fetchData() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
);
setFetchError(error);
} finally {
setFetching(false);
}
React.useEffect(() => {
async function fetchData() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
);
setFetchError(error);
} finally {
setFetching(false);
}
}
void fetchData();
}, [fetchError, t, fetching, collections]);
if (fetching) {
return null;
}
void fetchData();
}, [fetchError, t, fetching, collections]);
const isDark = ui.resolvedTheme === "dark";
// Eagerly resolve collection icon properties within this observer context
// to avoid MobX warnings when Radix Select clones elements for the trigger.
const options: Option[] = collections.nonPrivate.reduce(
(acc, collection) => {
const collectionIcon = collection.icon;
const rawColor = collection.color ?? colorPalette[0];
let icon: React.ReactElement;
if (!collectionIcon || collectionIcon === "collection") {
const color =
isDark && rawColor !== "currentColor"
? getLuminance(rawColor) > 0.09
? rawColor
: "currentColor"
: rawColor;
const Component = collection.isPrivate
? PrivateCollectionIcon
: CollectionIconComponent;
icon = <Component color={color} />;
} else {
let color = rawColor;
if (color !== "currentColor") {
if (isDark) {
color = getLuminance(color) > 0.09 ? color : "currentColor";
} else {
color = getLuminance(color) < 0.9 ? color : "currentColor";
}
}
icon = (
<Icon
value={collectionIcon}
color={color}
initial={collection.initial}
forceColor
/>
);
}
return [
const options: Option[] = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
...acc,
{
type: "item" as const,
type: "item",
label: collection.name,
value: collection.id,
icon,
icon: <CollectionIcon collection={collection} />,
},
];
},
[
{
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
);
],
[
{
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
),
[collections.nonPrivate, t]
);
return (
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
short
/>
);
if (fetching) {
return null;
}
);
return (
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
short
/>
);
};
export default DefaultCollectionInputSelect;
+10 -3
View File
@@ -1,17 +1,17 @@
import { observer } from "mobx-react";
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { archivePath, trashPath } from "~/utils/routeHelpers";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
@@ -67,6 +67,13 @@ function DocumentBreadcrumb(
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkAction({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkAction({
name: collection?.name,
section: ActiveDocumentSection,
+2 -2
View File
@@ -13,8 +13,8 @@ import Squircle from "@shared/components/Squircle";
import { s, hover, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Document from "~/models/Document";
import type Pin from "~/models/Pin";
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";
+4 -5
View File
@@ -1,9 +1,8 @@
import { action, computed, observable } from "mobx";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useMemo } from "react";
import type { Heading } from "@shared/utils/ProsemirrorHelper";
import type Document from "~/models/Document";
import type { Editor } from "~/editor";
import { createContext, useContext, useMemo, PropsWithChildren } from "react";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import Document from "~/models/Document";
import { Editor } from "~/editor";
class DocumentContext {
/** The current document */
@@ -3,15 +3,15 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import DocumentExplorer from "~/components/DocumentExplorer";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
@@ -24,7 +24,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
const { policies } = useStores();
const collectionTrees = useCollectionTrees();
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
const [copying, setCopying] = React.useState<boolean>(false);
const [recursive, setRecursive] = React.useState<boolean>(true);
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
null
@@ -37,8 +36,13 @@ function DocumentCopy({ document, onSubmit }: Props) {
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees]);
}, [policies, collectionTrees, document.isTemplate]);
const copy = async () => {
if (!selectedPath) {
@@ -47,7 +51,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
}
try {
setCopying(true);
const result = await document.duplicate({
publish,
recursive,
@@ -62,8 +65,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
onSubmit(result);
} catch (_err) {
toast.error(t("Couldnt copy the document, try again?"));
} finally {
setCopying(false);
}
};
@@ -75,32 +76,34 @@ function DocumentCopy({ document, onSubmit }: Props) {
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
{!document.isTemplate && (
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
)}
<Footer justify="space-between" align="center" gap={8}>
<Text ellipsis type="secondary">
<StyledText type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
@@ -110,9 +113,9 @@ function DocumentCopy({ document, onSubmit }: Props) {
) : (
t("Select a location to copy")
)}
</Text>
<Button disabled={!selectedPath || copying} onClick={copy}>
{copying ? `${t("Copying")}` : t("Copy")}
</StyledText>
<Button disabled={!selectedPath} onClick={copy}>
{t("Copy")}
</Button>
</Footer>
</FlexContainer>
-185
View File
@@ -1,185 +0,0 @@
import { observer } from "mobx-react";
import { useCallback, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { ExportContentType, NotificationEventType } from "@shared/types";
import type Document from "~/models/Document";
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";
type Props = {
document: Document;
onSubmit: () => void;
};
export const DocumentDownload = observer(({ document, onSubmit }: Props) => {
const { t } = useTranslation();
const { ui } = useStores();
const user = useCurrentUser();
const hasChildDocuments = !!document.childDocuments.length;
const [contentType, setContentType] = useState<ExportContentType>(
ExportContentType.Markdown
);
const [includeChildDocuments, setIncludeChildDocuments] =
useState<boolean>(hasChildDocuments);
const handleContentTypeChange = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setContentType(ev.target.value as ExportContentType);
},
[]
);
const handleIncludeChildDocumentsChange = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setIncludeChildDocuments(ev.target.checked);
},
[]
);
const handleSubmit = useCallback(async () => {
const response = await document.download({
contentType,
includeChildDocuments,
});
if (includeChildDocuments && response?.data?.fileOperation) {
const fileOperationId = response.data.fileOperation.id;
const toastId = `export-${fileOperationId}`;
const timeoutId = setTimeout(() => {
toast.success(t("Export started"), {
id: toastId,
description: t("A link to your file will be sent through email soon"),
duration: 3000,
});
ui.exportToasts.delete(fileOperationId);
}, 6000);
ui.registerExportToast(fileOperationId, toastId, timeoutId);
toast.loading(t("Export started"), {
id: toastId,
description: `${t("Preparing your download")}`,
duration: Infinity,
});
}
onSubmit();
}, [t, ui, document, contentType, includeChildDocuments, onSubmit]);
const items = useMemo(() => {
const radioItems = [
{
title: "Markdown",
description: t(
"A file containing the selected documents in Markdown format."
),
value: ExportContentType.Markdown,
},
{
title: "HTML",
description: t(
"A file containing the selected documents in HTML format."
),
value: ExportContentType.Html,
},
];
if (env.PDF_EXPORT_ENABLED) {
radioItems.push({
title: "PDF",
description: t(
"A file containing the selected documents in PDF format."
),
value: ExportContentType.Pdf,
});
}
return radioItems;
}, [t]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={includeChildDocuments ? t("Export") : t("Download")}
>
<Flex gap={12} column>
{items.map((item) => (
<Option key={item.value}>
<StyledInput
type="radio"
name="format"
value={item.value}
checked={contentType === item.value}
onChange={handleContentTypeChange}
/>
<div>
<Text as="p" size="small" weight="bold">
{item.title}
</Text>
{item.description ? (
<Text size="small" type="secondary">
{item.description}
</Text>
) : null}
</div>
</Option>
))}
</Flex>
{hasChildDocuments && (
<>
<hr style={{ margin: "16px 0 " }} />
<Option>
<StyledInput
type="checkbox"
name="includeChildDocuments"
checked={includeChildDocuments}
onChange={handleIncludeChildDocumentsChange}
/>
<Flex column gap={4}>
<Text as="p" size="small" weight="bold">
{t("Include child documents")}
</Text>
<Text as="p" size="small" type="secondary">
<Trans
defaults="When selected, exporting the document <em>{{documentName}}</em> may take some time."
values={{
documentName: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>{" "}
{user.subscribedToEventType(
NotificationEventType.ExportCompleted
) && t("You will receive an email when it's complete.")}
</Text>
</Flex>
</Option>
</>
)}
</ConfirmationDialog>
);
});
const Option = styled.label`
display: flex;
align-items: baseline;
gap: 16px;
p {
margin: 0;
}
`;
const StyledInput = styled.input`
position: relative;
top: 1.5px;
`;
@@ -16,11 +16,11 @@ import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
import DocumentExplorerNode from "./DocumentExplorerNode";
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
@@ -38,17 +38,9 @@ type Props = {
items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
/** Whether to show child documents */
showDocuments?: boolean;
};
function DocumentExplorer({
onSubmit,
onSelect,
items,
defaultValue,
showDocuments,
}: Props) {
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -149,8 +141,7 @@ function DocumentExplorer({
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const normalizedBaseDepth =
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -225,7 +216,7 @@ function DocumentExplorer({
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 || showDocuments !== false;
nodes[node].children.length > 0 || nodes[node].type === "collection";
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -411,11 +402,7 @@ function DocumentExplorer({
<ListSearch
ref={inputSearchRef}
onChange={handleSearch}
placeholder={
showDocuments
? `${t("Search collections & documents")}`
: `${t("Search collections")}`
}
placeholder={`${t("Search collections & documents")}`}
autoFocus
/>
<ListContainer>
@@ -1,17 +0,0 @@
import styled from "styled-components";
import Flex from "../Flex";
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
flex-shrink: 0;
`;
@@ -1,87 +0,0 @@
import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import type { NavigationNode } from "@shared/types";
import type Template from "~/models/Template";
import Button from "~/components/Button";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
template: Template;
};
function TemplateMove({ template }: Props) {
const { dialogs, policies } = useStores();
const { t } = useTranslation();
const collectionTrees = useCollectionTrees();
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(
() =>
collectionTrees
.map((node) => ({ ...node, children: [] }))
.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
),
[policies, collectionTrees]
);
const move = async () => {
if (!selectedPath) {
toast.message(t("Select a location to move"));
return;
}
try {
const collectionId = (selectedPath.collectionId ??
selectedPath.id) as string;
await template.save({ collectionId });
toast.success(t("Template moved"));
dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldnt move the template, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={move}
onSelect={selectPath}
showDocuments={false}
/>
<Footer justify="space-between" align="center" gap={8}>
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title,
}}
components={{
em: <strong />,
}}
/>
) : (
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath} onClick={move}>
{t("Move")}
</Button>
</Footer>
</FlexContainer>
);
}
export default observer(TemplateMove);
-3
View File
@@ -1,3 +0,0 @@
import DocumentExplorer from "./DocumentExplorer";
export default DocumentExplorer;
@@ -54,7 +54,6 @@ function DocumentExplorerNode(
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
<Spacer width={width}>
{hasChildren && (
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "./DocumentExplorerNode";
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
@@ -54,7 +54,6 @@ function DocumentExplorerSearchResult({
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
{icon}
<Flex>
+71 -75
View File
@@ -6,13 +6,12 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { DocumentIcon } from "outline-icons";
import styled, { css, useTheme } from "styled-components";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import Icon from "@shared/components/Icon";
import { s, hover } from "@shared/styles";
import type Document from "~/models/Document";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex";
@@ -22,7 +21,6 @@ import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
@@ -40,6 +38,7 @@ type Props = {
showCollection?: boolean;
showPublished?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -55,11 +54,9 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
const theme = useTheme();
const { userMemberships, groupMemberships } = useStores();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const isMobile = useMobile();
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
@@ -76,6 +73,7 @@ function DocumentListItem(
showCollection,
showPublished,
showDraft = true,
showTemplate,
highlight,
context,
...rest
@@ -83,7 +81,7 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived;
const canStar = !document.isArchived && !document.isTemplate;
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
@@ -101,10 +99,11 @@ function DocumentListItem(
return (
<ActionContextProvider
value={{
activeModels: [
document,
...(!isShared && document.collection ? [document.collection] : []),
],
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
}}
>
<ContextMenu
@@ -120,9 +119,6 @@ function DocumentListItem(
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
search: highlight
? `?q=${encodeURIComponent(highlight)}`
: undefined,
state: {
title: document.titleWithDefault,
sidebarContext,
@@ -131,55 +127,56 @@ function DocumentListItem(
{...rest}
{...rovingTabIndex}
>
<Flex gap={4} auto>
<IconWrapper>
{document.icon ? (
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
) : (
<DocumentIcon
outline={document.isDraft}
color={theme.textSecondary}
/>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
</IconWrapper>
<Content>
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && !isMobile && <StarButton document={document} />}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
</Content>
</Flex>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
</Content>
<Actions>
<DocumentMenu
document={document}
@@ -193,14 +190,6 @@ function DocumentListItem(
);
}
const IconWrapper = styled.div`
flex-shrink: 0;
display: flex;
align-items: flex-start;
justify-content: flex-start;
width: 24px;
`;
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
@@ -215,9 +204,12 @@ const Actions = styled(EventBoundary)`
flex-grow: 0;
color: ${s("textSecondary")};
${NudeButton}:${hover},
${NudeButton}[aria-expanded= "true"] {
background: ${s("sidebarControlHoverBackground")};
${NudeButton} {
&:
${hover},
&[aria-expanded= "true"] {
background: ${s("sidebarControlHoverBackground")};
}
}
${breakpoint("tablet")`
@@ -293,14 +285,18 @@ const Heading = styled.span<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
margin-top: 0;
margin-bottom: 0.1em;
margin-bottom: 0.25em;
white-space: nowrap;
color: ${s("text")};
font-family: ${s("fontFamily")};
font-weight: 500;
font-size: 18px;
font-size: 20px;
line-height: 1.2;
gap: 4px;
`;
const StarPositioner = styled(Flex)`
margin-left: 4px;
align-items: center;
`;
const Title = styled(Highlight)`
+9 -8
View File
@@ -1,12 +1,12 @@
import type { LocationDescriptor } from "history";
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import type Document from "~/models/Document";
import type Revision from "~/models/Revision";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import DocumentTasks from "~/components/DocumentTasks";
import Flex from "~/components/Flex";
@@ -52,6 +52,7 @@ const DocumentMeta: React.FC<Props> = ({
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -141,7 +142,7 @@ const DocumentMeta: React.FC<Props> = ({
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
: 0;
const canShowProgressBar = isTasks;
const canShowProgressBar = isTasks && !isTemplate;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
@@ -169,7 +170,7 @@ const DocumentMeta: React.FC<Props> = ({
};
return (
<Container align="center" $rtl={document.dir === "rtl"} {...rest} dir="ltr">
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? (
<Link to={to} replace={replace}>
{content}
@@ -181,7 +182,7 @@ const DocumentMeta: React.FC<Props> = ({
<span>
&nbsp;{t("in")}&nbsp;
<Strong>
<DocumentBreadcrumb document={document} maxDepth={1} onlyText />
<DocumentBreadcrumb document={document} onlyText />
</Strong>
</span>
)}
@@ -218,8 +219,8 @@ const Strong = styled.strong`
font-weight: 550;
`;
const Container = styled(Flex)<{ $rtl?: boolean }>`
justify-content: ${(props) => (props.$rtl ? "flex-end" : "flex-start")};
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")};
font-size: 13px;
white-space: nowrap;
+3 -3
View File
@@ -1,9 +1,9 @@
import type { TFunction } from "i18next";
import { TFunction } from "i18next";
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import type Document from "~/models/Document";
import Document from "~/models/Document";
import CircularProgressBar from "~/components/CircularProgressBar";
import usePrevious from "~/hooks/usePrevious";
import { bounceIn } from "~/styles/animations";
@@ -42,7 +42,7 @@ function DocumentTasks({ document }: Props) {
const message = getMessage(t, total, completed);
return (
<Flex align="center" style={{ padding: "0 1px" }} gap={2} shrink={false}>
<Flex align="center" style={{ padding: "0 1px" }} gap={2}>
{completed === total ? (
<Done
color={theme.accent}
+2 -2
View File
@@ -4,8 +4,8 @@ import { observer } from "mobx-react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import type Document from "~/models/Document";
import type User from "~/models/User";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
+1 -1
View File
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import type Collection from "~/models/Collection";
import Collection from "~/models/Collection";
type Props = {
enabled: boolean;
+2 -4
View File
@@ -2,7 +2,6 @@ import * as React from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import EventBoundary from "@shared/components/EventBoundary";
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
/** A callback when the title is submitted. */
@@ -142,12 +141,11 @@ function EditableTitle(
return (
<>
{isEditing ? (
<EventBoundary as="form" onSubmit={handleSave}>
<form onSubmit={handleSave}>
<Input
dir="auto"
type="text"
lang=""
name="title"
value={value}
onClick={stopPropagation}
onKeyDown={handleKeyDown}
@@ -157,7 +155,7 @@ function EditableTitle(
autoFocus
{...rest}
/>
</EventBoundary>
</form>
) : (
<Text
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
+8 -80
View File
@@ -3,9 +3,8 @@ import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { toast } from "sonner";
import { mergeRefs } from "react-merge-refs";
import type { Optional } from "utility-types";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import EditorContainer from "@shared/editor/components/Styles";
import { AttachmentPreset } from "@shared/types";
@@ -42,14 +41,7 @@ export type Props = Optional<
};
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const {
id,
onChange,
onCreateCommentMark,
onDeleteCommentMark,
onFileUploadStart,
onFileUploadStop,
} = props;
const { id, onChange, onCreateCommentMark, onDeleteCommentMark } = props;
const { comments } = useStores();
const { shareId } = useShare();
const dictionary = useDictionary();
@@ -58,27 +50,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
const previousCommentIds = React.useRef<string[]>();
// Upload progress tracking for delayed toast
const progressMap = React.useMemo(() => new Map<string, number>(), []);
const uploadState = React.useRef<{
toastId?: string | number;
timeoutId?: ReturnType<typeof setTimeout>;
progress: Map<string, number>;
}>({ progress: progressMap });
const handleUploadFile = React.useCallback(
async (
file: File | string,
uploadOptions?: {
id?: string;
onProgress?: (fractionComplete: number) => void;
}
) => {
async (file: File | string) => {
const options = {
id: uploadOptions?.id,
documentId: id,
preset: AttachmentPreset.DocumentAttachment,
onProgress: uploadOptions?.onProgress,
};
const result =
file instanceof File
@@ -91,49 +67,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { handleClickLink } = useEditorClickHandlers({ shareId });
// Show toast only after uploads have been running for 2 seconds
const handleFileUploadStart = React.useCallback(() => {
uploadState.current.timeoutId = setTimeout(() => {
uploadState.current.toastId = toast.loading(
dictionary.uploadingWithProgress(0)
);
}, 2000);
onFileUploadStart?.();
}, [onFileUploadStart, dictionary.uploadingWithProgress]);
const handleFileUploadProgress = React.useCallback(
(fileId: string, fractionComplete: number) => {
uploadState.current.progress.set(fileId, fractionComplete);
// Calculate average progress across all files
const progressValues = Array.from(uploadState.current.progress.values());
const avgProgress =
progressValues.reduce((a, b) => a + b, 0) / progressValues.length;
const percent = Math.round(avgProgress * 100);
// Update toast if visible
if (uploadState.current.toastId) {
toast.loading(dictionary.uploadingWithProgress(percent), {
id: uploadState.current.toastId,
});
}
},
[dictionary.uploadingWithProgress]
);
const handleFileUploadStop = React.useCallback(() => {
if (uploadState.current.timeoutId) {
clearTimeout(uploadState.current.timeoutId);
uploadState.current.timeoutId = undefined;
}
if (uploadState.current.toastId) {
toast.dismiss(uploadState.current.toastId);
uploadState.current.toastId = undefined;
}
uploadState.current.progress.clear();
onFileUploadStop?.();
}, [onFileUploadStop]);
const focusAtEnd = React.useCallback(() => {
localRef?.current?.focusAtEnd();
}, [localRef]);
@@ -180,18 +113,16 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
return insertFiles(view, event, pos, files, {
uploadFile: handleUploadFile,
onFileUploadStart: handleFileUploadStart,
onFileUploadStop: handleFileUploadStop,
onFileUploadProgress: handleFileUploadProgress,
onFileUploadStart: props.onFileUploadStart,
onFileUploadStop: props.onFileUploadStop,
dictionary,
isAttachment,
});
},
[
localRef,
handleFileUploadStart,
handleFileUploadStop,
handleFileUploadProgress,
props.onFileUploadStart,
props.onFileUploadStop,
dictionary,
handleUploadFile,
]
@@ -266,7 +197,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
<>
{paragraphs ? (
<EditorContainer
$rtl={props.dir === "rtl"}
rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
@@ -292,9 +223,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
onFileUploadStart={handleFileUploadStart}
onFileUploadStop={handleFileUploadStop}
onFileUploadProgress={handleFileUploadProgress}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
+3 -3
View File
@@ -7,6 +7,7 @@ import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import { getDataTransferFiles } from "@shared/utils/files";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Input, { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
@@ -15,7 +16,6 @@ import { compressImage } from "~/utils/compressImage";
import { generateEmojiNameFromFilename } from "~/utils/emoji";
import { AttachmentValidation, EmojiValidation } from "@shared/validations";
import { bytesToHumanReadable } from "@shared/utils/files";
import { VStack } from "./primitives/VStack";
type Props = {
onSubmit: () => void;
@@ -158,7 +158,7 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
<LabelText as="label">{t("Upload an image")}</LabelText>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<VStack>
<Flex column align="center" gap={8}>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
@@ -183,7 +183,7 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
</Text>
</>
)}
</VStack>
</Flex>
</DropZone>
<Input
+1 -2
View File
@@ -1,8 +1,7 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import type { WithTranslation } from "react-i18next";
import { withTranslation, Trans } from "react-i18next";
import { withTranslation, Trans, WithTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
+2 -2
View File
@@ -12,8 +12,8 @@ import {
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import type Document from "~/models/Document";
import type Event from "~/models/Event";
import Document from "~/models/Document";
import Event from "~/models/Event";
import Time from "~/components/Time";
import Logger from "~/utils/Logger";
import Text from "./Text";
+18 -33
View File
@@ -4,13 +4,15 @@ import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { FileOperationFormat, NotificationEventType } from "@shared/types";
import type Collection from "~/models/Collection";
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 history from "~/utils/history";
import { settingsPath } from "~/utils/routeHelpers";
type Props = {
collection?: Collection;
@@ -25,7 +27,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
React.useState<boolean>(true);
const [includePrivate, setIncludePrivate] = React.useState<boolean>(true);
const user = useCurrentUser();
const { collections, ui } = useStores();
const { collections } = useStores();
const { t } = useTranslation();
const appName = env.APP_NAME;
@@ -51,40 +53,23 @@ function ExportDialog({ collection, onSubmit }: Props) {
);
const handleSubmit = async () => {
let response;
if (collection) {
response = await collection.export(format, includeAttachments);
await collection.export(format, includeAttachments);
toast.success(t("Export started"), {
description: t(`Your file will be available in {{ location }} soon`, {
location: `"${t("Settings")} > ${t("Export")}"`,
}),
action: {
label: t("View"),
onClick: () => {
history.push(settingsPath("export"));
},
},
});
} else {
response = await collections.export({
format,
includeAttachments,
includePrivate,
});
await collections.export({ format, includeAttachments, includePrivate });
toast.success(t("Export started"));
}
if (response?.data?.fileOperation) {
const fileOperationId = response.data.fileOperation.id;
const toastId = `export-${fileOperationId}`;
const timeoutId = setTimeout(() => {
toast.success(t("Export started"), {
id: toastId,
description: t("A link to your file will be sent through email soon"),
duration: 3000,
});
ui.exportToasts.delete(fileOperationId);
}, 6000);
ui.registerExportToast(fileOperationId, toastId, timeoutId);
toast.loading(t("Export started"), {
id: toastId,
description: `${t("Preparing your download")}`,
duration: Infinity,
});
}
onSubmit();
};
+1 -1
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import type User from "~/models/User";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { s } from "@shared/styles";
+4 -9
View File
@@ -7,8 +7,7 @@ import type { FetchPageParams } from "~/stores/base/Store";
import Button, { Inner } from "~/components/Button";
import Text from "~/components/Text";
import Input, { NativeInput, Outline } from "./Input";
import type { PaginatedItem } from "./PaginatedList";
import PaginatedList from "./PaginatedList";
import PaginatedList, { PaginatedItem } from "./PaginatedList";
import { MenuProvider } from "./primitives/Menu/MenuContext";
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
import { MenuIconWrapper } from "./primitives/components/Menu";
@@ -26,10 +25,8 @@ type Props = {
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
showIcons?: boolean;
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
fetchQueryOptions?: Record<string, string>;
disclosure?: boolean;
};
const FilterOptions = ({
@@ -38,10 +35,8 @@ const FilterOptions = ({
className,
onSelect,
showFilter,
showIcons = true,
fetchQuery,
fetchQueryOptions,
disclosure = true,
...rest
}: Props) => {
const { t } = useTranslation();
@@ -62,7 +57,7 @@ const FilterOptions = ({
<MenuButton
key={option.key}
icon={
option.icon && showIcons ? (
option.icon ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : undefined
}
@@ -74,7 +69,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
/>
),
[onSelect, showIcons, selectedKeys]
[onSelect, selectedKeys]
);
const handleFilter = React.useCallback(
@@ -185,8 +180,8 @@ const FilterOptions = ({
<StyledButton
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
disclosure={disclosure}
neutral
disclosure
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
@@ -1,6 +1,6 @@
import * as React from "react";
import { richExtensions } from "@shared/editor/nodes";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import ErrorBoundary from "../ErrorBoundary";
@@ -1,8 +1,8 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import type User from "~/models/User";
import User from "~/models/User";
import Facepile from "~/components/Facepile";
import Flex from "~/components/Flex";
import {
@@ -3,11 +3,12 @@ import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import { richExtensions } from "@shared/editor/nodes";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { IntegrationService } from "@shared/types";
import {
IntegrationService,
UnfurlResourceType,
UnfurlResponse,
} from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
@@ -30,11 +31,9 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "linear.app"
? IntegrationService.Linear
: urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.GitLab;
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
@@ -62,18 +61,7 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
</Trans>
</Info>
</Flex>
{description && (
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Description>{description}</Description>
<Flex wrap>
{labels.map((label, index) => (
@@ -1,5 +1,5 @@
import * as React from "react";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { Preview, Title, Info, Card, CardContent } from "./Components";
@@ -3,10 +3,8 @@ import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
import { richExtensions } from "@shared/editor/nodes";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
@@ -50,18 +48,7 @@ const HoverPreviewPullRequest = React.forwardRef(
</Trans>
</Info>
</Flex>
{description && (
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Description>{description}</Description>
</Flex>
</CardContent>
</Card>
@@ -1,4 +1,5 @@
import * as React from "react";
import debounce from "lodash/debounce";
import styled from "styled-components";
import { s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
@@ -12,23 +13,37 @@ type Props = {
onSelect: (color: string) => void;
};
const IconColorPicker = ({ activeColor, onSelect }: Props) => {
const ColorPicker = ({ activeColor, onSelect }: Props) => {
const [selectedColor, setSelectedColor] = React.useState(activeColor);
const isBuiltInColor = colorPalette.includes(selectedColor);
const color = isBuiltInColor ? undefined : selectedColor;
const debouncedOnSelect = React.useMemo(
() =>
debounce((color: string) => {
onSelect(color);
}, 250),
[onSelect]
);
React.useEffect(
() => () => {
debouncedOnSelect.cancel();
},
[debouncedOnSelect]
);
React.useEffect(() => {
setSelectedColor(activeColor);
}, [activeColor]);
const handleSelect = (color: string) => {
setSelectedColor(color);
onSelect(color);
debouncedOnSelect(color);
};
return (
<Container justify="space-between" align="center" auto>
<PresetColors activeColor={selectedColor} onClick={handleSelect} />
<BuiltinColors activeColor={selectedColor} onClick={handleSelect}>
<Divider />
<SwatchButton
color={color}
@@ -36,7 +51,7 @@ const IconColorPicker = ({ activeColor, onSelect }: Props) => {
onChange={handleSelect}
pickerInModal
/>
</Container>
</BuiltinColors>
);
};
@@ -46,14 +61,18 @@ const Divider = styled.div`
background-color: ${s("inputBorder")};
`;
const PresetColors = ({
const BuiltinColors = ({
activeColor,
onClick,
className,
children,
}: {
activeColor: string;
onClick: (color: string) => void;
className?: string;
children?: React.ReactNode;
}) => (
<>
<Container className={className} justify="space-between" align="center" auto>
{colorPalette.map((color) => (
<ColorButton
key={color}
@@ -62,7 +81,8 @@ const PresetColors = ({
onClick={() => onClick(color)}
/>
))}
</>
{children}
</Container>
);
const Container = styled(Flex)`
@@ -71,4 +91,4 @@ const Container = styled(Flex)`
border-bottom: 1px solid ${s("inputBorder")};
`;
export default IconColorPicker;
export default ColorPicker;
@@ -1,8 +1,8 @@
import Flex from "@shared/components/Flex";
import styled from "styled-components";
import InputSearch from "~/components/InputSearch";
import { HStack } from "~/components/primitives/HStack";
export const UserInputContainer = styled(HStack)`
export const UserInputContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
@@ -2,19 +2,17 @@ import concat from "lodash/concat";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { EmojiSkinTone } from "@shared/types";
import { EmojiCategory, IconType } from "@shared/types";
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
import { DisplayCategory } from "../utils";
import type { DataNode, EmojiNode } from "./GridTemplate";
import GridTemplate from "./GridTemplate";
import GridTemplate, { DataNode, EmojiNode } from "./GridTemplate";
import SkinTonePicker from "./SkinTonePicker";
import { StyledInputSearch, UserInputContainer } from "./Components";
import { useIconState } from "../useIconState";
import useStores from "~/hooks/useStores";
import type Emoji from "~/models/Emoji";
import Emoji from "~/models/Emoji";
import { useComputed } from "~/hooks/useComputed";
import { MenuButton } from "./MenuButton";
import useCurrentTeam from "~/hooks/useCurrentTeam";
@@ -160,7 +158,7 @@ const EmojiPanel = ({
return (
<Flex column>
<UserInputContainer>
<UserInputContainer align="center" gap={8}>
<StyledInputSearch
ref={searchRef}
value={query}
@@ -1,6 +1,5 @@
import React from "react";
import type { ListChildComponentProps } from "react-window";
import { FixedSizeList } from "react-window";
import { FixedSizeList, ListChildComponentProps } from "react-window";
import styled from "styled-components";
type Props = {
@@ -6,9 +6,8 @@ import { IconLibrary } from "@shared/utils/IconLibrary";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import { DisplayCategory } from "../utils";
import IconColorPicker from "./IconColorPicker";
import type { DataNode } from "./GridTemplate";
import GridTemplate from "./GridTemplate";
import ColorPicker from "./ColorPicker";
import GridTemplate, { DataNode } from "./GridTemplate";
import { useIconState } from "../useIconState";
const IconNames = Object.keys(IconLibrary.mapping);
@@ -122,7 +121,7 @@ const IconPanel = ({
onChange={handleFilter}
/>
</InputSearchContainer>
<IconColorPicker
<ColorPicker
width={panelWidth}
activeColor={color}
onSelect={onColorChange}
+3 -21
View File
@@ -13,35 +13,17 @@ export default function CircleIcon({
retainColor,
...rest
}: Props) {
const isGradient = color === "rainbow";
const fillValue = isGradient ? "url(#circleIconGradient)" : color;
return (
<svg
fill={fillValue}
fill={color}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
style={retainColor ? { fill: fillValue } : undefined}
style={retainColor ? { fill: color } : undefined}
{...rest}
>
{isGradient && (
<defs>
<linearGradient
id="circleIconGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor="#ff5858" />
<stop offset="50%" stopColor="#fbcc34" />
<stop offset="100%" stopColor="#00c6ff" />
</linearGradient>
</defs>
)}
<circle cx="12" cy="12" r="8" />
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
</svg>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import type Collection from "~/models/Collection";
import Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
type Props = {
-19
View File
@@ -1,19 +0,0 @@
import styled from "styled-components";
export const ConnectedIcon = styled.div<{ color?: string }>`
width: 24px;
height: 24px;
position: relative;
&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
background-color: ${(props) => props.color ?? props.theme.accent};
border-radius: 50%;
transform: translate(-50%, -50%);
}
`;
@@ -1,9 +0,0 @@
import styled from "styled-components";
import CircleIcon from "./CircleIcon";
export const DottedCircleIcon = styled(CircleIcon)`
circle {
stroke: ${(props) => props.theme.textSecondary};
stroke-dasharray: 2, 2;
}
`;
+8 -7
View File
@@ -95,7 +95,7 @@ const IconWrapper = styled.span`
export const Outline = styled(Flex)<{
margin?: string | number;
hasError?: boolean;
$focused?: boolean;
focused?: boolean;
}>`
flex: 1;
margin: ${(props) =>
@@ -106,7 +106,7 @@ export const Outline = styled(Flex)<{
border-color: ${(props) =>
props.hasError
? props.theme.danger
: props.$focused
: props.focused
? props.theme.inputBorderFocused
: props.theme.inputBorder};
border-radius: 4px;
@@ -125,10 +125,11 @@ export const LabelText = styled.div`
display: inline-block;
`;
export interface Props extends Omit<
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"prefix"
> {
export interface Props
extends Omit<
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"prefix"
> {
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
labelHidden?: boolean;
label?: string;
@@ -224,7 +225,7 @@ function Input(
) : (
wrappedLabel
))}
<Outline $focused={focused} margin={margin}>
<Outline focused={focused} margin={margin}>
{prefix}
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
+1 -2
View File
@@ -1,7 +1,6 @@
import * as React from "react";
import styled from "styled-components";
import type { Props as InputProps } from "./Input";
import Input from "./Input";
import Input, { Props as InputProps } from "./Input";
import Relative from "./Sidebar/components/Relative";
import { SwatchButton } from "./SwatchButton";
@@ -2,10 +2,8 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { Option } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import { InputSelect, Option } from "~/components/InputSelect";
import { EmptySelectValue, Permission } from "~/types";
type Props = Pick<
React.ComponentProps<typeof InputSelect>,
+1 -2
View File
@@ -2,8 +2,7 @@ import { SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import type { Props as InputProps } from "~/components/Input";
import Input from "~/components/Input";
import Input, { Props as InputProps } from "~/components/Input";
type Props = InputProps & {
placeholder?: string;
+1 -2
View File
@@ -3,8 +3,7 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { CollectionPermission } from "@shared/types";
import type { Option } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import { InputSelect, Option } from "~/components/InputSelect";
import { EmptySelectValue } from "~/types";
type Props = {
+5 -7
View File
@@ -1,14 +1,11 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet-async";
import type { DefaultTheme } from "styled-components";
import styled from "styled-components";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
import { useRightSidebarContent } from "~/components/RightSidebarContext";
import SkipNavContent from "~/components/SkipNavContent";
import SkipNavLink from "~/components/SkipNavLink";
import env from "~/env";
@@ -21,15 +18,16 @@ type Props = {
title?: string;
/** Left sidebar content. */
sidebar?: React.ReactNode;
/** Right sidebar content. */
sidebarRight?: React.ReactNode;
};
const Layout = React.forwardRef(function Layout_(
{ title, children, sidebar }: Props,
{ title, children, sidebar, sidebarRight }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
const sidebarRight = useRightSidebarContent();
return (
<Container column auto ref={ref}>
@@ -62,7 +60,7 @@ const Layout = React.forwardRef(function Layout_(
{children}
</Content>
<AnimatePresence initial={false}>{sidebarRight}</AnimatePresence>
{sidebarRight}
</Container>
</Container>
);
+20 -20
View File
@@ -1,11 +1,12 @@
import { observer } from "mobx-react";
import * as Dialog from "@radix-ui/react-dialog";
import type { Keyframes } from "styled-components";
import styled, { css, keyframes } from "styled-components";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import styled, { css, Keyframes, keyframes } from "styled-components";
import {
ComponentProps,
createContext,
forwardRef,
HTMLAttributes,
ReactNode,
useCallback,
useContext,
useEffect,
@@ -41,12 +42,12 @@ import { Separator } from "./Actions";
import useSwipe from "~/hooks/useSwipe";
import { toast } from "sonner";
import { findIndex } from "lodash";
import type { LightboxImage } from "@shared/editor/lib/Lightbox";
import type { ReactZoomPanPinchRef } from "react-zoom-pan-pinch";
import { LightboxImage } from "@shared/editor/lib/Lightbox";
import {
TransformWrapper,
TransformComponent,
useTransformEffect,
ReactZoomPanPinchRef,
} from "react-zoom-pan-pinch";
import { transparentize } from "polished";
import { mergeRefs } from "react-merge-refs";
@@ -54,7 +55,6 @@ import { useEditor } from "~/editor/components/EditorContext";
import { NodeSelection } from "prosemirror-state";
import { ImageSource } from "@shared/editor/lib/FileHelper";
import Desktop from "~/utils/Desktop";
import { HStack } from "./primitives/HStack";
export enum LightboxStatus {
READY_TO_OPEN,
@@ -97,8 +97,6 @@ type Props = {
onUpdate: (activeImage: LightboxImage | null) => void;
/** Callback triggered when Lightbox closes */
onClose: () => void;
/** Whether the editor is read only */
readOnly?: boolean;
};
const ZoomPanPinchContext = createContext({ isImagePanning: false });
@@ -218,7 +216,7 @@ function usePanning() {
};
}
function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
const isIdle = useIdle(3 * Second.ms);
const { t } = useTranslation();
const imgRef = useRef<HTMLImageElement | null>(null);
@@ -506,16 +504,18 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
const toTx = to.center.x - final.center.x;
const toTy = to.center.y - final.center.y;
const fromS = from.width / final.width;
const toS = to.width / final.width;
const fromSx = from.width / final.width;
const fromSy = from.height / final.height;
const toSx = to.width / final.width;
const toSy = to.height / final.height;
return keyframes`
from {
translate: ${fromTx}px ${fromTy}px;
scale: ${fromS};
scale: ${fromSx} ${fromSy};
}
to {
translate: ${toTx}px ${toTy}px;
scale: ${toS};
scale: ${toSx} ${toSy};
}
`;
};
@@ -573,10 +573,8 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
};
const svgDataURLToBlob = (dataURL: string) => {
// Match the SVG data URL format (with or without charset)
const match = dataURL.match(
/^data:image\/svg\+xml(?:;charset=utf-8)?,(.*)$/i
);
// Match the SVG data URL format
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
if (!match) {
return;
}
@@ -773,8 +771,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
/>
</Tooltip>
{activeImage.source === ImageSource.DiagramsNet &&
!Desktop.isElectron() &&
!readOnly && (
!Desktop.isElectron() && (
<Tooltip content={t("Edit diagram")} placement="bottom">
<ActionButton
tabIndex={-1}
@@ -1104,13 +1101,16 @@ const ActionButton = styled(Button)`
background: transparent;
`;
const Actions = styled(HStack)<{
const Actions = styled.div<{
animation: Animation | null;
}>`
position: absolute;
top: 0;
right: 0;
margin: 16px 12px;
display: flex;
align-items: center;
gap: 8px;
z-index: ${depths.modal};
background: ${(props) => transparentize(0.2, props.theme.background)};
backdrop-filter: blur(4px);

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