mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
223 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd08bbf58 | |||
| bda95e4952 | |||
| 394c6e3b03 | |||
| 9113501906 | |||
| 92168c3641 | |||
| 5ea63aa1a2 | |||
| b1bf7c488b | |||
| 9811ab6aea | |||
| f0899f614b | |||
| c65b020655 | |||
| 9791ff1170 | |||
| a25f334bb1 | |||
| e1b2993bca | |||
| b3d4563730 | |||
| 7106263f88 | |||
| 3c2e9a9723 | |||
| bd01a62fc1 | |||
| 95106e695f | |||
| 39623b90bd | |||
| a3fcd71582 | |||
| a2f9962958 | |||
| 709184ae0b | |||
| bc5ffb79b2 | |||
| d703f8acf3 | |||
| 1c23cbec1b | |||
| 1caeafaeed | |||
| 969a7bb97d | |||
| 053693b9d5 | |||
| 7938ffdd7a | |||
| 9b8acf3efb | |||
| ac6b680cdb | |||
| 27c633eb8b | |||
| ca36451e42 | |||
| 0d198294eb | |||
| bc63aba1d1 | |||
| ea665b80ee | |||
| 492af6683b | |||
| f4b80d5301 | |||
| 58f0613b5f | |||
| c2edd41e87 | |||
| 62c0f15c06 | |||
| c75815b90c | |||
| 33e3680782 | |||
| b23a39bd39 | |||
| f329b56d0e | |||
| be3f28afea | |||
| 997d38a6ac | |||
| b5465376ae | |||
| 9ec6b8309d | |||
| aad2483ff9 | |||
| 0c0facc2a1 | |||
| e864684d56 | |||
| 88de417a21 | |||
| 985038525c | |||
| c808bed712 | |||
| bfddf4bb4c | |||
| 1cc10f5fff | |||
| ce3d710888 | |||
| 2aff3907c5 | |||
| 02c5c93bd8 | |||
| e9e565dc2b | |||
| 4126b94f7c | |||
| a21ddc4999 | |||
| 0d50f0d60a | |||
| c419c3ab63 | |||
| 8464d99589 | |||
| a20c8e5371 | |||
| 3a442ec5d3 | |||
| e32b3772b2 | |||
| b2c66c5190 | |||
| 875ba8d03c | |||
| bcf1155818 | |||
| 7e252f0892 | |||
| b2309df76d | |||
| 608a68b010 | |||
| 991df631ca | |||
| ad89288eac | |||
| b2bb2335a1 | |||
| 224230eaa0 | |||
| d0ede882c6 | |||
| b189c308e5 | |||
| cecc9ef576 | |||
| 553daed606 | |||
| 5c991bbd5f | |||
| 334b179048 | |||
| f6fbbcb1ad | |||
| 70b6476afa | |||
| a37bb13956 | |||
| c91272f820 | |||
| 60bf47ede0 | |||
| 03fe74710c | |||
| 370934bb0e | |||
| e044014cea | |||
| 5aff60e28b | |||
| fecca544f9 | |||
| 1eba87020c | |||
| 3f92e96006 | |||
| ae5cd6a159 | |||
| d2a0bf9923 | |||
| deadaa00f1 | |||
| 6366859935 | |||
| 82743b1c0a | |||
| 76a3ba4e83 | |||
| 09e99ac98d | |||
| c158697c91 | |||
| 7473d5b437 | |||
| ded7ff994e | |||
| a4a67f2cdd | |||
| c3ba14f069 | |||
| e9e13c4819 | |||
| 48aa4f33ce | |||
| f7b2eb0173 | |||
| 45c797653f | |||
| b424d92724 | |||
| 798184435b | |||
| 0f2513346a | |||
| 1186ddd3c0 | |||
| c4fe093a0d | |||
| ecaf116990 | |||
| e6f9b48530 | |||
| 70c55e4a42 | |||
| 667bfe68c5 | |||
| 84c00cfae7 | |||
| 2c3e736eb3 | |||
| 64ccdca0d7 | |||
| 62788c45e0 | |||
| b9addda229 | |||
| a23b04c8fa | |||
| 15213bbeb0 | |||
| 03950af3b7 | |||
| 8989287e8a | |||
| 6bab00b92e | |||
| 1e3aa2c59a | |||
| f3da1bc79e | |||
| 8c1be70198 | |||
| a9a54d5ada | |||
| 5126d8540e | |||
| 0c3ddef228 | |||
| 3f207aea49 | |||
| dcc3805438 | |||
| ecafd5f32a | |||
| f9dc1a3983 | |||
| 38eda7fa61 | |||
| 6461aabc52 | |||
| 4bd5585f8c | |||
| 1a4033dd2d | |||
| 9e725d618d | |||
| 08c0390295 | |||
| a1b9f900c7 | |||
| 92be631350 | |||
| 8d44a0fd92 | |||
| d43280f08e | |||
| bf62bd04b0 | |||
| dd2e8f258d | |||
| 5309e8bb01 | |||
| def9da6a12 | |||
| 63a6ed7f8d | |||
| cf488508b7 | |||
| efc988fb9f | |||
| b639841555 | |||
| f06defaa14 | |||
| fcf26e4b9b | |||
| df117ebad5 | |||
| 3a6df26c8c | |||
| 80d90e3201 | |||
| 841ab022a6 | |||
| c875a92b86 | |||
| a11a8442dc | |||
| 1e2159edf7 | |||
| 07118e8c94 | |||
| 620c654b26 | |||
| 6e2b53315b | |||
| 57b9cfdcf2 | |||
| 16470f0b8d | |||
| 8f8cbb6b71 | |||
| c8c2bd8cc9 | |||
| aab7de9781 | |||
| 158cac0a8a | |||
| d2b719a5a9 | |||
| 597b6d801c | |||
| 4a6e94be3f | |||
| a60d02898e | |||
| ee5164290d | |||
| 5d32db86cf | |||
| ab3994f3f1 | |||
| 1d919cc56a | |||
| 77cee2806c | |||
| 4774fa4fd0 | |||
| f4e7c43fe4 | |||
| c1010fc410 | |||
| 879d2b8198 | |||
| 82d7041b6b | |||
| 170f59c6ba | |||
| a5c22bbb09 | |||
| 2fb34a400d | |||
| 061ba46255 | |||
| 246aa83071 | |||
| 9db539dfce | |||
| 954946ae12 | |||
| 4a324784be | |||
| 0d76dfc9f4 | |||
| 9603e6d7c8 | |||
| e325867716 | |||
| bc6aa11f5d | |||
| 7c070df942 | |||
| 925a43bd36 | |||
| 6e99dff3c2 | |||
| 6bd775eb84 | |||
| 42a0958322 | |||
| 935e0bb7b9 | |||
| 871cb52a23 | |||
| 58f031c7e9 | |||
| fc01deeefd | |||
| 3109f49b40 | |||
| dab06d4dfa | |||
| dcddab47e1 | |||
| 0eee576b81 | |||
| 51a1d3bf50 | |||
| ff3b3ce552 | |||
| ab42e4fda8 | |||
| 2cb47aa421 | |||
| 7ff1c84530 | |||
| fba1bcef87 |
@@ -140,6 +140,11 @@ FORCE_HTTPS=true
|
||||
# and "X-Client-IP".
|
||||
# PROXY_IP_HEADER=
|
||||
|
||||
# Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
|
||||
# X-Forwarded-Proto) set by an upstream proxy. Set to false if not
|
||||
# running behind a proxy in production.
|
||||
# PROXY_HEADERS_TRUSTED=true
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
- name: Compress Images
|
||||
id: calibre
|
||||
uses: calibreapp/image-actions@main
|
||||
uses: calibreapp/image-actions@3d5873ac3e7bf1a38b24d9778d8dc639d5706d8b # main
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name != 'pull_request' &&
|
||||
steps.calibre.outputs.markdown != ''
|
||||
uses: peter-evans/create-pull-request@v3
|
||||
uses: peter-evans/create-pull-request@18f7dc018cc2cd597073088f7c7591b9d1c02672 # v3
|
||||
with:
|
||||
title: "chore: Auto Compress Images"
|
||||
branch-suffix: timestamp
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v2
|
||||
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
- name: Send bundle stats to RelativeCI
|
||||
uses: relative-ci/agent-action@v2
|
||||
uses: relative-ci/agent-action@38328454d6a23942175eba485fca4fbb807b1f03 # v2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Docker Build Check
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.base"
|
||||
pull_request:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.base"
|
||||
|
||||
env:
|
||||
BASE_IMAGE_NAME: outline-base
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker
|
||||
|
||||
- name: Build base image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}:latest
|
||||
push: false
|
||||
|
||||
- name: Build main image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}:latest
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
@@ -11,13 +12,13 @@ env:
|
||||
|
||||
jobs:
|
||||
build-arm:
|
||||
runs-on: ubicloud-standard-8-arm
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
@@ -37,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v7
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -45,8 +46,6 @@ jobs:
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
|
||||
- name: Docker meta
|
||||
@@ -61,7 +60,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -69,8 +68,6 @@ jobs:
|
||||
tags: ${{ env.IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
|
||||
@@ -90,13 +87,13 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
build-amd:
|
||||
runs-on: ubicloud-standard-8
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
@@ -116,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v7
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -124,8 +121,6 @@ jobs:
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
|
||||
- name: Docker meta
|
||||
@@ -140,7 +135,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -148,8 +143,6 @@ jobs:
|
||||
tags: ${{ env.IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
|
||||
@@ -169,7 +162,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubicloud-standard-8
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
needs:
|
||||
- build-amd
|
||||
- build-arm
|
||||
@@ -188,8 +181,8 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.check.outputs.updated == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
with:
|
||||
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
|
||||
title: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
|
||||
|
||||
@@ -14,6 +14,7 @@ data/*
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
.history
|
||||
|
||||
# Yarn Berry
|
||||
.yarn/*
|
||||
|
||||
@@ -73,6 +73,23 @@
|
||||
"eqeqeq": "error",
|
||||
"curly": "error",
|
||||
"no-console": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "prosemirror-tables",
|
||||
"importNames": [
|
||||
"addRowBefore",
|
||||
"addRowAfter",
|
||||
"addColumnBefore",
|
||||
"addColumnAfter"
|
||||
],
|
||||
"message": "Use the wrappers from shared/editor/commands/table instead, which respect the target index and place the cursor in the inserted cell."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-unused-expressions": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
@@ -93,6 +110,7 @@
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"typescript/restrict-template-expressions": "error",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-useless-default-assignment": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
# ---
|
||||
FROM node:24.15.0-slim AS runner
|
||||
FROM node:24.16.0-slim AS runner
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:24.15.0 AS deps
|
||||
FROM node:24.16.0 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.7.1
|
||||
Licensed Work: Outline 1.8.1
|
||||
The Licensed Work is (c) 2026 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
|
||||
@@ -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-05-04
|
||||
Change Date: 2030-06-06
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
trashPath,
|
||||
documentEditPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type {
|
||||
Action,
|
||||
@@ -108,19 +109,21 @@ export const openDocument = createActionWithChildren({
|
||||
shortcut: ["o", "d"],
|
||||
keywords: "go to",
|
||||
icon: <DocumentIcon />,
|
||||
children: ({ stores }) => {
|
||||
children: ({ stores, t }) => {
|
||||
const nodes = stores.collections.navigationNodes.reduce(
|
||||
(acc, node) => [...acc, ...node.children],
|
||||
[] as NavigationNode[]
|
||||
);
|
||||
const documents = stores.documents.orderedData;
|
||||
|
||||
return uniqBy([...documents, ...nodes], "id").map((item) =>
|
||||
createInternalLinkAction({
|
||||
return uniqBy([...documents, ...nodes], "id").map((item) => {
|
||||
const document = stores.documents.get(item.id);
|
||||
return createInternalLinkAction({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the document is renamed
|
||||
id: item.url,
|
||||
name: item.title,
|
||||
description: document ? documentBreadcrumbText(document, t) : undefined,
|
||||
icon: item.icon ? (
|
||||
<Icon
|
||||
value={item.icon}
|
||||
@@ -132,8 +135,8 @@ export const openDocument = createActionWithChildren({
|
||||
),
|
||||
section: DocumentSection,
|
||||
to: item.url,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -237,6 +240,26 @@ function findDocumentSiblingIndex(
|
||||
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the user can create a sibling of the given document.
|
||||
* A sibling shares the document's parent, so this mirrors the backend's
|
||||
* create authorization: create permission on the parent document, or on the
|
||||
* collection when the document is at the root.
|
||||
*
|
||||
* @param stores - the root stores.
|
||||
* @param document - the document to create a sibling of.
|
||||
* @returns true if the user can create a sibling.
|
||||
*/
|
||||
function canCreateSiblingDocument(
|
||||
stores: ActionContext["stores"],
|
||||
document: { collectionId?: string | null; parentDocumentId?: string }
|
||||
): boolean {
|
||||
return document.parentDocumentId
|
||||
? stores.policies.abilities(document.parentDocumentId).createChildDocument
|
||||
: !!document.collectionId &&
|
||||
stores.policies.abilities(document.collectionId).createDocument;
|
||||
}
|
||||
|
||||
export const createNestedDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("Nested document"),
|
||||
analyticsName: "New document",
|
||||
@@ -269,10 +292,14 @@ const createDocumentBefore = createInternalLinkAction({
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!document?.collectionId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
if (!document?.collectionId) {
|
||||
return false;
|
||||
}
|
||||
const collection = stores.collections.get(document.collectionId);
|
||||
if (collection?.sort.field === "title") {
|
||||
return false;
|
||||
}
|
||||
return canCreateSiblingDocument(stores, document);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -307,10 +334,14 @@ const createDocumentAfter = createInternalLinkAction({
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!document?.collectionId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
if (!document?.collectionId) {
|
||||
return false;
|
||||
}
|
||||
const collection = stores.collections.get(document.collectionId);
|
||||
if (collection?.sort.field === "title") {
|
||||
return false;
|
||||
}
|
||||
return canCreateSiblingDocument(stores, document);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -335,6 +366,18 @@ const createDocumentAfter = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
function isAlphabeticallySorted(
|
||||
stores: ActionContext["stores"],
|
||||
activeDocumentId: string
|
||||
): boolean {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document?.collectionId) {
|
||||
return false;
|
||||
}
|
||||
const collection = stores.collections.get(document.collectionId);
|
||||
return collection?.sort.field === "title";
|
||||
}
|
||||
|
||||
export const createNewDocument = createActionWithChildren({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
@@ -342,17 +385,48 @@ export const createNewDocument = createActionWithChildren({
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
if (!activeDocumentId || !currentTeamId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
if (!stores.policies.abilities(currentTeamId).createDocument) {
|
||||
return false;
|
||||
}
|
||||
return !isAlphabeticallySorted(stores, activeDocumentId);
|
||||
},
|
||||
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
|
||||
});
|
||||
|
||||
export const createNewDocumentInAlphabeticalCollection =
|
||||
createInternalLinkAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId || !currentTeamId) {
|
||||
return false;
|
||||
}
|
||||
if (!stores.policies.abilities(currentTeamId).createDocument) {
|
||||
return false;
|
||||
}
|
||||
if (!stores.policies.abilities(activeDocumentId).createChildDocument) {
|
||||
return false;
|
||||
}
|
||||
return isAlphabeticallySorted(stores, activeDocumentId);
|
||||
},
|
||||
to: ({ activeDocumentId, sidebarContext }) => {
|
||||
const [pathname, search] =
|
||||
newNestedDocumentPath(activeDocumentId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
@@ -1528,6 +1602,7 @@ export const rootDocumentActions = [
|
||||
createDocument,
|
||||
createDraftDocument,
|
||||
createNewDocument,
|
||||
createNewDocumentInAlphabeticalCollection,
|
||||
createNestedDocument,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LinkIcon, RestoreIcon, TrashIcon, DownloadIcon } from "outline-icons";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { ExportContentType } from "@shared/types";
|
||||
import Revision from "~/models/Revision";
|
||||
import stores from "~/stores";
|
||||
import { createAction, createActionWithChildren } from "~/actions";
|
||||
import { RevisionSection } from "~/actions/sections";
|
||||
@@ -21,7 +22,7 @@ export const restoreRevision = createAction({
|
||||
section: RevisionSection,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
|
||||
perform: async ({ event, location, activeDocumentId }) => {
|
||||
perform: async ({ event, location, activeDocumentId, getActiveModel }) => {
|
||||
event?.preventDefault();
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
@@ -30,7 +31,10 @@ export const restoreRevision = createAction({
|
||||
const match = matchPath<{ revisionId: string }>(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
});
|
||||
const revisionId = match?.params.revisionId;
|
||||
const revisionId = getActiveModel(Revision)?.id ?? match?.params.revisionId;
|
||||
if (!revisionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
|
||||
@@ -13,6 +13,10 @@ ActiveCollectionSection.priority = 0.8;
|
||||
|
||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DateSection = ({ t }: ActionContext) => t("Date");
|
||||
|
||||
DateSection.priority = 1;
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const SearchResultsSection = ({ t }: ActionContext) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -16,6 +16,7 @@ const Authenticated = ({ children }: Props) => {
|
||||
const { i18n } = useTranslation();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const language = user?.language;
|
||||
const hasLoggedOut = useRef(false);
|
||||
|
||||
// Watching for language changes here as this is the earliest point we might have the user
|
||||
// available and means we can start loading translations faster
|
||||
@@ -23,23 +24,36 @@ const Authenticated = ({ children }: Props) => {
|
||||
void changeLanguage(language, i18n);
|
||||
}, [i18n, language]);
|
||||
|
||||
const shouldLogout = !auth.authenticated && !auth.isFetching;
|
||||
|
||||
// Passive logout when we land here without an authenticated session – note we
|
||||
// intentionally do not revoke the server-side token, as that would clobber
|
||||
// the session in any other tab that may have already re-authenticated.
|
||||
useEffect(() => {
|
||||
if (shouldLogout && !hasLoggedOut.current) {
|
||||
hasLoggedOut.current = true;
|
||||
void auth.logout({
|
||||
savePath: true,
|
||||
clearCache: false,
|
||||
revokeToken: false,
|
||||
});
|
||||
}
|
||||
}, [shouldLogout, auth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.logoutRedirectUri) {
|
||||
window.location.href = auth.logoutRedirectUri;
|
||||
}
|
||||
}, [auth.logoutRedirectUri]);
|
||||
|
||||
if (auth.authenticated) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (auth.isFetching) {
|
||||
if (auth.isFetching || auth.logoutRedirectUri) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
void auth.logout({
|
||||
savePath: true,
|
||||
clearCache: false,
|
||||
});
|
||||
|
||||
if (auth.logoutRedirectUri) {
|
||||
window.location.href = auth.logoutRedirectUri;
|
||||
return null;
|
||||
}
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { EditorAwareHTML5Backend } from "~/components/EditorAwareHTML5Backend";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
@@ -104,14 +106,16 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<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>
|
||||
<DndProvider backend={EditorAwareHTML5Backend}>
|
||||
<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>
|
||||
</DndProvider>
|
||||
</PortalContext.Provider>
|
||||
</RightSidebarProvider>
|
||||
</DocumentContextProvider>
|
||||
|
||||
@@ -79,10 +79,10 @@ function Collaborators(props: Props) {
|
||||
// Memoize ids to avoid unnecessary effect executions
|
||||
const missingUserIds = useMemo(
|
||||
() =>
|
||||
uniq([...document.collaboratorIds, ...Array.from(presentIds)])
|
||||
uniq([...collaboratorIdsSet, ...presentIds])
|
||||
.filter((userId) => !users.get(userId))
|
||||
.sort(),
|
||||
[document.collaboratorIds, presentIds, users]
|
||||
[collaboratorIdsSet, presentIds, users]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useRecentDocumentActions = (count = 6) => {
|
||||
const { documents, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
@@ -19,6 +22,7 @@ const useRecentDocumentActions = (count = 6) => {
|
||||
name: item.titleWithDefault,
|
||||
analyticsName: "Recently viewed document",
|
||||
section: RecentSection,
|
||||
description: documentBreadcrumbText(item, t),
|
||||
icon: item.icon ? (
|
||||
<Icon
|
||||
value={item.icon}
|
||||
@@ -31,7 +35,7 @@ const useRecentDocumentActions = (count = 6) => {
|
||||
to: documentPath(item),
|
||||
})
|
||||
),
|
||||
[count, ui.activeDocumentId, documents.recentlyViewed]
|
||||
[count, ui.activeDocumentId, documents.recentlyViewed, t]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function DesktopEventHandler() {
|
||||
const hasDisabledUpdateMessage = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
Desktop.bridge?.redirect((path: string, replace = false) => {
|
||||
Desktop.bridge?.redirect((path: string, replace: boolean) => {
|
||||
if (replace) {
|
||||
history.replace(path);
|
||||
} else {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon, GoToIcon, 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 Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
@@ -20,6 +20,56 @@ import { archivePath, trashPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
/**
|
||||
* Returns the breadcrumb parts leading up to a document, separating the
|
||||
* (possibly deleted) collection label from ancestor document titles. The
|
||||
* document itself is not included.
|
||||
*
|
||||
* @param document - the document to compute the breadcrumb for.
|
||||
* @param t - translation function for fallback titles.
|
||||
* @returns the collection label and ancestor titles.
|
||||
*/
|
||||
export function documentBreadcrumbParts(
|
||||
document: Document,
|
||||
t: TFunction
|
||||
): { collection: string | undefined; ancestors: string[] } {
|
||||
let collectionLabel: string | undefined;
|
||||
if (document.isCollectionDeleted) {
|
||||
collectionLabel = t("Deleted Collection");
|
||||
} else if (document.collection?.name) {
|
||||
collectionLabel = document.collection.name;
|
||||
}
|
||||
|
||||
return {
|
||||
collection: collectionLabel,
|
||||
ancestors: document.pathTo
|
||||
.slice(0, -1)
|
||||
.map((node) => node.title || t("Untitled")),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the breadcrumb path leading up to a document as a plain text
|
||||
* string. Includes the collection name (or "Deleted Collection" fallback)
|
||||
* and any ancestor document titles, slash-separated.
|
||||
*
|
||||
* @param document - the document to compute the breadcrumb for.
|
||||
* @param t - translation function for fallback titles.
|
||||
* @returns the breadcrumb as a slash-separated string, or undefined if the
|
||||
* document has no resolvable parent context.
|
||||
*/
|
||||
export function documentBreadcrumbText(
|
||||
document: Document,
|
||||
t: TFunction
|
||||
): string | undefined {
|
||||
const parts = documentBreadcrumbParts(document, t);
|
||||
const segments = [
|
||||
...(parts.collection ? [parts.collection] : []),
|
||||
...parts.ancestors,
|
||||
];
|
||||
return segments.length ? segments.join(" / ") : undefined;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
document: Document;
|
||||
@@ -147,22 +197,25 @@ function DocumentBreadcrumb(
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const slicedPath = reverse
|
||||
? path.slice(depth && -depth)
|
||||
: path.slice(0, depth);
|
||||
const { collection: collectionLabel, ancestors: ancestorLabels } =
|
||||
documentBreadcrumbParts(document, t);
|
||||
|
||||
const slicedAncestors = reverse
|
||||
? ancestorLabels.slice(depth && -depth)
|
||||
: ancestorLabels.slice(0, depth);
|
||||
|
||||
const showCollection =
|
||||
collection &&
|
||||
(!reverse || depth === undefined || slicedPath.length < depth);
|
||||
!!collectionLabel &&
|
||||
(!reverse || depth === undefined || slicedAncestors.length < depth);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showCollection && collection.name}
|
||||
{slicedPath.map((node: NavigationNode, index: number) => (
|
||||
<React.Fragment key={node.id}>
|
||||
{showCollection && collectionLabel}
|
||||
{slicedAncestors.map((label, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{showCollection && <SmallSlash />}
|
||||
{node.title || t("Untitled")}
|
||||
{!showCollection && index !== slicedPath.length - 1 && (
|
||||
{label}
|
||||
{!showCollection && index !== slicedAncestors.length - 1 && (
|
||||
<SmallSlash />
|
||||
)}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -40,8 +40,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
return nodes;
|
||||
}, [policies, collectionTrees]);
|
||||
|
||||
const copy = async () => {
|
||||
if (!selectedPath) {
|
||||
const copy = async (path = selectedPath) => {
|
||||
if (!path) {
|
||||
toast.message(t("Select a location to copy"));
|
||||
return;
|
||||
}
|
||||
@@ -52,10 +52,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
publish,
|
||||
recursive,
|
||||
title: document.title,
|
||||
collectionId: selectedPath.collectionId,
|
||||
...(selectedPath.type === "document"
|
||||
? { parentDocumentId: selectedPath.id }
|
||||
: {}),
|
||||
collectionId: path.collectionId,
|
||||
...(path.type === "document" ? { parentDocumentId: path.id } : {}),
|
||||
});
|
||||
|
||||
toast.success(t("Document copied"));
|
||||
@@ -111,7 +109,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</Text>
|
||||
<Button disabled={!selectedPath || copying} onClick={copy}>
|
||||
<Button disabled={!selectedPath || copying} onClick={() => copy()}>
|
||||
{copying ? `${t("Copying")}…` : t("Copy")}
|
||||
</Button>
|
||||
</Footer>
|
||||
|
||||
@@ -31,7 +31,7 @@ import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
onSubmit: () => void;
|
||||
onSubmit: (item: NavigationNode | null) => void;
|
||||
/** A side-effect of item selection */
|
||||
onSelect: (item: NavigationNode | null) => void;
|
||||
/** Items to be shown in explorer */
|
||||
@@ -255,6 +255,13 @@ function DocumentExplorer({
|
||||
}
|
||||
};
|
||||
|
||||
const submitNode = (node: number) => {
|
||||
const selectedNode = nodes[node];
|
||||
|
||||
selectNode(selectedNode);
|
||||
onSubmit(selectedNode);
|
||||
};
|
||||
|
||||
const ListItem = observer(
|
||||
({
|
||||
index,
|
||||
@@ -311,7 +318,8 @@ function DocumentExplorer({
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
onClick={() => selectNode(nodes[index])}
|
||||
onDoubleClick={() => submitNode(index)}
|
||||
icon={renderedIcon}
|
||||
title={title}
|
||||
path={path}
|
||||
@@ -325,7 +333,8 @@ function DocumentExplorer({
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
onClick={() => selectNode(nodes[index])}
|
||||
onDoubleClick={() => submitNode(index)}
|
||||
onDisclosureClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
toggleCollapse(index);
|
||||
@@ -387,7 +396,7 @@ function DocumentExplorer({
|
||||
}
|
||||
case "Enter": {
|
||||
if (isModKey(ev)) {
|
||||
onSubmit();
|
||||
onSubmit(selectedNode);
|
||||
} else {
|
||||
toggleSelect(activeNode);
|
||||
}
|
||||
|
||||
@@ -29,8 +29,10 @@ type Props = {
|
||||
onDisclosureClick: (ev: React.MouseEvent) => void;
|
||||
/** Fired on pointer movement over the node; used to update the active highlight. */
|
||||
onPointerMove: (ev: React.MouseEvent) => void;
|
||||
/** Fired when the node is clicked to toggle its selection. */
|
||||
/** Fired when the node is clicked to select it. */
|
||||
onClick: (ev: React.MouseEvent) => void;
|
||||
/** Fired when the node is double-clicked to submit the current selection. */
|
||||
onDoubleClick: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
function DocumentExplorerNode(
|
||||
@@ -46,6 +48,7 @@ function DocumentExplorerNode(
|
||||
onDisclosureClick,
|
||||
onPointerMove,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLSpanElement>
|
||||
) {
|
||||
@@ -59,6 +62,7 @@ function DocumentExplorerNode(
|
||||
selected={selected}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
|
||||
@@ -17,6 +17,7 @@ type Props = {
|
||||
|
||||
onPointerMove: (ev: React.MouseEvent) => void;
|
||||
onClick: (ev: React.MouseEvent) => void;
|
||||
onDoubleClick: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
function DocumentExplorerSearchResult({
|
||||
@@ -28,6 +29,7 @@ function DocumentExplorerSearchResult({
|
||||
path,
|
||||
onPointerMove,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -36,6 +38,7 @@ function DocumentExplorerSearchResult({
|
||||
selected={selected}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
|
||||
@@ -56,17 +56,17 @@ function DocumentMove({ document }: Props) {
|
||||
return nodes;
|
||||
}, [policies, collectionTrees, document.id]);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
const move = async (path = selectedPath) => {
|
||||
if (!path) {
|
||||
toast.message(t("Select a location to move"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setMoving(true);
|
||||
const { type, id: parentDocumentId } = selectedPath;
|
||||
const { type, id: parentDocumentId } = path;
|
||||
|
||||
const collectionId = selectedPath.collectionId as string;
|
||||
const collectionId = path.collectionId as string;
|
||||
|
||||
if (type === "document") {
|
||||
await document.move({ collectionId, parentDocumentId });
|
||||
@@ -103,7 +103,7 @@ function DocumentMove({ document }: Props) {
|
||||
t("Select a location to move")
|
||||
)}
|
||||
</Text>
|
||||
<Button disabled={!selectedPath || moving} onClick={move}>
|
||||
<Button disabled={!selectedPath || moving} onClick={() => move()}>
|
||||
{moving ? `${t("Moving")}…` : t("Move")}
|
||||
</Button>
|
||||
</Footer>
|
||||
|
||||
@@ -33,15 +33,14 @@ function TemplateMove({ template }: Props) {
|
||||
[policies, collectionTrees]
|
||||
);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
const move = async (path = selectedPath) => {
|
||||
if (!path) {
|
||||
toast.message(t("Select a location to move"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const collectionId = (selectedPath.collectionId ??
|
||||
selectedPath.id) as string;
|
||||
const collectionId = (path.collectionId ?? path.id) as string;
|
||||
await template.save({ collectionId });
|
||||
|
||||
toast.success(t("Template moved"));
|
||||
@@ -76,7 +75,7 @@ function TemplateMove({ template }: Props) {
|
||||
t("Select a location to move")
|
||||
)}
|
||||
</Text>
|
||||
<Button disabled={!selectedPath} onClick={move}>
|
||||
<Button disabled={!selectedPath} onClick={() => move()}>
|
||||
{t("Move")}
|
||||
</Button>
|
||||
</Footer>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
@@ -27,6 +28,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
import { useDragDocument } from "./Sidebar/hooks/useDragAndDrop";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { ContextMenu } from "./Menu/ContextMenu";
|
||||
@@ -98,6 +100,23 @@ function DocumentListItem(
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
|
||||
|
||||
const [{ isDragging }, draggableRef] = useDragDocument(
|
||||
document.asNavigationNode,
|
||||
0,
|
||||
document,
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
const mergedRef = React.useMemo(
|
||||
() =>
|
||||
mergeRefs<HTMLAnchorElement>([
|
||||
itemRef,
|
||||
draggableRef,
|
||||
] as React.Ref<HTMLAnchorElement>[]),
|
||||
[itemRef, draggableRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
@@ -114,9 +133,10 @@ function DocumentListItem(
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
ref={mergedRef}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
$isDragging={isDragging}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
@@ -227,6 +247,7 @@ const Actions = styled(EventBoundary)`
|
||||
|
||||
const DocumentLink = styled(Link)<{
|
||||
$isStarred?: boolean;
|
||||
$isDragging?: boolean;
|
||||
$menuOpen?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
@@ -237,6 +258,8 @@ const DocumentLink = styled(Link)<{
|
||||
max-height: 50vh;
|
||||
width: calc(100vw - 8px);
|
||||
cursor: var(--pointer);
|
||||
transition: opacity 250ms ease;
|
||||
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { BackendFactory } from "dnd-core";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
|
||||
/**
|
||||
* react-dnd's HTML5 backend installs global capture-phase listeners on `window`
|
||||
* that call `preventDefault()` on drops whose dataTransfer resembles a native
|
||||
* item – including a dragged `<img>`, which is how ProseMirror serializes an
|
||||
* image drag.
|
||||
*
|
||||
* These handlers run before ProseMirror's, and they live on `window`, so a
|
||||
* propagation-based guard can't stop react-dnd without also starving the editor
|
||||
* of the event. Instead we wrap the backend and make its top-level capture
|
||||
* handlers no-op for events that occur within the editor surface.
|
||||
*/
|
||||
const captureHandlerNames = [
|
||||
"handleTopDragStartCapture",
|
||||
"handleTopDragEnterCapture",
|
||||
"handleTopDragOverCapture",
|
||||
"handleTopDragLeaveCapture",
|
||||
"handleTopDropCapture",
|
||||
"handleTopDragEndCapture",
|
||||
] as const;
|
||||
|
||||
const isWithinEditor = (target: EventTarget | null): boolean =>
|
||||
target instanceof Element && Boolean(target.closest(".ProseMirror"));
|
||||
|
||||
/**
|
||||
* An HTML5 drag-and-drop backend that ignores drag events originating within the
|
||||
* rich text editor so that ProseMirror can handle them itself.
|
||||
*
|
||||
* @param manager The drag-and-drop manager.
|
||||
* @param context The global context.
|
||||
* @param options Backend options.
|
||||
* @returns The wrapped HTML5 backend instance.
|
||||
*/
|
||||
export const EditorAwareHTML5Backend: BackendFactory = (
|
||||
manager,
|
||||
context,
|
||||
options
|
||||
) => {
|
||||
const backend = HTML5Backend(manager, context, options);
|
||||
|
||||
// The capture handlers are private instance fields on the backend, so reach
|
||||
// for them through an index signature view of the instance.
|
||||
const handlers = backend as unknown as Record<
|
||||
string,
|
||||
(event: DragEvent) => void
|
||||
>;
|
||||
|
||||
for (const name of captureHandlerNames) {
|
||||
const original = handlers[name];
|
||||
if (typeof original === "function") {
|
||||
handlers[name] = (event: DragEvent) => {
|
||||
if (isWithinEditor(event.target)) {
|
||||
return;
|
||||
}
|
||||
original.call(backend, event);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return backend;
|
||||
};
|
||||
@@ -1,16 +1,26 @@
|
||||
import { deburr } from "es-toolkit/compat";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { FetchPageParams } from "~/stores/base/Store";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import Input, { NativeInput, Outline } from "./Input";
|
||||
import type { PaginatedItem } from "./PaginatedList";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./primitives/Drawer";
|
||||
import { MenuProvider } from "./primitives/Menu/MenuContext";
|
||||
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
|
||||
import * as MenuComponents from "./primitives/components/Menu";
|
||||
import { MenuIconWrapper } from "./primitives/components/Menu";
|
||||
|
||||
interface TFilterOption extends PaginatedItem {
|
||||
@@ -34,7 +44,7 @@ type Props = {
|
||||
|
||||
const FilterOptions = ({
|
||||
options,
|
||||
selectedKeys = [],
|
||||
selectedKeys,
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
@@ -45,6 +55,7 @@ const FilterOptions = ({
|
||||
...rest
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -58,23 +69,45 @@ const FilterOptions = ({
|
||||
: "";
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(option) => (
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={
|
||||
option.icon && showIcons ? (
|
||||
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
|
||||
) : undefined
|
||||
}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
setOpen(false);
|
||||
}}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
/>
|
||||
),
|
||||
[onSelect, showIcons, selectedKeys]
|
||||
(option) => {
|
||||
const handleClick = () => {
|
||||
onSelect(option.key);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const icon =
|
||||
option.icon && showIcons ? (
|
||||
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
// On mobile the options render inside a Drawer (bottom sheet) rather than
|
||||
// a Radix dropdown menu, so use the raw menu components directly instead
|
||||
// of the dropdown-bound MenuButton which expects a menu root context.
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MenuComponents.MenuButton key={option.key} onClick={handleClick}>
|
||||
{icon}
|
||||
<MenuComponents.MenuLabel>{option.label}</MenuComponents.MenuLabel>
|
||||
<MenuComponents.SelectedIconWrapper aria-hidden>
|
||||
{selectedKeys.includes(option.key) ? (
|
||||
<CheckmarkIcon size={18} />
|
||||
) : null}
|
||||
</MenuComponents.SelectedIconWrapper>
|
||||
</MenuComponents.MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={icon}
|
||||
label={option.label}
|
||||
onClick={handleClick}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[onSelect, showIcons, selectedKeys, isMobile]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
@@ -169,39 +202,73 @@ const FilterOptions = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
searchInputRef.current?.focus();
|
||||
// Avoid auto-focusing on mobile as it immediately pops the on-screen
|
||||
// keyboard over the drawer.
|
||||
if (!isMobile) {
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
} else {
|
||||
setQuery("");
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, isMobile]);
|
||||
|
||||
const showFilterInput = showFilter || options.length > 10;
|
||||
const defaultLabel = rest.defaultLabel || t("Filter options");
|
||||
|
||||
const trigger = (
|
||||
<StyledButton
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
disclosure={disclosure}
|
||||
neutral
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
);
|
||||
|
||||
const list = (
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput && !isMobile ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
);
|
||||
|
||||
// On mobile render the options inside a Drawer (bottom sheet) to match the
|
||||
// popover style used by context menus across the app.
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent aria-label={defaultLabel} aria-describedby={undefined}>
|
||||
<DrawerTitle>{defaultLabel}</DrawerTitle>
|
||||
{showFilterInput && (
|
||||
<MobileSearchInput
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
margin={0}
|
||||
/>
|
||||
)}
|
||||
<StyledScrollable hiddenScrollbars>{list}</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu open={open} onOpenChange={setOpen}>
|
||||
<MenuTrigger>
|
||||
<StyledButton
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
disclosure={disclosure}
|
||||
neutral
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
</MenuTrigger>
|
||||
<MenuTrigger>{trigger}</MenuTrigger>
|
||||
<MenuContent aria-label={defaultLabel} align="start">
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
{list}
|
||||
{showFilterInput && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
@@ -260,6 +327,22 @@ const SearchInput = styled(Input)`
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileSearchInput = styled(Input)`
|
||||
/* "none" keeps an auto basis so the input retains its natural height; a
|
||||
flexible/0% basis would collapse it and overlap the list below. */
|
||||
flex: none;
|
||||
margin: 0 6px 6px;
|
||||
|
||||
${NativeInput} {
|
||||
/* 16px avoids iOS zooming the viewport when the input is focused. */
|
||||
font-size: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export const StyledButton = styled(Button)`
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
|
||||
@@ -107,7 +107,6 @@ const IconPicker = ({
|
||||
|
||||
const handleIconChange = React.useCallback(
|
||||
(ic: string) => {
|
||||
setOpen(false);
|
||||
const icType = determineIconType(ic);
|
||||
const finalColor = icType === IconType.SVG ? chosenColor : null;
|
||||
onChange(ic, finalColor);
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import {
|
||||
isModKey,
|
||||
@@ -117,6 +118,12 @@ function InputSearchPage({
|
||||
|
||||
const InputMaxWidth = styled(Input).attrs({ round: true })`
|
||||
max-width: min(calc(30vw + 20px), 100%);
|
||||
|
||||
/* On mobile the input grows to fill the header, so add a gap before the
|
||||
* adjacent action button (e.g. "New doc"). */
|
||||
${breakpoint("mobile", "tablet")`
|
||||
margin-inline-end: 8px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Shortcut = styled.span<{ $visible: boolean }>`
|
||||
|
||||
+173
-11
@@ -2,7 +2,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 type {
|
||||
ComponentProps,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
SyntheticEvent,
|
||||
} from "react";
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
@@ -18,6 +23,7 @@ import { Error as ImageError } from "@shared/editor/components/Image";
|
||||
import {
|
||||
BackIcon,
|
||||
CloseIcon,
|
||||
CommentIcon,
|
||||
CrossIcon,
|
||||
DownloadIcon,
|
||||
LinkIcon,
|
||||
@@ -55,6 +61,9 @@ import { NodeSelection } from "prosemirror-state";
|
||||
import { ImageSource } from "@shared/editor/lib/FileHelper";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { HStack } from "./primitives/HStack";
|
||||
import { useDocumentContext } from "./DocumentContext";
|
||||
import LightboxComments from "~/scenes/Document/components/Comments/LightboxComments";
|
||||
import { PortalContext } from "./Portal";
|
||||
|
||||
export enum LightboxStatus {
|
||||
READY_TO_OPEN,
|
||||
@@ -88,6 +97,15 @@ type Animation = {
|
||||
|
||||
const ANIMATION_DURATION = 0.3 * Second.ms;
|
||||
|
||||
/**
|
||||
* Stops a React synthetic event from propagating to ancestor handlers, including
|
||||
* Radix Dialog's outside-interaction detection and the editor's own click
|
||||
* handlers, so the comments sidebar can manage its own focus.
|
||||
*/
|
||||
const stopPropagation = (event: SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
type Props = {
|
||||
/** List of allowed images */
|
||||
images: LightboxImage[];
|
||||
@@ -225,6 +243,11 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
const [commentsRendered, setCommentsRendered] = useState(false);
|
||||
const [commentsVisible, setCommentsVisible] = useState(false);
|
||||
const [commentsPortalEl, setCommentsPortalEl] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
const animation = useRef<Animation | null>(null);
|
||||
const finalImage = useRef<{
|
||||
center: { x: number; y: number };
|
||||
@@ -233,6 +256,10 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
} | null>(null);
|
||||
const zoomPanPinchRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
const editor = useEditor();
|
||||
const { document: contextDocument } = useDocumentContext();
|
||||
const activeNode = editor?.view?.state?.doc?.nodeAt(activeImage.pos);
|
||||
const canShowComments =
|
||||
!!contextDocument && activeNode?.type.name === "image";
|
||||
|
||||
const currentImageIndex = findIndex(
|
||||
images,
|
||||
@@ -312,6 +339,19 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
}
|
||||
}, [status.lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (commentsOpen) {
|
||||
setCommentsRendered(true);
|
||||
const frame = window.requestAnimationFrame(() =>
|
||||
setCommentsVisible(true)
|
||||
);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}
|
||||
setCommentsVisible(false);
|
||||
const timer = window.setTimeout(() => setCommentsRendered(false), 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [commentsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.image === ImageStatus.MIN_ZOOM) {
|
||||
// It was observed that focus went to `body` as the zoom out button was disabled
|
||||
@@ -441,6 +481,10 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
)
|
||||
) {
|
||||
// Refresh the cached natural image position to account for any layout
|
||||
// changes (e.g., the comments sidebar opening) since the image loaded.
|
||||
rememberImagePosition();
|
||||
|
||||
// in lightbox
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
const {
|
||||
@@ -632,17 +676,30 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
}, [activeImage, status.lightbox]);
|
||||
|
||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
ev.preventDefault();
|
||||
// Don't intercept keys while typing into an input, textarea, or editor.
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
target !== ev.currentTarget &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
switch (ev.key) {
|
||||
case "ArrowLeft": {
|
||||
ev.preventDefault();
|
||||
prev();
|
||||
break;
|
||||
}
|
||||
case "ArrowRight": {
|
||||
ev.preventDefault();
|
||||
next();
|
||||
break;
|
||||
}
|
||||
case "Escape": {
|
||||
ev.preventDefault();
|
||||
close();
|
||||
break;
|
||||
}
|
||||
@@ -698,14 +755,21 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
onAnimationStart={handleFadeStart}
|
||||
onAnimationEnd={handleFadeEnd}
|
||||
/>
|
||||
<StyledContent onKeyDown={handleKeyDown} ref={contentRef}>
|
||||
<StyledContent
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={contentRef}
|
||||
$commentsOpen={canShowComments && commentsOpen}
|
||||
>
|
||||
<VisuallyHidden.Root>
|
||||
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{t("View, navigate, or download images in the document")}
|
||||
</Dialog.Description>
|
||||
</VisuallyHidden.Root>
|
||||
<Actions animation={animation.current}>
|
||||
<Actions
|
||||
animation={animation.current}
|
||||
$commentsOpen={canShowComments && commentsOpen}
|
||||
>
|
||||
<Tooltip content={t("Zoom in")} placement="bottom">
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
@@ -788,7 +852,22 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Separator />
|
||||
{canShowComments && (
|
||||
<Tooltip content={t("Comments")} placement="bottom">
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
onClick={() => setCommentsOpen((open) => !open)}
|
||||
aria-label={t("Comments")}
|
||||
aria-pressed={commentsOpen}
|
||||
size={32}
|
||||
icon={<CommentIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Actions>
|
||||
<CloseAction animation={animation.current}>
|
||||
<Dialog.Close asChild>
|
||||
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
|
||||
<ActionButton
|
||||
@@ -802,7 +881,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dialog.Close>
|
||||
</Actions>
|
||||
</CloseAction>
|
||||
{currentImageIndex > 0 &&
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
@@ -878,12 +957,36 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
) && (
|
||||
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
|
||||
<Nav
|
||||
dir="right"
|
||||
$hidden={isIdle}
|
||||
animation={animation.current}
|
||||
$commentsOpen={canShowComments && commentsOpen}
|
||||
>
|
||||
<NavButton onClick={next} size={32} aria-label={t("Next")}>
|
||||
<NextIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
{canShowComments && commentsRendered && contextDocument && (
|
||||
<CommentsSidebar
|
||||
ref={setCommentsPortalEl}
|
||||
animation={animation.current}
|
||||
$open={commentsVisible}
|
||||
onPointerDown={stopPropagation}
|
||||
onPointerUp={stopPropagation}
|
||||
onMouseDown={stopPropagation}
|
||||
onMouseUp={stopPropagation}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<PortalContext.Provider value={commentsPortalEl}>
|
||||
<LightboxComments
|
||||
document={contextDocument}
|
||||
pos={activeImage.pos}
|
||||
/>
|
||||
</PortalContext.Provider>
|
||||
</CommentsSidebar>
|
||||
)}
|
||||
</StyledContent>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -1090,7 +1193,7 @@ const StyledImg = styled.img<{
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(Dialog.Content)`
|
||||
const StyledContent = styled(Dialog.Content)<{ $commentsOpen: boolean }>`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: ${depths.modal};
|
||||
@@ -1098,6 +1201,8 @@ const StyledContent = styled(Dialog.Content)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
padding-inline-end: ${(props) => (props.$commentsOpen ? "360px" : "0")};
|
||||
transition: padding-inline-end 200ms ease-out;
|
||||
`;
|
||||
|
||||
const ActionButton = styled(Button)`
|
||||
@@ -1106,15 +1211,45 @@ const ActionButton = styled(Button)`
|
||||
|
||||
const Actions = styled(HStack)<{
|
||||
animation: Animation | null;
|
||||
$commentsOpen: boolean;
|
||||
}>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
right: ${(props) => (props.$commentsOpen ? "360px" : "44px")};
|
||||
margin: 16px 12px;
|
||||
z-index: ${depths.modal};
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: 6px;
|
||||
transition: right 200ms ease-out;
|
||||
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
? css`
|
||||
opacity: 0;
|
||||
`
|
||||
: props.animation.fadeIn
|
||||
? css`
|
||||
animation: ${props.animation.fadeIn.apply()}
|
||||
${props.animation.fadeIn.duration}ms;
|
||||
`
|
||||
: props.animation.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const CloseAction = styled.div<{ animation: Animation | null }>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: 16px 12px;
|
||||
z-index: ${depths.modal + 1};
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: 6px;
|
||||
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
@@ -1138,10 +1273,16 @@ const Nav = styled.div<{
|
||||
$hidden: boolean;
|
||||
dir: "left" | "right";
|
||||
animation: Animation | null;
|
||||
$commentsOpen?: boolean;
|
||||
}>`
|
||||
position: absolute;
|
||||
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
|
||||
transition: opacity 500ms ease-in-out;
|
||||
${(props) =>
|
||||
props.dir === "left"
|
||||
? "left: 0;"
|
||||
: `right: ${props.$commentsOpen ? "360px" : "0"};`}
|
||||
transition:
|
||||
opacity 500ms ease-in-out,
|
||||
right 200ms ease-out;
|
||||
z-index: ${depths.modal};
|
||||
${(props) => props.$hidden && "opacity: 0;"}
|
||||
${(props) =>
|
||||
@@ -1183,6 +1324,27 @@ const StyledError = styled(ImageError)<{
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const CommentsSidebar = styled.div<{
|
||||
animation: Animation | null;
|
||||
$open: boolean;
|
||||
}>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
transform: translateX(${(props) => (props.$open ? "0" : "100%")});
|
||||
transition: transform 200ms ease-out;
|
||||
${(props) =>
|
||||
props.animation?.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const NavButton = styled(NudeButton)`
|
||||
margin: 16px;
|
||||
opacity: 0.75;
|
||||
|
||||
@@ -33,6 +33,12 @@ type Props = {
|
||||
align?: "start" | "end";
|
||||
/** ARIA label for the menu */
|
||||
ariaLabel: string;
|
||||
/**
|
||||
* Whether the menu should lock page scroll and trap focus while open.
|
||||
* Defaults to true. Set to false to avoid the scrollbar-removal layout
|
||||
* shift when the menu lives inside a scrollable container.
|
||||
*/
|
||||
modal?: boolean;
|
||||
/** Additional component to display at the bottom of the top-level menu */
|
||||
append?: React.ReactNode;
|
||||
/** Callback when menu is opened */
|
||||
@@ -50,6 +56,7 @@ export const DropdownMenu = observer(
|
||||
children,
|
||||
align = "start",
|
||||
ariaLabel,
|
||||
modal = true,
|
||||
append,
|
||||
onOpen,
|
||||
onClose,
|
||||
@@ -116,7 +123,7 @@ export const DropdownMenu = observer(
|
||||
|
||||
return (
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu open={open} onOpenChange={handleOpenChange}>
|
||||
<Menu open={open} onOpenChange={handleOpenChange} modal={modal}>
|
||||
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
</MenuTrigger>
|
||||
|
||||
@@ -6,15 +6,30 @@ import { s } from "@shared/styles";
|
||||
type Props = React.ComponentProps<typeof OneTimePasswordRoot> & {
|
||||
/** The length of the OTP */
|
||||
length?: number;
|
||||
/**
|
||||
* Whether to accept uppercase letters in addition to digits. Lowercase input
|
||||
* is normalized to uppercase. Defaults to numeric only.
|
||||
*/
|
||||
alphanumeric?: boolean;
|
||||
};
|
||||
|
||||
const sanitizeAlphanumeric = (value: string) =>
|
||||
value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
|
||||
|
||||
export const OneTimePasswordInput = React.forwardRef(
|
||||
function OneTimePasswordInput_(
|
||||
{ length = 6, ...rest }: Props,
|
||||
{ length = 6, alphanumeric, ...rest }: Props,
|
||||
ref: React.RefObject<HTMLInputElement>
|
||||
) {
|
||||
const alphanumericProps = alphanumeric
|
||||
? {
|
||||
validationType: "none" as const,
|
||||
sanitizeValue: sanitizeAlphanumeric,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<OneTimePasswordRoot {...rest}>
|
||||
<OneTimePasswordRoot {...alphanumericProps} {...rest}>
|
||||
{Array.from({ length }, (_, i) => (
|
||||
<OneTimePasswordInputField key={i} />
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
DragActiveProvider,
|
||||
SidebarScrollProvider,
|
||||
} from "./components/DragActiveContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
@@ -58,68 +60,69 @@ function AppSidebar() {
|
||||
}
|
||||
}, [documents, collections, user.isViewer]);
|
||||
|
||||
const [dndArea, setDndArea] = useState();
|
||||
const handleSidebarRef = useCallback((node) => setDndArea(node), []);
|
||||
const html5Options = useMemo(
|
||||
() => ({
|
||||
rootElement: dndArea,
|
||||
}),
|
||||
[dndArea]
|
||||
);
|
||||
// Scrollable reads ref.current internally for its shadow/ResizeObserver
|
||||
// logic, so we must pass an object ref — a callback ref would leave those
|
||||
// reads undefined. We mirror the attached node into state so the
|
||||
// SidebarScrollProvider can re-render descendants with the scroll element.
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollArea, setScrollArea] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
setScrollArea(scrollRef.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sidebar hidden={!ui.readyToShow} ref={handleSidebarRef}>
|
||||
{dndArea && (
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
<DragPlaceholder />
|
||||
<Sidebar hidden={!ui.readyToShow}>
|
||||
<DragActiveProvider>
|
||||
<DragPlaceholder />
|
||||
|
||||
<TeamMenu>
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={24} alt={t("Logo")} />}
|
||||
>
|
||||
{isMobile ? null : (
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed
|
||||
? t("Expand sidebar")
|
||||
: t("Collapse sidebar")
|
||||
}
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</SidebarButton>
|
||||
</TeamMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
onClick={handleSearchClick}
|
||||
/>
|
||||
{can.createDocument && <DraftsLink />}
|
||||
</Section>
|
||||
</Overflow>
|
||||
<Scrollable flex shadow>
|
||||
<TeamMenu>
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={24} alt={t("Logo")} />}
|
||||
>
|
||||
{isMobile ? null : (
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed
|
||||
? t("Expand sidebar")
|
||||
: t("Collapse sidebar")
|
||||
}
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</SidebarButton>
|
||||
</TeamMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
onClick={handleSearchClick}
|
||||
/>
|
||||
{can.createDocument && <DraftsLink />}
|
||||
</Section>
|
||||
</Overflow>
|
||||
<Scrollable flex shadow ref={scrollRef}>
|
||||
<SidebarScrollProvider value={scrollArea}>
|
||||
<Section>
|
||||
<Starred />
|
||||
</Section>
|
||||
@@ -138,9 +141,9 @@ function AppSidebar() {
|
||||
{can.createDocument && <TrashLink />}
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
</DndProvider>
|
||||
)}
|
||||
</SidebarScrollProvider>
|
||||
</Scrollable>
|
||||
</DragActiveProvider>
|
||||
<HistoryNavigation />
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import { groupBy } from "es-toolkit/compat";
|
||||
import { observer } from "mobx-react";
|
||||
import { BackIcon, SidebarIcon } from "outline-icons";
|
||||
import { BackIcon } from "outline-icons";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import Version from "./components/Version";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
|
||||
function SettingsSidebar() {
|
||||
const { ui, integrations } = useStores();
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const configs = useSettingsConfig();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const groupedConfig = groupBy(
|
||||
configs.filter((item) =>
|
||||
@@ -45,31 +40,12 @@ function SettingsSidebar() {
|
||||
}, [history]);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<Sidebar canCollapse={false}>
|
||||
<SidebarButton
|
||||
title={t("Return to App")}
|
||||
image={<StyledBackIcon />}
|
||||
onClick={returnToApp}
|
||||
>
|
||||
{isMobile ? null : (
|
||||
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
|
||||
<ToggleButton
|
||||
aria-label={
|
||||
ui.sidebarCollapsed
|
||||
? t("Expand sidebar")
|
||||
: t("Collapse sidebar")
|
||||
}
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</SidebarButton>
|
||||
/>
|
||||
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
@@ -63,6 +64,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
const [hasPointerMoved, setPointerMoved] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const internalRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const mergedRef = React.useMemo(() => mergeRefs([internalRef, ref]), [ref]);
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
@@ -174,6 +177,31 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
}
|
||||
}, [ui.sidebarIsClosed]);
|
||||
|
||||
// Reset stale hover state when the sidebar becomes visible after being
|
||||
// hidden via display:none (e.g. returning from settings). Without this, a
|
||||
// pointer-leave event never fires when navigating away while hovering, so
|
||||
// isHovering stays true and the sidebar appears expanded until the cursor
|
||||
// re-enters and leaves.
|
||||
React.useEffect(() => {
|
||||
const el = internalRef.current;
|
||||
if (!el || typeof IntersectionObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
let wasVisible = false;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const nowVisible = entry.isIntersecting;
|
||||
if (nowVisible && !wasVisible) {
|
||||
setHovering(false);
|
||||
setPointerMoved(false);
|
||||
}
|
||||
wasVisible = nowVisible;
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), ANIMATION_MS);
|
||||
@@ -237,7 +265,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
<TooltipProvider>
|
||||
<Container
|
||||
id="sidebar"
|
||||
ref={ref}
|
||||
ref={mergedRef}
|
||||
style={style}
|
||||
$hidden={hidden}
|
||||
$isHovering={isHovering}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { DocumentPermission, UserPreference } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import type Collection from "~/models/Collection";
|
||||
@@ -16,6 +17,7 @@ import type { RefHandle } from "~/components/EditableTitle";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import useOnScreen from "~/hooks/useOnScreen";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
useDropToReorderDocument,
|
||||
useDropToReparentDocument,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useIsDragActive, useSidebarScrollElement } from "./DragActiveContext";
|
||||
import { useSidebarExpansion } from "./SidebarExpansionContext";
|
||||
import DocumentRow from "./DocumentRow";
|
||||
import DropCursor from "./DropCursor";
|
||||
@@ -44,34 +47,28 @@ type Props = {
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
const DocumentLink = observer(function DocumentLinkInner({
|
||||
node,
|
||||
collection,
|
||||
membership,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
isDraft,
|
||||
depth,
|
||||
index,
|
||||
parentId,
|
||||
}: Props) {
|
||||
// Approximate rendered row height; used to reserve space for unmounted rows so
|
||||
// the scroll container stays the right height and IntersectionObserver triggers
|
||||
// correctly as the user scrolls.
|
||||
const ROW_HEIGHT = 30;
|
||||
|
||||
// Pre-mount rows just outside the viewport so scrolling stays smooth and drop
|
||||
// targets exist a screen ahead when a drag starts.
|
||||
const ROOT_MARGIN = "300px 0px";
|
||||
|
||||
const DocumentLink = observer(function DocumentLink(props: Props) {
|
||||
const { node, collection, activeDocument } = props;
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const can = usePolicy(node.id);
|
||||
const canUpdate = can.update;
|
||||
const expansion = useSidebarExpansion();
|
||||
const expanded = expansion.isExpanded(node.id);
|
||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||
const hasChildDocuments =
|
||||
!!node.children.length || activeDocument?.parentDocumentId === node.id;
|
||||
const document = documents.get(node.id);
|
||||
const { fetchChildDocuments } = documents;
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
const sidebarContext = useSidebarContext();
|
||||
const user = useCurrentUser();
|
||||
const expansion = useSidebarExpansion();
|
||||
const expanded = expansion.isExpanded(node.id);
|
||||
const { fetchChildDocuments } = documents;
|
||||
|
||||
// Keep expansion/data effects on the outer so they run regardless of whether
|
||||
// the heavy row content is currently mounted.
|
||||
React.useEffect(() => {
|
||||
if (expanded && !hasChildDocuments) {
|
||||
expansion.collapse(node.id);
|
||||
@@ -93,6 +90,121 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
isActiveDocument,
|
||||
]);
|
||||
|
||||
const insertDraftChild = !!(
|
||||
activeDocument?.isDraft &&
|
||||
activeDocument?.isActive &&
|
||||
activeDocument?.parentDocumentId === node.id
|
||||
);
|
||||
|
||||
const draftNavNode = insertDraftChild
|
||||
? activeDocument?.asNavigationNode
|
||||
: undefined;
|
||||
|
||||
const nodeChildren = React.useMemo(
|
||||
() =>
|
||||
collection && draftNavNode
|
||||
? sortNavigationNodes(
|
||||
[draftNavNode, ...node.children],
|
||||
collection.sort,
|
||||
false
|
||||
)
|
||||
: node.children,
|
||||
[draftNavNode, collection, node.children]
|
||||
);
|
||||
|
||||
// Visibility gate: only mount the heavy inner content when scrolled near the
|
||||
// viewport, but keep it mounted while a drag is in progress so the dragged
|
||||
// source (or a drop target the user is heading toward) isn't yanked.
|
||||
const scrollRoot = useSidebarScrollElement();
|
||||
const placeholderRef = React.useRef<HTMLDivElement>(null);
|
||||
const observerOptions = React.useMemo(
|
||||
() => ({ root: scrollRoot, rootMargin: ROOT_MARGIN }),
|
||||
[scrollRoot]
|
||||
);
|
||||
const isOnScreen = useOnScreen(placeholderRef, observerOptions);
|
||||
const isDragActive = useIsDragActive();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
// Flip mount state during render (not in an effect) so the first paint
|
||||
// already contains the row content when the placeholder is on screen,
|
||||
// avoiding a blank frame.
|
||||
if (isOnScreen && !mounted) {
|
||||
setMounted(true);
|
||||
} else if (!isOnScreen && !isDragActive && mounted) {
|
||||
setMounted(false);
|
||||
}
|
||||
|
||||
// The inner row's own scrollIntoView only fires while it is mounted, which
|
||||
// skips active documents that are virtualized off-screen
|
||||
React.useLayoutEffect(() => {
|
||||
if (
|
||||
isActiveDocument &&
|
||||
sidebarContext === "collections" &&
|
||||
placeholderRef.current
|
||||
) {
|
||||
scrollIntoView(placeholderRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
boundary: (parent) => parent.id !== "sidebar",
|
||||
});
|
||||
}
|
||||
}, [isActiveDocument, sidebarContext]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={placeholderRef} style={{ minHeight: ROW_HEIGHT }}>
|
||||
{mounted ? (
|
||||
<DocumentLinkInner {...props} hasChildren={nodeChildren.length > 0} />
|
||||
) : null}
|
||||
</div>
|
||||
<Folder expanded={expanded}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
membership={props.membership}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={props.prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={props.depth + 1}
|
||||
index={childIndex}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
type InnerProps = Props & {
|
||||
hasChildren: boolean;
|
||||
};
|
||||
|
||||
const DocumentLinkInner = observer(function DocumentLinkInner({
|
||||
node,
|
||||
collection,
|
||||
membership,
|
||||
prefetchDocument,
|
||||
isDraft,
|
||||
depth,
|
||||
index,
|
||||
parentId,
|
||||
hasChildren,
|
||||
}: InnerProps) {
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const can = usePolicy(node.id);
|
||||
const canUpdate = can.update;
|
||||
const document = documents.get(node.id);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
const sidebarContext = useSidebarContext();
|
||||
const user = useCurrentUser();
|
||||
const expansion = useSidebarExpansion();
|
||||
const expanded = expansion.isExpanded(node.id);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev?: React.MouseEvent<HTMLElement>) => {
|
||||
if (expanded) {
|
||||
@@ -192,14 +304,17 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] =
|
||||
useDropToReparentDocument(node, handleExpand, parentRef);
|
||||
|
||||
// Fall back so document-only access (e.g. "Manage" on a parent) can reorder.
|
||||
const moveCollectionId = collection?.id ?? document?.collectionId;
|
||||
|
||||
const [{ isOverReorder: isOverReorderAbove }, dropToReorderAbove] =
|
||||
useDropToReorderDocument(node, collection, (item) => {
|
||||
if (!collection) {
|
||||
if (!moveCollectionId) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
collectionId: moveCollectionId,
|
||||
parentDocumentId: parentId,
|
||||
index,
|
||||
};
|
||||
@@ -207,49 +322,26 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] =
|
||||
useDropToReorderDocument(node, collection, (item) => {
|
||||
if (!collection) {
|
||||
if (!moveCollectionId) {
|
||||
return;
|
||||
}
|
||||
if (expansion.isExpanded(node.id)) {
|
||||
return {
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
collectionId: moveCollectionId,
|
||||
parentDocumentId: node.id,
|
||||
index: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
collectionId: moveCollectionId,
|
||||
parentDocumentId: parentId,
|
||||
index: index + 1,
|
||||
};
|
||||
});
|
||||
|
||||
const insertDraftChild = !!(
|
||||
activeDocument?.isDraft &&
|
||||
activeDocument?.isActive &&
|
||||
activeDocument?.parentDocumentId === node.id
|
||||
);
|
||||
|
||||
const draftNavNode = insertDraftChild
|
||||
? activeDocument?.asNavigationNode
|
||||
: undefined;
|
||||
|
||||
const nodeChildren = React.useMemo(
|
||||
() =>
|
||||
collection && draftNavNode
|
||||
? sortNavigationNodes(
|
||||
[draftNavNode, ...node.children],
|
||||
collection.sort,
|
||||
false
|
||||
)
|
||||
: node.children,
|
||||
[draftNavNode, collection, node.children]
|
||||
);
|
||||
|
||||
const title = document?.title || node.title || t("Untitled");
|
||||
const hasChildren = nodeChildren.length > 0;
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input: string) => {
|
||||
@@ -300,8 +392,15 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
// Without a collection we can't read isManualSort; fall back to the shared
|
||||
// membership's permission, which is the same for every descendant.
|
||||
const canReorderHere = collection
|
||||
? collection.isManualSort
|
||||
: membership?.permission === DocumentPermission.Admin ||
|
||||
membership?.permission === DocumentPermission.ReadWrite;
|
||||
|
||||
const cursorBefore =
|
||||
isDraggingAnyDocument && collection?.isManualSort && index === 0 ? (
|
||||
isDraggingAnyDocument && canReorderHere && index === 0 ? (
|
||||
<DropCursor
|
||||
isActiveDrop={isOverReorderAbove}
|
||||
innerRef={dropToReorderAbove}
|
||||
@@ -310,7 +409,7 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
) : undefined;
|
||||
|
||||
const cursorAfter =
|
||||
isDraggingAnyDocument && collection?.isManualSort ? (
|
||||
isDraggingAnyDocument && canReorderHere ? (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
) : undefined;
|
||||
|
||||
@@ -321,7 +420,7 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
to={toPath}
|
||||
depth={depth}
|
||||
isDraft={isDraft}
|
||||
scrollIntoViewIfNeeded={sidebarContext === "collections"}
|
||||
scrollIntoViewIfNeeded={false}
|
||||
icon={iconElement}
|
||||
canEdit={canUpdate}
|
||||
labelText={title}
|
||||
@@ -348,24 +447,7 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
contextAction={contextMenuAction}
|
||||
isActiveOverride={isActiveCheck}
|
||||
onClickIntent={handlePrefetch}
|
||||
>
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={childIndex}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</DocumentRow>
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import { useDragLayer } from "react-dnd";
|
||||
|
||||
const DragActiveContext = React.createContext(false);
|
||||
|
||||
const SidebarScrollContext = React.createContext<HTMLElement | null>(null);
|
||||
|
||||
/**
|
||||
* Provides the sidebar's scroll container so descendants can use it as the
|
||||
* IntersectionObserver root when deciding whether to render heavy content.
|
||||
*/
|
||||
export const SidebarScrollProvider = SidebarScrollContext.Provider;
|
||||
|
||||
/**
|
||||
* Returns the sidebar scroll container element, or null if not within a
|
||||
* SidebarScrollProvider.
|
||||
*/
|
||||
export function useSidebarScrollElement(): HTMLElement | null {
|
||||
return React.useContext(SidebarScrollContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes once to react-dnd's drag state and exposes a boolean via context.
|
||||
*
|
||||
* Visibility-gated sidebar rows read this to keep their inner content mounted
|
||||
* for the duration of a drag, so that scrolling away from the dragged source
|
||||
* (or a drop target the user is heading toward) does not unmount it mid-drag.
|
||||
*/
|
||||
export function DragActiveProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const isDragging = useDragLayer((monitor) => monitor.isDragging());
|
||||
return (
|
||||
<DragActiveContext.Provider value={isDragging}>
|
||||
{children}
|
||||
</DragActiveContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether any react-dnd drag is currently active.
|
||||
*/
|
||||
export function useIsDragActive(): boolean {
|
||||
return React.useContext(DragActiveContext);
|
||||
}
|
||||
@@ -19,7 +19,8 @@ const layerStyles: React.CSSProperties = {
|
||||
function getItemStyles(
|
||||
initialOffset: XYCoord | null,
|
||||
currentOffset: XYCoord | null,
|
||||
sidebarWidth: number
|
||||
sidebarWidth: number,
|
||||
constrainToSidebar: boolean
|
||||
) {
|
||||
if (!initialOffset || !currentOffset) {
|
||||
return {
|
||||
@@ -27,10 +28,14 @@ function getItemStyles(
|
||||
};
|
||||
}
|
||||
const { y } = currentOffset;
|
||||
const x = Math.max(
|
||||
initialOffset.x,
|
||||
Math.min(initialOffset.x + sidebarWidth / 4, currentOffset.x)
|
||||
);
|
||||
// Sidebar drags keep the ghost tethered near its origin, but drags from
|
||||
// outside the sidebar should follow the cursor freely.
|
||||
const x = constrainToSidebar
|
||||
? Math.max(
|
||||
initialOffset.x,
|
||||
Math.min(initialOffset.x + sidebarWidth / 4, currentOffset.x)
|
||||
)
|
||||
: currentOffset.x;
|
||||
|
||||
const transform = `translate(${x}px, ${y}px)`;
|
||||
return {
|
||||
@@ -60,7 +65,14 @@ const DragPlaceholder = () => {
|
||||
|
||||
return (
|
||||
<div style={layerStyles}>
|
||||
<div style={getItemStyles(initialOffset, currentOffset, ui.sidebarWidth)}>
|
||||
<div
|
||||
style={getItemStyles(
|
||||
initialOffset,
|
||||
currentOffset,
|
||||
ui.sidebarWidth,
|
||||
item.constrainToSidebar !== false
|
||||
)}
|
||||
>
|
||||
<GhostLink
|
||||
icon={item.icon}
|
||||
label={item.title || t("Untitled")}
|
||||
|
||||
@@ -210,6 +210,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
isDraft={childNode.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
parentId={document.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
|
||||
@@ -11,7 +11,7 @@ import Desktop from "~/utils/Desktop";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
|
||||
export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
|
||||
position: "top" | "bottom";
|
||||
position?: "top" | "bottom";
|
||||
title: React.ReactNode;
|
||||
image: React.ReactNode;
|
||||
showMoreMenu?: boolean;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { observer } from "mobx-react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type Star from "~/models/Star";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -29,6 +31,7 @@ function Starred() {
|
||||
);
|
||||
const [reorderStarProps, dropToReorder] = useDropToReorderStar();
|
||||
const [createStarProps, dropToStarRef] = useDropToCreateStar();
|
||||
const [sectionStarProps, dropToSectionRef] = useDropToCreateStar();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -42,46 +45,64 @@ function Starred() {
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!loading && !end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
<Section
|
||||
ref={dropToSectionRef}
|
||||
$isActiveDrop={
|
||||
sectionStarProps.isDragging && sectionStarProps.isOverCursor
|
||||
}
|
||||
>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!loading && !end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
</Section>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const Section = styled.div<{ $isActiveDrop?: boolean }>`
|
||||
border-radius: 8px;
|
||||
transition: background 100ms ease-in-out;
|
||||
|
||||
${(props) =>
|
||||
props.$isActiveDrop &&
|
||||
css`
|
||||
background: ${s("sidebarActiveBackground")};
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(Starred);
|
||||
|
||||
@@ -22,6 +22,12 @@ import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
collectionId: string;
|
||||
/**
|
||||
* Whether the drag ghost should stay tethered to the sidebar. Defaults to
|
||||
* tethered when unset — the placeholder only lets the ghost follow the
|
||||
* cursor when this is explicitly `false` (e.g. drags from a document list).
|
||||
*/
|
||||
constrainToSidebar?: boolean;
|
||||
};
|
||||
|
||||
function useHover(
|
||||
@@ -105,6 +111,12 @@ export function useDropToCreateStar(getIndex?: () => string) {
|
||||
>({
|
||||
accept,
|
||||
drop: async (item, monitor) => {
|
||||
// A more specific drop target (e.g. a reorder cursor) has already
|
||||
// handled this drop, so avoid creating a duplicate star.
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = monitor.getItemType();
|
||||
let model;
|
||||
|
||||
@@ -122,7 +134,7 @@ export function useDropToCreateStar(getIndex?: () => string) {
|
||||
);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverCursor: !!monitor.isOver(),
|
||||
isOverCursor: !!monitor.isOver({ shallow: true }),
|
||||
isDragging: accept.includes(String(monitor.getItemType())),
|
||||
}),
|
||||
});
|
||||
@@ -163,12 +175,16 @@ export function useDropToReorderStar(getIndex?: () => string) {
|
||||
* @param depth The depth of the node in the sidebar.
|
||||
* @param document The related Document model.
|
||||
* @param isEditing Whether the sidebar item is currently being edited.
|
||||
* @param constrainToSidebar Whether the drag ghost should stay tethered to the
|
||||
* sidebar. Defaults to true; pass false when dragging from outside the sidebar
|
||||
* (e.g. a document list) so the ghost follows the cursor.
|
||||
*/
|
||||
export function useDragDocument(
|
||||
node: NavigationNode,
|
||||
depth: number,
|
||||
document?: Document,
|
||||
isEditing?: boolean
|
||||
isEditing?: boolean,
|
||||
constrainToSidebar = true
|
||||
) {
|
||||
const icon = document?.icon || node.icon || node.emoji;
|
||||
const color = document?.color || node.color;
|
||||
@@ -188,6 +204,7 @@ export function useDragDocument(
|
||||
<Icon initial={initial} value={icon} color={color} />
|
||||
) : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
constrainToSidebar,
|
||||
}) as DragObject,
|
||||
canDrag: () => !!document?.isActive && !isEditing,
|
||||
collect: (monitor) => ({
|
||||
|
||||
@@ -31,6 +31,26 @@ const Theme: React.FC = ({ children }: Props) => {
|
||||
);
|
||||
}, [ui.resolvedTheme]);
|
||||
|
||||
// Some editor elements such as Mermaid diagrams rely on theme-changed event
|
||||
// to render the correct color.
|
||||
// Listen on the print media query, which fires consistently for both the
|
||||
// print dialog and print preview.
|
||||
React.useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("print");
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("theme-changed", {
|
||||
detail: {
|
||||
isDark: event.matches ? false : ui.resolvedTheme === "dark",
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, [ui.resolvedTheme]);
|
||||
|
||||
return (
|
||||
<DirectionProvider dir={direction}>
|
||||
<ThemeProvider theme={theme}>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { UserValidation } from "@shared/validations";
|
||||
import type User from "~/models/User";
|
||||
import Button from "~/components/Button";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ImageInput from "~/scenes/Settings/components/ImageInput";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Text from "./Text";
|
||||
|
||||
@@ -71,7 +75,7 @@ export function UserDeleteDialog({ user, onSubmit }: Props) {
|
||||
danger
|
||||
>
|
||||
{t(
|
||||
"Are you sure you want to permanently delete {{ userName }}? This operation is unrecoverable, consider suspending the user instead.",
|
||||
"Are you sure you want to permanently delete {{ userName }}? This operation is unrecoverable. Any API keys, webhooks, and integrations they created will stop working — consider suspending the user instead.",
|
||||
{
|
||||
userName: user.name,
|
||||
}
|
||||
@@ -142,6 +146,48 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export const UserChangeAvatarDialog = observer(function UserChangeAvatarDialog({
|
||||
user,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleAvatarChange = async (avatarUrl: string | null) => {
|
||||
try {
|
||||
await user.save({ avatarUrl });
|
||||
toast.success(t("Profile picture updated"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarError = (error: string | null | undefined) => {
|
||||
toast.error(error || t("Unable to upload new profile picture"));
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex column gap={16}>
|
||||
<Flex justify="center">
|
||||
<ImageInput
|
||||
alt={t("Profile picture")}
|
||||
onSuccess={handleAvatarChange}
|
||||
onError={handleAvatarError}
|
||||
model={user}
|
||||
showRemoveOption={false}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex justify="flex-end" gap={8}>
|
||||
{user.avatarUrl && (
|
||||
<Button onClick={() => handleAvatarChange(null)} neutral>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSubmit}>{t("Done")}</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
export function UserChangeEmailDialog({ user, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const actor = useCurrentUser();
|
||||
|
||||
@@ -96,6 +96,10 @@ function useConnectionHandlers() {
|
||||
|
||||
toast.error(message);
|
||||
|
||||
if (message === "No access token") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
Sentry.captureException(err);
|
||||
} else {
|
||||
|
||||
@@ -102,6 +102,7 @@ const StyledContent = styled(m.div)`
|
||||
|
||||
const StyledInnerContent = styled(Flex)`
|
||||
padding: 6px;
|
||||
padding-bottom: calc(6px + var(--sab, 0px));
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
|
||||
@@ -108,6 +108,9 @@ export const MenuExternalLink = styled.a`
|
||||
|
||||
export const MenuSubTrigger = styled.div<BaseMenuItemProps>`
|
||||
${BaseMenuItemCSS}
|
||||
// Reserve space for the absolutely-positioned disclosure arrow so long
|
||||
// labels truncate before it rather than overlapping.
|
||||
padding-inline-end: 32px;
|
||||
`;
|
||||
|
||||
export const MenuSeparator = styled.hr`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { debounce } from "es-toolkit/compat";
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CaretUpIcon,
|
||||
@@ -211,9 +212,31 @@ export default function FindAndReplace({
|
||||
});
|
||||
}, [caseSensitive, editor.commands, searchTerm]);
|
||||
|
||||
// Searching the document on every keystroke is expensive in long documents –
|
||||
// it traverses the entire doc and rebuilds highlights – so debounce.
|
||||
const debouncedFind = React.useMemo(
|
||||
() =>
|
||||
debounce(
|
||||
(attrs: {
|
||||
text: string;
|
||||
caseSensitive: boolean;
|
||||
regexEnabled: boolean;
|
||||
}) => {
|
||||
editor.commands.find(attrs);
|
||||
},
|
||||
100
|
||||
),
|
||||
[editor.commands]
|
||||
);
|
||||
|
||||
React.useEffect(() => () => debouncedFind.cancel(), [debouncedFind]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
function nextPrevious() {
|
||||
// Ensure any pending debounced search has run so navigation acts on the
|
||||
// results for the text currently in the input.
|
||||
debouncedFind.flush();
|
||||
if (ev.shiftKey) {
|
||||
editor.commands.prevSearchMatch();
|
||||
} else {
|
||||
@@ -243,7 +266,7 @@ export default function FindAndReplace({
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor.commands, selectInputText]
|
||||
[debouncedFind, editor.commands, selectInputText]
|
||||
);
|
||||
|
||||
const handleReplace = React.useCallback(
|
||||
@@ -274,13 +297,13 @@ export default function FindAndReplace({
|
||||
ev.stopPropagation();
|
||||
setSearchTerm(ev.currentTarget.value);
|
||||
|
||||
editor.commands.find({
|
||||
debouncedFind({
|
||||
text: ev.currentTarget.value,
|
||||
caseSensitive,
|
||||
regexEnabled,
|
||||
});
|
||||
},
|
||||
[caseSensitive, editor.commands, regexEnabled]
|
||||
[caseSensitive, debouncedFind, regexEnabled]
|
||||
);
|
||||
|
||||
const handleReplaceKeyDown = React.useCallback(
|
||||
@@ -331,6 +354,9 @@ export default function FindAndReplace({
|
||||
} else {
|
||||
onClose();
|
||||
setShowReplace(false);
|
||||
// Cancel any pending debounced find so it can't reactivate highlights
|
||||
// after the search has been cleared.
|
||||
debouncedFind.cancel();
|
||||
editor.commands.clearSearch();
|
||||
}
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -346,7 +372,10 @@ export default function FindAndReplace({
|
||||
>
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.prevSearchMatch()}
|
||||
onClick={() => {
|
||||
debouncedFind.flush();
|
||||
editor.commands.prevSearchMatch();
|
||||
}}
|
||||
aria-label={t("Previous match")}
|
||||
>
|
||||
<CaretUpIcon />
|
||||
@@ -355,7 +384,10 @@ export default function FindAndReplace({
|
||||
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.nextSearchMatch()}
|
||||
onClick={() => {
|
||||
debouncedFind.flush();
|
||||
editor.commands.nextSearchMatch();
|
||||
}}
|
||||
aria-label={t("Next match")}
|
||||
>
|
||||
<CaretDownIcon />
|
||||
|
||||
@@ -420,11 +420,24 @@ const Wrapper = styled.div<WrapperProps>`
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
& button,
|
||||
& a,
|
||||
& input {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
${({ active }) =>
|
||||
active &&
|
||||
`
|
||||
transform: translateY(-6px) scale(1);
|
||||
opacity: 1;
|
||||
|
||||
& button,
|
||||
& a,
|
||||
& input {
|
||||
pointer-events: auto;
|
||||
transition: pointer-events 0s 300ms;
|
||||
}
|
||||
`};
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import { RemoveScroll } from "react-remove-scroll";
|
||||
import styled from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { collapseSelection } from "@shared/editor/commands/collapseSelection";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { toMenuItems, toMobileMenuItems } from "~/components/Menu/transformer";
|
||||
import * as Components from "~/components/primitives/components/Menu";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import type { MenuItem as TMenuItem, MenuItemWithChildren } from "~/types";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { mapMenuItems } from "../menus/mapMenuItems";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { useInlineMenuAnchor } from "./useInlineMenuAnchor";
|
||||
|
||||
type Props = {
|
||||
items: MenuItem[];
|
||||
/** Whether the document is right-to-left. */
|
||||
rtl: boolean;
|
||||
};
|
||||
|
||||
// The virtual anchor is an invisible zero-size element; the hook positions it
|
||||
// over the selection and Radix anchors the menu to it.
|
||||
const anchorStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a selection-toolbar menu inline — a vertical menu anchored to the
|
||||
* selection with no trigger button — by holding a Radix dropdown `open`
|
||||
* against a virtual anchor positioned over the selection. Radix provides the
|
||||
* positioning, collision handling, submenus, and keyboard navigation. Page
|
||||
* scroll is locked while open (via RemoveScroll, as Radix does for modal
|
||||
* menus) without enabling Radix's modal mode, which conflicts with the menu
|
||||
* being opened by an editor selection rather than a trigger.
|
||||
*/
|
||||
const InlineMenu: React.FC<Props> = ({ items, rtl }) => {
|
||||
const { t } = useTranslation();
|
||||
const { commands, view } = useEditor();
|
||||
const { state } = view;
|
||||
const isMobile = useMobile();
|
||||
const {
|
||||
ref: anchorRef,
|
||||
key: anchorKey,
|
||||
side,
|
||||
align,
|
||||
sideOffset,
|
||||
} = useInlineMenuAnchor(rtl);
|
||||
|
||||
const mapped = React.useMemo(
|
||||
() => mapMenuItems(items, commands, view, state),
|
||||
[items, commands, view, state]
|
||||
);
|
||||
|
||||
const preventFocus = React.useCallback((ev: Event) => {
|
||||
ev.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Dismiss the menu by collapsing the selection so the toolbar stops matching.
|
||||
const handleDismiss = React.useCallback(() => {
|
||||
collapseSelection()(view.state, view.dispatch);
|
||||
}, [view]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<InlineMenuDrawer
|
||||
items={mapped}
|
||||
ariaLabel={t("Options")}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuProvider variant="dropdown">
|
||||
<DropdownMenuPrimitive.Root
|
||||
key={anchorKey}
|
||||
open={!!anchorKey}
|
||||
modal={false}
|
||||
>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<div ref={anchorRef} aria-hidden style={anchorStyle} />
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={6}
|
||||
aria-label={t("Options")}
|
||||
onCloseAutoFocus={preventFocus}
|
||||
onInteractOutside={handleDismiss}
|
||||
onEscapeKeyDown={handleDismiss}
|
||||
asChild
|
||||
>
|
||||
<RemoveScroll as={Slot} allowPinchZoom>
|
||||
<Components.MenuContent
|
||||
maxHeightVar="--radix-dropdown-menu-content-available-height"
|
||||
transformOriginVar="--radix-dropdown-menu-content-transform-origin"
|
||||
hiddenScrollbars
|
||||
>
|
||||
<EventBoundary>{toMenuItems(mapped)}</EventBoundary>
|
||||
</Components.MenuContent>
|
||||
</RemoveScroll>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
</MenuProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Time for the drawer's close animation to play before the selection is
|
||||
// collapsed (which unmounts the menu).
|
||||
const DRAWER_CLOSE_MS = 500;
|
||||
|
||||
type InlineMenuDrawerProps = {
|
||||
items: TMenuItem[];
|
||||
ariaLabel: string;
|
||||
/** Collapse the selection so the toolbar stops rendering the menu. */
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mobile presentation of the inline menu: a bottom drawer with submenu drill-in,
|
||||
* matching the other menus. The menu is held open while the selection matches;
|
||||
* closing animates the drawer out before collapsing the selection.
|
||||
*/
|
||||
function InlineMenuDrawer({
|
||||
items,
|
||||
ariaLabel,
|
||||
onDismiss,
|
||||
}: InlineMenuDrawerProps) {
|
||||
const [open, setOpen] = React.useState(true);
|
||||
const [submenuName, setSubmenuName] = React.useState<string>();
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
setSubmenuName(undefined);
|
||||
onDismiss();
|
||||
}, DRAWER_CLOSE_MS);
|
||||
}, [onDismiss]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
close();
|
||||
}
|
||||
},
|
||||
[close]
|
||||
);
|
||||
|
||||
const menuItems = React.useMemo(() => {
|
||||
if (!items.length || !submenuName) {
|
||||
return items;
|
||||
}
|
||||
const submenu = items.find(
|
||||
(item) => item.type === "submenu" && item.title === submenuName
|
||||
) as MenuItemWithChildren | undefined;
|
||||
return submenu?.items ?? items;
|
||||
}, [items, submenuName]);
|
||||
|
||||
const content = toMobileMenuItems(menuItems, close, setSubmenuName);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent aria-label={ariaLabel} aria-describedby={undefined}>
|
||||
<DrawerTitle hidden>{ariaLabel}</DrawerTitle>
|
||||
<StyledScrollable hiddenScrollbars>{content}</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export default InlineMenu;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { runInAction } from "mobx";
|
||||
import {
|
||||
DocumentIcon,
|
||||
PlusIcon,
|
||||
@@ -14,11 +15,20 @@ import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import {
|
||||
dateToReadable,
|
||||
dateToRelativeReadable,
|
||||
parseISODate,
|
||||
toISODate,
|
||||
} from "@shared/utils/date";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { parseNaturalLanguageDate } from "@shared/utils/parseNaturalLanguageDate";
|
||||
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import { DynamicCalendarIcon } from "@shared/components/DynamicCalendarIcon";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
DateSection,
|
||||
DocumentsSection,
|
||||
UserSection,
|
||||
CollectionsSection,
|
||||
@@ -26,18 +36,20 @@ import {
|
||||
} from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
|
||||
import SuggestionsMenu from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
interface MentionItem extends MenuItem {
|
||||
attrs: {
|
||||
id: string;
|
||||
type: MentionType;
|
||||
modelId: string;
|
||||
label: string;
|
||||
// Date mentions intentionally omit a label — their text is derived from
|
||||
// the ISO `modelId` so nothing human-readable is persisted.
|
||||
label?: string;
|
||||
actorId?: string;
|
||||
};
|
||||
}
|
||||
@@ -47,15 +59,72 @@ type Props = Omit<
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
>;
|
||||
|
||||
function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
function MentionMenu({ search = "", isActive, ...rest }: Props) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { auth, documents, users, collections, groups } = useStores();
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
const userLocale = useUserLocale();
|
||||
const maxResultsInSection = search ? 25 : 5;
|
||||
|
||||
// Surface a date suggestion when the search query parses as a natural
|
||||
// language date (e.g. "tomorrow", "next friday", "jan 2"). Parsing is
|
||||
// asynchronous as chrono-node is loaded lazily, so the result is held in
|
||||
// state and applied once resolved.
|
||||
const [parsedISODate, setParsedISODate] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!search) {
|
||||
setParsedISODate(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void parseNaturalLanguageDate(search)
|
||||
.then((date) => {
|
||||
if (!cancelled) {
|
||||
setParsedISODate(date ? toISODate(date) : undefined);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Parsing failed (e.g. the chrono chunk failed to load); drop the
|
||||
// suggestion rather than leaving a stale one.
|
||||
if (!cancelled) {
|
||||
setParsedISODate(undefined);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
let dateItems: MentionItem[] = [];
|
||||
|
||||
if (actorId && parsedISODate) {
|
||||
const title = dateToRelativeReadable(parsedISODate, t, userLocale);
|
||||
const subtitle = dateToReadable(parsedISODate, userLocale);
|
||||
|
||||
dateItems = [
|
||||
{
|
||||
name: "mention",
|
||||
icon: (
|
||||
<DynamicCalendarIcon day={parseISODate(parsedISODate)?.getDate()} />
|
||||
),
|
||||
title,
|
||||
subtitle: title !== subtitle ? subtitle : undefined,
|
||||
section: DateSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Date,
|
||||
modelId: parsedISODate,
|
||||
actorId,
|
||||
},
|
||||
} as MentionItem,
|
||||
];
|
||||
}
|
||||
|
||||
const { loading, request } = useRequest(
|
||||
useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", {
|
||||
@@ -87,7 +156,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
// Computed in the render body so MobX observer can track store access
|
||||
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
|
||||
// runs outside the reactive context and triggered MobX warnings.
|
||||
const items: MentionItem[] = actorId
|
||||
const mentionItems: MentionItem[] = actorId
|
||||
? users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
@@ -253,9 +322,12 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
])
|
||||
: [];
|
||||
|
||||
const items: MentionItem[] = [...dateItems, ...mentionItems];
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
if (
|
||||
item.attrs.type === MentionType.Date ||
|
||||
item.attrs.type === MentionType.Document ||
|
||||
item.attrs.type === MentionType.Collection
|
||||
) {
|
||||
|
||||
@@ -2,34 +2,19 @@ import type { EditorState, Selection } from "prosemirror-state";
|
||||
import Suggestion from "~/editor/extensions/Suggestion";
|
||||
import { NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { buildSelectionContext } from "@shared/editor/lib/buildSelectionContext";
|
||||
import {
|
||||
getMarkRange,
|
||||
getMarkRangeNodeSelection,
|
||||
} from "@shared/editor/queries/getMarkRange";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInNotice } from "@shared/editor/queries/isInNotice";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import {
|
||||
getColumnIndex,
|
||||
getRowIndex,
|
||||
isTableSelected,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MenuType, type MenuItem } from "@shared/editor/types";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import getAttachmentMenuItems from "../menus/attachment";
|
||||
import getCodeMenuItems from "../menus/code";
|
||||
import getDividerMenuItems from "../menus/divider";
|
||||
import getFormattingMenuItems from "../menus/formatting";
|
||||
import getImageMenuItems from "../menus/image";
|
||||
import getNoticeMenuItems from "../menus/notice";
|
||||
import getReadOnlyMenuItems from "../menus/readOnly";
|
||||
import getTableMenuItems from "../menus/table";
|
||||
import getTableColMenuItems from "../menus/tableCol";
|
||||
import getTableRowMenuItems from "../menus/tableRow";
|
||||
import {
|
||||
columnDragPluginKey,
|
||||
rowDragPluginKey,
|
||||
@@ -39,6 +24,7 @@ import { MediaLinkEditor } from "./MediaLinkEditor";
|
||||
import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor from "./LinkEditor";
|
||||
import ToolbarMenu from "./ToolbarMenu";
|
||||
import InlineMenu from "./InlineMenu";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
|
||||
type Props = {
|
||||
@@ -64,7 +50,6 @@ function useIsDragging(state: EditorState) {
|
||||
useEventListener("dragend", setNotDragging);
|
||||
useEventListener("drop", setNotDragging);
|
||||
|
||||
// Check if table row or column is being dragged
|
||||
const columnDragState = columnDragPluginKey.getState(state);
|
||||
const rowDragState = rowDragPluginKey.getState(state);
|
||||
const isTableDragging =
|
||||
@@ -81,8 +66,7 @@ enum Toolbar {
|
||||
|
||||
export function SelectionToolbar(props: Props) {
|
||||
const { readOnly = false } = props;
|
||||
const { view, extensions, commands } = useEditor();
|
||||
const { t } = useTranslation();
|
||||
const { view, extensions, commands, selectionToolbarMenus } = useEditor();
|
||||
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useMobile();
|
||||
const isActive = props.isActive || isMobile;
|
||||
@@ -144,7 +128,6 @@ export function SelectionToolbar(props: Props) {
|
||||
}
|
||||
}, [activeToolbar]);
|
||||
|
||||
// Refocus the editor when the link toolbar closes to prevent focus loss
|
||||
const prevActiveToolbar = React.useRef(activeToolbar);
|
||||
React.useLayoutEffect(() => {
|
||||
if (
|
||||
@@ -175,7 +158,6 @@ export function SelectionToolbar(props: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't collapse selection if any suggestion menu is open
|
||||
const isSuggestionMenuOpen = extensions.extensions.some(
|
||||
(ext) => ext instanceof Suggestion && ext.isOpen
|
||||
);
|
||||
@@ -228,51 +210,16 @@ export function SelectionToolbar(props: Props) {
|
||||
|
||||
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
|
||||
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
const colIndex = getColumnIndex(state);
|
||||
const rowIndex = getRowIndex(state);
|
||||
const isImageSelection =
|
||||
selection instanceof NodeSelection && selection.node.type.name === "image";
|
||||
const isAttachmentSelection =
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === "attachment";
|
||||
// Build selection context once, shared across all menu matchers
|
||||
const ctx = buildSelectionContext(state, { readOnly, isTemplate, rtl });
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
let align: "center" | "start" | "end" = "center";
|
||||
// Find the first matching menu from the registry (sorted by priority)
|
||||
const matched = selectionToolbarMenus.find((menu) => menu.matches(ctx));
|
||||
|
||||
if (
|
||||
isCodeSelection &&
|
||||
(selection.empty || selection instanceof NodeSelection)
|
||||
) {
|
||||
items = getCodeMenuItems(state, readOnly, t);
|
||||
align = "end";
|
||||
} else if (isTableSelected(state)) {
|
||||
items = getTableMenuItems(state, readOnly, t);
|
||||
} else if (colIndex !== undefined) {
|
||||
items = getTableColMenuItems(state, readOnly, t, {
|
||||
index: colIndex,
|
||||
rtl,
|
||||
});
|
||||
} else if (rowIndex !== undefined) {
|
||||
items = getTableRowMenuItems(state, readOnly, t, {
|
||||
index: rowIndex,
|
||||
});
|
||||
} else if (isImageSelection) {
|
||||
items = getImageMenuItems(state, readOnly, t);
|
||||
} else if (isAttachmentSelection) {
|
||||
items = getAttachmentMenuItems(state, readOnly, t);
|
||||
} else if (isDividerSelection) {
|
||||
items = getDividerMenuItems(state, readOnly, t);
|
||||
} else if (readOnly) {
|
||||
items = getReadOnlyMenuItems(state, !!canUpdate, t);
|
||||
} else if (isNoticeSelection && selection.empty) {
|
||||
items = getNoticeMenuItems(state, readOnly, t);
|
||||
align = "end";
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, t);
|
||||
}
|
||||
let items: MenuItem[] = matched ? matched.getItems(ctx) : [];
|
||||
const align = matched?.align ?? "center";
|
||||
|
||||
// Some extensions may be disabled, remove corresponding items
|
||||
// Filter out items for disabled extensions or invisible items
|
||||
items = items.filter((item) => {
|
||||
if (item.name === "separator") {
|
||||
return true;
|
||||
@@ -318,6 +265,16 @@ export function SelectionToolbar(props: Props) {
|
||||
setActiveToolbar(null);
|
||||
};
|
||||
|
||||
// Inline menus render as a vertical menu anchored to the selection rather
|
||||
// than as a horizontal toolbar with trigger buttons.
|
||||
if (
|
||||
matched?.variant === MenuType.inline &&
|
||||
activeToolbar === Toolbar.Menu &&
|
||||
items.length
|
||||
) {
|
||||
return <InlineMenu items={items} rtl={rtl} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
align={align}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { MenuHeader } from "~/components/primitives/components/Menu";
|
||||
export type Props<T extends MenuItem = MenuItem> = {
|
||||
rtl: boolean;
|
||||
isActive: boolean;
|
||||
search: string;
|
||||
search?: string;
|
||||
trigger: string | string[];
|
||||
uploadFile?: (file: File) => Promise<string>;
|
||||
onFileUploadStart?: () => void;
|
||||
@@ -407,10 +407,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
for (const embed of embeds) {
|
||||
if (embed.title && embed.visible !== false && !embed.disabled) {
|
||||
embedItems.push(
|
||||
new EmbedDescriptor({
|
||||
...embed,
|
||||
name: "embed",
|
||||
})
|
||||
new EmbedDescriptor(Object.assign({}, embed, { name: "embed" }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import * as Toolbar from "@radix-ui/react-toolbar";
|
||||
import { closeHistory } from "@shared/editor/lib/closeHistory";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { hideScrollbars, s } from "@shared/styles";
|
||||
import { TooltipProvider } from "~/components/TooltipContext";
|
||||
import type { MenuItem as TMenuItem } from "~/types";
|
||||
import { mapMenuItems } from "../menus/mapMenuItems";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { MediaDimension } from "./MediaDimension";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
@@ -48,67 +50,11 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const handleClick = (menuItem: MenuItem) => () => {
|
||||
if (!menuItem.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commands[menuItem.name]) {
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
} else if (menuItem.onClick) {
|
||||
menuItem.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
|
||||
children.map((child) => {
|
||||
if (child.name === "separator") {
|
||||
return { type: "separator", visible: child.visible };
|
||||
}
|
||||
if ("content" in child) {
|
||||
return {
|
||||
type: "custom",
|
||||
visible: child.visible,
|
||||
content: child.content,
|
||||
};
|
||||
}
|
||||
const resolvedChildren = resolveChildren(child.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(c) => "preventCloseCondition" in c
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
visible: child.visible,
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapChildren(resolvedChildren),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "button",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
dangerous: child.dangerous,
|
||||
visible: child.visible,
|
||||
selected:
|
||||
child.active !== undefined ? child.active(state) : undefined,
|
||||
onClick: handleClick(child),
|
||||
};
|
||||
});
|
||||
|
||||
const resolvedItemChildren = resolveChildren(item.children);
|
||||
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
|
||||
const resolvedItemChildren =
|
||||
typeof item.children === "function" ? item.children() : item.children;
|
||||
return resolvedItemChildren
|
||||
? mapMenuItems(resolvedItemChildren, commands, view, state)
|
||||
: [];
|
||||
}, [isOpen, commands]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
@@ -158,9 +104,11 @@ function ToolbarMenu(props: Props) {
|
||||
}
|
||||
|
||||
// otherwise, run the associated editor command
|
||||
closeHistory(view);
|
||||
commands[item.name](
|
||||
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
|
||||
);
|
||||
closeHistory(view);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { selectedRect } from "prosemirror-tables";
|
||||
import * as React from "react";
|
||||
import type { EditorView } from "prosemirror-view";
|
||||
import { ColumnSelection } from "@shared/editor/selection/ColumnSelection";
|
||||
import { RowSelection } from "@shared/editor/selection/RowSelection";
|
||||
import { isTableSelected } from "@shared/editor/queries/table";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
type Align = "start" | "center" | "end";
|
||||
|
||||
const DEFAULT_SIDE_OFFSET = 4;
|
||||
|
||||
// Column and row menus open next to a grip handle. The grip is modelled as a
|
||||
// strip just outside the cell edge so the two distances are independent:
|
||||
// opening to the outside clears the grip (strip thickness + offset), while
|
||||
// flipping across sits only a small gap (offset) away.
|
||||
const OUTSIDE_CLEARANCE = 20;
|
||||
const FLIP_GAP = 0;
|
||||
const GRIP_INSET = OUTSIDE_CLEARANCE - FLIP_GAP;
|
||||
const GRIP_SIDE_OFFSET = FLIP_GAP;
|
||||
|
||||
type Anchor = {
|
||||
/** Viewport rect to anchor the menu to. */
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Which side of the anchor the menu opens towards. */
|
||||
side: Side;
|
||||
/** How the menu aligns along the anchor edge. */
|
||||
align: Align;
|
||||
/** Distance in pixels between the anchor and the menu. */
|
||||
sideOffset: number;
|
||||
/** Stable identifier for the anchored target, changes when it moves. */
|
||||
key: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the rect and placement to anchor an inline selection menu to, based
|
||||
* on the current table/column/row selection. The menu opens to the "outside"
|
||||
* of the table (above a column, beside a row) to cover the least content, and
|
||||
* is centered on the anchor for minimal pointer movement. Returns null when
|
||||
* there is no supported selection.
|
||||
*
|
||||
* @param view - the editor view.
|
||||
* @param rtl - whether the document is right-to-left.
|
||||
* @returns the anchor, or null.
|
||||
*/
|
||||
function getAnchor(view: EditorView, rtl: boolean): Anchor | null {
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
if (isTableSelected(state)) {
|
||||
const rect = selectedRect(state);
|
||||
const bounds = (
|
||||
view.domAtPos(rect.tableStart).node as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
// A horizontal line at the table's top edge so it stays near the top
|
||||
// whether the menu opens above or flips below.
|
||||
return {
|
||||
top: bounds.top,
|
||||
left: bounds.left,
|
||||
width: bounds.width,
|
||||
height: 0,
|
||||
side: "top",
|
||||
align: "start",
|
||||
sideOffset: DEFAULT_SIDE_OFFSET,
|
||||
key: `table-${rect.tableStart}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (selection instanceof ColumnSelection && selection.isColSelection()) {
|
||||
const rect = selectedRect(state);
|
||||
const cell = (
|
||||
view.domAtPos(rect.tableStart).node as HTMLElement
|
||||
).querySelector(`tr > *:nth-child(${rect.left + 1})`);
|
||||
if (cell instanceof HTMLElement) {
|
||||
const bounds = cell.getBoundingClientRect();
|
||||
// A strip just above the column's top edge (the grip), spanning the
|
||||
// column width so the menu centers on the column.
|
||||
return {
|
||||
top: bounds.top - GRIP_INSET,
|
||||
left: bounds.left,
|
||||
width: bounds.width,
|
||||
height: GRIP_INSET,
|
||||
side: "top",
|
||||
align: "center",
|
||||
sideOffset: GRIP_SIDE_OFFSET,
|
||||
key: `col-${rect.tableStart}-${rect.left}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (selection instanceof RowSelection && selection.isRowSelection()) {
|
||||
const rect = selectedRect(state);
|
||||
const cell = (
|
||||
view.domAtPos(rect.tableStart).node as HTMLElement
|
||||
).querySelector(`tr:nth-child(${rect.top + 1}) > *`);
|
||||
if (cell instanceof HTMLElement) {
|
||||
const bounds = cell.getBoundingClientRect();
|
||||
// A strip just outside the row's grip edge (left, or right in RTL),
|
||||
// spanning the row height so the menu centers on the row.
|
||||
return {
|
||||
top: bounds.top,
|
||||
left: rtl ? bounds.right : bounds.left - GRIP_INSET,
|
||||
width: GRIP_INSET,
|
||||
height: bounds.height,
|
||||
side: rtl ? "right" : "left",
|
||||
align: "center",
|
||||
sideOffset: GRIP_SIDE_OFFSET,
|
||||
key: `row-${rect.tableStart}-${rect.top}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions an invisible virtual anchor element over the current table, column,
|
||||
* or row selection so a Radix dropdown can anchor an inline menu to it. The
|
||||
* returned `key` changes when the anchored target changes; spread it onto the
|
||||
* menu root so Radix repositions for a new target.
|
||||
*
|
||||
* @param rtl - whether the document is right-to-left.
|
||||
* @returns the anchor ref to attach to the virtual trigger, the target key, and
|
||||
* the side/align the menu should open with.
|
||||
*/
|
||||
export function useInlineMenuAnchor(rtl: boolean) {
|
||||
const { view } = useEditor();
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const anchor = getAnchor(view, rtl);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
if (element && anchor) {
|
||||
element.style.top = `${anchor.top}px`;
|
||||
element.style.left = `${anchor.left}px`;
|
||||
element.style.width = `${anchor.width}px`;
|
||||
element.style.height = `${anchor.height}px`;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ref,
|
||||
key: anchor?.key,
|
||||
side: anchor?.side ?? "top",
|
||||
align: anchor?.align ?? "start",
|
||||
sideOffset: anchor?.sideOffset ?? DEFAULT_SIDE_OFFSET,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
allowSpaces: false,
|
||||
requireSearchTerm: false,
|
||||
enabledInCode: false,
|
||||
enabledInMarks: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,7 +88,8 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
condition: ({ node, $start, state }) =>
|
||||
$start.depth === 1 &&
|
||||
state.selection.$from.pos === $start.pos + node.content.size &&
|
||||
node.textContent === "/",
|
||||
node.textContent === "/" &&
|
||||
node.firstChild?.marks.length === 0,
|
||||
text: ` ${t("Keep typing to filter")}…`,
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -61,7 +61,7 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
.map((node) => ProsemirrorHelper.toPlainText(node))
|
||||
.join("\n")
|
||||
: mdSerializer.serialize(slice.content, {
|
||||
softBreak: true,
|
||||
commonMark: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -163,6 +163,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
|
||||
dispatch?.(state.tr.setMeta(pluginKey, {}));
|
||||
this.expandFoldedTogglesForCurrentMatch();
|
||||
this.expandCollapsedCodeBlockForCurrentMatch();
|
||||
this.scrollToCurrentMatch();
|
||||
|
||||
return true;
|
||||
@@ -213,6 +214,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
|
||||
dispatch?.(state.tr.setMeta(pluginKey, {}));
|
||||
this.expandFoldedTogglesForCurrentMatch();
|
||||
this.expandCollapsedCodeBlockForCurrentMatch();
|
||||
this.scrollToCurrentMatch();
|
||||
return true;
|
||||
};
|
||||
@@ -298,6 +300,18 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a collapsed code block if it contains the current match.
|
||||
*/
|
||||
private expandCollapsedCodeBlockForCurrentMatch() {
|
||||
const result = this.results[this.currentResultIndex];
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.editor.commands.expandCodeBlockAt(result.from);
|
||||
}
|
||||
|
||||
private rebaseNextResult(replace: string, index: number, lastOffset = 0) {
|
||||
const nextIndex = index + 1;
|
||||
|
||||
@@ -367,6 +381,11 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
}
|
||||
});
|
||||
|
||||
// Tracks already-seen match positions so duplicate matches (possible because
|
||||
// we search the deburred text concatenated with the original) can be skipped
|
||||
// in constant time rather than rescanning the entire results array.
|
||||
const seen = new Set<string>();
|
||||
|
||||
mergedTextNodes.forEach((node) => {
|
||||
const { text = "", pos, type } = node;
|
||||
try {
|
||||
@@ -391,11 +410,13 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already exists in results, possible due to duplicated
|
||||
// search string on L257
|
||||
if (this.results.some((r) => r.from === from && r.to === to)) {
|
||||
// Check if already exists in results, possible because we search
|
||||
// over `deburr(text) + text`
|
||||
const key = `${from}:${to}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
this.results.push({ from, to, type });
|
||||
}
|
||||
@@ -469,6 +490,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
}
|
||||
}
|
||||
|
||||
this.highlightRanges = allRanges;
|
||||
CSS.highlights.set("search-results", new Highlight(...allRanges));
|
||||
if (currentRanges.length) {
|
||||
CSS.highlights.set(
|
||||
@@ -481,6 +503,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
}
|
||||
|
||||
private clearHighlights() {
|
||||
this.highlightRanges = [];
|
||||
if (!supportsHighlightAPI) {
|
||||
return;
|
||||
}
|
||||
@@ -489,6 +512,25 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
this.currentHighlightRange = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the highlight ranges need to be rebuilt against the live
|
||||
* DOM. The CSS Custom Highlight API holds static ranges that detach when the
|
||||
* editor re-renders its DOM without changing the doc, so highlights are stale
|
||||
* when a built range's nodes have disconnected, or when some matches have not
|
||||
* yet been resolved to ranges (e.g. inside a node view that mounts later).
|
||||
*
|
||||
* @returns whether the highlights should be rebuilt.
|
||||
*/
|
||||
private highlightsStale() {
|
||||
if (this.highlightRanges.length < this.results.length) {
|
||||
return true;
|
||||
}
|
||||
return this.highlightRanges.some(
|
||||
(range) =>
|
||||
!range.startContainer.isConnected || !range.endContainer.isConnected
|
||||
);
|
||||
}
|
||||
|
||||
private handleEscape = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("q")) {
|
||||
@@ -522,6 +564,8 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
|
||||
private currentHighlightRange?: StaticRange;
|
||||
|
||||
private highlightRanges: StaticRange[] = [];
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
@@ -590,13 +634,27 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
return {
|
||||
update: (view) => {
|
||||
const generation = pluginKey.getState(view.state) as number;
|
||||
// The results changed (search ran, doc changed, fold toggled), so
|
||||
// always rebuild.
|
||||
if (generation !== lastGeneration) {
|
||||
lastGeneration = generation;
|
||||
this.updateHighlights();
|
||||
return;
|
||||
}
|
||||
// Results unchanged: only rebuild when the static highlight ranges
|
||||
// have detached from a DOM re-render that didn't bump the generation.
|
||||
if (this.searchTerm && this.highlightsStale()) {
|
||||
this.updateHighlights();
|
||||
}
|
||||
},
|
||||
destroy: () => {
|
||||
this.clearHighlights();
|
||||
// The highlight registry is global and keyed by fixed names, so
|
||||
// only tear down highlights when this editor actually owns an
|
||||
// active search — otherwise an unmounting editor could wipe the
|
||||
// highlights another editor just set during a route transition.
|
||||
if (this.searchTerm) {
|
||||
this.clearHighlights();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -41,10 +41,9 @@ export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const isHoverTarget = (target: Element | null, view: EditorView) =>
|
||||
const isHoverTarget = (target: Element | null) =>
|
||||
target instanceof HTMLElement &&
|
||||
this.editor.elementRef.current?.contains(target) &&
|
||||
(!view.editable || (view.editable && !view.hasFocus()));
|
||||
this.editor.elementRef.current?.contains(target);
|
||||
|
||||
let hoveringTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
@@ -52,11 +51,11 @@ export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
|
||||
new Plugin({
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mouseover: (view: EditorView, event: MouseEvent) => {
|
||||
mouseover: (_view: EditorView, event: MouseEvent) => {
|
||||
const target = (event.target as HTMLElement)?.closest(
|
||||
".use-hover-preview"
|
||||
);
|
||||
if (isHoverTarget(target, view)) {
|
||||
if (isHoverTarget(target)) {
|
||||
hoveringTimeout = setTimeout(
|
||||
action(async () => {
|
||||
const element = target as HTMLElement;
|
||||
@@ -79,7 +78,15 @@ export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
|
||||
documentId,
|
||||
});
|
||||
|
||||
if (unfurl) {
|
||||
// The fetch is async, so the pointer may have already
|
||||
// left the target (or the node may have been removed) by
|
||||
// the time it resolves – only show the preview if the
|
||||
// element is still hovered.
|
||||
if (
|
||||
unfurl &&
|
||||
element.isConnected &&
|
||||
element.matches(":hover")
|
||||
) {
|
||||
this.state.activeLinkElement = element;
|
||||
this.state.unfurlId = transformedUrl;
|
||||
} else {
|
||||
@@ -94,11 +101,11 @@ export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
|
||||
}
|
||||
return false;
|
||||
},
|
||||
mouseout: action((view: EditorView, event: MouseEvent) => {
|
||||
mouseout: action((_view: EditorView, event: MouseEvent) => {
|
||||
const target = (event.target as HTMLElement)?.closest(
|
||||
".use-hover-preview"
|
||||
);
|
||||
if (isHoverTarget(target, view)) {
|
||||
if (isHoverTarget(target)) {
|
||||
clearTimeout(hoveringTimeout);
|
||||
this.state.activeLinkElement = null;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
yUndoPlugin,
|
||||
undo,
|
||||
redo,
|
||||
undoCommand,
|
||||
redoCommand,
|
||||
} from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { Second } from "@shared/utils/time";
|
||||
|
||||
type UserAwareness = {
|
||||
@@ -105,7 +108,7 @@ export default class Multiplayer extends Extension<MultiplayerOptions> {
|
||||
|
||||
return {
|
||||
style: `background-color: ${u.color}${opacity}`,
|
||||
class: "ProseMirror-yjs-selection",
|
||||
class: EditorStyleHelper.multiplayerSelection,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -136,4 +139,12 @@ export default class Multiplayer extends Extension<MultiplayerOptions> {
|
||||
redo: () => redo,
|
||||
};
|
||||
}
|
||||
|
||||
keys() {
|
||||
return {
|
||||
"Mod-z": undoCommand,
|
||||
"Mod-y": redoCommand,
|
||||
"Shift-Mod-z": redoCommand,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,15 +55,11 @@ export default class PasteHandler extends Extension {
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_, event) => {
|
||||
if (event.key === "Shift") {
|
||||
this.shiftKey = true;
|
||||
}
|
||||
this.shiftKey = event.shiftKey;
|
||||
return false;
|
||||
},
|
||||
keyup: (_, event) => {
|
||||
if (event.key === "Shift") {
|
||||
this.shiftKey = false;
|
||||
}
|
||||
this.shiftKey = event.shiftKey;
|
||||
return false;
|
||||
},
|
||||
},
|
||||
@@ -108,22 +104,31 @@ export default class PasteHandler extends Extension {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the clipboard contents can be parsed as a single url
|
||||
if (isUrl(text)) {
|
||||
// If the HTML on the clipboard is from Claude then the best
|
||||
// compatability is to just use the HTML parser.
|
||||
if (html?.includes("font-claude-response-body")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the clipboard contents can be parsed as a single url.
|
||||
// Trim first so surrounding whitespace from the clipboard (e.g. a
|
||||
// trailing newline appended by the source) doesn't prevent URL
|
||||
// detection and skip the paste menu.
|
||||
const trimmedText = text.trim();
|
||||
if (isUrl(trimmedText)) {
|
||||
// If there is selected text then we want to wrap it in a link to the url
|
||||
if (!state.selection.empty) {
|
||||
toggleMark(this.editor.schema.marks.link, { href: text })(
|
||||
state,
|
||||
dispatch
|
||||
);
|
||||
toggleMark(this.editor.schema.marks.link, {
|
||||
href: trimmedText,
|
||||
})(state, dispatch);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Is the link a link to a document? If so, we can grab the title and insert it.
|
||||
const containsHash = text.includes("#");
|
||||
const containsHash = trimmedText.includes("#");
|
||||
|
||||
if (isDocumentUrl(text)) {
|
||||
const slug = parseDocumentSlug(text);
|
||||
if (isDocumentUrl(trimmedText)) {
|
||||
const slug = parseDocumentSlug(trimmedText);
|
||||
|
||||
if (slug) {
|
||||
void stores.documents
|
||||
@@ -147,7 +152,7 @@ export default class PasteHandler extends Extension {
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const { hash } = new URL(text);
|
||||
const { hash } = new URL(trimmedText);
|
||||
const hasEmoji =
|
||||
determineIconType(document.icon) ===
|
||||
IconType.Emoji;
|
||||
@@ -164,11 +169,11 @@ export default class PasteHandler extends Extension {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
this.insertLink(text);
|
||||
this.insertLink(trimmedText);
|
||||
});
|
||||
}
|
||||
} else if (isCollectionUrl(text)) {
|
||||
const slug = parseCollectionSlug(text);
|
||||
} else if (isCollectionUrl(trimmedText)) {
|
||||
const slug = parseCollectionSlug(trimmedText);
|
||||
|
||||
if (slug) {
|
||||
stores.collections
|
||||
@@ -192,7 +197,7 @@ export default class PasteHandler extends Extension {
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const { hash } = new URL(text);
|
||||
const { hash } = new URL(trimmedText);
|
||||
const hasEmoji =
|
||||
determineIconType(collection.icon) ===
|
||||
IconType.Emoji;
|
||||
@@ -209,11 +214,11 @@ export default class PasteHandler extends Extension {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
this.insertLink(text);
|
||||
this.insertLink(trimmedText);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.insertLink(text);
|
||||
this.insertLink(trimmedText);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -7,7 +7,21 @@ import Extension from "@shared/editor/lib/Extension";
|
||||
import { isInNotice } from "@shared/editor/queries/isInNotice";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import {
|
||||
MenuType,
|
||||
type SelectionToolbarMenuDescriptor,
|
||||
} from "@shared/editor/types";
|
||||
import { SelectionToolbar } from "../components/SelectionToolbar";
|
||||
import getAttachmentMenuItems from "../menus/attachment";
|
||||
import getCodeMenuItems from "../menus/code";
|
||||
|
||||
import getFormattingMenuItems from "../menus/formatting";
|
||||
import getImageMenuItems from "../menus/image";
|
||||
import getNoticeMenuItems from "../menus/notice";
|
||||
import getReadOnlyMenuItems from "../menus/readOnly";
|
||||
import getTableMenuItems from "../menus/table";
|
||||
import getTableColMenuItems from "../menus/tableCol";
|
||||
import getTableRowMenuItems from "../menus/tableRow";
|
||||
|
||||
export default class SelectionToolbarExtension extends Extension {
|
||||
get name() {
|
||||
@@ -31,6 +45,75 @@ export default class SelectionToolbarExtension extends Extension {
|
||||
@observable
|
||||
state: Selection | boolean = false;
|
||||
|
||||
/**
|
||||
* Returns all selection toolbar menu descriptors. Each descriptor declares
|
||||
* when it matches (via a predicate on SelectionContext) and what items to
|
||||
* show. The toolbar evaluates them in priority order and uses the first
|
||||
* match.
|
||||
*
|
||||
* @returns an array of selection toolbar menu descriptors.
|
||||
*/
|
||||
selectionToolbarMenus(): SelectionToolbarMenuDescriptor[] {
|
||||
return [
|
||||
{
|
||||
priority: 100,
|
||||
align: "end",
|
||||
matches: (ctx) =>
|
||||
ctx.isInCodeBlock &&
|
||||
(ctx.isEmpty || ctx.selectedNodeType !== undefined),
|
||||
getItems: (ctx) => getCodeMenuItems(ctx),
|
||||
},
|
||||
{
|
||||
priority: 90,
|
||||
variant: MenuType.inline,
|
||||
matches: (ctx) => ctx.isTableSelected,
|
||||
getItems: (ctx) => getTableMenuItems(ctx),
|
||||
},
|
||||
{
|
||||
priority: 85,
|
||||
variant: MenuType.inline,
|
||||
matches: (ctx) => ctx.colIndex !== undefined,
|
||||
getItems: (ctx) => getTableColMenuItems(ctx),
|
||||
},
|
||||
{
|
||||
priority: 80,
|
||||
variant: MenuType.inline,
|
||||
matches: (ctx) => ctx.rowIndex !== undefined,
|
||||
getItems: (ctx) => getTableRowMenuItems(ctx),
|
||||
},
|
||||
{
|
||||
priority: 50,
|
||||
matches: (ctx) => ctx.selectedNodeType === "image",
|
||||
getItems: (ctx) => getImageMenuItems(ctx),
|
||||
},
|
||||
{
|
||||
priority: 50,
|
||||
matches: (ctx) => ctx.selectedNodeType === "attachment",
|
||||
getItems: (ctx) => getAttachmentMenuItems(ctx),
|
||||
},
|
||||
{
|
||||
priority: 30,
|
||||
matches: (ctx) => ctx.readOnly,
|
||||
getItems: (ctx) =>
|
||||
getReadOnlyMenuItems(
|
||||
ctx,
|
||||
this.editor.props.canUpdate ?? false
|
||||
),
|
||||
},
|
||||
{
|
||||
priority: 20,
|
||||
align: "end",
|
||||
matches: (ctx) => ctx.isInNotice && ctx.isEmpty,
|
||||
getItems: (ctx) => getNoticeMenuItems(ctx),
|
||||
},
|
||||
{
|
||||
priority: 0,
|
||||
matches: () => true,
|
||||
getItems: (ctx) => getFormattingMenuItems(ctx),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private handleUpdate = action((view: EditorView) => {
|
||||
const { state } = view;
|
||||
this.state = this.calculateState(state);
|
||||
|
||||
@@ -4,7 +4,10 @@ import { InputRule } from "prosemirror-inputrules";
|
||||
import type { NodeType, Schema } from "prosemirror-model";
|
||||
import type { EditorState, Plugin } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { SuggestionsMenuPlugin } from "@shared/editor/plugins/SuggestionsMenuPlugin";
|
||||
import {
|
||||
isTriggerMarked,
|
||||
SuggestionsMenuPlugin,
|
||||
} from "@shared/editor/plugins/SuggestionsMenuPlugin";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
|
||||
/**
|
||||
@@ -14,6 +17,12 @@ import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
export type SuggestionOptions = {
|
||||
/** Whether the suggestion menu is allowed to open inside code blocks or inline code. */
|
||||
enabledInCode: boolean;
|
||||
/**
|
||||
* Whether the suggestion menu may open when the trigger character carries a
|
||||
* mark (e.g. bold, italic, link). Defaults to true – disable for menus where
|
||||
* the trigger is only meaningful as plain text, such as the block menu.
|
||||
*/
|
||||
enabledInMarks?: boolean;
|
||||
/** Character (or list of characters) that opens the suggestion menu. */
|
||||
trigger: string | string[];
|
||||
/** Whether spaces are allowed inside the search term. */
|
||||
@@ -37,7 +46,7 @@ export default class Suggestion<
|
||||
: `(?:${triggers.map(escapeRegExp).join("|")})`;
|
||||
|
||||
this.openRegex = new RegExp(
|
||||
`(?:^|\\s|\\(|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
|
||||
`(?:^|\\s|\\(|\\+|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
|
||||
this.options.allowSpaces ? "\\s{1}" : ""
|
||||
}\\.\\-–_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
"u"
|
||||
@@ -45,7 +54,18 @@ export default class Suggestion<
|
||||
}
|
||||
|
||||
get plugins(): Plugin[] {
|
||||
return [new SuggestionsMenuPlugin(this.state, this.openRegex)];
|
||||
return [
|
||||
new SuggestionsMenuPlugin(
|
||||
this.state,
|
||||
this.openRegex,
|
||||
this.enabledInMarks
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** Whether the menu may open when the trigger character carries a mark. */
|
||||
protected get enabledInMarks(): boolean {
|
||||
return this.options.enabledInMarks ?? true;
|
||||
}
|
||||
|
||||
keys() {
|
||||
@@ -62,21 +82,29 @@ export default class Suggestion<
|
||||
inputRules = (_options: { type: NodeType; schema: Schema }) => [
|
||||
new InputRule(
|
||||
this.openRegex,
|
||||
action((state: EditorState, match: RegExpMatchArray) => {
|
||||
const { parent } = state.selection.$from;
|
||||
if (
|
||||
match &&
|
||||
(parent.type.name === "paragraph" ||
|
||||
parent.type.name === "heading") &&
|
||||
(!isInCode(state) || this.options.enabledInCode)
|
||||
) {
|
||||
if (match[0].length <= 2) {
|
||||
this.state.open = true;
|
||||
action(
|
||||
(
|
||||
state: EditorState,
|
||||
match: RegExpMatchArray,
|
||||
_start: number,
|
||||
end: number
|
||||
) => {
|
||||
const { parent } = state.selection.$from;
|
||||
if (
|
||||
match &&
|
||||
(parent.type.name === "paragraph" ||
|
||||
parent.type.name === "heading") &&
|
||||
(!isInCode(state) || this.options.enabledInCode) &&
|
||||
(this.enabledInMarks || !isTriggerMarked(state, end, match))
|
||||
) {
|
||||
if (match[0].length <= 2) {
|
||||
this.state.open = true;
|
||||
}
|
||||
this.state.query = match[1];
|
||||
}
|
||||
this.state.query = match[1];
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
)
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
+28
-18
@@ -35,7 +35,10 @@ import type { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer"
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { basicExtensions as extensions } from "@shared/editor/nodes";
|
||||
import type ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
import type { ComponentProps } from "@shared/editor/types";
|
||||
import type {
|
||||
ComponentProps,
|
||||
SelectionToolbarMenuDescriptor,
|
||||
} from "@shared/editor/types";
|
||||
import type {
|
||||
ProsemirrorData,
|
||||
ProsemirrorMark,
|
||||
@@ -223,11 +226,12 @@ export class Editor extends React.PureComponent<
|
||||
[name: string]: NodeViewConstructor;
|
||||
};
|
||||
|
||||
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
|
||||
widgets: { [name: string]: React.FC<WidgetProps> };
|
||||
renderers = observable.set<NodeViewRenderer<ComponentProps>>();
|
||||
nodes: { [name: string]: NodeSpec };
|
||||
marks: { [name: string]: MarkSpec };
|
||||
commands: Record<string, CommandFactory>;
|
||||
selectionToolbarMenus: SelectionToolbarMenuDescriptor[];
|
||||
rulePlugins: PluginSimple[];
|
||||
events = new EventEmitter();
|
||||
mutationObserver?: MutationObserver;
|
||||
@@ -341,6 +345,7 @@ export class Editor extends React.PureComponent<
|
||||
|
||||
this.view = this.createView();
|
||||
this.commands = this.createCommands();
|
||||
this.selectionToolbarMenus = this.extensions.selectionToolbarMenus;
|
||||
}
|
||||
|
||||
private createExtensions() {
|
||||
@@ -367,13 +372,13 @@ export class Editor extends React.PureComponent<
|
||||
});
|
||||
}
|
||||
|
||||
private createNodeViews() {
|
||||
return this.extensions.extensions
|
||||
.filter((extension: ReactNode) => extension.component)
|
||||
.reduce(
|
||||
(nodeViews, extension: ReactNode) => ({
|
||||
...nodeViews,
|
||||
[extension.name]: (
|
||||
private createNodeViews(): { [name: string]: NodeViewConstructor } {
|
||||
return Object.fromEntries(
|
||||
this.extensions.extensions
|
||||
.filter((extension: ReactNode) => extension.component)
|
||||
.map((extension: ReactNode) => [
|
||||
extension.name,
|
||||
(
|
||||
node: ProsemirrorNode,
|
||||
view: EditorView,
|
||||
getPos: () => number,
|
||||
@@ -387,9 +392,8 @@ export class Editor extends React.PureComponent<
|
||||
getPos,
|
||||
decorations,
|
||||
}),
|
||||
}),
|
||||
{}
|
||||
);
|
||||
])
|
||||
) as { [name: string]: NodeViewConstructor };
|
||||
}
|
||||
|
||||
private createCommands() {
|
||||
@@ -573,12 +577,18 @@ export class Editor extends React.PureComponent<
|
||||
this.mutationObserver = observe(
|
||||
hash,
|
||||
(element) => {
|
||||
const pos = this.view.posAtDOM(element, 0, 1);
|
||||
this.view.dispatch(
|
||||
this.view.state.tr.setSelection(
|
||||
TextSelection.near(this.view.state.doc.resolve(pos), 1)
|
||||
)
|
||||
);
|
||||
try {
|
||||
const pos = this.view.posAtDOM(element, 0, 1);
|
||||
if (pos >= 0 && pos <= this.view.state.doc.content.size) {
|
||||
this.view.dispatch(
|
||||
this.view.state.tr.setSelection(
|
||||
TextSelection.near(this.view.state.doc.resolve(pos), 1)
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (_err) {
|
||||
// posAtDOM may throw if the element is not part of the editor doc
|
||||
}
|
||||
|
||||
if (isVisible(element)) {
|
||||
element.scrollIntoView();
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { t } from "i18next";
|
||||
import { TrashIcon, DownloadIcon, ReplaceIcon, PDFIcon } from "outline-icons";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import type { MenuItem, SelectionContext } from "@shared/editor/types";
|
||||
|
||||
/**
|
||||
* Returns menu items for the attachment selection toolbar.
|
||||
*
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function attachmentMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
t: TFunction
|
||||
ctx: SelectionContext
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
if (ctx.readOnly) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { schema } = state;
|
||||
const { schema, state } = ctx;
|
||||
const isAttachmentWithPreview = isNodeActive(schema.nodes.attachment, {
|
||||
preview: true,
|
||||
});
|
||||
|
||||
+13
-17
@@ -17,7 +17,6 @@ import {
|
||||
WarningIcon,
|
||||
InfoIcon,
|
||||
AttachmentIcon,
|
||||
ClockIcon,
|
||||
CalendarIcon,
|
||||
MathIcon,
|
||||
DoneIcon,
|
||||
@@ -26,9 +25,12 @@ import {
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { TFunction } from "i18next";
|
||||
import Image from "@shared/editor/components/Img";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import { toISODate } from "@shared/utils/date";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
@@ -124,8 +126,6 @@ export default function blockMenuItems(
|
||||
keywords: "pdf upload attach",
|
||||
attrs: {
|
||||
accept: "application/pdf",
|
||||
width: 300,
|
||||
height: 424,
|
||||
preview: true,
|
||||
},
|
||||
},
|
||||
@@ -186,22 +186,18 @@ export default function blockMenuItems(
|
||||
attrs: { markup: "***" },
|
||||
},
|
||||
{
|
||||
name: "date",
|
||||
// Inserts a date mention for today. Supersedes the deprecated "Current
|
||||
// date/time" commands that inserted a static string or template token.
|
||||
name: "mention",
|
||||
title: t("Current date"),
|
||||
keywords: "clock today",
|
||||
icon: <CalendarIcon />,
|
||||
},
|
||||
{
|
||||
name: "time",
|
||||
title: t("Current time"),
|
||||
keywords: "clock now",
|
||||
icon: <ClockIcon />,
|
||||
},
|
||||
{
|
||||
name: "datetime",
|
||||
title: t("Current date and time"),
|
||||
keywords: "clock today date",
|
||||
keywords: "clock today date time now",
|
||||
icon: <CalendarIcon />,
|
||||
appendSpace: true,
|
||||
attrs: () => ({
|
||||
id: uuidv4(),
|
||||
type: MentionType.Date,
|
||||
modelId: toISODate(new Date()),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CopyIcon, EditIcon, ExpandedIcon, TextWrapIcon } from "outline-icons";
|
||||
import type { Node as ProseMirrorNode } from "prosemirror-model";
|
||||
import { NodeSelection } from "prosemirror-state";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import {
|
||||
pluginKey as mermaidPluginKey,
|
||||
type MermaidState,
|
||||
@@ -12,15 +11,20 @@ import {
|
||||
getLabelForLanguage,
|
||||
} from "@shared/editor/lib/code";
|
||||
import { isMermaid } from "@shared/editor/lib/isCode";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { t } from "i18next";
|
||||
import type { MenuItem, SelectionContext } from "@shared/editor/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
|
||||
/**
|
||||
* Returns menu items for the code block selection toolbar.
|
||||
*
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function codeMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean | undefined,
|
||||
t: TFunction
|
||||
ctx: SelectionContext
|
||||
): MenuItem[] {
|
||||
const { state, readOnly } = ctx;
|
||||
const node =
|
||||
state.selection instanceof NodeSelection
|
||||
? state.selection.node
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
|
||||
export default function dividerMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
t: TFunction
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
const { schema } = state;
|
||||
|
||||
return [
|
||||
{
|
||||
name: "hr",
|
||||
tooltip: t("Divider"),
|
||||
attrs: { markup: "---" },
|
||||
active: isNodeActive(schema.nodes.hr, { markup: "---" }),
|
||||
icon: <HorizontalRuleIcon />,
|
||||
},
|
||||
{
|
||||
name: "hr",
|
||||
tooltip: t("Page break"),
|
||||
attrs: { markup: "***" },
|
||||
active: isNodeActive(schema.nodes.hr, { markup: "***" }),
|
||||
icon: <PageBreakIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -25,22 +25,16 @@ import {
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
|
||||
import HighlightColorPicker from "../components/HighlightColorPicker";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
|
||||
import { getDocumentHighlightColors } from "@shared/editor/queries/getDocumentHighlightColors";
|
||||
import { getMarksBetween } from "@shared/editor/queries/getMarksBetween";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInList } from "@shared/editor/queries/isInList";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { MenuItem, SelectionContext } from "@shared/editor/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import type { TFunction } from "i18next";
|
||||
import { t } from "i18next";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import {
|
||||
isMobile as isMobileDevice,
|
||||
isTouchDevice,
|
||||
} from "@shared/utils/browser";
|
||||
import {
|
||||
getColorSetForSelectedCells,
|
||||
getDocumentTableBackgroundColors,
|
||||
@@ -49,24 +43,30 @@ import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import type { CellSelection } from "prosemirror-tables";
|
||||
import TableCell from "@shared/editor/nodes/TableCell";
|
||||
import Highlight from "@shared/editor/marks/Highlight";
|
||||
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
|
||||
|
||||
export default function formattingMenuItems(
|
||||
state: EditorState,
|
||||
isTemplate: boolean,
|
||||
t: TFunction
|
||||
): MenuItem[] {
|
||||
const { schema } = state;
|
||||
const isCode = isInCode(state);
|
||||
const isCodeBlock = isInCode(state, { onlyBlock: true });
|
||||
const isEmpty = state.selection.empty;
|
||||
const isMobile = isMobileDevice();
|
||||
const isTouch = isTouchDevice();
|
||||
const isList = isInList(state);
|
||||
const isTableCell = state.selection instanceof CellSelection;
|
||||
/**
|
||||
* Returns menu items for the default formatting selection toolbar.
|
||||
*
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function formattingMenuItems(ctx: SelectionContext): MenuItem[] {
|
||||
const {
|
||||
schema,
|
||||
state,
|
||||
isTemplate,
|
||||
isMobile,
|
||||
isTouch,
|
||||
isEmpty,
|
||||
isInCode,
|
||||
isInCodeBlock,
|
||||
isInList: isList,
|
||||
isTableCell,
|
||||
} = ctx;
|
||||
|
||||
const highlight = getMarksBetween(
|
||||
state.selection.from,
|
||||
@@ -83,6 +83,9 @@ export default function formattingMenuItems(
|
||||
|
||||
const selectedCellsColorSet = getColorSetForSelectedCells(state.selection);
|
||||
|
||||
const canFormatInline = !isInCodeBlock && (!isMobile || !isEmpty);
|
||||
const canFormatBlock = !isInCodeBlock && (!isMobile || isEmpty);
|
||||
|
||||
return [
|
||||
{
|
||||
name: "placeholder",
|
||||
@@ -101,7 +104,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+B`,
|
||||
icon: <BoldIcon />,
|
||||
active: isMarkActive(schema.marks.strong),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
visible: canFormatInline,
|
||||
},
|
||||
{
|
||||
name: "em",
|
||||
@@ -109,7 +112,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+I`,
|
||||
icon: <ItalicIcon />,
|
||||
active: isMarkActive(schema.marks.em),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
visible: canFormatInline,
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
@@ -117,7 +120,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+D`,
|
||||
icon: <StrikethroughIcon />,
|
||||
active: isMarkActive(schema.marks.strikethrough),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
visible: canFormatInline,
|
||||
},
|
||||
{
|
||||
tooltip: t("Background color"),
|
||||
@@ -133,12 +136,10 @@ export default function formattingMenuItems(
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
visible: !isCode && (!isMobile || !isEmpty) && isTableCell,
|
||||
visible: !isInCode && (!isMobile || !isEmpty) && isTableCell,
|
||||
children: (): MenuItem[] => {
|
||||
// Get all unique background colors used in table cells (lazily computed when menu opens)
|
||||
const documentTableColors = getDocumentTableBackgroundColors(state);
|
||||
|
||||
// Filter out preset colors and currently selected colors
|
||||
const nonPresetDocumentColors = documentTableColors.filter(
|
||||
(color: string) =>
|
||||
!TableCell.isPresetColor(color) && !selectedCellsColorSet.has(color)
|
||||
@@ -181,7 +182,6 @@ export default function formattingMenuItems(
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Add all other document table background colors
|
||||
...nonPresetDocumentColors.map((color: string) => ({
|
||||
name: "toggleCellSelectionBackgroundAndCollapseSelection",
|
||||
label: color,
|
||||
@@ -225,12 +225,10 @@ export default function formattingMenuItems(
|
||||
<HighlightIcon />
|
||||
),
|
||||
active: () => !!highlight,
|
||||
visible: !isCode && (!isMobile || !isEmpty) && !isTableCell,
|
||||
visible: !isInCode && (!isMobile || !isEmpty) && !isTableCell,
|
||||
children: (): MenuItem[] => {
|
||||
// Get all unique highlight colors used in the document (lazily computed when menu opens)
|
||||
const documentHighlightColors = getDocumentHighlightColors(state);
|
||||
|
||||
// Filter out preset colors and the currently selected color
|
||||
const currentHighlightColor = highlight?.mark.attrs.color;
|
||||
const nonPresetDocumentColors = documentHighlightColors.filter(
|
||||
(color: string) =>
|
||||
@@ -276,7 +274,6 @@ export default function formattingMenuItems(
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Add all other document highlight colors
|
||||
...nonPresetDocumentColors.map((color: string) => ({
|
||||
name: "highlight",
|
||||
label: color,
|
||||
@@ -313,11 +310,11 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+E`,
|
||||
icon: <CodeIcon />,
|
||||
active: isMarkActive(schema.marks.code_inline),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
visible: canFormatInline,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: !isCodeBlock,
|
||||
visible: !isInCodeBlock,
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
@@ -326,7 +323,7 @@ export default function formattingMenuItems(
|
||||
icon: <Heading1Icon />,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 1 }),
|
||||
attrs: { level: 1 },
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
visible: canFormatBlock,
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
@@ -335,7 +332,7 @@ export default function formattingMenuItems(
|
||||
icon: <Heading2Icon />,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 2 }),
|
||||
attrs: { level: 2 },
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
visible: canFormatBlock,
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
@@ -344,7 +341,7 @@ export default function formattingMenuItems(
|
||||
icon: <Heading3Icon />,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 3 }),
|
||||
attrs: { level: 3 },
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
visible: canFormatBlock,
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
@@ -353,7 +350,7 @@ export default function formattingMenuItems(
|
||||
icon: <BlockQuoteIcon />,
|
||||
active: isNodeActive(schema.nodes.blockquote),
|
||||
attrs: { level: 2 },
|
||||
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
|
||||
visible: !isInCodeBlock && !isTableCell && (!isMobile || isEmpty),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
@@ -376,7 +373,7 @@ export default function formattingMenuItems(
|
||||
tooltip: t("Toggle block"),
|
||||
active: isNodeActive(schema.nodes.container_toggle),
|
||||
attrs: { id: uuidv4() },
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
visible: canFormatBlock,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
@@ -388,7 +385,7 @@ export default function formattingMenuItems(
|
||||
icon: <TodoListIcon />,
|
||||
keywords: "checklist checkbox task",
|
||||
active: isNodeActive(schema.nodes.checkbox_list),
|
||||
visible: !isCodeBlock && !isTableCell && (!isList || !isTouch),
|
||||
visible: !isInCodeBlock && !isTableCell && (!isList || !isTouch),
|
||||
},
|
||||
{
|
||||
name: "bullet_list",
|
||||
@@ -396,7 +393,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `⇧+Ctrl+8`,
|
||||
icon: <BulletedListIcon />,
|
||||
active: isNodeActive(schema.nodes.bullet_list),
|
||||
visible: !isCodeBlock && !isTableCell && (!isList || !isTouch),
|
||||
visible: !isInCodeBlock && !isTableCell && (!isList || !isTouch),
|
||||
},
|
||||
{
|
||||
name: "ordered_list",
|
||||
@@ -404,39 +401,45 @@ export default function formattingMenuItems(
|
||||
shortcut: `⇧+Ctrl+9`,
|
||||
icon: <OrderedListIcon />,
|
||||
active: isNodeActive(schema.nodes.ordered_list),
|
||||
visible: !isCodeBlock && !isTableCell && (!isList || !isTouch),
|
||||
visible: !isInCodeBlock && !isTableCell && (!isList || !isTouch),
|
||||
},
|
||||
{
|
||||
name: "outdentList",
|
||||
tooltip: t("Outdent"),
|
||||
shortcut: `⇧+Tab`,
|
||||
icon: <OutdentIcon />,
|
||||
visible: isTouch && isList,
|
||||
visible:
|
||||
(isTouch || isMobile) &&
|
||||
isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
},
|
||||
{
|
||||
name: "indentList",
|
||||
tooltip: t("Indent"),
|
||||
shortcut: `Tab`,
|
||||
icon: <IndentIcon />,
|
||||
visible: isTouch && isList,
|
||||
visible:
|
||||
(isTouch || isMobile) &&
|
||||
isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
},
|
||||
{
|
||||
name: "outdentCheckboxList",
|
||||
tooltip: t("Outdent"),
|
||||
shortcut: `⇧+Tab`,
|
||||
icon: <OutdentIcon />,
|
||||
visible: isTouch && isInList(state, { types: ["checkbox_list"] }),
|
||||
visible:
|
||||
(isTouch || isMobile) && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
{
|
||||
name: "indentCheckboxList",
|
||||
tooltip: t("Indent"),
|
||||
shortcut: `Tab`,
|
||||
icon: <IndentIcon />,
|
||||
visible: isTouch && isInList(state, { types: ["checkbox_list"] }),
|
||||
visible:
|
||||
(isTouch || isMobile) && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: !isCodeBlock,
|
||||
visible: !isInCodeBlock,
|
||||
},
|
||||
{
|
||||
name: "addLink",
|
||||
@@ -445,14 +448,14 @@ export default function formattingMenuItems(
|
||||
icon: <LinkIcon />,
|
||||
attrs: { href: "" },
|
||||
active: isMarkActive(schema.marks.link, undefined, { exact: true }),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
visible: canFormatInline,
|
||||
},
|
||||
{
|
||||
name: "comment",
|
||||
tooltip: t("Comment"),
|
||||
shortcut: `${metaDisplay}+⌥+M`,
|
||||
icon: <CommentIcon />,
|
||||
label: isCodeBlock ? t("Comment") : undefined,
|
||||
label: isInCodeBlock ? t("Comment") : undefined,
|
||||
active: isMarkActive(
|
||||
schema.marks.comment,
|
||||
{ resolved: false },
|
||||
@@ -462,14 +465,14 @@ export default function formattingMenuItems(
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
|
||||
visible: isInCode && !isInCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
name: "copyToClipboard",
|
||||
icon: <CopyIcon />,
|
||||
tooltip: t("Copy"),
|
||||
shortcut: `${metaDisplay}+C`,
|
||||
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
|
||||
visible: isInCode && !isInCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -10,24 +10,27 @@ import {
|
||||
CommentIcon,
|
||||
LinkIcon,
|
||||
} from "outline-icons";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { MenuItem, SelectionContext } from "@shared/editor/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import { ImageSource } from "@shared/editor/lib/FileHelper";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { t } from "i18next";
|
||||
|
||||
/**
|
||||
* Returns menu items for the image selection toolbar.
|
||||
*
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function imageMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
t: TFunction
|
||||
ctx: SelectionContext
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
if (ctx.readOnly) {
|
||||
return [];
|
||||
}
|
||||
const { schema } = state;
|
||||
const { schema, state } = ctx;
|
||||
const isLeftAligned = isNodeActive(schema.nodes.image, {
|
||||
layoutClass: "left-50",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import type { EditorView } from "prosemirror-view";
|
||||
import { closeHistory } from "@shared/editor/lib/closeHistory";
|
||||
import type { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { MenuItem as TMenuItem } from "~/types";
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
/**
|
||||
* Maps editor `MenuItem`s into the primitive `MenuItem`s consumed by
|
||||
* `toMenuItems`. Shared by the toolbar dropdown and the inline menu so menu
|
||||
* presentation stays consistent. Resolves nested children into submenus and
|
||||
* binds each leaf to its editor command (or `onClick`).
|
||||
*
|
||||
* @param items - the editor menu items to map.
|
||||
* @param commands - the editor command registry.
|
||||
* @param view - the editor view, used to checkpoint history around commands.
|
||||
* @param state - the editor state, used to resolve dynamic attrs and active state.
|
||||
* @returns the mapped primitive menu items.
|
||||
*/
|
||||
export function mapMenuItems(
|
||||
items: MenuItem[],
|
||||
commands: Record<string, CommandFactory>,
|
||||
view: EditorView,
|
||||
state: EditorState
|
||||
): TMenuItem[] {
|
||||
const handleClick = (item: MenuItem) => () => {
|
||||
if (!item.name) {
|
||||
return;
|
||||
}
|
||||
if (commands[item.name]) {
|
||||
closeHistory(view);
|
||||
commands[item.name](
|
||||
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
|
||||
);
|
||||
closeHistory(view);
|
||||
} else if (item.onClick) {
|
||||
item.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return items.map((item) => {
|
||||
if (item.name === "separator") {
|
||||
return { type: "separator", visible: item.visible };
|
||||
}
|
||||
|
||||
if ("content" in item) {
|
||||
return { type: "custom", visible: item.visible, content: item.content };
|
||||
}
|
||||
|
||||
const resolvedChildren = resolveChildren(item.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(child) => "preventCloseCondition" in child
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
title: item.label,
|
||||
icon: item.icon,
|
||||
visible: item.visible,
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapMenuItems(resolvedChildren, commands, view, state),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "button",
|
||||
title: item.label,
|
||||
icon: item.icon,
|
||||
dangerous: item.dangerous,
|
||||
visible: item.visible,
|
||||
selected: item.active !== undefined ? item.active(state) : undefined,
|
||||
onClick: handleClick(item),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
DoneIcon,
|
||||
ExpandedIcon,
|
||||
@@ -6,16 +6,19 @@ import {
|
||||
StarredIcon,
|
||||
WarningIcon,
|
||||
} from "outline-icons";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import { NoticeTypes } from "@shared/editor/nodes/Notice";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { MenuItem, SelectionContext } from "@shared/editor/types";
|
||||
|
||||
/**
|
||||
* Returns menu items for the notice/callout selection toolbar.
|
||||
*
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function noticeMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean | undefined,
|
||||
t: TFunction
|
||||
ctx: SelectionContext
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node(-1);
|
||||
const node = ctx.selection.$from.node(-1);
|
||||
const currentStyle = node?.attrs.style as NoticeTypes;
|
||||
|
||||
const mapping = {
|
||||
@@ -28,7 +31,7 @@ export default function noticeMenuItems(
|
||||
return [
|
||||
{
|
||||
name: "container_notice",
|
||||
visible: !readOnly,
|
||||
visible: !ctx.readOnly,
|
||||
label: mapping[currentStyle],
|
||||
icon: <ExpandedIcon />,
|
||||
children: [
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { t } from "i18next";
|
||||
import { CommentIcon } from "outline-icons";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { MenuItem, SelectionContext } from "@shared/editor/types";
|
||||
|
||||
/**
|
||||
* Returns menu items for the read-only selection toolbar.
|
||||
*
|
||||
* @param ctx - the current selection context.
|
||||
* @param canUpdate - whether the user has permission to update the document.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function readOnlyMenuItems(
|
||||
state: EditorState,
|
||||
canUpdate: boolean,
|
||||
t: TFunction
|
||||
ctx: SelectionContext,
|
||||
canUpdate: boolean
|
||||
): MenuItem[] {
|
||||
const { schema } = state;
|
||||
const isEmpty = state.selection.empty;
|
||||
const { schema } = ctx;
|
||||
|
||||
return [
|
||||
{
|
||||
visible: canUpdate && !isEmpty,
|
||||
visible: canUpdate && !ctx.isEmpty,
|
||||
name: "comment",
|
||||
tooltip: t("Comment"),
|
||||
label: t("Comment"),
|
||||
|
||||
+21
-21
@@ -4,21 +4,22 @@ import {
|
||||
TableColumnsDistributeIcon,
|
||||
TrashIcon,
|
||||
} from "outline-icons";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { t } from "i18next";
|
||||
import type { MenuItem, SelectionContext } from "@shared/editor/types";
|
||||
import { TableLayout } from "@shared/editor/types";
|
||||
|
||||
export default function tableMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
t: TFunction
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
/**
|
||||
* Returns menu items for the table selection toolbar (full table selected).
|
||||
*
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function tableMenuItems(ctx: SelectionContext): MenuItem[] {
|
||||
if (ctx.readOnly) {
|
||||
return [];
|
||||
}
|
||||
const { schema } = state;
|
||||
const { schema, state } = ctx;
|
||||
|
||||
const isFullWidth = isNodeActive(schema.nodes.table, {
|
||||
layout: TableLayout.fullWidth,
|
||||
@@ -27,33 +28,32 @@ export default function tableMenuItems(
|
||||
return [
|
||||
{
|
||||
name: "setTableAttr",
|
||||
tooltip: isFullWidth ? t("Default width") : t("Full width"),
|
||||
label: isFullWidth ? t("Default width") : t("Full width"),
|
||||
icon: <AlignFullWidthIcon />,
|
||||
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
|
||||
active: () => isFullWidth,
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
tooltip: t("Distribute columns"),
|
||||
label: t("Distribute columns"),
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteTable",
|
||||
tooltip: t("Delete table"),
|
||||
icon: <TrashIcon />,
|
||||
name: "exportTable",
|
||||
label: t("Export as CSV"),
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "exportTable",
|
||||
tooltip: t("Export as CSV"),
|
||||
label: "CSV",
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
name: "deleteTable",
|
||||
label: t("Delete table"),
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+140
-130
@@ -5,7 +5,6 @@ import {
|
||||
AlignCenterIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderColumnIcon,
|
||||
TableMergeCellsIcon,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
SortDescendingIcon,
|
||||
TableColumnsDistributeIcon,
|
||||
} from "outline-icons";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import { CellSelection, selectedRect } from "prosemirror-tables";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import {
|
||||
@@ -24,16 +22,25 @@ import {
|
||||
isMultipleCellSelection,
|
||||
tableHasRowspan,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
|
||||
import { t } from "i18next";
|
||||
import type {
|
||||
MenuItem,
|
||||
NodeAttrMark,
|
||||
SelectionContext,
|
||||
} from "@shared/editor/types";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
|
||||
import TableCell from "@shared/editor/nodes/TableCell";
|
||||
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
|
||||
/**
|
||||
* Get the set of background colors used in a column
|
||||
* Get the set of background colors used in a column.
|
||||
*
|
||||
* @param state - the current editor state.
|
||||
* @param colIndex - the column index.
|
||||
* @returns a set of hex color strings.
|
||||
*/
|
||||
function getColumnColors(state: EditorState, colIndex: number): Set<string> {
|
||||
const colors = new Set<string>();
|
||||
@@ -55,21 +62,21 @@ function getColumnColors(state: EditorState, colIndex: number): Set<string> {
|
||||
return colors;
|
||||
}
|
||||
|
||||
export default function tableColMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
t: TFunction,
|
||||
options: {
|
||||
index: number;
|
||||
rtl: boolean;
|
||||
}
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
/**
|
||||
* Returns menu items for the table column selection toolbar.
|
||||
*
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function tableColMenuItems(ctx: SelectionContext): MenuItem[] {
|
||||
if (ctx.readOnly) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { index, rtl } = options;
|
||||
const { schema, selection } = state;
|
||||
const index = ctx.colIndex!;
|
||||
const rtl = ctx.rtl;
|
||||
const { schema, state } = ctx;
|
||||
const { selection } = state;
|
||||
const selectedCols = getAllSelectedColumns(state);
|
||||
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
@@ -88,60 +95,65 @@ export default function tableColMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: t("Align left"),
|
||||
icon: <AlignLeftIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "left",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: t("Align center"),
|
||||
label: t("Align"),
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "center" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "center",
|
||||
}),
|
||||
children: [
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: t("Align left"),
|
||||
icon: <AlignLeftIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "left",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: t("Align center"),
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "center" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "center",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: t("Align right"),
|
||||
icon: <AlignRightIcon />,
|
||||
attrs: { index, alignment: "right" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "right",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: t("Align right"),
|
||||
icon: <AlignRightIcon />,
|
||||
attrs: { index, alignment: "right" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "right",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: t("Sort ascending"),
|
||||
attrs: { index, direction: "asc" },
|
||||
label: t("Sort"),
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
children: [
|
||||
{
|
||||
name: "sortTable",
|
||||
label: t("Sort ascending"),
|
||||
attrs: { index, direction: "asc" },
|
||||
icon: <SortAscendingIcon />,
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
label: t("Sort descending"),
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescendingIcon />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: t("Sort descending"),
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
tooltip: t("Background color"),
|
||||
label: t("Background"),
|
||||
icon:
|
||||
colColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
@@ -155,7 +167,7 @@ export default function tableColMenuItems(
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: t("None"),
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
icon: <DottedCircleIcon color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
@@ -199,71 +211,69 @@ export default function tableColMenuItems(
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "toggleHeaderColumn",
|
||||
label: t("Toggle header"),
|
||||
icon: <TableHeaderColumnIcon />,
|
||||
visible: index === 0,
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnAfter" : "addColumnBefore",
|
||||
label: rtl ? t("Insert after") : t("Insert before"),
|
||||
icon: <InsertLeftIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnBefore" : "addColumnAfter",
|
||||
label: rtl ? t("Insert before") : t("Insert after"),
|
||||
icon: <InsertRightIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: t("Move left"),
|
||||
icon: <ArrowLeftIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: t("Move right"),
|
||||
icon: <ArrowRightIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.width - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: t("Merge cells"),
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: t("Split cell"),
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
visible: selectedCols.length > 1,
|
||||
label: t("Distribute columns"),
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteColumn",
|
||||
dangerous: true,
|
||||
label: t("Delete"),
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
],
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "toggleHeaderColumn",
|
||||
label: t("Toggle header"),
|
||||
icon: <TableHeaderColumnIcon />,
|
||||
visible: index === 0,
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnAfter" : "addColumnBefore",
|
||||
label: rtl ? t("Insert after") : t("Insert before"),
|
||||
icon: <InsertLeftIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnBefore" : "addColumnAfter",
|
||||
label: rtl ? t("Insert before") : t("Insert after"),
|
||||
icon: <InsertRightIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: t("Move left"),
|
||||
icon: <ArrowLeftIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: t("Move right"),
|
||||
icon: <ArrowRightIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.width - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: t("Merge cells"),
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: t("Split cell"),
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
visible: selectedCols.length > 1,
|
||||
label: t("Distribute columns"),
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteColumn",
|
||||
dangerous: true,
|
||||
label: t("Delete"),
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
TrashIcon,
|
||||
InsertAboveIcon,
|
||||
InsertBelowIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderRowIcon,
|
||||
TableSplitCellsIcon,
|
||||
@@ -15,8 +14,12 @@ import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
|
||||
import { t } from "i18next";
|
||||
import type {
|
||||
MenuItem,
|
||||
NodeAttrMark,
|
||||
SelectionContext,
|
||||
} from "@shared/editor/types";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
|
||||
@@ -24,7 +27,11 @@ import TableCell from "@shared/editor/nodes/TableCell";
|
||||
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
|
||||
|
||||
/**
|
||||
* Get the set of background colors used in a row
|
||||
* Get the set of background colors used in a row.
|
||||
*
|
||||
* @param state - the current editor state.
|
||||
* @param rowIndex - the row index.
|
||||
* @returns a set of hex color strings.
|
||||
*/
|
||||
function getRowColors(state: EditorState, rowIndex: number): Set<string> {
|
||||
const colors = new Set<string>();
|
||||
@@ -46,19 +53,19 @@ function getRowColors(state: EditorState, rowIndex: number): Set<string> {
|
||||
return colors;
|
||||
}
|
||||
|
||||
export default function tableRowMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
t: TFunction,
|
||||
options: {
|
||||
index: number;
|
||||
}
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
/**
|
||||
* Returns menu items for the table row selection toolbar.
|
||||
*
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function tableRowMenuItems(ctx: SelectionContext): MenuItem[] {
|
||||
if (ctx.readOnly) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { index } = options;
|
||||
const index = ctx.rowIndex!;
|
||||
const { state } = ctx;
|
||||
const { selection } = state;
|
||||
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
@@ -77,7 +84,42 @@ export default function tableRowMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
tooltip: t("Background color"),
|
||||
name: "toggleHeaderRow",
|
||||
label: t("Toggle header"),
|
||||
icon: <TableHeaderRowIcon />,
|
||||
visible: index === 0,
|
||||
},
|
||||
{
|
||||
name: "addRowBefore",
|
||||
label: t("Insert before"),
|
||||
icon: <InsertAboveIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "addRowAfter",
|
||||
label: t("Insert after"),
|
||||
icon: <InsertBelowIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: t("Move up"),
|
||||
icon: <ArrowUpIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: t("Move down"),
|
||||
icon: <ArrowDownIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.height - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
label: t("Background"),
|
||||
icon:
|
||||
rowColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
@@ -91,7 +133,7 @@ export default function tableRowMenuItems(
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: t("None"),
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
icon: <DottedCircleIcon color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
@@ -135,65 +177,28 @@ export default function tableRowMenuItems(
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "toggleHeaderRow",
|
||||
label: t("Toggle header"),
|
||||
icon: <TableHeaderRowIcon />,
|
||||
visible: index === 0,
|
||||
},
|
||||
{
|
||||
name: "addRowBefore",
|
||||
label: t("Insert before"),
|
||||
icon: <InsertAboveIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "addRowAfter",
|
||||
label: t("Insert after"),
|
||||
icon: <InsertBelowIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: t("Move up"),
|
||||
icon: <ArrowUpIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: t("Move down"),
|
||||
icon: <ArrowDownIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.height - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: t("Merge cells"),
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: t("Split cell"),
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteRow",
|
||||
label: t("Delete"),
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
],
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: t("Merge cells"),
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: t("Split cell"),
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteRow",
|
||||
label: t("Delete"),
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+154
-88
@@ -1,8 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import type { ReactNode } from "react";
|
||||
import React, { createContext, useContext } from "react";
|
||||
import React, { createContext, useCallback, useContext, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router";
|
||||
import { useHistory } from "react-router";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type Model from "~/models/base/Model";
|
||||
import type Policy from "~/models/Policy";
|
||||
@@ -55,98 +55,159 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
|
||||
const parentContext = useContext(ActionContext);
|
||||
const stores = useStores();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { activeModels: valueModels, ...overrides } = value;
|
||||
|
||||
// Create the base context if we don't have a parent context
|
||||
const baseContext: ActionContextType = parentContext ?? {
|
||||
isMenu: false,
|
||||
isCommandBar: false,
|
||||
isButton: false,
|
||||
// Use history (stable reference) and read location lazily via a getter so
|
||||
// navigation does not invalidate the context value. Action perform/visible
|
||||
// callbacks see the current location at call time via history.location,
|
||||
// which react-router updates on every navigation.
|
||||
const history = useHistory();
|
||||
|
||||
// Legacy (backward compatibility)
|
||||
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
|
||||
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
|
||||
const {
|
||||
activeModels: valueModels,
|
||||
isMenu,
|
||||
isCommandBar,
|
||||
isButton,
|
||||
sidebarContext,
|
||||
event,
|
||||
} = value;
|
||||
|
||||
getActiveModels: <T extends Model>(
|
||||
modelClass: new (...args: never[]) => T
|
||||
): T[] => stores.ui.getActiveModels<T>(modelClass),
|
||||
// Track membership of stores.ui.activeModels so memos invalidate when it changes.
|
||||
// Reading inside the observer-wrapped render keeps MobX subscriptions intact.
|
||||
const activeModelsKey = Array.from(stores.ui.activeModels.keys()).join(",");
|
||||
const activeCollectionIdFromStore = stores.ui.activeCollectionId ?? undefined;
|
||||
const activeDocumentIdFromStore = stores.ui.activeDocumentId ?? undefined;
|
||||
const currentUserId = stores.auth.user?.id;
|
||||
const currentTeamId = stores.auth.team?.id;
|
||||
|
||||
getActiveModel: <T extends Model>(
|
||||
modelClass: new (...args: never[]) => T
|
||||
): T | undefined => stores.ui.getActiveModels<T>(modelClass)[0],
|
||||
const getActiveModels = useCallback(
|
||||
<T extends Model>(modelClass: new (...args: never[]) => T): T[] => {
|
||||
if (valueModels && valueModels.length > 0) {
|
||||
const matching = valueModels.filter(
|
||||
(model): model is T => model instanceof modelClass
|
||||
);
|
||||
if (matching.length > 0) {
|
||||
return matching;
|
||||
}
|
||||
}
|
||||
if (parentContext) {
|
||||
return parentContext.getActiveModels(modelClass);
|
||||
}
|
||||
return stores.ui.getActiveModels<T>(modelClass);
|
||||
},
|
||||
[valueModels, parentContext, stores]
|
||||
);
|
||||
|
||||
getActivePolicies: <T extends Model>(
|
||||
modelClass: new (...args: never[]) => T
|
||||
): Policy[] =>
|
||||
stores.ui
|
||||
.getActiveModels<T>(modelClass)
|
||||
const getActiveModel = useCallback(
|
||||
<T extends Model>(modelClass: new (...args: never[]) => T): T | undefined =>
|
||||
getActiveModels(modelClass)[0],
|
||||
[getActiveModels]
|
||||
);
|
||||
|
||||
const getActivePolicies = useCallback(
|
||||
<T extends Model>(modelClass: new (...args: never[]) => T): Policy[] =>
|
||||
getActiveModels(modelClass)
|
||||
.map((node) => stores.policies.get(node.id))
|
||||
.filter((policy): policy is Policy => policy !== undefined),
|
||||
[getActiveModels, stores]
|
||||
);
|
||||
|
||||
isModelActive: (model: Model): boolean => stores.ui.isModelActive(model),
|
||||
activeModels: new Set(stores.ui.activeModels.values()),
|
||||
const allActiveModels = useMemo(() => {
|
||||
const base = parentContext
|
||||
? parentContext.activeModels
|
||||
: new Set(stores.ui.activeModels.values());
|
||||
if (valueModels && valueModels.length > 0) {
|
||||
return new Set([...base, ...valueModels]);
|
||||
}
|
||||
return base;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentContext, stores, valueModels, activeModelsKey]);
|
||||
|
||||
currentUserId: stores.auth.user?.id,
|
||||
currentTeamId: stores.auth.team?.id,
|
||||
location,
|
||||
const isModelActive = useCallback(
|
||||
(model: Model): boolean => allActiveModels.has(model),
|
||||
[allActiveModels]
|
||||
);
|
||||
|
||||
const contextValue = useMemo<ActionContextType>(() => {
|
||||
const baseContext: ActionContextType = parentContext ?? {
|
||||
isMenu: false,
|
||||
isCommandBar: false,
|
||||
isButton: false,
|
||||
|
||||
// Legacy (backward compatibility)
|
||||
activeCollectionId: activeCollectionIdFromStore,
|
||||
activeDocumentId: activeDocumentIdFromStore,
|
||||
|
||||
getActiveModels,
|
||||
getActiveModel,
|
||||
getActivePolicies,
|
||||
isModelActive,
|
||||
activeModels: allActiveModels,
|
||||
|
||||
currentUserId,
|
||||
currentTeamId,
|
||||
// Consumers reading `ctx.location` get the current location at access time.
|
||||
location: history.location,
|
||||
stores,
|
||||
t,
|
||||
};
|
||||
|
||||
// Derive legacy IDs from value models, falling back to base context
|
||||
const activeCollectionId =
|
||||
valueModels?.find(
|
||||
(m) => (m.constructor as typeof Model).modelName === "Collection"
|
||||
)?.id ?? baseContext.activeCollectionId;
|
||||
|
||||
const activeDocumentId =
|
||||
valueModels?.find(
|
||||
(m) => (m.constructor as typeof Model).modelName === "Document"
|
||||
)?.id ?? baseContext.activeDocumentId;
|
||||
|
||||
const result = {
|
||||
...baseContext,
|
||||
...(isMenu !== undefined ? { isMenu } : {}),
|
||||
...(isCommandBar !== undefined ? { isCommandBar } : {}),
|
||||
...(isButton !== undefined ? { isButton } : {}),
|
||||
...(sidebarContext !== undefined ? { sidebarContext } : {}),
|
||||
...(event !== undefined ? { event } : {}),
|
||||
activeCollectionId,
|
||||
activeDocumentId,
|
||||
getActiveModels,
|
||||
getActiveModel,
|
||||
getActivePolicies,
|
||||
isModelActive,
|
||||
activeModels: allActiveModels,
|
||||
};
|
||||
|
||||
// Define `location` as a getter so reads always return the current
|
||||
// location without invalidating this memo on navigation.
|
||||
Object.defineProperty(result, "location", {
|
||||
get: () => history.location,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [
|
||||
parentContext,
|
||||
stores,
|
||||
t,
|
||||
};
|
||||
|
||||
// Override model accessors when models are provided in value
|
||||
const getActiveModels =
|
||||
valueModels && valueModels.length > 0
|
||||
? <T extends Model>(modelClass: new (...args: never[]) => T): T[] => {
|
||||
const matching = valueModels.filter(
|
||||
(model): model is T => model instanceof modelClass
|
||||
);
|
||||
return matching.length > 0
|
||||
? matching
|
||||
: baseContext.getActiveModels(modelClass);
|
||||
}
|
||||
: baseContext.getActiveModels;
|
||||
|
||||
const getActiveModel = <T extends Model>(
|
||||
modelClass: new (...args: never[]) => T
|
||||
): T | undefined => getActiveModels(modelClass)[0];
|
||||
|
||||
const getActivePolicies = <T extends Model>(
|
||||
modelClass: new (...args: never[]) => T
|
||||
): Policy[] =>
|
||||
getActiveModels(modelClass)
|
||||
.map((node) => stores.policies.get(node.id))
|
||||
.filter((policy): policy is Policy => policy !== undefined);
|
||||
|
||||
const allActiveModels =
|
||||
valueModels && valueModels.length > 0
|
||||
? new Set([...baseContext.activeModels, ...valueModels])
|
||||
: baseContext.activeModels;
|
||||
|
||||
const isModelActive = (model: Model): boolean => allActiveModels.has(model);
|
||||
|
||||
// Derive legacy IDs from value models, falling back to base context
|
||||
const activeCollectionId =
|
||||
valueModels?.find(
|
||||
(m) => (m.constructor as typeof Model).modelName === "Collection"
|
||||
)?.id ?? baseContext.activeCollectionId;
|
||||
|
||||
const activeDocumentId =
|
||||
valueModels?.find(
|
||||
(m) => (m.constructor as typeof Model).modelName === "Document"
|
||||
)?.id ?? baseContext.activeDocumentId;
|
||||
|
||||
const contextValue: ActionContextType = {
|
||||
...baseContext,
|
||||
...overrides,
|
||||
activeCollectionId,
|
||||
activeDocumentId,
|
||||
history,
|
||||
valueModels,
|
||||
isMenu,
|
||||
isCommandBar,
|
||||
isButton,
|
||||
sidebarContext,
|
||||
event,
|
||||
activeCollectionIdFromStore,
|
||||
activeDocumentIdFromStore,
|
||||
currentUserId,
|
||||
currentTeamId,
|
||||
getActiveModels,
|
||||
getActiveModel,
|
||||
getActivePolicies,
|
||||
isModelActive,
|
||||
activeModels: allActiveModels,
|
||||
};
|
||||
allActiveModels,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ActionContext.Provider value={contextValue}>
|
||||
@@ -173,15 +234,20 @@ export default function useActionContext(
|
||||
): ActionContextType {
|
||||
const contextValue = useContext(ActionContext);
|
||||
|
||||
// If we have a context value from a provider, use it as the base
|
||||
if (contextValue) {
|
||||
return {
|
||||
...contextValue,
|
||||
...overrides,
|
||||
};
|
||||
if (!contextValue) {
|
||||
throw new Error(
|
||||
"useActionContext must be used within an ActionContextProvider"
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"useActionContext must be used within an ActionContextProvider"
|
||||
);
|
||||
// Short-circuit when no overrides are provided so consumers get a stable
|
||||
// reference and don't re-render unnecessarily.
|
||||
if (!overrides || Object.keys(overrides).length === 0) {
|
||||
return contextValue;
|
||||
}
|
||||
|
||||
return {
|
||||
...contextValue,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
editDocument,
|
||||
shareDocument,
|
||||
createNewDocument,
|
||||
createNewDocumentInAlphabeticalCollection,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
@@ -103,6 +104,7 @@ export function useDocumentMenuAction({
|
||||
applyTemplateFactory({ actions: templateMenuActions }),
|
||||
importDocument,
|
||||
createNewDocument,
|
||||
createNewDocumentInAlphabeticalCollection,
|
||||
pinDocument,
|
||||
ActionSeparator,
|
||||
openDocumentComments,
|
||||
|
||||
+61
-15
@@ -2,34 +2,80 @@ import * as React from "react";
|
||||
|
||||
const isSupported = "IntersectionObserver" in window;
|
||||
|
||||
// Parses a rootMargin string ("10px 20px" / "10px" / "10px 20px 30px 40px")
|
||||
// into [top, right, bottom, left] in pixels. Percentages are not supported in
|
||||
// the synchronous fast path and fall back to 0.
|
||||
function parseRootMargin(
|
||||
rootMargin: string | undefined
|
||||
): [number, number, number, number] {
|
||||
if (!rootMargin) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
const parts = rootMargin
|
||||
.split(/\s+/)
|
||||
.map((p) => (p.endsWith("px") ? parseFloat(p) : 0));
|
||||
const [t = 0, r = t, b = t, l = r] = parts;
|
||||
return [t, r, b, l];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to return if a given ref is visible on screen.
|
||||
*
|
||||
* @returns boolean if the node is visible
|
||||
*/
|
||||
export default function useOnScreen(ref: React.RefObject<HTMLElement>) {
|
||||
export default function useOnScreen(
|
||||
ref: React.RefObject<HTMLElement>,
|
||||
options?: IntersectionObserverInit
|
||||
) {
|
||||
const root = options?.root;
|
||||
const rootMargin = options?.rootMargin;
|
||||
const threshold = Array.isArray(options?.threshold)
|
||||
? options?.threshold.join(",")
|
||||
: options?.threshold;
|
||||
|
||||
const [isIntersecting, setIntersecting] = React.useState(!isSupported);
|
||||
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
let observer: IntersectionObserver | undefined;
|
||||
|
||||
if (isSupported) {
|
||||
observer = new IntersectionObserver(([entry]) => {
|
||||
// Update our state when observer callback fires
|
||||
setIntersecting(entry.isIntersecting);
|
||||
});
|
||||
if (!element) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
observer?.observe(element);
|
||||
// Synchronous initial check so the first paint is correct.
|
||||
const [mt, mr, mb, ml] = parseRootMargin(rootMargin);
|
||||
const rect = element.getBoundingClientRect();
|
||||
const rootRect =
|
||||
root instanceof Element
|
||||
? root.getBoundingClientRect()
|
||||
: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: window.innerHeight,
|
||||
right: window.innerWidth,
|
||||
};
|
||||
const initialVisible =
|
||||
rect.bottom >= rootRect.top - mt &&
|
||||
rect.top <= rootRect.bottom + mb &&
|
||||
rect.right >= rootRect.left - ml &&
|
||||
rect.left <= rootRect.right + mr;
|
||||
|
||||
setIntersecting(initialVisible);
|
||||
|
||||
if (!isSupported) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
setIntersecting(entry.isIntersecting);
|
||||
}, options);
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
if (element) {
|
||||
observer?.unobserve(element);
|
||||
}
|
||||
observer.unobserve(element);
|
||||
};
|
||||
}, [ref]);
|
||||
// Re-create when option primitives change; options object identity ignored
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref, root, rootMargin, threshold]);
|
||||
|
||||
return isIntersecting;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import useEventListener from "./useEventListener";
|
||||
|
||||
/**
|
||||
* Returns the current selected text in the document.
|
||||
*
|
||||
* @returns the current selected text.
|
||||
*/
|
||||
export function getSelectedText() {
|
||||
return window.getSelection()?.toString() ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that returns the currently selected text.
|
||||
*
|
||||
* @returns The selected text
|
||||
*/
|
||||
export default function useTextSelection() {
|
||||
const [selection, setSelection] = useState<string>("");
|
||||
const [selection, setSelection] = useState(getSelectedText);
|
||||
|
||||
useEventListener(
|
||||
"selectionchange",
|
||||
() => {
|
||||
const selection = window.getSelection();
|
||||
const text = selection?.toString();
|
||||
setSelection(text ?? "");
|
||||
setSelection(getSelectedText());
|
||||
},
|
||||
document
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
UserChangeEmailDialog,
|
||||
UserChangeAvatarDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
|
||||
/**
|
||||
@@ -33,6 +34,21 @@ export function useUserMenuActions(targetUser: User | null) {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(targetUser ?? ({} as User));
|
||||
|
||||
const openAvatarDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Change profile picture"),
|
||||
content: (
|
||||
<UserChangeAvatarDialog
|
||||
user={targetUser}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, targetUser]);
|
||||
|
||||
const openNameDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
@@ -127,6 +143,12 @@ export function useUserMenuActions(targetUser: User | null) {
|
||||
visible: can.demote || can.promote,
|
||||
children: roleChangeActions,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change profile picture")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: openAvatarDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change name")}…`,
|
||||
section: UserSection,
|
||||
@@ -177,6 +199,7 @@ export function useUserMenuActions(targetUser: User | null) {
|
||||
can.update,
|
||||
can.resendInvite,
|
||||
roleChangeActions,
|
||||
openAvatarDialog,
|
||||
openNameDialog,
|
||||
openEmailDialog,
|
||||
resendInvitation,
|
||||
|
||||
@@ -91,6 +91,7 @@ function CommentMenu({
|
||||
action={rootAction}
|
||||
align="end"
|
||||
ariaLabel={t("Comment options")}
|
||||
modal={false}
|
||||
>
|
||||
<OverflowMenuButton className={className} />
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -29,7 +29,7 @@ export class ProsemirrorHelper {
|
||||
);
|
||||
|
||||
const markdown = serializer.serialize(doc, {
|
||||
softBreak: true,
|
||||
commonMark: true,
|
||||
});
|
||||
return markdown;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import Route from "~/components/ProfiledRoute";
|
||||
import WebsocketProvider from "~/components/WebsocketProvider";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQueryNotices from "~/hooks/useQueryNotices";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import {
|
||||
archivePath,
|
||||
@@ -53,6 +54,7 @@ const RedirectDocument = ({
|
||||
* the user to be logged in.
|
||||
*/
|
||||
function AuthenticatedRoutes() {
|
||||
useQueryNotices();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import DelayedMount from "~/components/DelayedMount";
|
||||
import FullscreenLoading from "~/components/FullscreenLoading";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import env from "~/env";
|
||||
import useQueryNotices from "~/hooks/useQueryNotices";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import { matchDocumentSlug as documentSlug } from "~/utils/routeHelpers";
|
||||
import useAutoRefresh from "~/hooks/useAutoRefresh";
|
||||
@@ -18,7 +17,6 @@ const Logout = lazy(() => import("~/scenes/Logout"));
|
||||
const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize"));
|
||||
|
||||
export default function Routes() {
|
||||
useQueryNotices();
|
||||
useAutoRefresh();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { format as formatDate } from "date-fns";
|
||||
import { CalendarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import { Calendar } from "@shared/components/Calendar";
|
||||
import { dateLocale } from "@shared/utils/date";
|
||||
import Button from "~/components/Button";
|
||||
import {
|
||||
@@ -21,25 +20,10 @@ type Props = {
|
||||
const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
|
||||
const styles = React.useMemo(
|
||||
() =>
|
||||
({
|
||||
"--rdp-caption-font-size": "16px",
|
||||
"--rdp-cell-size": "34px",
|
||||
"--rdp-selected-text": theme.accentText,
|
||||
"--rdp-accent-color": theme.accent,
|
||||
"--rdp-accent-color-dark": theme.accent,
|
||||
"--rdp-background-color": theme.listItemHoverBackground,
|
||||
"--rdp-background-color-dark": theme.listItemHoverBackground,
|
||||
}) as React.CSSProperties,
|
||||
[theme]
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(date: Date) => {
|
||||
setOpen(false);
|
||||
@@ -51,7 +35,7 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>
|
||||
<StyledPopoverButton icon={<Icon />} neutral>
|
||||
<StyledPopoverButton neutral>
|
||||
{selectedDate
|
||||
? formatDate(selectedDate, "MMM dd, yyyy", { locale })
|
||||
: t("Choose a date")}
|
||||
@@ -63,12 +47,12 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
side="right"
|
||||
shrink
|
||||
>
|
||||
<DayPicker
|
||||
<Calendar
|
||||
required
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleSelect}
|
||||
style={styles}
|
||||
locale={locale}
|
||||
disabled={{ before: new Date() }}
|
||||
/>
|
||||
</PopoverContent>
|
||||
@@ -76,23 +60,9 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Icon = () => (
|
||||
<IconWrapper>
|
||||
<CalendarIcon />
|
||||
</IconWrapper>
|
||||
);
|
||||
|
||||
const StyledPopoverButton = styled(Button)`
|
||||
margin-top: 12px;
|
||||
width: 150px;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default ExpiryDatePicker;
|
||||
|
||||
@@ -13,7 +13,6 @@ import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { dateToExpiry } from "~/utils/date";
|
||||
import "react-day-picker/dist/style.css";
|
||||
import ExpiryDatePicker from "./components/ExpiryDatePicker";
|
||||
import { ExpiryType, ExpiryValues, calculateExpiryDate } from "./utils";
|
||||
|
||||
@@ -123,7 +122,7 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
<Flex align="center" gap={16}>
|
||||
<Flex align="center" gap={8}>
|
||||
<StyledExpirySelect
|
||||
options={expiryOptions}
|
||||
value={expiryType}
|
||||
|
||||
@@ -179,7 +179,7 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
<Notices collection={collection} />
|
||||
<Header
|
||||
collection={collection}
|
||||
isEditing={isEditRoute && !!user?.separateEditMode}
|
||||
isEditing={isEditRoute || !user?.separateEditMode}
|
||||
/>
|
||||
|
||||
<PinnedDocuments
|
||||
|
||||
@@ -13,6 +13,38 @@ export interface Example {
|
||||
* Node and mark names are matched against those defined in the shared editor schema.
|
||||
*/
|
||||
export const examples: Example[] = [
|
||||
{
|
||||
id: "word-replacement",
|
||||
name: "Word replacement (interleaved)",
|
||||
before: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "no-await-in-loop",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
after: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "jsx-no-jsx-as-prop",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "simple-text",
|
||||
name: "Simple text change",
|
||||
@@ -632,6 +664,846 @@ export const examples: Example[] = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tc",
|
||||
name: "More table changes",
|
||||
before: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: {
|
||||
level: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
text: "Perf:",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "Code that can be written to run faster.",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tr",
|
||||
content: [
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "Rule name",
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "strong",
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "Source",
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "strong",
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "Default",
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "strong",
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "Fixable?",
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "strong",
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tr",
|
||||
content: [
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "no-await-in-loop",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "eslint",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tr",
|
||||
content: [
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "prefer-set-has",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "unicorn",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "\ud83d\udee0\ufe0f",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tr",
|
||||
content: [
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "no-map-spread",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "oxc",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "\ud83d\udee0\ufe0f\ud83d\udca1",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tr",
|
||||
content: [
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "no-array-index-key",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "react",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
after: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: {
|
||||
level: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
text: "Perf:",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "Code that can be written to run faster.",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tr",
|
||||
content: [
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "Rule name",
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "strong",
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "Source",
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "strong",
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "Default",
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "strong",
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "Fixable?",
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "strong",
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tr",
|
||||
content: [
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "jsx-no-jsx-as-prop",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "react-perf",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tr",
|
||||
content: [
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "no-map-spread",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "oxc",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "\ud83d\udee0\ufe0f",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
"data-name": "bulb",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tr",
|
||||
content: [
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "prefer-array-find",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "unicorn",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
"data-name": "construction",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tr",
|
||||
content: [
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "prefer-set-has",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: "unicorn",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "td",
|
||||
attrs: {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
"data-name": "warning",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
"data-name": "hammer_and_wrench",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "table-add-row",
|
||||
name: "Table: add row",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { basicExtensions, withComments } from "@shared/editor/nodes";
|
||||
import CodeBlock from "@shared/editor/nodes/CodeBlock";
|
||||
import CodeFence from "@shared/editor/nodes/CodeFence";
|
||||
import HardBreak from "@shared/editor/nodes/HardBreak";
|
||||
import type { Props as EditorProps } from "~/components/Editor";
|
||||
import Editor from "~/components/Editor";
|
||||
@@ -17,6 +19,8 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
|
||||
const extensions = [
|
||||
...withComments(basicExtensions),
|
||||
CodeBlock,
|
||||
CodeFence,
|
||||
HardBreak,
|
||||
SmartText,
|
||||
PasteHandler,
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import { parseReactionShorthand } from "@shared/editor/lib/emoji";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation, CommentValidation } from "@shared/validations";
|
||||
@@ -57,6 +58,11 @@ type Props = {
|
||||
onBlur?: () => void;
|
||||
/** Callback when user presses up arrow at the start of the editor */
|
||||
onUpArrowAtStart?: () => void;
|
||||
/**
|
||||
* Callback invoked when a new top-level comment is about to be created,
|
||||
* just before it is added to the store. Receives the generated comment id.
|
||||
*/
|
||||
onBeforeCreate?: (commentId: string) => void;
|
||||
};
|
||||
|
||||
function CommentForm({
|
||||
@@ -68,6 +74,7 @@ function CommentForm({
|
||||
onFocus,
|
||||
onBlur,
|
||||
onUpArrowAtStart,
|
||||
onBeforeCreate,
|
||||
autoFocus,
|
||||
standalone,
|
||||
placeholder,
|
||||
@@ -151,6 +158,30 @@ function CommentForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// "+:emoji:" shorthand: react to the comment above instead of replying.
|
||||
if (thread && !thread.isNew) {
|
||||
const emoji = parseReactionShorthand(draft);
|
||||
if (emoji) {
|
||||
const target = comments
|
||||
.inThread(thread.id)
|
||||
.filter((comment) => !comment.isNew)
|
||||
.pop();
|
||||
|
||||
if (target) {
|
||||
onSaveDraft(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
void target.addReaction({ emoji, user });
|
||||
onSubmit?.();
|
||||
|
||||
// re-focus the comment editor
|
||||
setTimeout(() => {
|
||||
editorRef.current?.focusAtStart();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commentDraft = draft;
|
||||
onSaveDraft(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
@@ -167,6 +198,9 @@ function CommentForm({
|
||||
);
|
||||
|
||||
comment.id = uuidv4();
|
||||
if (!thread) {
|
||||
onBeforeCreate?.(comment.id);
|
||||
}
|
||||
comments.add(comment);
|
||||
|
||||
comment
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { ProsemirrorData, ProsemirrorMark } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type Document from "~/models/Document";
|
||||
import CommentForm from "./CommentForm";
|
||||
import CommentThread from "./CommentThread";
|
||||
|
||||
type Props = {
|
||||
/** The document the image belongs to. */
|
||||
document: Document;
|
||||
/** The position of the image node in the document. */
|
||||
pos: number;
|
||||
};
|
||||
|
||||
function LightboxComments({ document, pos }: Props) {
|
||||
const { comments } = useStores();
|
||||
const { editor, focusedCommentId, setFocusedCommentId } =
|
||||
useDocumentContext();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(document);
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
`draft-${document.id}-image-${pos}`,
|
||||
undefined
|
||||
);
|
||||
|
||||
const commentIds = editor?.view?.state?.doc
|
||||
? ProsemirrorHelper.getCommentIdsAtPos(editor.view.state.doc, pos)
|
||||
: [];
|
||||
|
||||
const threads = comments
|
||||
.threadsInDocument(document.id)
|
||||
.filter(
|
||||
(comment) => commentIds.includes(comment.id) && !comment.isResolved
|
||||
);
|
||||
|
||||
const draftCommentIdRef = useRef<string | null>(null);
|
||||
|
||||
// When submitting a new comment from the bottom form, anchor it to the image
|
||||
// by adding a draft comment mark to the image node's `attrs.marks`. The mark
|
||||
// is flipped to `draft: false` in `handleSubmit` once the comment has been
|
||||
// persisted on the server.
|
||||
const handleBeforeCreate = useCallback(
|
||||
(commentId: string) => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const { state, dispatch } = editor.view;
|
||||
const node = state.doc.nodeAt(pos);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingMarks = (node.attrs.marks ?? []) as ProsemirrorMark[];
|
||||
const newMark: ProsemirrorMark = {
|
||||
type: "comment",
|
||||
attrs: {
|
||||
id: commentId,
|
||||
userId: user.id,
|
||||
draft: true,
|
||||
},
|
||||
};
|
||||
const newAttrs = {
|
||||
...node.attrs,
|
||||
marks: [...existingMarks, newMark],
|
||||
};
|
||||
dispatch(state.tr.setNodeMarkup(pos, undefined, newAttrs));
|
||||
draftCommentIdRef.current = commentId;
|
||||
},
|
||||
[editor, pos, user.id]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
setFocusedCommentId(null);
|
||||
const commentId = draftCommentIdRef.current;
|
||||
if (commentId) {
|
||||
editor?.updateComment(commentId, { draft: false });
|
||||
draftCommentIdRef.current = null;
|
||||
}
|
||||
}, [editor, setFocusedCommentId]);
|
||||
|
||||
const focusedComment =
|
||||
focusedCommentId && commentIds.includes(focusedCommentId)
|
||||
? comments.get(focusedCommentId)
|
||||
: undefined;
|
||||
|
||||
const hasComments = threads.length > 0;
|
||||
const canComment = can.comment;
|
||||
|
||||
return (
|
||||
<Wrapper column>
|
||||
<Header>{t("Comments")}</Header>
|
||||
<Body bottomShadow={!focusedComment} hiddenScrollbars topShadow>
|
||||
<List $hasComments={hasComments}>
|
||||
{hasComments ? (
|
||||
threads.map((thread) => (
|
||||
<CommentThread
|
||||
key={thread.id}
|
||||
comment={thread}
|
||||
document={document}
|
||||
recessed={!!focusedComment && focusedComment.id !== thread.id}
|
||||
focused={focusedComment?.id === thread.id}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<NoComments align="center" justify="center" auto>
|
||||
<Empty>{t("No comments yet")}</Empty>
|
||||
</NoComments>
|
||||
)}
|
||||
</List>
|
||||
</Body>
|
||||
{canComment && !focusedComment && (
|
||||
<NewCommentForm
|
||||
draft={draft}
|
||||
onSaveDraft={onSaveDraft}
|
||||
documentId={document.id}
|
||||
placeholder={`${t("Add a comment")}…`}
|
||||
autoFocus={false}
|
||||
standalone
|
||||
onBeforeCreate={handleBeforeCreate}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
background: ${s("background")};
|
||||
border-inline-start: 1px solid ${s("divider")};
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
flex-shrink: 0;
|
||||
padding: 20px 16px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${s("text")};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const Body = styled(Scrollable)`
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const List = styled.div<{ $hasComments: boolean }>`
|
||||
height: ${(props) => (props.$hasComments ? "auto" : "100%")};
|
||||
padding-bottom: 12px;
|
||||
`;
|
||||
|
||||
const NoComments = styled(Flex)`
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
`;
|
||||
|
||||
const NewCommentForm = styled(CommentForm)`
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
padding-inline-end: 18px;
|
||||
padding-inline-start: 12px;
|
||||
border-top: 1px solid ${s("divider")};
|
||||
`;
|
||||
|
||||
export default observer(LightboxComments);
|
||||
@@ -27,6 +27,7 @@ import type { Editor as TEditor } from "~/editor";
|
||||
import type { Properties } from "~/types";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import isTextInput from "~/utils/isTextInput";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { emojiToUrl } from "~/utils/emoji";
|
||||
import { documentHistoryPath, documentEditPath } from "~/utils/routeHelpers";
|
||||
@@ -125,6 +126,16 @@ function DocumentScene({
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace(document.url, {
|
||||
...location.state,
|
||||
restore: undefined,
|
||||
revisionId: undefined,
|
||||
});
|
||||
|
||||
if (!revisionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await client.post("/revisions.info", {
|
||||
id: revisionId,
|
||||
});
|
||||
@@ -135,22 +146,32 @@ function DocumentScene({
|
||||
new AllSelection(editor.view.state.doc)
|
||||
);
|
||||
toast.success(t("Document restored"));
|
||||
history.replace(document.url, history.location.state);
|
||||
}
|
||||
}, [location, replaceSelection, t, history, document.url]);
|
||||
|
||||
const onUndoRedo = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (isModKey(event)) {
|
||||
const target =
|
||||
event.target instanceof Element ? event.target : undefined;
|
||||
|
||||
// The editor handles undo/redo through its own keymap when focused
|
||||
if (
|
||||
editorRef.current?.view?.hasFocus() ||
|
||||
(target && (isTextInput(target) || !!target.closest(".ProseMirror")))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (!readOnly) {
|
||||
editorRef.current?.commands.redo();
|
||||
editorRef.current?.commands.redo?.();
|
||||
}
|
||||
} else {
|
||||
if (!readOnly) {
|
||||
editorRef.current?.commands.undo();
|
||||
editorRef.current?.commands.undo?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeModels: [document] }}>
|
||||
<ActionContextProvider value={{ activeModels: [document, item] }}>
|
||||
<ContextMenu
|
||||
action={contextMenuAction}
|
||||
ariaLabel={t("Revision options")}
|
||||
|
||||
@@ -58,12 +58,13 @@ function Insights({ document }: Props) {
|
||||
{t(`Last updated`)}{" "}
|
||||
<Time dateTime={document.updatedAt} addSuffix />
|
||||
</li>
|
||||
{document.sourceMetadata && (
|
||||
{(document.sourceName ||
|
||||
document.sourceMetadata?.fileName) && (
|
||||
<li>
|
||||
{t("Imported from {{ source }}", {
|
||||
source:
|
||||
document.sourceName ??
|
||||
`“${document.sourceMetadata.fileName}”`,
|
||||
`“${document.sourceMetadata?.fileName}”`,
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { toast } from "sonner";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import { EditorUpdateError } from "@shared/collaboration/CloseEvents";
|
||||
import History from "@shared/editor/extensions/History";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import type { Props as EditorProps } from "~/components/Editor";
|
||||
@@ -130,7 +131,7 @@ function MultiplayerEditor(
|
||||
.fetchAuth()
|
||||
.then(() => {
|
||||
provider.setConfiguration({ token: auth.collaborationToken });
|
||||
provider.connect();
|
||||
void provider.connect();
|
||||
provider.shouldConnect = true;
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -256,8 +257,12 @@ function MultiplayerEditor(
|
||||
return props.extensions;
|
||||
}
|
||||
|
||||
// The Yjs undo manager (added by the Multiplayer extension below) is the
|
||||
// sole source of undo/redo history when collaborating.
|
||||
return [
|
||||
...(props.extensions || []),
|
||||
...(props.extensions || []).filter(
|
||||
(extension) => extension !== History && !(extension instanceof History)
|
||||
),
|
||||
new MultiplayerExtension({
|
||||
user,
|
||||
provider: remoteProvider,
|
||||
@@ -284,7 +289,7 @@ function MultiplayerEditor(
|
||||
!isVisible &&
|
||||
remoteProvider.status === WebSocketStatus.Connected
|
||||
) {
|
||||
void remoteProvider.disconnect();
|
||||
remoteProvider.disconnect();
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -6,6 +6,7 @@ import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
export function SearchHighlightChip() {
|
||||
const { t } = useTranslation();
|
||||
@@ -50,6 +51,7 @@ export function SearchHighlightChip() {
|
||||
}
|
||||
|
||||
const Chip = styled.button`
|
||||
${undraggableOnDesktop()}
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { shouldAutoDeleteDraftOnUnmount } from "./useDocumentSave";
|
||||
|
||||
describe("shouldAutoDeleteDraftOnUnmount", () => {
|
||||
const baseOptions = {
|
||||
title: "",
|
||||
createdById: "user-1",
|
||||
currentUserId: "user-1",
|
||||
isDraft: true,
|
||||
isActive: true,
|
||||
hasEmptyTitle: true,
|
||||
isPersistedOnce: true,
|
||||
};
|
||||
|
||||
it("does not auto delete drafts with non-empty editor content", () => {
|
||||
expect(
|
||||
shouldAutoDeleteDraftOnUnmount({
|
||||
...baseOptions,
|
||||
isEditorEmpty: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("auto deletes drafts that are still empty and untitled", () => {
|
||||
expect(
|
||||
shouldAutoDeleteDraftOnUnmount({
|
||||
...baseOptions,
|
||||
isEditorEmpty: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user