mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98445e9996 | |||
| da6a449cf3 | |||
| 4631b5ccaa | |||
| 4d5895d2a8 | |||
| 3543fafee3 | |||
| e77cdc2903 | |||
| ecba11b786 | |||
| 6d13347806 | |||
| 36773febd2 | |||
| fa8d82d82a | |||
| cc6d2dc471 | |||
| 5035ad2027 | |||
| 06ec6fdfbb | |||
| acc8d99ca0 | |||
| 7da3108412 | |||
| 7e56d04285 | |||
| 3987b7de3d | |||
| 6daed33b4a | |||
| 3551d16bd8 | |||
| 641c0da603 | |||
| 7768273255 | |||
| 9cadcc668c | |||
| adc11aee9f | |||
| 7ab247f367 | |||
| 9ec5c473f1 | |||
| 02bdb2e464 | |||
| 77d50f8323 | |||
| 76691e8aaa | |||
| 633d41e67f | |||
| 3db845b395 | |||
| 3269eacf68 | |||
| eef2ea4347 | |||
| a2ce13a7dd | |||
| ff13f1a452 | |||
| a5d065e5ec | |||
| fc6152bd55 | |||
| 06d4d7e893 | |||
| a85f36d896 | |||
| 5231318e55 | |||
| 916032508c | |||
| 1a3478a228 | |||
| 1028edaa03 | |||
| 6a736072f0 | |||
| 94f302f712 | |||
| d2ef7e770d | |||
| 323094ce57 | |||
| 0e596f61c8 | |||
| a23888f5d6 | |||
| 515e160bdb | |||
| c853063d1f | |||
| e86593f234 | |||
| 285b770b3d | |||
| 2c27ef9c2c | |||
| 3704dc2a4d | |||
| d37422ab8a | |||
| a75af8759b | |||
| 7c048ef168 | |||
| b3b4ed1dc0 | |||
| 1417a4b958 | |||
| c33d9fd6ec | |||
| 84b874c1a3 | |||
| 2da2081b6f | |||
| 0c3c92aebf | |||
| 6ed666fb38 | |||
| 79ea6279d5 | |||
| fd7f359489 | |||
| 3d7f971d86 | |||
| 9e8f206ebf | |||
| 61d8c2bdb6 | |||
| e77d918871 | |||
| dddf28a834 | |||
| b694250f51 | |||
| d7374730e3 | |||
| 908d0408f5 | |||
| 269bd60b5a | |||
| 87c03fd088 | |||
| b9a8b0f6d6 | |||
| 34ee3b7ea7 | |||
| 5ffe02bcc0 | |||
| 670428d322 | |||
| 3e58a6ca46 | |||
| b21d548d06 | |||
| cadbd0d698 | |||
| 6fdba0ecba | |||
| bb72774f2d | |||
| 76868a3083 | |||
| 0865052bb8 | |||
| de6bc9beca | |||
| e97944ab40 | |||
| 5cfea207e6 | |||
| 95f0c42d56 | |||
| ee7738c141 | |||
| 76701e35ec | |||
| ae8c2aae15 | |||
| a9fa2ed72b | |||
| 0deb7e7f09 | |||
| a544559de2 | |||
| 79fe08e9b6 | |||
| c8d8ba3914 | |||
| 7a148b0353 | |||
| ca891a56da | |||
| 294d3e896a | |||
| d947f8fda2 | |||
| 6dd228a533 | |||
| c7d847215c | |||
| 6995ca8521 | |||
| 8a3452e664 | |||
| f6315875b4 | |||
| f4e53da1bf | |||
| 643188b2f3 | |||
| 6f8f25b0d1 | |||
| 10c3edded7 | |||
| 398943d084 | |||
| a02677c2b1 | |||
| ebf2029539 | |||
| 0df42cb4c7 | |||
| 72c9091b7e | |||
| 740e33156d | |||
| d8ef7b2892 | |||
| 0f9146066c | |||
| 06a1428cbc | |||
| e71a425268 | |||
| 12d31468f8 | |||
| 211c57f6aa | |||
| bb475f3e4e | |||
| 9b95a58822 | |||
| fce02996f9 | |||
| 1aa05b797c | |||
| b69feb50a7 | |||
| 640ecca9ca | |||
| 5fbaa32f18 | |||
| 50b2cf2706 | |||
| db9deb2a46 | |||
| 72cc740b1c | |||
| 4d9717631d | |||
| 69e07a9c21 | |||
| 7b27b74e24 | |||
| 92db179230 | |||
| fa93092f79 | |||
| 63c5938a43 | |||
| a4f77e4438 | |||
| 7bb8ff4797 | |||
| bcdedd53d8 | |||
| 37b18ab940 | |||
| 42d699fabe | |||
| 8026dac146 | |||
| a5e1f613fc | |||
| 3d1f55b605 |
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Close unsigned PRs
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const now = new Date();
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
steps:
|
||||
- name: Checkout Branch
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v5
|
||||
- name: Compress Images
|
||||
id: calibre
|
||||
uses: calibreapp/image-actions@main
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
minPctChange: "10"
|
||||
- name: Create Pull Request
|
||||
# If it's not a Pull Request then commit any changes as a new PR.
|
||||
if: |
|
||||
|
||||
+13
-13
@@ -25,9 +25,9 @@ jobs:
|
||||
node-version: [20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "yarn"
|
||||
@@ -38,8 +38,8 @@ jobs:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
@@ -50,8 +50,8 @@ jobs:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
app: ${{ steps.filter.outputs.app }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
@@ -92,8 +92,8 @@ jobs:
|
||||
matrix:
|
||||
test-group: [app, shared]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
@@ -124,8 +124,8 @@ jobs:
|
||||
shard: [1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
@@ -141,8 +141,8 @@ jobs:
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
run-linters:
|
||||
if: startsWith(github.actor, 'codegen-sh')
|
||||
name: Run linters
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# Give the default GITHUB_TOKEN write permission to commit and push the
|
||||
# added or changed files to the repository.
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn lint --fix
|
||||
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "Applied automatic fixes"
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
|
||||
+2
-4
@@ -20,8 +20,7 @@
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
@@ -48,8 +47,7 @@
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"testEnvironment": "jsdom",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ require("@dotenvx/dotenvx").config({
|
||||
var path = require('path');
|
||||
|
||||
module.exports = {
|
||||
'config': path.resolve('server/config', 'database.json'),
|
||||
'config': path.resolve('server/config', 'database.js'),
|
||||
'migrations-path': path.resolve('server', 'migrations'),
|
||||
'models-path': path.resolve('server', 'models'),
|
||||
}
|
||||
|
||||
+12
-13
@@ -6,7 +6,7 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
# ---
|
||||
FROM node:22-slim AS runner
|
||||
FROM node:22.21.0-slim AS runner
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
@@ -14,7 +14,13 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=base $APP_PATH/build ./build
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
|
||||
COPY --from=base $APP_PATH/server ./server
|
||||
COPY --from=base $APP_PATH/public ./public
|
||||
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
@@ -23,20 +29,13 @@ COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
# Install wget to healthcheck the server
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
chown -R nodejs:nodejs $APP_PATH/build && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
||||
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
|
||||
VOLUME /var/lib/outline/data
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:20 AS deps
|
||||
FROM node:22.21.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 0.87.4
|
||||
Licensed Work: Outline 1.0.1
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -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: 2029-09-18
|
||||
Change Date: 2029-10-29
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
+7
-7
@@ -5,6 +5,13 @@
|
||||
{
|
||||
"files": ["**/*.{jsx,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
"name": "crypto",
|
||||
"message": "Do not use, does not work in environments without SSL."
|
||||
}
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
@@ -13,13 +20,6 @@
|
||||
"group": ["mime-types"],
|
||||
"message": "Do not use the mime-types package in the browser."
|
||||
}
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"name": "reakit/Menu",
|
||||
"importNames": ["useMenuState"],
|
||||
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import {
|
||||
AlphabeticalReverseSortIcon,
|
||||
AlphabeticalSortIcon,
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
ManualSortIcon,
|
||||
NewDocumentIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
@@ -22,11 +26,11 @@ import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createActionV2WithChildren,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
@@ -37,10 +41,16 @@ import {
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import ExportDialog from "~/components/ExportDialog";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import history from "~/utils/history";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
<DynamicCollectionIcon collection={collection} />
|
||||
);
|
||||
const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Collection/SharePopover")
|
||||
);
|
||||
|
||||
export const openCollection = createAction({
|
||||
name: ({ t }) => t("Open collection"),
|
||||
@@ -137,6 +147,129 @@ export const editCollectionPermissions = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createActionV2({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ImportIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return !!stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypes.join(", ");
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
const document = await documents.import(
|
||||
file,
|
||||
null,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
});
|
||||
|
||||
export const sortCollection = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Sort in sidebar"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
!!stores.policies.abilities(activeCollectionId).update,
|
||||
icon: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
const sortAlphabetical = collection?.sort.field === "title";
|
||||
const sortDir = collection?.sort.direction;
|
||||
|
||||
return sortAlphabetical ? (
|
||||
sortDir === "asc" ? (
|
||||
<AlphabeticalSortIcon />
|
||||
) : (
|
||||
<AlphabeticalReverseSortIcon />
|
||||
)
|
||||
) : (
|
||||
<ManualSortIcon />
|
||||
);
|
||||
},
|
||||
children: [
|
||||
createActionV2({
|
||||
name: ({ t }) => t("A-Z sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "asc"
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Z-A sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "desc"
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
direction: "desc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Manual sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.sort.field !== "title";
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "index",
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const searchInCollection = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Search in collection"),
|
||||
analyticsName: "Search collection",
|
||||
|
||||
@@ -176,6 +176,21 @@ export const toggleDebugLogging = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleDebugSafeArea = createAction({
|
||||
name: () => "Toggle menu safe area debugging",
|
||||
icon: <ToolsIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: ({ stores }) => {
|
||||
stores.ui.toggleDebugSafeArea();
|
||||
toast.message(
|
||||
stores.ui.debugSafeArea
|
||||
? "Menu safe area debugging enabled"
|
||||
: "Menu safe area debugging disabled"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleFeatureFlag = createAction({
|
||||
name: "Toggle feature flag",
|
||||
icon: <BeakerIcon />,
|
||||
@@ -209,6 +224,7 @@ export const developer = createAction({
|
||||
children: [
|
||||
copyId,
|
||||
toggleDebugLogging,
|
||||
toggleDebugSafeArea,
|
||||
toggleFeatureFlag,
|
||||
createToast,
|
||||
createTestUsers,
|
||||
|
||||
@@ -50,7 +50,6 @@ import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInT
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
import {
|
||||
@@ -82,7 +81,14 @@ import {
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
|
||||
import Insights from "~/scenes/Document/components/Insights";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Insights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
);
|
||||
const SharePopover = lazyWithRetry(
|
||||
() => import("~/components/Sharing/Document/SharePopover")
|
||||
);
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
@@ -593,12 +599,15 @@ export const copyDocumentAsMarkdown = createActionV2({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
perform: async ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
copy(document.toMarkdown());
|
||||
const { ProsemirrorHelper } = await import(
|
||||
"~/models/helpers/ProsemirrorHelper"
|
||||
);
|
||||
copy(ProsemirrorHelper.toMarkdown(document));
|
||||
toast.success(t("Markdown copied to clipboard"));
|
||||
}
|
||||
},
|
||||
@@ -612,12 +621,15 @@ export const copyDocumentAsPlainText = createActionV2({
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
perform: async ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
copy(document.toPlainText());
|
||||
const { ProsemirrorHelper } = await import(
|
||||
"~/models/helpers/ProsemirrorHelper"
|
||||
);
|
||||
copy(ProsemirrorHelper.toPlainText(document));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
},
|
||||
@@ -849,7 +861,7 @@ export const importDocument = createActionV2({
|
||||
}
|
||||
|
||||
if (activeCollectionId) {
|
||||
return !!stores.policies.abilities(activeCollectionId).update;
|
||||
return !!stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -862,7 +874,6 @@ export const importDocument = createActionV2({
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,7 @@ export const inviteUser = createAction({
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Invite to workspace"),
|
||||
width: "500px",
|
||||
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
|
||||
@@ -13,7 +13,6 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
@@ -37,8 +37,9 @@ const DocumentComments = lazyWithRetry(
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
|
||||
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
|
||||
const SettingsSidebar = lazyWithRetry(
|
||||
() => import("~/components/Sidebar/Settings")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -130,9 +131,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<React.Suspense fallback={null}>
|
||||
<CommandBar />
|
||||
</React.Suspense>
|
||||
<CommandBar />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</DocumentContextProvider>
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import Initials from "./Initials";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
export enum AvatarSize {
|
||||
Small = 16,
|
||||
@@ -22,6 +23,7 @@ export interface IAvatar {
|
||||
avatarUrl: string | null;
|
||||
color?: string;
|
||||
initial?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
@@ -42,6 +44,8 @@ type Props = {
|
||||
className?: string;
|
||||
/** Optional style */
|
||||
style?: React.CSSProperties;
|
||||
/** Whether to show a tooltip */
|
||||
showTooltip?: boolean;
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
@@ -50,12 +54,13 @@ function Avatar(props: Props) {
|
||||
style,
|
||||
variant = AvatarVariant.Round,
|
||||
className,
|
||||
showTooltip,
|
||||
...rest
|
||||
} = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<Relative
|
||||
style={style}
|
||||
$variant={variant}
|
||||
@@ -73,6 +78,12 @@ function Avatar(props: Props) {
|
||||
)}
|
||||
</Relative>
|
||||
);
|
||||
|
||||
return showTooltip ? (
|
||||
<Tooltip content={props.alt || model?.name || ""}>{content}</Tooltip>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
Avatar.defaultProps = {
|
||||
|
||||
@@ -26,6 +26,7 @@ export function GroupAvatar({
|
||||
return (
|
||||
<Squircle color={color ?? theme.text} size={size} className={className}>
|
||||
<GroupIcon
|
||||
data-fixed-color
|
||||
color={backgroundColor ?? theme.background}
|
||||
size={size * 0.75}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import env from "~/env";
|
||||
@@ -44,4 +45,4 @@ const Link = styled.a`
|
||||
}
|
||||
`;
|
||||
|
||||
export default Branding;
|
||||
export default React.memo(Branding);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${s("sidebarText")};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default Header;
|
||||
@@ -1,13 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const MenuIconWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 6px;
|
||||
margin-left: -4px;
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default MenuIconWrapper;
|
||||
@@ -1,217 +0,0 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import { ellipsis, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "../Text";
|
||||
import MenuIconWrapper from "./MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
onClick?: (event: React.MouseEvent) => void | Promise<void>;
|
||||
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
to?: LocationDescriptor;
|
||||
href?: string;
|
||||
target?: string;
|
||||
as?: string | React.ComponentType<any>;
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
|
||||
const MenuItem = (
|
||||
{
|
||||
onClick,
|
||||
onPointerMove,
|
||||
children,
|
||||
active,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
icon,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) => {
|
||||
const content = React.useCallback(
|
||||
(props) => {
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const preventDefault = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
const handleClick = async (ev: React.MouseEvent) => {
|
||||
hide?.();
|
||||
|
||||
if (onClick) {
|
||||
preventDefault(ev);
|
||||
await onClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$active={active}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
onPointerDown={preventDefault}
|
||||
onMouseDown={preventDefault}
|
||||
ref={mergeRefs([
|
||||
ref,
|
||||
props.ref as React.RefObject<HTMLAnchorElement>,
|
||||
])}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<SelectedWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
</SelectedWrapper>
|
||||
)}
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
<Title>{children}</Title>
|
||||
</MenuAnchor>
|
||||
);
|
||||
},
|
||||
[active, as, hide, icon, onClick, ref, children, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onPointerMove={disabled ? undefined : onPointerMove}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.svg`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
${ellipsis()}
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
type MenuAnchorProps = {
|
||||
level?: number;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
disclosure?: boolean;
|
||||
$active?: boolean;
|
||||
};
|
||||
|
||||
export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
color: ${(props) =>
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
${(props) => props.disabled && "pointer-events: none;"}
|
||||
|
||||
${(props) =>
|
||||
props.$active === undefined &&
|
||||
!props.disabled &&
|
||||
`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
props.dangerous ? props.theme.danger : props.theme.accent
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
|
||||
${Text} {
|
||||
color: ${transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.$active &&
|
||||
!props.disabled &&
|
||||
`
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 12px;
|
||||
padding-right: ${(props: MenuAnchorProps) =>
|
||||
props.disclosure ? 32 : 12}px;
|
||||
font-size: 14px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
${MenuAnchorCSS}
|
||||
`;
|
||||
|
||||
const SelectedWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
margin-left: -8px;
|
||||
flex-shrink: 0;
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
|
||||
@@ -1,27 +0,0 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = React.ComponentProps<typeof MenuButton> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function OverflowMenuButton({ className, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton
|
||||
className={className}
|
||||
aria-label={t("More options")}
|
||||
{...props}
|
||||
>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
</MenuSeparator>
|
||||
);
|
||||
}
|
||||
|
||||
const HorizontalRule = styled.hr`
|
||||
margin: 6px 0;
|
||||
`;
|
||||
@@ -1,264 +0,0 @@
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
MenuStateReturn,
|
||||
} from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
|
||||
import Flex from "~/components/Flex";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
MenuSeparator,
|
||||
MenuHeading,
|
||||
MenuItem as TMenuItem,
|
||||
} from "~/types";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Header from "./Header";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import MouseSafeArea from "./MouseSafeArea";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
|
||||
type Props = Omit<MenuStateReturn, "items"> & {
|
||||
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||
context?: Partial<ActionContext>;
|
||||
items?: TMenuItem[];
|
||||
showIcons?: boolean;
|
||||
};
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
transform: rotate(270deg);
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
`;
|
||||
|
||||
type SubMenuProps = MenuStateReturn & {
|
||||
templateItems: TMenuItem[];
|
||||
parentMenuState: Omit<MenuStateReturn, "items">;
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenu = React.forwardRef(function _Template(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState({
|
||||
parentId: parentMenuState.baseId,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor disclosure {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Submenu")}
|
||||
onClick={parentMenuState.hide}
|
||||
parentMenuState={parentMenuState}
|
||||
>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
return items
|
||||
.filter((item) => item.visible !== false)
|
||||
.reduce((acc, item) => {
|
||||
// trim separator if the previous item was a separator
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
acc[acc.length - 1]?.type === "separator"
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
return [...acc, item];
|
||||
}, [] as TMenuItem[])
|
||||
.filter((item, index, arr) => {
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
(index === 0 || index === arr.length - 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
const ctx = useActionContext({
|
||||
isMenu: true,
|
||||
});
|
||||
|
||||
const templateItems = actions
|
||||
? actions.map((item) =>
|
||||
item.type === "separator" || item.type === "heading"
|
||||
? item
|
||||
: actionToMenuItem(item, ctx)
|
||||
)
|
||||
: items || [];
|
||||
|
||||
const filteredTemplates = filterTemplateItems(templateItems);
|
||||
|
||||
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
|
||||
(item) =>
|
||||
item.type !== "separator" && item.type !== "heading" && !!item.icon
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredTemplates.map((item, index) => {
|
||||
if (
|
||||
iconIsPresentInAnyMenuItem &&
|
||||
item.type !== "separator" &&
|
||||
item.type !== "heading" &&
|
||||
showIcons !== false
|
||||
) {
|
||||
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
|
||||
}
|
||||
|
||||
if (item.type === "route") {
|
||||
return (
|
||||
<MenuItem
|
||||
as={Link}
|
||||
id={`${item.title}-${index}`}
|
||||
to={item.to}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "link") {
|
||||
return (
|
||||
<MenuItem
|
||||
id={`${item.title}-${index}`}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
target={
|
||||
typeof item.href === "string" ? undefined : item.href.target
|
||||
}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "button") {
|
||||
const menuItem = (
|
||||
<MenuItem
|
||||
as="button"
|
||||
id={`${item.title}-${index}`}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return item.tooltip ? (
|
||||
<Tooltip
|
||||
content={item.tooltip}
|
||||
placement={"bottom"}
|
||||
key={`tooltip-${item.title}-${index}`}
|
||||
>
|
||||
<div>{menuItem}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
|
||||
{menuItem}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "submenu") {
|
||||
// Skip rendering empty submenus
|
||||
return item.items.length > 0 ? (
|
||||
<BaseMenuItem
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
as={SubMenu}
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={
|
||||
<Title
|
||||
title={item.title}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
/>
|
||||
}
|
||||
{...menu}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (item.type === "separator") {
|
||||
return <Separator key={`separator-${index}`} />;
|
||||
}
|
||||
|
||||
if (item.type === "heading") {
|
||||
return (
|
||||
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
|
||||
);
|
||||
}
|
||||
|
||||
// This should never be reached for Reakit dropdown menu.
|
||||
// Added for exhaustiveness check.
|
||||
if (item.type === "group") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const _exhaustiveCheck: never = item;
|
||||
return _exhaustiveCheck;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Title({
|
||||
title,
|
||||
icon,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Flex align="center">
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
{title}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo<Props>(Template);
|
||||
@@ -1,317 +0,0 @@
|
||||
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuStateReturn } from "reakit/Menu";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
import {
|
||||
fadeIn,
|
||||
fadeAndSlideUp,
|
||||
fadeAndSlideDown,
|
||||
mobileContextMenu,
|
||||
} from "~/styles/animations";
|
||||
|
||||
export type Placement =
|
||||
| "auto-start"
|
||||
| "auto"
|
||||
| "auto-end"
|
||||
| "top-start"
|
||||
| "top"
|
||||
| "top-end"
|
||||
| "right-start"
|
||||
| "right"
|
||||
| "right-end"
|
||||
| "bottom-end"
|
||||
| "bottom"
|
||||
| "bottom-start"
|
||||
| "left-end"
|
||||
| "left"
|
||||
| "left-start";
|
||||
|
||||
type Props = MenuStateReturn & {
|
||||
"aria-label"?: string;
|
||||
/** Reference to the rendered menu div element */
|
||||
menuRef?: React.RefObject<HTMLDivElement>;
|
||||
/** The parent menu state if this is a submenu. */
|
||||
parentMenuState?: Omit<MenuStateReturn, "items">;
|
||||
/** Called when the context menu is opened. */
|
||||
onOpen?: () => void;
|
||||
/** Called when the context menu is closed. */
|
||||
onClose?: () => void;
|
||||
/** Called when the context menu is clicked. */
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
/** The maximum width of the context menu. */
|
||||
maxWidth?: number;
|
||||
/** The minimum height of the context menu. */
|
||||
minHeight?: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ContextMenu: React.FC<Props> = ({
|
||||
menuRef,
|
||||
children,
|
||||
onOpen,
|
||||
onClose,
|
||||
parentMenuState,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { setIsMenuOpen } = useMenuContext();
|
||||
const isMobile = useMobile();
|
||||
const isSubMenu = !!parentMenuState;
|
||||
|
||||
useUnmount(() => {
|
||||
setIsMenuOpen(false);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
onOpen?.();
|
||||
|
||||
if (!isSubMenu) {
|
||||
setIsMenuOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rest.visible && previousVisible) {
|
||||
onClose?.();
|
||||
|
||||
if (!isSubMenu) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
onOpen,
|
||||
onClose,
|
||||
previousVisible,
|
||||
rest.visible,
|
||||
ui.sidebarCollapsed,
|
||||
setIsMenuOpen,
|
||||
isSubMenu,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// sets the menu height based on the available space between the disclosure/
|
||||
// trigger and the bottom of the window
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
hideOnClickOutside={!isMobile}
|
||||
preventBodyScroll={false}
|
||||
{...rest}
|
||||
>
|
||||
{(props) => (
|
||||
<InnerContextMenu
|
||||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
menuProps={props as any}
|
||||
{...rest}
|
||||
isSubMenu={isSubMenu}
|
||||
>
|
||||
{children}
|
||||
</InnerContextMenu>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type InnerContextMenuProps = MenuStateReturn & {
|
||||
isSubMenu: boolean;
|
||||
menuProps: { style?: React.CSSProperties; placement: string };
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inner context menu allows deferring expensive window measurement hooks etc
|
||||
* until the menu is actually opened.
|
||||
*/
|
||||
const InnerContextMenu = (props: InnerContextMenuProps) => {
|
||||
const { menuProps } = props;
|
||||
// kind of hacky, but this is an effective way of telling which way
|
||||
// the menu will _actually_ be placed when taking into account screen
|
||||
// positioning.
|
||||
const topAnchor =
|
||||
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
|
||||
const rightAnchor = menuProps.placement === "bottom-end";
|
||||
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMobile();
|
||||
|
||||
const maxHeight = useMenuHeight({
|
||||
visible: props.visible,
|
||||
elementRef: props.unstable_disclosureRef,
|
||||
});
|
||||
|
||||
// We must manually manage scroll lock for iOS support so that the scrollable
|
||||
// element can be passed into body-scroll-lock. See:
|
||||
// https://github.com/ariakit/ariakit/issues/469
|
||||
React.useEffect(() => {
|
||||
const scrollElement = backgroundRef.current;
|
||||
if (props.visible && scrollElement && !props.isSubMenu) {
|
||||
disableBodyScroll(scrollElement, {
|
||||
reserveScrollBarGap: true,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (scrollElement && !props.isSubMenu) {
|
||||
enableBodyScroll(scrollElement);
|
||||
}
|
||||
};
|
||||
}, [props.isSubMenu, props.visible]);
|
||||
|
||||
useEventListener(
|
||||
"animationstart",
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const parent = event.target.parentElement;
|
||||
if (parent) {
|
||||
parent.style.pointerEvents = "none";
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundRef.current
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const parent = event.target.parentElement;
|
||||
if (parent) {
|
||||
parent.style.pointerEvents = "auto";
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundRef.current
|
||||
);
|
||||
|
||||
const style =
|
||||
topAnchor && !isMobile
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile && (
|
||||
<Backdrop
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
props.hide?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Position {...menuProps}>
|
||||
<Background
|
||||
dir="auto"
|
||||
maxWidth={props.maxWidth}
|
||||
minHeight={props.minHeight}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={style}
|
||||
>
|
||||
{props.visible || props.animating ? props.children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextMenu;
|
||||
|
||||
export const Backdrop = styled.div`
|
||||
animation: ${fadeIn} 200ms ease-in-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${s("backdrop")};
|
||||
z-index: ${depths.menu - 1};
|
||||
`;
|
||||
|
||||
export const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${depths.menu};
|
||||
|
||||
// Note: pointer events are re-enabled after the animation ends, see event listeners above
|
||||
pointer-events: none;
|
||||
|
||||
&:focus-visible {
|
||||
transition-delay: 250ms;
|
||||
transition-property: outline-width;
|
||||
transition-duration: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* overrides make mobile-first coding style challenging
|
||||
* so we explicitly define mobile breakpoint here
|
||||
*/
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed !important;
|
||||
transform: none !important;
|
||||
top: auto !important;
|
||||
right: 8px !important;
|
||||
bottom: 16px !important;
|
||||
left: 8px !important;
|
||||
`};
|
||||
`;
|
||||
|
||||
type BackgroundProps = {
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
min-width: 180px;
|
||||
min-height: ${(props) => props.minHeight || 44}px;
|
||||
max-height: 75vh;
|
||||
font-weight: normal;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
animation: ${(props: BackgroundProps) =>
|
||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: ${(props: BackgroundProps) =>
|
||||
props.rightAnchor ? "75%" : "25%"} 0;
|
||||
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
|
||||
max-height: 100vh;
|
||||
background: ${(props: BackgroundProps) => props.theme.menuBackground};
|
||||
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
|
||||
`};
|
||||
`;
|
||||
@@ -1,7 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Guide from "~/components/Guide";
|
||||
import Modal from "~/components/Modal";
|
||||
import { Suspense } from "react";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Guide = lazyWithRetry(() => import("~/components/Guide"));
|
||||
const Modal = lazyWithRetry(() => import("~/components/Modal"));
|
||||
|
||||
function Dialogs() {
|
||||
const { dialogs } = useStores();
|
||||
@@ -9,7 +12,7 @@ function Dialogs() {
|
||||
const modals = [...modalStack];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
{guide ? (
|
||||
<Guide
|
||||
isOpen={guide.isOpen}
|
||||
@@ -29,11 +32,13 @@ function Dialogs() {
|
||||
}}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
width={modal.width}
|
||||
height={modal.height}
|
||||
>
|
||||
{modal.content}
|
||||
</Modal>
|
||||
))}
|
||||
</>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { subDays } from "date-fns";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
|
||||
import { useRef, useCallback, useMemo } from "react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
|
||||
import { useRef, useCallback, Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
@@ -19,10 +19,12 @@ import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const ReadingTime = lazyWithRetry(() => import("./ReadingTime"));
|
||||
|
||||
type Props = {
|
||||
/** The pin record */
|
||||
@@ -76,6 +78,13 @@ function DocumentCard(props: Props) {
|
||||
const isRecentlyUpdated =
|
||||
new Date(document.updatedAt) > subDays(new Date(), 7);
|
||||
|
||||
const updatedAt = (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Reorderable
|
||||
ref={setNodeRef}
|
||||
@@ -150,12 +159,11 @@ function DocumentCard(props: Props) {
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
{isRecentlyUpdated ? (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
updatedAt
|
||||
) : (
|
||||
<ReadingTime document={document} />
|
||||
<Suspense fallback={updatedAt}>
|
||||
<ReadingTime document={document} />
|
||||
</Suspense>
|
||||
)}
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
@@ -177,21 +185,6 @@ function DocumentCard(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = useMemo(() => document.toMarkdown(), [document]);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSquircle = ({
|
||||
icon,
|
||||
color,
|
||||
|
||||
@@ -3,6 +3,7 @@ import concat from "lodash/concat";
|
||||
import difference from "lodash/difference";
|
||||
import fill from "lodash/fill";
|
||||
import filter from "lodash/filter";
|
||||
import flatten from "lodash/flatten";
|
||||
import includes from "lodash/includes";
|
||||
import map from "lodash/map";
|
||||
import { observer } from "mobx-react";
|
||||
@@ -27,7 +28,6 @@ import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ancestors, descendants, flattenTree } from "~/utils/tree";
|
||||
import flatten from "lodash/flatten";
|
||||
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
@@ -49,8 +49,13 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>();
|
||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||
() => {
|
||||
const node =
|
||||
defaultValue && items.find((item) => item.id === defaultValue);
|
||||
if (!defaultValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search through all nodes in the tree, not just top-level items
|
||||
const allNodes = flatten(items.map(flattenTree));
|
||||
const node = allNodes.find((item) => item.id === defaultValue);
|
||||
return node || null;
|
||||
}
|
||||
);
|
||||
@@ -59,7 +64,9 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
|
||||
if (defaultValue) {
|
||||
const node = items.find((item) => item.id === defaultValue);
|
||||
// Search through all nodes in the tree, not just top-level items
|
||||
const allNodes = flatten(items.map(flattenTree));
|
||||
const node = allNodes.find((item) => item.id === defaultValue);
|
||||
if (node) {
|
||||
return ancestors(node).map((ancestorNode) => ancestorNode.id);
|
||||
}
|
||||
@@ -104,19 +111,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
);
|
||||
}, [items.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultValue && selectedNode && listRef) {
|
||||
const index = nodes.findIndex((node) => node.id === selectedNode.id);
|
||||
if (index > 0) {
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
function getNodes() {
|
||||
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
||||
return expandedNodes.includes(item.id)
|
||||
@@ -130,6 +124,19 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
|
||||
React.useEffect(() => {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultValue && selectedNode && listRef) {
|
||||
const index = nodes.findIndex((node) => node.id === selectedNode.id);
|
||||
if (index > 0) {
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, [defaultValue, selectedNode, nodes]);
|
||||
const baseDepth = nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
|
||||
@@ -94,7 +94,7 @@ function DocumentListItem(
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ document });
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
|
||||
@@ -39,6 +39,7 @@ function DocumentTasks({ document }: Props) {
|
||||
const done = completed === total;
|
||||
const previousDone = usePrevious(done);
|
||||
const message = getMessage(t, total, completed);
|
||||
|
||||
return (
|
||||
<>
|
||||
{completed === total ? (
|
||||
|
||||
@@ -32,6 +32,7 @@ function EditableTitle(
|
||||
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
|
||||
const [originalValue, setOriginalValue] = React.useState(title);
|
||||
const [value, setValue] = React.useState(title);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
setIsEditing,
|
||||
@@ -65,6 +66,10 @@ function EditableTitle(
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue === originalValue || trimmedValue.length === 0) {
|
||||
@@ -74,18 +79,22 @@ function EditableTitle(
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(trimmedValue);
|
||||
setOriginalValue(trimmedValue);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
setValue(value);
|
||||
setIsEditing(true);
|
||||
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsEditing(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[originalValue, value, onCancel, onSubmit]
|
||||
[originalValue, value, onCancel, onSubmit, isSubmitting]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
|
||||
@@ -25,6 +25,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
);
|
||||
const [includeAttachments, setIncludeAttachments] =
|
||||
React.useState<boolean>(true);
|
||||
const [includePrivate, setIncludePrivate] = React.useState<boolean>(true);
|
||||
const user = useCurrentUser();
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -44,6 +45,13 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleIncludePrivateChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIncludePrivate(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (collection) {
|
||||
await collection.export(format, includeAttachments);
|
||||
@@ -59,7 +67,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await collections.export(format, includeAttachments);
|
||||
await collections.export({ format, includeAttachments, includePrivate });
|
||||
toast.success(t("Export started"));
|
||||
}
|
||||
onSubmit();
|
||||
@@ -123,37 +131,62 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="small">{item.description}</Text>
|
||||
<Text size="small" type="secondary">
|
||||
{item.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Flex>
|
||||
<hr />
|
||||
<Option>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includeAttachments"
|
||||
checked={includeAttachments}
|
||||
onChange={handleIncludeAttachmentsChange}
|
||||
/>
|
||||
<div>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{t("Include attachments")}
|
||||
</Text>
|
||||
<Text size="small">
|
||||
{t("Including uploaded images and files in the exported data")}.
|
||||
</Text>{" "}
|
||||
</div>
|
||||
</Option>
|
||||
<HR />
|
||||
<Flex gap={12} column>
|
||||
<Option>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includeAttachments"
|
||||
checked={includeAttachments}
|
||||
onChange={handleIncludeAttachmentsChange}
|
||||
/>
|
||||
<div>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{t("Include attachments")}
|
||||
</Text>
|
||||
<Text size="small" type="secondary">
|
||||
{t("Including uploaded images and files in the exported data")}.
|
||||
</Text>{" "}
|
||||
</div>
|
||||
</Option>
|
||||
<Option>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includePrivate"
|
||||
checked={includePrivate}
|
||||
onChange={handleIncludePrivateChange}
|
||||
/>
|
||||
<div>
|
||||
<Text as="p" size="small" weight="bold">
|
||||
{t("Include private collections")}
|
||||
</Text>
|
||||
</div>
|
||||
</Option>
|
||||
</Flex>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const HR = styled.hr`
|
||||
margin: 16px 0;
|
||||
`;
|
||||
|
||||
const Option = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
|
||||
input {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import styled from "styled-components";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** The users to display */
|
||||
@@ -21,6 +22,8 @@ type Props = {
|
||||
model: User;
|
||||
}
|
||||
>;
|
||||
/** Whether to show tooltips on hover, defaults to true */
|
||||
showTooltip?: boolean;
|
||||
};
|
||||
|
||||
function Facepile({
|
||||
@@ -29,6 +32,7 @@ function Facepile({
|
||||
size = AvatarSize.Large,
|
||||
limit = 8,
|
||||
renderAvatar = Avatar,
|
||||
showTooltip = true,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -51,6 +55,7 @@ function Facepile({
|
||||
<Component
|
||||
key={model.id}
|
||||
{...{
|
||||
showTooltip,
|
||||
model,
|
||||
size,
|
||||
style: {
|
||||
@@ -101,6 +106,11 @@ const Avatars = styled(Flex)`
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
cursor: var(--pointer);
|
||||
|
||||
*:hover {
|
||||
clip-path: none !important;
|
||||
box-shadow: 0 0 0 2px ${s("background")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Facepile);
|
||||
|
||||
@@ -13,6 +13,7 @@ import useStores from "~/hooks/useStores";
|
||||
import LoadingIndicator from "../LoadingIndicator";
|
||||
import { CARD_MARGIN } from "./Components";
|
||||
import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
import HoverPreviewGroup from "./HoverPreviewGroup";
|
||||
import HoverPreviewIssue from "./HoverPreviewIssue";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
@@ -132,6 +133,13 @@ const HoverPreviewDesktop = observer(
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Group ? (
|
||||
<HoverPreviewGroup
|
||||
ref={cardRef}
|
||||
name={data.name}
|
||||
memberCount={data.memberCount}
|
||||
users={data.users}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
@@ -295,10 +303,10 @@ const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
${({ direction, theme }) =>
|
||||
${({ direction }) =>
|
||||
direction === Direction.UP
|
||||
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
|
||||
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
|
||||
? `border-bottom-color: rgba(0, 0, 0, 0.1)`
|
||||
: `border-top-color: rgba(0, 0, 0, 0.1)`};
|
||||
${({ direction }) =>
|
||||
direction === Direction.UP ? "right: -1px" : "left: -1px"};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import User from "~/models/User";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Info,
|
||||
Card,
|
||||
CardContent,
|
||||
Description,
|
||||
} from "./Components";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
|
||||
|
||||
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
|
||||
{ name, memberCount, users }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview as="div">
|
||||
<Card fadeOut={false} ref={ref}>
|
||||
<CardContent>
|
||||
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
|
||||
<Flex column gap={2} align="start">
|
||||
<Title>{name}</Title>
|
||||
<Info>
|
||||
{memberCount === 1 ? "1 member" : `${memberCount} members`}
|
||||
</Info>
|
||||
{users.length > 0 && (
|
||||
<Description>
|
||||
<Facepile
|
||||
users={users.map(
|
||||
(member) =>
|
||||
({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
avatarUrl: member.avatarUrl,
|
||||
color: member.color,
|
||||
initial: member.name ? member.name[0] : "?",
|
||||
}) as User
|
||||
)}
|
||||
overflow={Math.max(0, memberCount - users.length)}
|
||||
limit={MAX_AVATAR_DISPLAY}
|
||||
/>
|
||||
</Description>
|
||||
)}
|
||||
</Flex>
|
||||
</ErrorBoundary>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewGroup;
|
||||
@@ -7,7 +7,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
|
||||
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ avatarUrl, name, lastActive, color, email }: Props,
|
||||
{ avatarUrl, name, lastActive, color }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
@@ -25,7 +25,6 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
/>
|
||||
<Flex column gap={2} justify="center">
|
||||
<Title>{name}</Title>
|
||||
{email && <Info>{email}</Info>}
|
||||
<Info>{lastActive}</Info>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { QuestionMarkIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import Separator from "./ContextMenu/Separator";
|
||||
import Flex from "./Flex";
|
||||
import { LabelText } from "./Input";
|
||||
import NudeButton from "./NudeButton";
|
||||
@@ -219,9 +217,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
(option: Option, idx: number) => {
|
||||
if (option.type === "separator") {
|
||||
return <Separator />;
|
||||
return <InputSelectSeparator key={`separator-${idx}`} />;
|
||||
}
|
||||
|
||||
const isSelected = option === selectedOption;
|
||||
@@ -343,9 +341,9 @@ function Option({
|
||||
{option.description && (
|
||||
<>
|
||||
|
||||
<Description type="tertiary" size="small" ellipsis>
|
||||
<Text type="tertiary" size="small" ellipsis>
|
||||
– {option.description}
|
||||
</Description>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</OptionContainer>
|
||||
@@ -361,15 +359,6 @@ const OptionContainer = styled(Flex)`
|
||||
min-height: 24px;
|
||||
`;
|
||||
|
||||
const Description = styled(Text)`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: ${(props) => transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
+27
-30
@@ -12,7 +12,6 @@ import SkipNavLink from "~/components/SkipNavLink";
|
||||
import env from "~/env";
|
||||
import useAutoRefresh from "~/hooks/useAutoRefresh";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { MenuProvider } from "~/hooks/useMenuContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
@@ -38,41 +37,39 @@ const Layout = React.forwardRef(function Layout_(
|
||||
});
|
||||
|
||||
return (
|
||||
<MenuProvider>
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
</Helmet>
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
</Helmet>
|
||||
|
||||
<SkipNavLink />
|
||||
<SkipNavLink />
|
||||
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
|
||||
<Container auto>
|
||||
<MenuProvider>{sidebar}</MenuProvider>
|
||||
<Container auto>
|
||||
{sidebar}
|
||||
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Content>
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
</MenuProvider>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+459
-116
@@ -1,12 +1,21 @@
|
||||
import { useEditor } from "~/editor/components/EditorContext";
|
||||
import { observer } from "mobx-react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { findChildren } from "@shared/editor/queries/findChildren";
|
||||
import findIndex from "lodash/findIndex";
|
||||
import styled, { css, Keyframes, keyframes } from "styled-components";
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { sanitizeUrl } from "@shared/utils/urls";
|
||||
import { Error } from "@shared/editor/components/Image";
|
||||
import {
|
||||
ComponentProps,
|
||||
createContext,
|
||||
forwardRef,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { Error as ImageError } from "@shared/editor/components/Image";
|
||||
import {
|
||||
BackIcon,
|
||||
CloseIcon,
|
||||
@@ -14,12 +23,13 @@ import {
|
||||
DownloadIcon,
|
||||
LinkIcon,
|
||||
NextIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from "outline-icons";
|
||||
import { depths, extraArea, s } from "@shared/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
import useIdle from "~/hooks/useIdle";
|
||||
import { Second } from "@shared/utils/time";
|
||||
import { downloadImageNode } from "@shared/editor/nodes/Image";
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -29,6 +39,17 @@ import Button from "./Button";
|
||||
import CopyToClipboard from "./CopyToClipboard";
|
||||
import { Separator } from "./Actions";
|
||||
import useSwipe from "~/hooks/useSwipe";
|
||||
import { toast } from "sonner";
|
||||
import { findIndex } from "lodash";
|
||||
import { LightboxImage } from "@shared/editor/lib/Lightbox";
|
||||
import {
|
||||
TransformWrapper,
|
||||
TransformComponent,
|
||||
useTransformEffect,
|
||||
ReactZoomPanPinchRef,
|
||||
} from "react-zoom-pan-pinch";
|
||||
import { transparentize } from "polished";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
|
||||
export enum LightboxStatus {
|
||||
READY_TO_OPEN,
|
||||
@@ -43,6 +64,9 @@ export enum ImageStatus {
|
||||
LOADING,
|
||||
ERROR,
|
||||
LOADED,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM,
|
||||
ZOOMED,
|
||||
}
|
||||
type Status = {
|
||||
lightbox: LightboxStatus | null;
|
||||
@@ -60,46 +84,152 @@ type Animation = {
|
||||
const ANIMATION_DURATION = 0.3 * Second.ms;
|
||||
|
||||
type Props = {
|
||||
/** Callback triggered when the active image position is updated */
|
||||
onUpdate: (pos: number | null) => void;
|
||||
/** List of allowed images */
|
||||
images: LightboxImage[];
|
||||
/** The position of the currently active image in the document */
|
||||
activePos: number | null;
|
||||
activeImage: LightboxImage;
|
||||
/** Callback triggered when the active image is updated */
|
||||
onUpdate: (activeImage: LightboxImage | null) => void;
|
||||
/** Callback triggered when Lightbox closes */
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function Lightbox({ onUpdate, activePos }: Props) {
|
||||
const { view } = useEditor();
|
||||
const ZoomPanPinchContext = createContext({ isImagePanning: false });
|
||||
type ZoomablePannablePinchableProps = {
|
||||
children: ReactNode;
|
||||
panningDisabled: boolean;
|
||||
disabled: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const ZoomablePannablePinchable = forwardRef<
|
||||
ReactZoomPanPinchRef,
|
||||
ZoomablePannablePinchableProps
|
||||
>(({ children, panningDisabled, disabled, onClose }, ref) => {
|
||||
const { isPanning, ...panningHandlers } = usePanning();
|
||||
const wrapperRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
const scale = wrapperRef.current?.instance.transformState.scale ?? 1;
|
||||
|
||||
const wrapperProps = useMemo(
|
||||
() =>
|
||||
({
|
||||
onClick: (event) => {
|
||||
if (scale > 1) {
|
||||
return;
|
||||
}
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
["IMG", "INPUT", "BUTTON", "A"].includes(
|
||||
(event.target as Element).tagName
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose?.();
|
||||
},
|
||||
}) satisfies HTMLAttributes<HTMLDivElement>,
|
||||
[onClose, scale]
|
||||
);
|
||||
|
||||
return (
|
||||
<ZoomPanPinchContext.Provider value={{ isImagePanning: isPanning }}>
|
||||
<TransformWrapper
|
||||
ref={mergeRefs([ref, wrapperRef])}
|
||||
disabled={disabled}
|
||||
doubleClick={{ disabled: true }}
|
||||
minScale={1}
|
||||
maxScale={8}
|
||||
panning={{
|
||||
disabled: panningDisabled,
|
||||
}}
|
||||
{...panningHandlers}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: isPanning ? "grabbing" : scale > 1 ? "grab" : "zoom-out",
|
||||
}}
|
||||
contentStyle={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: "56px",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
{children}
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
</ZoomPanPinchContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
function usePanning() {
|
||||
const [isPanning, setPanning] = useState(false);
|
||||
const dragged = useRef(false);
|
||||
|
||||
const onPanningStart: ComponentProps<
|
||||
typeof TransformWrapper
|
||||
>["onPanningStart"] = (ref) => {
|
||||
const zoomedIn = ref.state.scale > 1;
|
||||
if (zoomedIn) {
|
||||
setPanning(ref.instance.isPanning);
|
||||
}
|
||||
};
|
||||
|
||||
const onPanning: ComponentProps<
|
||||
typeof TransformWrapper
|
||||
>["onPanning"] = () => {
|
||||
dragged.current = true;
|
||||
};
|
||||
|
||||
const onPanningStop: ComponentProps<
|
||||
typeof TransformWrapper
|
||||
>["onPanningStop"] = (ref, event) => {
|
||||
setPanning(ref.instance.isPanning);
|
||||
if (dragged.current) {
|
||||
dragged.current = false;
|
||||
} else if (event.target instanceof HTMLImageElement) {
|
||||
const zoomedOut = Math.abs(ref.state.scale - 1) < 0.001;
|
||||
if (zoomedOut) {
|
||||
ref.zoomIn();
|
||||
} else {
|
||||
ref.resetTransform();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isPanning,
|
||||
onPanningStart,
|
||||
onPanning,
|
||||
onPanningStop,
|
||||
};
|
||||
}
|
||||
|
||||
function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
|
||||
const isIdle = useIdle(3 * Second.ms);
|
||||
const { t } = useTranslation();
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
|
||||
const [imageElements] = useState(
|
||||
view?.dom.querySelectorAll(".component-image img")
|
||||
);
|
||||
const animation = useRef<Animation | null>(null);
|
||||
const finalImage = useRef<{
|
||||
center: { x: number; y: number };
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const zoomPanPinchRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
|
||||
const imageNodes = useMemo(
|
||||
() =>
|
||||
view
|
||||
? findChildren(
|
||||
view.state.doc,
|
||||
(child) => child.type === view.state.schema.nodes.image,
|
||||
true
|
||||
)
|
||||
: [],
|
||||
[view]
|
||||
);
|
||||
const currentImageIndex = findIndex(
|
||||
imageNodes,
|
||||
(node) => node.pos === activePos
|
||||
images,
|
||||
(img) => img.getPos() === activeImage.getPos()
|
||||
);
|
||||
const currentImageNode =
|
||||
currentImageIndex >= 0 ? imageNodes[currentImageIndex].node : undefined;
|
||||
|
||||
// Debugging status changes
|
||||
// useEffect(() => {
|
||||
@@ -108,15 +238,21 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
// );
|
||||
// }, [status]);
|
||||
|
||||
useEffect(() => () => view.focus(), []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (status.lightbox === LightboxStatus.CLOSED) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[status.lightbox]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
!!activePos &&
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.READY_TO_OPEN,
|
||||
image: status.image,
|
||||
});
|
||||
}, [!!activePos]);
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.READY_TO_OPEN,
|
||||
image: status.image,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.image === ImageStatus.LOADED) {
|
||||
@@ -139,6 +275,18 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
}, [status.image, status.lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
status.lightbox === LightboxStatus.OPENED &&
|
||||
status.image === ImageStatus.LOADED
|
||||
) {
|
||||
setStatus({
|
||||
lightbox: LightboxStatus.OPENED,
|
||||
image: ImageStatus.MIN_ZOOM,
|
||||
});
|
||||
}
|
||||
}, [status.lightbox, status.image]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.lightbox === LightboxStatus.READY_TO_CLOSE) {
|
||||
setupFadeOut();
|
||||
@@ -156,6 +304,15 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
}, [status.lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.image === ImageStatus.MIN_ZOOM) {
|
||||
// It was observed that focus went to `body` as the zoom out button was disabled
|
||||
// upon clicking it. This stopped navigating to next/previous image using arrow keys.
|
||||
// So focusing the content div here to restore the functionality.
|
||||
contentRef.current?.focus();
|
||||
}
|
||||
}, [status.image]);
|
||||
|
||||
const rememberImagePosition = () => {
|
||||
if (imgRef.current) {
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
@@ -179,11 +336,10 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
const setupZoomIn = () => {
|
||||
if (imgRef.current) {
|
||||
// in editor
|
||||
const editorImageEl = imageElements[currentImageIndex];
|
||||
const editorImageEl = activeImage.getElement();
|
||||
if (!editorImageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
|
||||
const {
|
||||
top: editorImgTop,
|
||||
@@ -270,7 +426,13 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
};
|
||||
|
||||
const setupZoomOut = () => {
|
||||
if (imgRef.current) {
|
||||
if (
|
||||
imgRef.current &&
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
)
|
||||
) {
|
||||
// in lightbox
|
||||
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
|
||||
const {
|
||||
@@ -289,7 +451,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
};
|
||||
|
||||
// in editor
|
||||
const editorImageEl = imageElements[currentImageIndex];
|
||||
const editorImageEl = activeImage.getElement();
|
||||
let to;
|
||||
if (editorImageEl?.isConnected) {
|
||||
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
|
||||
@@ -364,33 +526,31 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
if (!activePos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prev = () => {
|
||||
if (status.lightbox === LightboxStatus.OPENED) {
|
||||
if (!activePos) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
status.lightbox === LightboxStatus.OPENED &&
|
||||
(status.image === ImageStatus.MIN_ZOOM ||
|
||||
status.image === ImageStatus.ERROR)
|
||||
) {
|
||||
const prevIndex = currentImageIndex - 1;
|
||||
if (prevIndex < 0) {
|
||||
return;
|
||||
}
|
||||
onUpdate(imageNodes[prevIndex].pos);
|
||||
onUpdate(images[prevIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (status.lightbox === LightboxStatus.OPENED) {
|
||||
if (!activePos) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
status.lightbox === LightboxStatus.OPENED &&
|
||||
(status.image === ImageStatus.MIN_ZOOM ||
|
||||
status.image === ImageStatus.ERROR)
|
||||
) {
|
||||
const nextIndex = currentImageIndex + 1;
|
||||
if (nextIndex >= imageNodes.length) {
|
||||
if (nextIndex >= images.length) {
|
||||
return;
|
||||
}
|
||||
onUpdate(imageNodes[nextIndex].pos);
|
||||
onUpdate(images[nextIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -406,12 +566,63 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
if (currentImageNode && status.lightbox === LightboxStatus.OPENED) {
|
||||
void downloadImageNode(currentImageNode);
|
||||
const svgDataURLToBlob = (dataURL: string) => {
|
||||
// Match the SVG data URL format
|
||||
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedSVGData = match[1];
|
||||
const decodedSVGData = decodeURIComponent(encodedSVGData);
|
||||
|
||||
// Convert string to Uint8Array
|
||||
const uint8 = new Uint8Array(decodedSVGData.length);
|
||||
for (let i = 0; i < decodedSVGData.length; ++i) {
|
||||
uint8[i] = decodedSVGData.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Create and return the Blob
|
||||
return new Blob([uint8], { type: "image/svg+xml" });
|
||||
};
|
||||
|
||||
const downloadImage = async (src: string, saveAs: string) => {
|
||||
let imageBlob;
|
||||
if (isInternalUrl(src)) {
|
||||
const image = await fetch(src);
|
||||
imageBlob = await image.blob();
|
||||
} else {
|
||||
// Assuming it's a mermaid svg
|
||||
imageBlob = svgDataURLToBlob(src);
|
||||
}
|
||||
|
||||
if (!imageBlob) {
|
||||
toast.error(t("Unable to download image"));
|
||||
return;
|
||||
}
|
||||
|
||||
const imageURL = URL.createObjectURL(imageBlob);
|
||||
const name = saveAs || "image";
|
||||
const extension = imageBlob.type.split(/\/|\+/g)[1];
|
||||
|
||||
// create a temporary link node and click it with our image data
|
||||
const link = document.createElement("a");
|
||||
link.href = imageURL;
|
||||
link.download = `${name}.${extension}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// cleanup
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(imageURL);
|
||||
};
|
||||
|
||||
const download = useCallback(() => {
|
||||
if (activeImage && status.lightbox === LightboxStatus.OPENED) {
|
||||
void downloadImage(activeImage.getSrc(), activeImage.getAlt());
|
||||
}
|
||||
}, [activeImage, status.lightbox]);
|
||||
|
||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
ev.preventDefault();
|
||||
switch (ev.key) {
|
||||
@@ -459,14 +670,8 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentImageNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const src = sanitizeUrl(currentImageNode.attrs.src) ?? "";
|
||||
|
||||
return (
|
||||
<Dialog.Root open={!!activePos}>
|
||||
<Dialog.Root open={true}>
|
||||
<Dialog.Portal>
|
||||
<StyledOverlay
|
||||
ref={overlayRef}
|
||||
@@ -474,7 +679,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
onAnimationStart={handleFadeStart}
|
||||
onAnimationEnd={handleFadeEnd}
|
||||
/>
|
||||
<StyledContent onKeyDown={handleKeyDown}>
|
||||
<StyledContent onKeyDown={handleKeyDown} ref={contentRef}>
|
||||
<VisuallyHidden.Root>
|
||||
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
@@ -482,10 +687,52 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
</Dialog.Description>
|
||||
</VisuallyHidden.Root>
|
||||
<Actions animation={animation.current}>
|
||||
<Tooltip content={t("Zoom in")} placement="bottom">
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
status.image === ImageStatus.MAX_ZOOM ||
|
||||
status.image === ImageStatus.ERROR
|
||||
}
|
||||
onClick={() => {
|
||||
if (zoomPanPinchRef.current) {
|
||||
zoomPanPinchRef.current.zoomIn();
|
||||
}
|
||||
}}
|
||||
aria-label={t("Zoom in")}
|
||||
size={32}
|
||||
icon={<ZoomInIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Zoom out")} placement="bottom">
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
disabled={
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (zoomPanPinchRef.current) {
|
||||
zoomPanPinchRef.current.zoomOut();
|
||||
}
|
||||
}}
|
||||
aria-label={t("Zoom out")}
|
||||
size={32}
|
||||
icon={<ZoomOutIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Tooltip>
|
||||
<Separator />
|
||||
<Tooltip content={t("Copy link")} placement="bottom">
|
||||
<CopyToClipboard text={imgRef.current?.src ?? ""}>
|
||||
<Button
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
disabled={status.image === ImageStatus.ERROR}
|
||||
aria-label={t("Copy link")}
|
||||
size={32}
|
||||
icon={<LinkIcon />}
|
||||
@@ -495,8 +742,9 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Download")} placement="bottom">
|
||||
<Button
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
disabled={status.image === ImageStatus.ERROR}
|
||||
onClick={download}
|
||||
aria-label={t("Download")}
|
||||
size={32}
|
||||
@@ -508,7 +756,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
<Separator />
|
||||
<Dialog.Close asChild>
|
||||
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
|
||||
<Button
|
||||
<ActionButton
|
||||
tabIndex={-1}
|
||||
onClick={close}
|
||||
aria-label={t("Close")}
|
||||
@@ -520,49 +768,87 @@ function Lightbox({ onUpdate, activePos }: Props) {
|
||||
</Tooltip>
|
||||
</Dialog.Close>
|
||||
</Actions>
|
||||
{currentImageIndex > 0 && (
|
||||
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
|
||||
<BackIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
<Image
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={currentImageNode.attrs.alt ?? ""}
|
||||
onLoading={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADING,
|
||||
})
|
||||
{currentImageIndex > 0 &&
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
) && (
|
||||
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
|
||||
<BackIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
<ZoomablePannablePinchable
|
||||
panningDisabled={
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
)
|
||||
}
|
||||
onLoad={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADED,
|
||||
})
|
||||
}
|
||||
onError={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.ERROR,
|
||||
})
|
||||
}
|
||||
onSwipeRight={prev}
|
||||
onSwipeLeft={next}
|
||||
onSwipeUp={close}
|
||||
onSwipeDown={close}
|
||||
status={status}
|
||||
animation={animation.current}
|
||||
/>
|
||||
{currentImageIndex < imageNodes.length - 1 && (
|
||||
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={next} size={32} aria-label={t("Next")}>
|
||||
<NextIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
disabled={status.image === ImageStatus.ERROR}
|
||||
ref={zoomPanPinchRef}
|
||||
onClose={close}
|
||||
>
|
||||
<Image
|
||||
ref={imgRef}
|
||||
src={activeImage.getSrc()}
|
||||
alt={activeImage.getAlt()}
|
||||
onLoading={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADING,
|
||||
})
|
||||
}
|
||||
onLoad={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.LOADED,
|
||||
})
|
||||
}
|
||||
onError={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.ERROR,
|
||||
})
|
||||
}
|
||||
onSwipeRight={prev}
|
||||
onSwipeLeft={next}
|
||||
onSwipeUp={close}
|
||||
onSwipeDown={close}
|
||||
status={status}
|
||||
animation={animation.current}
|
||||
onMinZoom={() => {
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.MIN_ZOOM,
|
||||
});
|
||||
}}
|
||||
onZoom={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.ZOOMED,
|
||||
})
|
||||
}
|
||||
onMaxZoom={() =>
|
||||
setStatus({
|
||||
lightbox: status.lightbox,
|
||||
image: ImageStatus.MAX_ZOOM,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ZoomablePannablePinchable>
|
||||
{currentImageIndex < images.length - 1 &&
|
||||
!(
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
) && (
|
||||
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
|
||||
<NavButton onClick={next} size={32} aria-label={t("Next")}>
|
||||
<NextIcon size={32} />
|
||||
</NavButton>
|
||||
</Nav>
|
||||
)}
|
||||
</StyledContent>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -581,6 +867,9 @@ type ImageProps = {
|
||||
onSwipeDown: () => void;
|
||||
status: Status;
|
||||
animation: Animation | null;
|
||||
onMinZoom: () => void;
|
||||
onZoom: () => void;
|
||||
onMaxZoom: () => void;
|
||||
};
|
||||
|
||||
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
@@ -596,6 +885,9 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
onSwipeDown,
|
||||
status,
|
||||
animation,
|
||||
onMinZoom,
|
||||
onZoom,
|
||||
onMaxZoom,
|
||||
}: ImageProps,
|
||||
ref
|
||||
) {
|
||||
@@ -608,6 +900,25 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
onSwipeDown,
|
||||
});
|
||||
|
||||
const { isImagePanning } = useContext(ZoomPanPinchContext);
|
||||
|
||||
useTransformEffect(({ state, instance }) => {
|
||||
const minScale = instance.props.minScale ?? 1;
|
||||
const maxScale = instance.props.maxScale ?? 8;
|
||||
const { scale } = state;
|
||||
if (scale === minScale && status.image === ImageStatus.ZOOMED) {
|
||||
onMinZoom();
|
||||
} else if (scale === maxScale && status.image === ImageStatus.ZOOMED) {
|
||||
onMaxZoom();
|
||||
} else if (
|
||||
scale > minScale &&
|
||||
scale < maxScale &&
|
||||
status.image !== ImageStatus.ZOOMED
|
||||
) {
|
||||
onZoom();
|
||||
}
|
||||
});
|
||||
|
||||
const [hidden, setHidden] = useState(
|
||||
status.image === null || status.image === ImageStatus.LOADING
|
||||
);
|
||||
@@ -642,9 +953,15 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
onError={onError}
|
||||
onLoad={onLoad}
|
||||
$hidden={hidden}
|
||||
$zoomedIn={
|
||||
status.image === ImageStatus.ZOOMED ||
|
||||
status.image === ImageStatus.MAX_ZOOM
|
||||
}
|
||||
$zoomedOut={status.image === ImageStatus.MIN_ZOOM}
|
||||
$panning={isImagePanning}
|
||||
/>
|
||||
<Caption>
|
||||
{status.image === ImageStatus.LOADED &&
|
||||
{status.image === ImageStatus.MIN_ZOOM &&
|
||||
status.lightbox === LightboxStatus.OPENED ? (
|
||||
<Fade>{alt}</Fade>
|
||||
) : null}
|
||||
@@ -700,12 +1017,25 @@ const StyledOverlay = styled(Dialog.Overlay)<{
|
||||
|
||||
const StyledImg = styled.img<{
|
||||
$hidden: boolean;
|
||||
$zoomedIn: boolean;
|
||||
$zoomedOut: boolean;
|
||||
$panning: boolean;
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
visibility: ${(props) => (props.$hidden ? "hidden" : "visible")};
|
||||
pointer-events: auto !important;
|
||||
max-width: 100%;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
cursor: ${(props) =>
|
||||
props.$panning
|
||||
? "grabbing"
|
||||
: props.$zoomedOut
|
||||
? "zoom-in"
|
||||
: props.$zoomedIn
|
||||
? "zoom-out"
|
||||
: "default"};
|
||||
|
||||
${(props) =>
|
||||
props.animation?.zoomIn
|
||||
? css`
|
||||
@@ -717,7 +1047,12 @@ const StyledImg = styled.img<{
|
||||
animation: ${props.animation.zoomOut.apply()}
|
||||
${props.animation.zoomOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
: props.animation?.fadeOut
|
||||
? css`
|
||||
animation: ${props.animation.fadeOut.apply()}
|
||||
${props.animation.fadeOut.duration}ms;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(Dialog.Content)`
|
||||
@@ -728,7 +1063,10 @@ const StyledContent = styled(Dialog.Content)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
padding: 56px;
|
||||
`;
|
||||
|
||||
const ActionButton = styled(Button)`
|
||||
background: transparent;
|
||||
`;
|
||||
|
||||
const Actions = styled.div<{
|
||||
@@ -741,6 +1079,10 @@ const Actions = styled.div<{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: ${depths.modal};
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: 6px;
|
||||
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
@@ -768,6 +1110,7 @@ const Nav = styled.div<{
|
||||
position: absolute;
|
||||
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
|
||||
transition: opacity 500ms ease-in-out;
|
||||
z-index: ${depths.modal};
|
||||
${(props) => props.$hidden && "opacity: 0;"}
|
||||
${(props) =>
|
||||
props.animation === null
|
||||
@@ -787,7 +1130,7 @@ const Nav = styled.div<{
|
||||
: ""}
|
||||
`;
|
||||
|
||||
const StyledError = styled(Error)<{
|
||||
const StyledError = styled(ImageError)<{
|
||||
animation: Animation | null;
|
||||
}>`
|
||||
${(props) =>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
|
||||
export const MeasuredContainer = <T extends React.ElementType>({
|
||||
as: As,
|
||||
as: As = "div",
|
||||
name,
|
||||
children,
|
||||
...rest
|
||||
@@ -11,7 +11,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
action?: ActionV2WithChildren;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** ARIA label for the menu */
|
||||
@@ -35,10 +35,10 @@ export const ContextMenu = observer(
|
||||
return [];
|
||||
}
|
||||
|
||||
return (action.children as ActionV2Variant[]).map((childAction) =>
|
||||
actionV2ToMenuItem(childAction, actionContext)
|
||||
return ((action?.children as ActionV2Variant[]) ?? []).map(
|
||||
(childAction) => actionV2ToMenuItem(childAction, actionContext)
|
||||
);
|
||||
}, [open, action.children, actionContext]);
|
||||
}, [open, action?.children, actionContext]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -68,7 +68,7 @@ export const ContextMenu = observer(
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
if (isMobile || !action || menuItems.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
} from "~/components/primitives/Menu";
|
||||
import * as Components from "~/components/primitives/components/Menu";
|
||||
import { MenuItem } from "~/types";
|
||||
import { MouseSafeArea } from "~/components/MouseSafeArea";
|
||||
import { createRef } from "react";
|
||||
|
||||
export function toMenuItems(items: MenuItem[]) {
|
||||
const filteredItems = filterMenuItems(items);
|
||||
const parentRef = createRef<HTMLDivElement>();
|
||||
|
||||
if (!filteredItems.length) {
|
||||
return null;
|
||||
@@ -88,7 +91,10 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<SubMenuContent>{submenuItems}</SubMenuContent>
|
||||
<SubMenuContent ref={parentRef}>
|
||||
<MouseSafeArea parentRef={parentRef} />
|
||||
{submenuItems}
|
||||
</SubMenuContent>
|
||||
</SubMenu>
|
||||
);
|
||||
}
|
||||
|
||||
+21
-14
@@ -22,6 +22,8 @@ type Props = {
|
||||
isOpen: boolean;
|
||||
title?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
onRequestClose: () => void;
|
||||
};
|
||||
|
||||
@@ -30,6 +32,8 @@ const Modal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
title = "Untitled",
|
||||
style,
|
||||
width,
|
||||
height,
|
||||
onRequestClose,
|
||||
}: Props) => {
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
@@ -57,7 +61,7 @@ const Modal: React.FC<Props> = ({
|
||||
>
|
||||
{isMobile ? (
|
||||
<Mobile>
|
||||
<Content>
|
||||
<MobileContent>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && (
|
||||
<Text size="xlarge" weight="bold">
|
||||
@@ -66,7 +70,7 @@ const Modal: React.FC<Props> = ({
|
||||
)}
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Centered>
|
||||
</Content>
|
||||
</MobileContent>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} />
|
||||
</Close>
|
||||
@@ -76,7 +80,7 @@ const Modal: React.FC<Props> = ({
|
||||
</Back>
|
||||
</Mobile>
|
||||
) : (
|
||||
<Small>
|
||||
<Wrapper $width={width} $height={height}>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
// maxHeight needed for proper overflow behavior in Safari
|
||||
@@ -84,9 +88,9 @@ const Modal: React.FC<Props> = ({
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent style={style} shadow>
|
||||
<DesktopContent style={style} shadow>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</SmallContent>
|
||||
</DesktopContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
@@ -94,7 +98,7 @@ const Modal: React.FC<Props> = ({
|
||||
</NudeButton>
|
||||
</Header>
|
||||
</Centered>
|
||||
</Small>
|
||||
</Wrapper>
|
||||
)}
|
||||
</StyledContent>
|
||||
</Dialog.Portal>
|
||||
@@ -142,7 +146,7 @@ const Mobile = styled.div`
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const Content = styled(Scrollable)`
|
||||
const MobileContent = styled(Scrollable)`
|
||||
width: 100%;
|
||||
padding: 8vh 12px;
|
||||
|
||||
@@ -151,6 +155,10 @@ const Content = styled(Scrollable)`
|
||||
`};
|
||||
`;
|
||||
|
||||
const DesktopContent = styled(Scrollable)`
|
||||
padding: 8px 24px 24px;
|
||||
`;
|
||||
|
||||
const Centered = styled(Flex)`
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
@@ -207,14 +215,17 @@ const Header = styled(Flex)`
|
||||
padding: 24px 24px 12px;
|
||||
`;
|
||||
|
||||
const Small = styled.div`
|
||||
const Wrapper = styled.div<{
|
||||
$width?: number | string;
|
||||
$height?: number | string;
|
||||
}>`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
margin: 25vh auto auto auto;
|
||||
width: 75vw;
|
||||
min-width: 350px;
|
||||
max-width: 450px;
|
||||
max-height: 65vh;
|
||||
max-width: ${(props) => props.$width || "450px"};
|
||||
max-height: ${(props) => props.$height || "70vh"};
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -237,8 +248,4 @@ const Small = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const SmallContent = styled(Scrollable)`
|
||||
padding: 8px 24px 24px;
|
||||
`;
|
||||
|
||||
export default observer(Modal);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useMousePosition } from "~/hooks/useMousePosition";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Positions = {
|
||||
/** Sub-menu x */
|
||||
@@ -21,7 +24,7 @@ type Positions = {
|
||||
* allow moving cursor to lower parts of sub-menu without the sub-menu
|
||||
* disappearing.
|
||||
*/
|
||||
export default function MouseSafeArea(props: {
|
||||
export const MouseSafeArea = observer(function MouseSafeArea_(props: {
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const {
|
||||
@@ -30,15 +33,32 @@ export default function MouseSafeArea(props: {
|
||||
height: h = 0,
|
||||
width: w = 0,
|
||||
} = props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const { ui } = useStores();
|
||||
const [mouseX, mouseY] = useMousePosition();
|
||||
const [isVisible, setIsVisible] = React.useState(true);
|
||||
const positions = { x, y, h, w, mouseX, mouseY };
|
||||
const distance = Math.abs(mouseX - x);
|
||||
const prevDistance = usePrevious(distance) ?? distance;
|
||||
|
||||
// Hide the safe area if the mouse is moving _away_ from the menu
|
||||
React.useEffect(() => {
|
||||
if (distance > prevDistance) {
|
||||
setIsVisible(false);
|
||||
} else if (distance < prevDistance) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, [distance, prevDistance]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
|
||||
backgroundColor: ui.debugSafeArea ? "rgba(255,0,0,0.2)" : undefined,
|
||||
right: getRight(positions),
|
||||
left: getLeft(positions),
|
||||
height: h,
|
||||
@@ -47,24 +67,26 @@ export default function MouseSafeArea(props: {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const buffer = 10;
|
||||
|
||||
const getLeft = ({ x, mouseX }: Positions) =>
|
||||
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
|
||||
mouseX > x ? undefined : -Math.max(x - mouseX + buffer, buffer) + "px";
|
||||
|
||||
const getRight = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
|
||||
mouseX > x ? -Math.max(mouseX - (x + w) + buffer, buffer) + "px" : undefined;
|
||||
|
||||
const getWidth = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x
|
||||
? Math.max(mouseX - (x + w), 10) + "px"
|
||||
: Math.max(x - mouseX, 10) + "px";
|
||||
? Math.max(mouseX - (x + w - buffer), buffer) + "px"
|
||||
: Math.max(x - mouseX + buffer, buffer) + "px";
|
||||
|
||||
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
|
||||
mouseX > x
|
||||
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
|
||||
? `polygon(0% 0%, 0% 100%, 100% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%)`
|
||||
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
|
||||
}%, 100% ${(100 * (mouseY - y)) / h - buffer}%)`
|
||||
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - buffer}%, 0% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%, 100% 100%)`;
|
||||
@@ -6,13 +6,17 @@ import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, hover, truncateMultiline } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import CommentEditor from "~/scenes/Document/components/CommentEditor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import { UnreadBadge } from "../UnreadBadge";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const CommentEditor = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/CommentEditor")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
notification: Notification;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Popover,
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Notifications from "./Notifications";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const Notifications = lazyWithRetry(() => import("./Notifications"));
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -16,18 +18,18 @@ type Props = {
|
||||
const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { notifications } = useStores();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const scrollableRef = React.useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
void notifications.fetchPage({ archived: false });
|
||||
}, [notifications]);
|
||||
|
||||
const handleRequestClose = React.useCallback(() => {
|
||||
const handleRequestClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleAutoFocus = React.useCallback((event: Event) => {
|
||||
const handleAutoFocus = useCallback((event: Event) => {
|
||||
// Prevent focus from moving to the popover content
|
||||
event.preventDefault();
|
||||
|
||||
@@ -48,10 +50,12 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
onOpenAutoFocus={handleAutoFocus}
|
||||
shrink
|
||||
>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
</Suspense>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { EyeIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import type Document from "~/models/Document";
|
||||
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = useMemo(
|
||||
() => ProsemirrorHelper.toMarkdown(document),
|
||||
[document]
|
||||
);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadingTime;
|
||||
@@ -71,6 +71,19 @@ function InnerPublicAccess({ collection, share }: Props) {
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowTOCChanged = useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showTOC: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
@@ -204,6 +217,31 @@ function InnerPublicAccess({ collection, share }: Props) {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show table of contents")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the table of contents on documents by default"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show table of contents")}
|
||||
checked={share?.showTOC ?? false}
|
||||
onChange={handleShowTOCChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ShareLinkInput
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
|
||||
@@ -77,6 +77,19 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowTOCChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
showTOC: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
@@ -241,6 +254,31 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Show table of contents")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Display the table of contents on documents by default"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Show table of contents")}
|
||||
checked={share?.showTOC ?? false}
|
||||
onChange={handleShowTOCChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { SharedCollectionLink } from "./components/SharedCollectionLink";
|
||||
import { SharedDocumentLink } from "./components/SharedDocumentLink";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type Props = {
|
||||
share: Share;
|
||||
@@ -37,12 +38,16 @@ function SharedSidebar({ share }: Props) {
|
||||
const rootNode = share.tree;
|
||||
const shareId = share.urlId || share.id;
|
||||
|
||||
useEffect(() => {
|
||||
ui.tocVisible = share.showTOC;
|
||||
}, []);
|
||||
|
||||
if (!rootNode?.children.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSidebar $hoverTransition={!teamAvailable}>
|
||||
<StyledSidebar $hoverTransition={!teamAvailable} canResize={false}>
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
@@ -52,9 +57,7 @@ function SharedSidebar({ share }: Props) {
|
||||
onClick={() =>
|
||||
history.push(user ? homePath() : sharedModelPath(shareId))
|
||||
}
|
||||
>
|
||||
<ToggleSidebar />
|
||||
</SidebarButton>
|
||||
/>
|
||||
)}
|
||||
<ScrollContainer topShadow flex>
|
||||
<TopSection>
|
||||
@@ -141,7 +144,8 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
|
||||
${({ $hoverTransition }) =>
|
||||
$hoverTransition &&
|
||||
`
|
||||
&: ${hover} {
|
||||
@media (hover: hover) {
|
||||
&:${hover} {
|
||||
${StyledSearchPopover} {
|
||||
width: 85%;
|
||||
}
|
||||
@@ -149,6 +153,7 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
|
||||
${ToggleWrapper} {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { depths, s } from "@shared/styles";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -26,13 +25,15 @@ import { useTranslation } from "react-i18next";
|
||||
const ANIMATION_MS = 250;
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
hidden?: boolean;
|
||||
/** Whether the sidebar can be resized and collapsed, defaults to true. */
|
||||
canResize?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
{ children, hidden = false, className }: Props,
|
||||
{ children, hidden = false, canResize = true, className }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
@@ -41,11 +42,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const { ui } = useStores();
|
||||
const location = useLocation();
|
||||
const previousLocation = usePrevious(location);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const isMobile = useMobile();
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const collapsed = ui.sidebarIsClosed && canResize;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
@@ -256,10 +256,12 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
</SidebarButton>
|
||||
</AccountMenu>
|
||||
)}
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
{canResize && (
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -25,6 +25,8 @@ import DropToImport from "./DropToImport";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -84,33 +86,43 @@ const CollectionLink: React.FC<Props> = ({
|
||||
editableTitleRef.current?.setIsEditing(true);
|
||||
}, [editableTitleRef]);
|
||||
|
||||
const newChildTitleRef = React.useRef<RefHandle>(null);
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection.id,
|
||||
title: input,
|
||||
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument);
|
||||
try {
|
||||
newChildTitleRef.current?.setIsEditing(false);
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection.id,
|
||||
title: input,
|
||||
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
} catch (_err) {
|
||||
newChildTitleRef.current?.setIsEditing(true);
|
||||
}
|
||||
},
|
||||
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
|
||||
);
|
||||
|
||||
const contextMenuAction = useCollectionMenuAction({
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
|
||||
<Relative ref={mergeRefs([parentRef, dropRef])}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
@@ -122,6 +134,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
contextAction={contextMenuAction}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
@@ -185,11 +198,12 @@ const CollectionLink: React.FC<Props> = ({
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={newChildTitleRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -18,10 +18,14 @@ import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarAction from "./SidebarAction";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import Text from "@shared/components/Text";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
|
||||
function Collections() {
|
||||
const { documents, collections } = useStores();
|
||||
const { documents, auth, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(auth.team?.id);
|
||||
const orderedCollections = collections.allActive;
|
||||
|
||||
const params = useMemo(
|
||||
@@ -57,7 +61,7 @@ function Collections() {
|
||||
<PaginatedList<Collection>
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.allActive}
|
||||
items={orderedCollections}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
@@ -68,6 +72,20 @@ function Collections() {
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
empty={
|
||||
// No need for empty state if we're displaying the createCollection action
|
||||
can.createCollection ? null : (
|
||||
<SidebarLink
|
||||
label={
|
||||
<Text type="tertiary" size="small" italic>
|
||||
{t("No collections")}
|
||||
</Text>
|
||||
}
|
||||
onClick={() => {}}
|
||||
depth={1.5}
|
||||
/>
|
||||
)
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item, index) => (
|
||||
<DraggableCollectionLink
|
||||
|
||||
@@ -22,7 +22,7 @@ function Disclosure({ onClick, root, expanded, ...rest }: Props) {
|
||||
aria-label={expanded ? t("Collapse") : t("Expand")}
|
||||
{...rest}
|
||||
>
|
||||
<StyledCollapsedIcon expanded={expanded} size={20} />
|
||||
<StyledCollapsedIcon $expanded={expanded} size={20} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -52,13 +52,13 @@ const Button = styled(NudeButton)<{ $root?: boolean }>`
|
||||
`;
|
||||
|
||||
const StyledCollapsedIcon = styled(CollapsedIcon)<{
|
||||
expanded?: boolean;
|
||||
$expanded?: boolean;
|
||||
}>`
|
||||
transition:
|
||||
opacity 100ms ease,
|
||||
transform 100ms ease,
|
||||
fill 50ms !important;
|
||||
${(props) => !props.expanded && "transform: rotate(-90deg);"};
|
||||
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
// Enables identifying this component within styled components
|
||||
|
||||
@@ -35,6 +35,8 @@ import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
@@ -279,30 +281,36 @@ function InnerDocumentLink(
|
||||
[setExpanded, setCollapsed, hasChildren, expanded]
|
||||
);
|
||||
|
||||
const newChildTitleRef = React.useRef<RefHandle>(null);
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection?.id,
|
||||
parentDocumentId: node.id,
|
||||
fullWidth:
|
||||
doc?.fullWidth ??
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
title: input,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument, node.id);
|
||||
try {
|
||||
newChildTitleRef.current?.setIsEditing(false);
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection?.id,
|
||||
parentDocumentId: node.id,
|
||||
fullWidth:
|
||||
doc?.fullWidth ??
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
title: input,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument, node.id);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
} catch (_err) {
|
||||
newChildTitleRef.current?.setIsEditing(true);
|
||||
}
|
||||
},
|
||||
[
|
||||
documents,
|
||||
@@ -316,8 +324,70 @@ function InnerDocumentLink(
|
||||
]
|
||||
);
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
|
||||
|
||||
const labelElement = React.useMemo(
|
||||
() => (
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
isEditing={isEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
),
|
||||
[title, handleTitleChange, isEditing, setIsEditing, canUpdate]
|
||||
);
|
||||
|
||||
const menuElement = React.useMemo(
|
||||
() =>
|
||||
document && !isMoving && !isEditing && !isDraggingAnyDocument ? (
|
||||
<Fade>
|
||||
{can.createChildDocument && (
|
||||
<Tooltip content={t("New doc")}>
|
||||
<NudeButton
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
setExpanded();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined,
|
||||
[
|
||||
document,
|
||||
isMoving,
|
||||
isEditing,
|
||||
isDraggingAnyDocument,
|
||||
can.createChildDocument,
|
||||
t,
|
||||
setIsAddingNewChild,
|
||||
setExpanded,
|
||||
handleRename,
|
||||
handleMenuOpen,
|
||||
handleMenuClose,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: node.id,
|
||||
}}
|
||||
>
|
||||
<Relative ref={parentRef}>
|
||||
<Draggable
|
||||
key={node.id}
|
||||
@@ -334,19 +404,10 @@ function InnerDocumentLink(
|
||||
expanded={hasChildren ? isExpanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
contextAction={contextMenuAction}
|
||||
to={toPath}
|
||||
icon={iconElement}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
isEditing={isEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
label={labelElement}
|
||||
isActive={isActiveCheck}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
@@ -355,35 +416,7 @@ function InnerDocumentLink(
|
||||
scrollIntoViewIfNeeded={sidebarContext === "collections"}
|
||||
isDraft={isDraft}
|
||||
ref={ref}
|
||||
menu={
|
||||
document &&
|
||||
!isMoving &&
|
||||
!isEditing &&
|
||||
!isDraggingAnyDocument ? (
|
||||
<Fade>
|
||||
{can.createChildDocument && (
|
||||
<Tooltip content={t("New doc")}>
|
||||
<NudeButton
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
setExpanded();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
menu={menuElement}
|
||||
/>
|
||||
</DropToImport>
|
||||
</div>
|
||||
@@ -405,6 +438,7 @@ function InnerDocumentLink(
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={newChildTitleRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -425,7 +459,7 @@ function InnerDocumentLink(
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,19 +8,32 @@ import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { RequestResponse } from "~/hooks/usePaginatedRequest";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import { t } from "i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Props = {
|
||||
/** The group to render */
|
||||
group: Group;
|
||||
/** The response from the group memberships request */
|
||||
response: RequestResponse<GroupMembership>;
|
||||
};
|
||||
|
||||
const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
const GroupLink: React.FC<Props> = ({ group, response }) => {
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = groupSidebarContext(group.id);
|
||||
const { loading, next, end, error } = response;
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(t("Could not load shared documents"));
|
||||
}
|
||||
}, [error, t]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
@@ -50,6 +63,14 @@ const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
depth={1}
|
||||
/>
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={loading}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
</Folder>
|
||||
</SidebarContext.Provider>
|
||||
</Relative>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const Header: React.FC<Props> = ({ id, title, children }: Props) => {
|
||||
<H3>
|
||||
<Button onClick={handleClick} disabled={!id}>
|
||||
{title}
|
||||
{id && <Disclosure expanded={expanded} size={20} />}
|
||||
{id && <Disclosure $expanded={expanded} size={20} />}
|
||||
</Button>
|
||||
</H3>
|
||||
{expanded && (firstRender ? children : <Fade>{children}</Fade>)}
|
||||
@@ -91,12 +91,12 @@ const Button = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
|
||||
const Disclosure = styled(CollapsedIcon)<{ $expanded?: boolean }>`
|
||||
transition:
|
||||
opacity 100ms ease,
|
||||
transform 100ms ease,
|
||||
fill 50ms !important;
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
|
||||
opacity: 0;
|
||||
`;
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ function SharedWithMe() {
|
||||
const history = useHistory();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
|
||||
usePaginatedRequest<GroupMembership>(groupMemberships.fetchAll);
|
||||
const gmResponse = usePaginatedRequest<GroupMembership>(
|
||||
groupMemberships.fetchAll
|
||||
);
|
||||
|
||||
const { loading, next, end, error, page } =
|
||||
usePaginatedRequest<UserMembership>(userMemberships.fetchPage, {
|
||||
@@ -108,7 +110,7 @@ function SharedWithMe() {
|
||||
<Flex column>
|
||||
<Header id="shared" title={t("Shared with me")}>
|
||||
{user.groupsWithDocumentMemberships.map((group) => (
|
||||
<GroupLink key={group.id} group={group} />
|
||||
<GroupLink key={group.id} group={group} response={gmResponse} />
|
||||
))}
|
||||
<Relative>
|
||||
{reorderProps.isDragging && (
|
||||
|
||||
@@ -11,6 +11,10 @@ import useClickIntent from "~/hooks/useClickIntent";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import Disclosure from "./Disclosure";
|
||||
import NavLink, { Props as NavLinkProps } from "./NavLink";
|
||||
import { ActionV2WithChildren } from "~/types";
|
||||
import { ContextMenu } from "~/components/Menu/ContextMenu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
|
||||
type Props = Omit<NavLinkProps, "to"> & {
|
||||
to?: LocationDescriptor;
|
||||
@@ -32,6 +36,7 @@ type Props = Omit<NavLinkProps, "to"> & {
|
||||
isDraft?: boolean;
|
||||
depth?: number;
|
||||
scrollIntoViewIfNeeded?: boolean;
|
||||
contextAction?: ActionV2WithChildren;
|
||||
};
|
||||
|
||||
const activeDropStyle = {
|
||||
@@ -62,19 +67,29 @@ function SidebarLink(
|
||||
onDisclosureClick,
|
||||
disabled,
|
||||
unreadBadge,
|
||||
contextAction,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent);
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
paddingLeft: `${(depth || 0) * 16 + 12}px`,
|
||||
paddingRight: unreadBadge ? "32px" : undefined,
|
||||
}),
|
||||
[depth]
|
||||
);
|
||||
|
||||
const unreadStyle = React.useMemo(
|
||||
() => ({
|
||||
right: -12,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const activeStyle = React.useMemo(
|
||||
() => ({
|
||||
color: theme.text,
|
||||
@@ -84,41 +99,58 @@ function SidebarLink(
|
||||
[theme.text, theme.sidebarActiveBackground, style]
|
||||
);
|
||||
|
||||
const hoverStyle = React.useMemo(
|
||||
() => ({
|
||||
color: theme.text,
|
||||
...style,
|
||||
}),
|
||||
[theme.text, style]
|
||||
);
|
||||
|
||||
const [openContextMenu, setOpen, setClosed] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
$isDraft={isDraft}
|
||||
$disabled={disabled}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
// @ts-expect-error exact does not exist on div
|
||||
exact={exact !== false}
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
className={className}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
<ContextMenu
|
||||
action={contextAction}
|
||||
ariaLabel={t("Link options")}
|
||||
onOpen={setOpen}
|
||||
onClose={setClosed}
|
||||
>
|
||||
<Content>
|
||||
{expanded !== undefined && (
|
||||
<Disclosure
|
||||
expanded={expanded}
|
||||
onMouseDown={onDisclosureClick}
|
||||
onClick={preventDefault}
|
||||
root={depth === 0}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
{unreadBadge && <UnreadBadge />}
|
||||
</Content>
|
||||
</Link>
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
$isDraft={isDraft}
|
||||
$disabled={disabled}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
style={openContextMenu ? hoverStyle : active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
// @ts-expect-error exact does not exist on div
|
||||
exact={exact !== false}
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
className={className}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
>
|
||||
<Content>
|
||||
{expanded !== undefined && (
|
||||
<Disclosure
|
||||
expanded={expanded}
|
||||
onMouseDown={onDisclosureClick}
|
||||
onClick={preventDefault}
|
||||
root={depth === 0}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
{unreadBadge && <UnreadBadge style={unreadStyle} />}
|
||||
</Content>
|
||||
</Link>
|
||||
</ContextMenu>
|
||||
{menu && <Actions showActions={showActions}>{menu}</Actions>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -28,11 +28,174 @@ import SidebarContext, {
|
||||
starredSidebarContext,
|
||||
} from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { type ConnectDragSource } from "react-dnd";
|
||||
|
||||
type Props = {
|
||||
star: Star;
|
||||
};
|
||||
|
||||
type StarredDocumentLinkProps = {
|
||||
star: Star;
|
||||
documentId: string;
|
||||
expanded: boolean;
|
||||
sidebarContext: SidebarContextType;
|
||||
isDragging: boolean;
|
||||
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
handlePrefetch: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
menuOpen: boolean;
|
||||
handleMenuOpen: () => void;
|
||||
handleMenuClose: () => void;
|
||||
draggableRef: ConnectDragSource;
|
||||
cursor: React.ReactNode;
|
||||
};
|
||||
|
||||
type StarredCollectionLinkProps = {
|
||||
star: Star;
|
||||
collection: any;
|
||||
expanded: boolean;
|
||||
sidebarContext: SidebarContextType;
|
||||
isDragging: boolean;
|
||||
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
draggableRef: ConnectDragSource;
|
||||
cursor: React.ReactNode;
|
||||
displayChildDocuments: boolean;
|
||||
reorderStarProps: any;
|
||||
};
|
||||
|
||||
function StarredDocumentLink({
|
||||
star,
|
||||
documentId,
|
||||
expanded,
|
||||
sidebarContext,
|
||||
isDragging,
|
||||
handleDisclosureClick,
|
||||
handlePrefetch,
|
||||
icon,
|
||||
label,
|
||||
menuOpen,
|
||||
handleMenuOpen,
|
||||
handleMenuClose,
|
||||
draggableRef,
|
||||
cursor,
|
||||
}: StarredDocumentLinkProps) {
|
||||
const { collections, documents } = useStores();
|
||||
|
||||
const document = documents.get(documentId);
|
||||
|
||||
const documentCollection = document?.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const childDocuments = documentCollection
|
||||
? documentCollection.getChildrenForDocument(documentId)
|
||||
: [];
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
const displayChildDocuments = expanded && !isDragging;
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId });
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document.id,
|
||||
}}
|
||||
>
|
||||
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
<SidebarLink
|
||||
depth={0}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
contextAction={contextMenuAction}
|
||||
icon={icon}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={label}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document && !isDragging ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={documentCollection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function StarredCollectionLink({
|
||||
star,
|
||||
collection,
|
||||
sidebarContext,
|
||||
isDragging,
|
||||
handleDisclosureClick,
|
||||
draggableRef,
|
||||
cursor,
|
||||
displayChildDocuments,
|
||||
reorderStarProps,
|
||||
}: StarredCollectionLinkProps) {
|
||||
const { documents } = useStores();
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
<CollectionLink
|
||||
collection={collection}
|
||||
expanded={isDragging ? undefined : displayChildDocuments}
|
||||
activeDocument={documents.active}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
isDraggingAnyCollection={reorderStarProps.isDragging}
|
||||
/>
|
||||
</Draggable>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function StarredLink({ star }: Props) {
|
||||
const theme = useTheme();
|
||||
const { ui, collections, documents } = useStores();
|
||||
@@ -123,95 +286,40 @@ function StarredLink({ star }: Props) {
|
||||
);
|
||||
|
||||
if (documentId) {
|
||||
const document = documents.get(documentId);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const documentCollection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const childDocuments = documentCollection
|
||||
? documentCollection.getChildrenForDocument(documentId)
|
||||
: [];
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
<SidebarLink
|
||||
depth={0}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={icon}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={label}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document && !isDragging ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={documentCollection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
</>
|
||||
<StarredDocumentLink
|
||||
star={star}
|
||||
documentId={documentId}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
handlePrefetch={handlePrefetch}
|
||||
icon={icon}
|
||||
label={label}
|
||||
menuOpen={menuOpen}
|
||||
handleMenuOpen={handleMenuOpen}
|
||||
handleMenuClose={handleMenuClose}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
return (
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
<CollectionLink
|
||||
collection={collection}
|
||||
expanded={isDragging ? undefined : displayChildDocuments}
|
||||
activeDocument={documents.active}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
isDraggingAnyCollection={reorderStarProps.isDragging}
|
||||
/>
|
||||
</Draggable>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
<StarredCollectionLink
|
||||
star={star}
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
displayChildDocuments={displayChildDocuments}
|
||||
reorderStarProps={reorderStarProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,28 @@ export default function useCollectionDocuments(
|
||||
collection: Collection | undefined,
|
||||
activeDocument: Document | undefined
|
||||
) {
|
||||
const insertDraftDocument = useMemo(
|
||||
() =>
|
||||
activeDocument &&
|
||||
activeDocument.isActive &&
|
||||
activeDocument.isDraft &&
|
||||
activeDocument.collectionId === collection?.id &&
|
||||
!activeDocument.parentDocumentId,
|
||||
[
|
||||
activeDocument?.isActive,
|
||||
activeDocument?.isDraft,
|
||||
activeDocument?.collectionId,
|
||||
activeDocument?.parentDocumentId,
|
||||
collection?.id,
|
||||
]
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!collection?.sortedDocuments) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const insertDraftDocument =
|
||||
activeDocument?.isActive &&
|
||||
activeDocument?.isDraft &&
|
||||
activeDocument?.collectionId === collection.id &&
|
||||
!activeDocument?.parentDocumentId;
|
||||
|
||||
return insertDraftDocument
|
||||
return insertDraftDocument && activeDocument
|
||||
? sortNavigationNodes(
|
||||
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
|
||||
collection.sort,
|
||||
@@ -26,14 +36,9 @@ export default function useCollectionDocuments(
|
||||
)
|
||||
: collection.sortedDocuments;
|
||||
}, [
|
||||
activeDocument?.isActive,
|
||||
activeDocument?.isDraft,
|
||||
activeDocument?.collectionId,
|
||||
activeDocument?.parentDocumentId,
|
||||
insertDraftDocument,
|
||||
activeDocument?.asNavigationNode,
|
||||
collection,
|
||||
collection?.sortedDocuments,
|
||||
collection?.id,
|
||||
collection?.sort,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ export function useSidebarLabelAndIcon(
|
||||
return {
|
||||
label: document.titleWithDefault,
|
||||
icon: document.icon ? (
|
||||
<Icon value={document.icon} color={document.color ?? undefined} />
|
||||
<Icon
|
||||
value={document.icon}
|
||||
initial={document.initial}
|
||||
color={document.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
),
|
||||
|
||||
@@ -5,7 +5,6 @@ import GlobalStyles from "@shared/styles/globals";
|
||||
import { TeamPreference, UserPreference } from "@shared/types";
|
||||
import useBuildTheme from "~/hooks/useBuildTheme";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { TooltipStyles } from "./Tooltip";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -30,7 +29,6 @@ const Theme: React.FC = ({ children }: Props) => {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<>
|
||||
<TooltipStyles />
|
||||
<GlobalStyles
|
||||
useCursorPointer={auth.user?.getPreference(
|
||||
UserPreference.UseCursorPointer
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { createGlobalStyle, keyframes } from "styled-components";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useTooltipContext } from "./TooltipContext";
|
||||
@@ -285,8 +285,4 @@ const StyledContent = styled(TooltipPrimitive.Content)`
|
||||
}
|
||||
`;
|
||||
|
||||
export const TooltipStyles = createGlobalStyle`
|
||||
/* Legacy styles for backward compatibility - can be removed after migration */
|
||||
`;
|
||||
|
||||
export default Tooltip;
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { Props as ButtonProps } from "~/components/Button";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
|
||||
import {
|
||||
SelectItemIndicator,
|
||||
@@ -99,6 +98,10 @@ const InputSelectSeparator = React.forwardRef<
|
||||
));
|
||||
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
|
||||
|
||||
const Separator = styled.hr`
|
||||
margin: 6px 0;
|
||||
`;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(InputSelectPrimitive.Content)`
|
||||
z-index: ${depths.menu};
|
||||
|
||||
@@ -104,7 +104,7 @@ const MenuContent = React.forwardRef<
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Content ref={ref} {...rest} {...offsetProp} collisionPadding={6} asChild>
|
||||
<Content ref={ref} {...offsetProp} {...rest} collisionPadding={6} asChild>
|
||||
<Components.MenuContent {...contentProps} hiddenScrollbars>
|
||||
{children}
|
||||
</Components.MenuContent>
|
||||
|
||||
@@ -9,6 +9,8 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import { transparentize } from "polished";
|
||||
|
||||
export const SelectItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -114,6 +116,10 @@ const ItemContainer = styled(Flex)`
|
||||
color: ${s("accentText")};
|
||||
fill: ${s("accentText")};
|
||||
}
|
||||
|
||||
${Text} {
|
||||
color: ${(props) => transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type BaseMenuItemProps = {
|
||||
disabled?: boolean;
|
||||
$active?: boolean;
|
||||
$dangerous?: boolean;
|
||||
};
|
||||
|
||||
@@ -45,24 +46,38 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$active &&
|
||||
!props.disabled &&
|
||||
`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
props.$dangerous ? props.theme.danger : props.theme.accent
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg:not([data-fixed-color]) {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
!props.disabled &&
|
||||
`
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
props.$dangerous ? props.theme.danger : props.theme.accent
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg:not([data-fixed-color]) {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
props.$dangerous ? props.theme.danger : props.theme.accent
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import getMenuItems from "../menus/block";
|
||||
import { useEditor } from "./EditorContext";
|
||||
@@ -13,20 +14,25 @@ function BlockMenu(props: Props) {
|
||||
const dictionary = useDictionary();
|
||||
const { elementRef } = useEditor();
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
shortcut={item.shortcut}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
filterable
|
||||
trigger="/"
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
shortcut={item.shortcut}
|
||||
/>
|
||||
)}
|
||||
renderMenuItem={renderMenuItem}
|
||||
items={getMenuItems(dictionary, elementRef)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
|
||||
import { search as emojiSearch } from "@shared/utils/emoji";
|
||||
import EmojiMenuItem from "./EmojiMenuItem";
|
||||
@@ -45,18 +45,23 @@ const EmojiMenu = (props: Props) => {
|
||||
[search]
|
||||
);
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
<EmojiMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.description}
|
||||
emoji={item.emoji}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<EmojiMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.description}
|
||||
emoji={item.emoji}
|
||||
/>
|
||||
)}
|
||||
renderMenuItem={renderMenuItem}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NodeSelection } from "prosemirror-state";
|
||||
import { CellSelection, selectedRect } from "prosemirror-tables";
|
||||
import { selectedRect } from "prosemirror-tables";
|
||||
import * as React from "react";
|
||||
import { Portal as ReactPortal } from "react-portal";
|
||||
import styled, { css } from "styled-components";
|
||||
@@ -15,8 +15,12 @@ import useMobile from "~/hooks/useMobile";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { ColumnSelection } from "@shared/editor/selection/ColumnSelection";
|
||||
import { RowSelection } from "@shared/editor/selection/RowSelection";
|
||||
import { isTableSelected } from "@shared/editor/queries/table";
|
||||
|
||||
type Props = {
|
||||
align?: "start" | "end" | "center";
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
@@ -35,18 +39,26 @@ const defaultPosition = {
|
||||
function usePosition({
|
||||
menuRef,
|
||||
active,
|
||||
align = "center",
|
||||
}: {
|
||||
menuRef: React.RefObject<HTMLDivElement>;
|
||||
active?: boolean;
|
||||
align?: Props["align"];
|
||||
}) {
|
||||
const { view } = useEditor();
|
||||
const { selection } = view.state;
|
||||
const menuWidth = menuRef.current?.offsetWidth;
|
||||
const menuHeight = menuRef.current?.offsetHeight;
|
||||
const [menuWidth, setMenuWidth] = React.useState(0);
|
||||
const menuHeight = 36;
|
||||
|
||||
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
|
||||
return defaultPosition;
|
||||
}
|
||||
// Measure the menu width after DOM updates to ensure accurate positioning
|
||||
React.useLayoutEffect(() => {
|
||||
if (menuRef.current) {
|
||||
const width = menuRef.current.offsetWidth;
|
||||
if (width !== menuWidth) {
|
||||
setMenuWidth(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// based on the start and end of the selection calculate the position at
|
||||
// the center top
|
||||
@@ -68,7 +80,7 @@ function usePosition({
|
||||
right: Math.max(fromPos.right, toPos.right),
|
||||
};
|
||||
|
||||
const offsetParent = menuRef.current.offsetParent
|
||||
const offsetParent = menuRef.current?.offsetParent
|
||||
? menuRef.current.offsetParent.getBoundingClientRect()
|
||||
: ({
|
||||
width: window.innerWidth,
|
||||
@@ -93,19 +105,23 @@ function usePosition({
|
||||
if (position !== null) {
|
||||
const element = view.nodeDOM(position);
|
||||
const bounds = (element as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.right - menuWidth;
|
||||
selectionBounds.top = bounds.top + menuHeight;
|
||||
selectionBounds.left = bounds.right;
|
||||
selectionBounds.right = bounds.right;
|
||||
}
|
||||
}
|
||||
|
||||
if (!active || !menuRef.current || !menuHeight) {
|
||||
return defaultPosition;
|
||||
}
|
||||
|
||||
// tables are an oddity, and need their own positioning logic
|
||||
const isColSelection =
|
||||
selection instanceof CellSelection && selection.isColSelection();
|
||||
selection instanceof ColumnSelection && selection.isColSelection();
|
||||
const isRowSelection =
|
||||
selection instanceof CellSelection && selection.isRowSelection();
|
||||
selection instanceof RowSelection && selection.isRowSelection();
|
||||
|
||||
if (isColSelection && isRowSelection) {
|
||||
if (isTableSelected(view.state)) {
|
||||
const rect = selectedRect(view.state);
|
||||
const table = view.domAtPos(rect.tableStart);
|
||||
const bounds = (table.node as HTMLElement).getBoundingClientRect();
|
||||
@@ -160,6 +176,8 @@ function usePosition({
|
||||
top: Math.round(top - menuHeight - offsetParent.top),
|
||||
offset: 0,
|
||||
visible: true,
|
||||
blockSelection: false,
|
||||
maxWidth: "100%",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -180,7 +198,11 @@ function usePosition({
|
||||
),
|
||||
Math.max(
|
||||
Math.max(offsetParent.x, margin),
|
||||
centerOfSelection - menuWidth / 2
|
||||
align === "center"
|
||||
? centerOfSelection - menuWidth / 2
|
||||
: align === "start"
|
||||
? selectionBounds.left
|
||||
: selectionBounds.right
|
||||
)
|
||||
);
|
||||
const top = Math.max(
|
||||
@@ -200,8 +222,12 @@ function usePosition({
|
||||
top: Math.round(top - offsetParent.top),
|
||||
offset: Math.round(offset),
|
||||
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
|
||||
blockSelection:
|
||||
codeBlock || isColSelection || isRowSelection || noticeBlock,
|
||||
blockSelection: !!(
|
||||
codeBlock ||
|
||||
isColSelection ||
|
||||
isRowSelection ||
|
||||
noticeBlock
|
||||
),
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
@@ -216,6 +242,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
let position = usePosition({
|
||||
menuRef,
|
||||
active: props.active,
|
||||
align: props.align,
|
||||
});
|
||||
|
||||
if (isSelectingText) {
|
||||
@@ -271,13 +298,13 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
ref={menuRef}
|
||||
$offset={position.offset}
|
||||
style={{
|
||||
width: props.width,
|
||||
minWidth: props.width,
|
||||
maxWidth: `${position.maxWidth}px`,
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<Background align={props.align}>{props.children}</Background>
|
||||
</Wrapper>
|
||||
</Portal>
|
||||
);
|
||||
@@ -292,7 +319,7 @@ type WrapperProps = {
|
||||
const arrow = (props: WrapperProps) =>
|
||||
props.arrow
|
||||
? css`
|
||||
&::before {
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 24px;
|
||||
@@ -300,11 +327,14 @@ const arrow = (props: WrapperProps) =>
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 3px;
|
||||
z-index: -1;
|
||||
z-index: 0;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: calc(50% - ${props.$offset || 0}px);
|
||||
pointer-events: none;
|
||||
|
||||
// clip to show only the bottom right corner
|
||||
clip-path: polygon(100% 50%, 100% 100%, 50% 100%);
|
||||
}
|
||||
`
|
||||
: "";
|
||||
@@ -335,22 +365,41 @@ const MobileWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div<WrapperProps>`
|
||||
will-change: opacity, transform;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
opacity: 0;
|
||||
const Background = styled.div<{ align: Props["align"] }>`
|
||||
position: relative;
|
||||
background-color: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 4px;
|
||||
height: 36px;
|
||||
|
||||
${(props) =>
|
||||
props.align === "start" &&
|
||||
`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.align === "end" &&
|
||||
`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div<WrapperProps>`
|
||||
will-change: opacity, transform;
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
|
||||
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
transition-delay: 150ms;
|
||||
line-height: 0;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -282,7 +282,8 @@ const LinkEditor: React.FC<Props> = ({
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
pointer-events: all;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
`;
|
||||
|
||||
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
|
||||
|
||||
+30
-35
@@ -3,71 +3,57 @@ import { Node } from "prosemirror-model";
|
||||
import { Selection, TextSelection } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
|
||||
import Flex from "~/components/Flex";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Input from "~/editor/components/Input";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
|
||||
type Props = {
|
||||
node: Node;
|
||||
view: EditorView;
|
||||
dictionary: Dictionary;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
export function EmbedLinkEditor({ node, view, dictionary }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const embeds = useEmbeds();
|
||||
|
||||
const url = node.attrs.href as string;
|
||||
export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
|
||||
const url = (node.attrs.href ?? node.attrs.src) as string;
|
||||
const [localUrl, setLocalUrl] = useState(url);
|
||||
|
||||
const moveSelectionToEnd = useCallback(() => {
|
||||
const { state, dispatch } = view;
|
||||
|
||||
const nextSelection = Selection.findFrom(
|
||||
state.tr.doc.resolve(state.selection.from),
|
||||
1,
|
||||
true
|
||||
);
|
||||
const selection = nextSelection ?? TextSelection.create(state.tr.doc, 0);
|
||||
|
||||
const selection = nextSelection ?? TextSelection.create(state.tr.doc, 0);
|
||||
dispatch(state.tr.setSelection(selection));
|
||||
view.focus();
|
||||
}, [view]);
|
||||
|
||||
const openEmbed = useCallback(() => {
|
||||
const openLink = useCallback(() => {
|
||||
window.open(url, "_blank");
|
||||
}, [url]);
|
||||
|
||||
const removeEmbed = useCallback(() => {
|
||||
const remove = useCallback(() => {
|
||||
const { state, dispatch } = view;
|
||||
dispatch(state.tr.deleteSelection());
|
||||
}, [view]);
|
||||
|
||||
const updateEmbed = useCallback(() => {
|
||||
const matchingEmbed = getMatchingEmbed(embeds, localUrl);
|
||||
|
||||
if (!matchingEmbed) {
|
||||
toast.error(t("Sorry, invalid embed link"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { state, dispatch } = view;
|
||||
dispatch(
|
||||
state.tr.setNodeMarkup(state.selection.from, undefined, {
|
||||
...node.attrs,
|
||||
href: localUrl,
|
||||
})
|
||||
);
|
||||
const update = useCallback(() => {
|
||||
const { state } = view;
|
||||
const hrefType = node.type.name === "image" ? "src" : "href";
|
||||
const tr = state.tr.setNodeMarkup(state.selection.from, undefined, {
|
||||
...node.attrs,
|
||||
[hrefType]: localUrl,
|
||||
});
|
||||
|
||||
view.dispatch(tr);
|
||||
moveSelectionToEnd();
|
||||
}, [t, localUrl, embeds, node, view, moveSelectionToEnd]);
|
||||
}, [localUrl, node, view, moveSelectionToEnd]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -78,7 +64,7 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
|
||||
switch (event.key) {
|
||||
case "Enter": {
|
||||
event.preventDefault();
|
||||
updateEmbed();
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,12 +75,13 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[updateEmbed, moveSelectionToEnd]
|
||||
[update, moveSelectionToEnd]
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Input
|
||||
autoFocus={autoFocus}
|
||||
value={localUrl}
|
||||
placeholder={dictionary.pasteLink}
|
||||
onChange={(e) => setLocalUrl(e.target.value)}
|
||||
@@ -102,13 +89,19 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
|
||||
readOnly={!view.editable}
|
||||
/>
|
||||
<Tooltip content={dictionary.openLink}>
|
||||
<ToolbarButton onClick={openEmbed} disabled={!localUrl}>
|
||||
<ToolbarButton onClick={openLink} disabled={!localUrl}>
|
||||
<OpenIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
{view.editable && (
|
||||
<Tooltip content={dictionary.deleteEmbed}>
|
||||
<ToolbarButton onClick={removeEmbed}>
|
||||
<Tooltip
|
||||
content={
|
||||
node.type.name === "embed"
|
||||
? dictionary.deleteEmbed
|
||||
: dictionary.deleteImage
|
||||
}
|
||||
>
|
||||
<ToolbarButton onClick={remove}>
|
||||
<TrashIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
@@ -119,5 +112,7 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
pointer-events: all;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
min-width: 350px;
|
||||
`;
|
||||
@@ -1,22 +1,23 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { v4 } from "uuid";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
DocumentsSection,
|
||||
UserSection,
|
||||
CollectionsSection,
|
||||
GroupSection,
|
||||
} from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -25,6 +26,7 @@ import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
interface MentionItem extends MenuItem {
|
||||
attrs: {
|
||||
@@ -45,7 +47,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [items, setItems] = useState<MentionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const { auth, documents, users, collections } = useStores();
|
||||
const { auth, documents, users, collections, groups } = useStores();
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
@@ -53,11 +55,17 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
|
||||
const { loading, request } = useRequest(
|
||||
useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query: search });
|
||||
const res = await client.post("/suggestions.mention", {
|
||||
query: search,
|
||||
limit: maxResultsInSection,
|
||||
});
|
||||
|
||||
res.data.documents.map(documents.add);
|
||||
res.data.users.map(users.add);
|
||||
res.data.collections.map(collections.add);
|
||||
runInAction(() => {
|
||||
res.data.documents.map(documents.add);
|
||||
res.data.users.map(users.add);
|
||||
res.data.collections.map(collections.add);
|
||||
res.data.groups.map(groups.add);
|
||||
});
|
||||
}, [search, documents, users, collections])
|
||||
);
|
||||
|
||||
@@ -92,7 +100,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
section: UserSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
id: uuidv4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId,
|
||||
@@ -100,6 +108,32 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
.concat(
|
||||
groups
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map((group) => ({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24, marginRight: 4 }}
|
||||
>
|
||||
<GroupAvatar group={group} size={AvatarSize.Small} />
|
||||
</Flex>
|
||||
),
|
||||
title: group.name,
|
||||
section: GroupSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Group,
|
||||
modelId: group.id,
|
||||
actorId,
|
||||
label: group.name,
|
||||
},
|
||||
}))
|
||||
)
|
||||
.concat(
|
||||
documents
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
@@ -124,7 +158,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: doc.id,
|
||||
actorId,
|
||||
@@ -152,7 +186,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
section: CollectionsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
id: uuidv4(),
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
actorId,
|
||||
@@ -172,9 +206,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
priority: -1,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: v4(),
|
||||
modelId: uuidv4(),
|
||||
actorId,
|
||||
label: search,
|
||||
},
|
||||
@@ -184,7 +218,17 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
setItems(items);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
|
||||
}, [
|
||||
t,
|
||||
actorId,
|
||||
loading,
|
||||
search,
|
||||
users,
|
||||
documents,
|
||||
maxResultsInSection,
|
||||
groups,
|
||||
collections,
|
||||
]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
@@ -197,29 +241,57 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
// Check if the mentioned user has access to the document
|
||||
const res = await client.post("/documents.users", {
|
||||
id: documentId,
|
||||
userId: item.attrs.modelId,
|
||||
});
|
||||
|
||||
if (!res.data.length) {
|
||||
const user = users.get(item.attrs.modelId);
|
||||
if (item.attrs.type === MentionType.User) {
|
||||
// Check if the mentioned user has access to the document
|
||||
const res = await client.post("/documents.users", {
|
||||
id: documentId,
|
||||
userId: item.attrs.modelId,
|
||||
});
|
||||
if (!res.data.length) {
|
||||
const user = users.get(item.attrs.modelId);
|
||||
toast.message(
|
||||
t(
|
||||
"{{ userName }} won't be notified, as they do not have access to this document",
|
||||
{
|
||||
userName: item.attrs.label,
|
||||
}
|
||||
),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
duration: 10000,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (item.attrs.type === MentionType.Group) {
|
||||
const group = groups.get(item.attrs.modelId);
|
||||
toast.message(
|
||||
t(
|
||||
"{{ userName }} won't be notified, as they do not have access to this document",
|
||||
`Members of "{{ groupName }}" that have access to this document will be notified`,
|
||||
{
|
||||
userName: item.attrs.label,
|
||||
groupName: item.attrs.label,
|
||||
}
|
||||
),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
icon: group ? <GroupAvatar group={group} /> : undefined,
|
||||
duration: 10000,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[t, users, documentId]
|
||||
[t, users, documentId, groups]
|
||||
);
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
subtitle={item.subtitle}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
// Prevent showing the menu until we have data otherwise it will be positioned
|
||||
@@ -235,15 +307,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
filterable={false}
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
subtitle={item.subtitle}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
/>
|
||||
)}
|
||||
renderMenuItem={renderMenuItem}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { EmailIcon, LinkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 } from "uuid";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
@@ -27,6 +27,18 @@ type Props = Omit<
|
||||
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
const items = useItems({ pastedText, embeds });
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
if (!items) {
|
||||
props.onClose();
|
||||
return null;
|
||||
@@ -37,14 +49,7 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
{...props}
|
||||
trigger=""
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
/>
|
||||
)}
|
||||
renderMenuItem={renderMenuItem}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
@@ -82,7 +87,7 @@ function useItems({
|
||||
|
||||
mentionType = integration
|
||||
? determineMentionType({ url, integration })
|
||||
: undefined;
|
||||
: MentionType.URL;
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -97,11 +102,11 @@ function useItems({
|
||||
icon: <EmailIcon />,
|
||||
visible: !!mentionType,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
id: uuidv4(),
|
||||
type: mentionType,
|
||||
label: pastedText,
|
||||
href: pastedText,
|
||||
modelId: v4(),
|
||||
modelId: uuidv4(),
|
||||
actorId: user?.id,
|
||||
},
|
||||
appendSpace: true,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import some from "lodash/some";
|
||||
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInNotice } from "@shared/editor/queries/isInNotice";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
|
||||
import {
|
||||
getColumnIndex,
|
||||
getRowIndex,
|
||||
isTableSelected,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import getAttachmentMenuItems from "../menus/attachment";
|
||||
import getCodeMenuItems from "../menus/code";
|
||||
import getDividerMenuItems from "../menus/divider";
|
||||
@@ -23,75 +23,36 @@ import getImageMenuItems from "../menus/image";
|
||||
import getNoticeMenuItems from "../menus/notice";
|
||||
import getReadOnlyMenuItems from "../menus/readOnly";
|
||||
import getTableMenuItems from "../menus/table";
|
||||
import getTableCellMenuItems from "../menus/tableCell";
|
||||
import getTableColMenuItems from "../menus/tableCol";
|
||||
import getTableRowMenuItems from "../menus/tableRow";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { EmbedLinkEditor } from "./EmbedLinkEditor";
|
||||
import { MediaLinkEditor } from "./MediaLinkEditor";
|
||||
import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor from "./LinkEditor";
|
||||
import ToolbarMenu from "./ToolbarMenu";
|
||||
|
||||
type Props = {
|
||||
/** Whether the text direction is right-to-left */
|
||||
rtl: boolean;
|
||||
/** Whether the current document is a template */
|
||||
isTemplate: boolean;
|
||||
/** Whether the toolbar is currently active/visible */
|
||||
isActive: boolean;
|
||||
/** The current selection */
|
||||
selection?: Selection;
|
||||
/** Whether the editor is in read-only mode */
|
||||
readOnly?: boolean;
|
||||
/** Whether the user has permission to add comments */
|
||||
canComment?: boolean;
|
||||
/** Whether the user has permission to update the document */
|
||||
canUpdate?: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
/** Callback function when a link is clicked */
|
||||
onClickLink: (
|
||||
href: string,
|
||||
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
};
|
||||
|
||||
function useIsActive(state: EditorState) {
|
||||
const { selection, doc } = state;
|
||||
|
||||
if (isMarkActive(state.schema.marks.link)(state)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(isNodeActive(state.schema.nodes.code_block)(state) ||
|
||||
isNodeActive(state.schema.nodes.code_fence)(state)) &&
|
||||
selection.from > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isInNotice(state) && selection.from > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!selection || selection.empty) {
|
||||
return false;
|
||||
}
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === "hr") {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
selection instanceof NodeSelection &&
|
||||
["image", "attachment", "embed"].includes(selection.node.type.name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (selection instanceof NodeSelection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectionText = doc.cut(selection.from, selection.to).textContent;
|
||||
if (selection instanceof TextSelection && !selectionText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const slice = selection.content();
|
||||
const fragment = slice.content;
|
||||
const nodes = (fragment as any).content;
|
||||
|
||||
return some(nodes, (n) => n.content.size);
|
||||
}
|
||||
|
||||
function useIsDragging() {
|
||||
const [isDragging, setDragging, setNotDragging] = useBoolean();
|
||||
useEventListener("dragstart", setDragging);
|
||||
@@ -100,25 +61,19 @@ function useIsDragging() {
|
||||
return isDragging;
|
||||
}
|
||||
|
||||
export default function SelectionToolbar(props: Props) {
|
||||
const { onClose, readOnly, onOpen } = props;
|
||||
export function SelectionToolbar(props: Props) {
|
||||
const { readOnly = false } = props;
|
||||
const { view, commands } = useEditor();
|
||||
const dictionary = useDictionary();
|
||||
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useMobile();
|
||||
const isActive = useIsActive(view.state) || isMobile;
|
||||
const isActive = props.isActive || isMobile;
|
||||
const isDragging = useIsDragging();
|
||||
const previousIsActive = usePrevious(isActive);
|
||||
const [isEditingImgUrl, setIsEditingImgUrl] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Trigger callbacks when the toolbar is opened or closed
|
||||
if (previousIsActive && !isActive) {
|
||||
onClose();
|
||||
}
|
||||
if (!previousIsActive && isActive) {
|
||||
onOpen();
|
||||
}
|
||||
}, [isActive, onClose, onOpen, previousIsActive]);
|
||||
setIsEditingImgUrl(false);
|
||||
}, [isActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (ev: MouseEvent): void => {
|
||||
@@ -141,6 +96,8 @@ export default function SelectionToolbar(props: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEditingImgUrl(false);
|
||||
|
||||
const { dispatch } = view;
|
||||
dispatch(
|
||||
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
|
||||
@@ -152,7 +109,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
return () => {
|
||||
window.removeEventListener("mouseup", handleClickOutside);
|
||||
};
|
||||
}, [isActive, previousIsActive, readOnly, view]);
|
||||
}, [isActive, readOnly, view]);
|
||||
|
||||
const handleOnSelectLink = ({
|
||||
href,
|
||||
@@ -174,19 +131,17 @@ export default function SelectionToolbar(props: Props) {
|
||||
);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
if ((readOnly && !canComment) || isDragging) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
const colIndex = getColumnIndex(state);
|
||||
const rowIndex = getRowIndex(state);
|
||||
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
|
||||
const isCellSelection = selection instanceof CellSelection;
|
||||
const link = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const isImageSelection =
|
||||
selection instanceof NodeSelection && selection.node.type.name === "image";
|
||||
@@ -199,27 +154,33 @@ export default function SelectionToolbar(props: Props) {
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
let align: "center" | "start" | "end" = "center";
|
||||
|
||||
if (isCodeSelection && selection.empty) {
|
||||
items = getCodeMenuItems(state, readOnly, dictionary);
|
||||
} else if (isTableSelection) {
|
||||
items = getTableMenuItems(state, dictionary);
|
||||
align = "end";
|
||||
} else if (isTableSelected(state)) {
|
||||
items = getTableMenuItems(state, readOnly, dictionary);
|
||||
} else if (colIndex !== undefined) {
|
||||
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
|
||||
items = getTableColMenuItems(state, readOnly, dictionary, {
|
||||
index: colIndex,
|
||||
rtl,
|
||||
});
|
||||
} else if (rowIndex !== undefined) {
|
||||
items = getTableRowMenuItems(state, rowIndex, dictionary);
|
||||
} else if (isCellSelection) {
|
||||
items = getTableCellMenuItems(state, dictionary);
|
||||
items = getTableRowMenuItems(state, readOnly, dictionary, {
|
||||
index: rowIndex,
|
||||
});
|
||||
} else if (isImageSelection) {
|
||||
items = readOnly ? [] : getImageMenuItems(state, dictionary);
|
||||
items = getImageMenuItems(state, readOnly, dictionary);
|
||||
} else if (isAttachmentSelection) {
|
||||
items = readOnly ? [] : getAttachmentMenuItems(state, dictionary);
|
||||
items = getAttachmentMenuItems(state, readOnly, dictionary);
|
||||
} else if (isDividerSelection) {
|
||||
items = getDividerMenuItems(state, dictionary);
|
||||
items = getDividerMenuItems(state, readOnly, dictionary);
|
||||
} else if (readOnly) {
|
||||
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
|
||||
} else if (isNoticeSelection && selection.empty) {
|
||||
items = getNoticeMenuItems(state, readOnly, dictionary);
|
||||
align = "end";
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, dictionary);
|
||||
}
|
||||
@@ -249,8 +210,12 @@ export default function SelectionToolbar(props: Props) {
|
||||
const showLinkToolbar =
|
||||
link && link.from === selection.from && link.to === selection.to;
|
||||
|
||||
const isEditingMedia =
|
||||
isEmbedSelection || (isImageSelection && isEditingImgUrl);
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
align={align}
|
||||
active={isActive}
|
||||
ref={menuRef}
|
||||
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
|
||||
@@ -266,15 +231,22 @@ export default function SelectionToolbar(props: Props) {
|
||||
onClickLink={props.onClickLink}
|
||||
onSelectLink={handleOnSelectLink}
|
||||
/>
|
||||
) : isEmbedSelection ? (
|
||||
<EmbedLinkEditor
|
||||
) : isEditingMedia ? (
|
||||
<MediaLinkEditor
|
||||
key={`embed-${selection.from}`}
|
||||
node={(selection as NodeSelection).node}
|
||||
node={selection.node}
|
||||
view={view}
|
||||
dictionary={dictionary}
|
||||
autoFocus={isEditingImgUrl}
|
||||
/>
|
||||
) : (
|
||||
<ToolbarMenu items={items} {...rest} />
|
||||
<ToolbarMenu
|
||||
items={items}
|
||||
{...rest}
|
||||
handlers={{
|
||||
editImageUrl: () => setIsEditingImgUrl(true),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FloatingToolbar>
|
||||
);
|
||||
|
||||
@@ -14,13 +14,13 @@ import { MenuItem } from "@shared/editor/types";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import Header from "~/components/ContextMenu/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import Input from "./Input";
|
||||
import { MenuHeader } from "~/components/primitives/components/Menu";
|
||||
|
||||
type TopAnchor = {
|
||||
top: number;
|
||||
@@ -641,13 +641,19 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnClick = () => {
|
||||
handleClickItem(item);
|
||||
};
|
||||
|
||||
const currentHeading =
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const response = (
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== previousHeading && (
|
||||
<Header key={currentHeading}>{currentHeading}</Header>
|
||||
<MenuHeader key={currentHeading}>
|
||||
{currentHeading}
|
||||
</MenuHeader>
|
||||
)}
|
||||
<ListItem
|
||||
onPointerMove={handlePointerMove}
|
||||
@@ -655,7 +661,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
onClick: () => handleClickItem(item),
|
||||
onClick: handleOnClick,
|
||||
})}
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
|
||||
@@ -2,8 +2,12 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import { usePortalContext } from "~/components/Portal";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuIconWrapper,
|
||||
MenuLabel,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
|
||||
export type Props = {
|
||||
/** Whether the item is selected */
|
||||
@@ -53,17 +57,22 @@ function SuggestionsMenuItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
<MenuButton
|
||||
ref={ref}
|
||||
active={selected}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
onPointerMove={disabled ? undefined : onPointerMove}
|
||||
icon={icon}
|
||||
$active={selected}
|
||||
>
|
||||
{title}
|
||||
{subtitle && <Subtitle $active={selected}>· {subtitle}</Subtitle>}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuItem>
|
||||
<MenuIconWrapper>{icon}</MenuIconWrapper>
|
||||
<MenuLabel>
|
||||
{title}
|
||||
{subtitle && (
|
||||
<Subtitle $active={selected}>· {subtitle}</Subtitle>
|
||||
)}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuLabel>
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,4 +92,4 @@ const Shortcut = styled.span<{ $active?: boolean }>`
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
export default SuggestionsMenuItem;
|
||||
export default React.memo(SuggestionsMenuItem);
|
||||
|
||||
@@ -31,6 +31,9 @@ export default styled.button.attrs((props) => ({
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
||||
// extraArea overlaps slightly, this ensures the currently hovered button is on top
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
@@ -44,7 +47,7 @@ export default styled.button.attrs((props) => ({
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
${extraArea(4)}
|
||||
${extraArea(5)}
|
||||
|
||||
${(props) =>
|
||||
props.active &&
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMenuState } from "reakit";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import * as Toolbar from "@radix-ui/react-toolbar";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { s } from "@shared/styles";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { TooltipProvider } from "~/components/TooltipContext";
|
||||
import { MenuItem as TMenuItem } from "~/types";
|
||||
import { useEditor } from "./EditorContext";
|
||||
@@ -14,18 +11,29 @@ import { MediaDimension } from "./MediaDimension";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
import ToolbarSeparator from "./ToolbarSeparator";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { toMenuItems } from "~/components/Menu/transformer";
|
||||
import { MenuContent } from "~/components/primitives/Menu";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { Menu, MenuTrigger } from "~/components/primitives/Menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
|
||||
type Props = {
|
||||
items: MenuItem[];
|
||||
handlers?: Record<string, (...args: any[]) => void>;
|
||||
};
|
||||
|
||||
/*
|
||||
* Renders a dropdown menu in the floating toolbar.
|
||||
*/
|
||||
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
const menu = useMenuState();
|
||||
function ToolbarDropdown(props: {
|
||||
active: boolean;
|
||||
item: MenuItem;
|
||||
handlers?: Record<string, Function>;
|
||||
}) {
|
||||
const { commands, view } = useEditor();
|
||||
const { item } = props;
|
||||
const { t } = useTranslation();
|
||||
const { item, handlers } = props;
|
||||
const { state } = view;
|
||||
|
||||
const items: TMenuItem[] = useMemo(() => {
|
||||
@@ -34,11 +42,19 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
return;
|
||||
}
|
||||
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
if (commands[menuItem.name]) {
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
} else if (handlers && handlers[menuItem.name]) {
|
||||
handlers[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return item.children
|
||||
@@ -60,24 +76,30 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
: [];
|
||||
}, [item.children, commands, state]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
ev.stopImmediatePropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(buttonProps) => (
|
||||
<ToolbarButton
|
||||
{...buttonProps}
|
||||
hovering={menu.visible}
|
||||
aria-label={item.tooltip}
|
||||
<EventBoundary>
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu>
|
||||
<MenuTrigger>
|
||||
<ToolbarButton aria-label={item.label ? undefined : item.tooltip}>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent
|
||||
align="end"
|
||||
aria-label={item.tooltip || t("More options")}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={item.label} {...menu}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
{toMenuItems(items)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
</EventBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,40 +120,48 @@ function ToolbarMenu(props: Props) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<FlexibleWrapper>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator" && item.visible !== false) {
|
||||
return <ToolbarSeparator key={index} />;
|
||||
}
|
||||
if (item.visible === false || (!item.skipIcon && !item.icon)) {
|
||||
return null;
|
||||
}
|
||||
const isActive = item.active ? item.active(state) : false;
|
||||
<Toolbar.Root asChild>
|
||||
<FlexibleWrapper>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator" && item.visible !== false) {
|
||||
return <ToolbarSeparator key={index} />;
|
||||
}
|
||||
if (item.visible === false || (!item.skipIcon && !item.icon)) {
|
||||
return null;
|
||||
}
|
||||
const isActive = item.active ? item.active(state) : false;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
shortcut={item.shortcut}
|
||||
content={item.label === item.tooltip ? undefined : item.tooltip}
|
||||
>
|
||||
{item.name === "dimensions" ? (
|
||||
<MediaDimension key={index} />
|
||||
) : item.children ? (
|
||||
<ToolbarDropdown active={isActive && !item.label} item={item} />
|
||||
) : (
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
active={isActive && !item.label}
|
||||
aria-label={item.label ? undefined : item.tooltip}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</FlexibleWrapper>
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
shortcut={item.shortcut}
|
||||
content={item.label === item.tooltip ? undefined : item.tooltip}
|
||||
>
|
||||
{item.name === "dimensions" ? (
|
||||
<MediaDimension key={index} />
|
||||
) : item.children ? (
|
||||
<ToolbarDropdown
|
||||
handlers={props.handlers}
|
||||
active={isActive && !item.label}
|
||||
item={item}
|
||||
/>
|
||||
) : (
|
||||
<Toolbar.Button asChild>
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
active={isActive && !item.label}
|
||||
aria-label={item.label ? undefined : item.tooltip}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
</Toolbar.Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</FlexibleWrapper>
|
||||
</Toolbar.Root>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -141,6 +171,7 @@ const FlexibleWrapper = styled.div`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
justify-content: space-evenly;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isList } from "@shared/editor/queries/isList";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
/**
|
||||
@@ -19,33 +18,32 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
new Plugin({
|
||||
key: new PluginKey("clipboardTextSerializer"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice, view) => {
|
||||
const isMultiline = slice.content.childCount > 1;
|
||||
clipboardTextSerializer: (slice) => {
|
||||
// Check if the only node is a code block
|
||||
const isSingleCodeBlock =
|
||||
slice.content.childCount === 1 &&
|
||||
(slice.content.firstChild?.type.name === "code_block" ||
|
||||
slice.content.firstChild?.type.name === "code_fence");
|
||||
|
||||
// This is a cheap way to determine if the content is "complex",
|
||||
// aka it has multiple marks or formatting. In which case we'll use
|
||||
// markdown formatting
|
||||
const hasMultipleListItems = slice.content.content
|
||||
.filter((node) => node.content.content.length > 1)
|
||||
.some((node) => isList(node, view.state.schema));
|
||||
const hasMultipleBlockTypes =
|
||||
[
|
||||
...new Set(
|
||||
slice.content.content
|
||||
.filter((node) => node.content.content.length > 1)
|
||||
.map((node) => node.type.name)
|
||||
),
|
||||
].length > 1;
|
||||
const copyAsMarkdown =
|
||||
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
|
||||
// Check if the only mark is a code mark
|
||||
const marks = new Set<string>();
|
||||
slice.content.descendants((node) => {
|
||||
node.marks.forEach((mark) => marks.add(mark.type.name));
|
||||
});
|
||||
const hasOnlyCodeMark =
|
||||
marks.size === 1 && marks.has("code_inline");
|
||||
|
||||
|
||||
return copyAsMarkdown
|
||||
? mdSerializer.serialize(slice.content, {
|
||||
softBreak: true,
|
||||
})
|
||||
: slice.content.content
|
||||
// Use plain text serializer only for code-only content
|
||||
const usePlainText = isSingleCodeBlock || hasOnlyCodeMark;
|
||||
|
||||
return usePlainText
|
||||
? slice.content.content
|
||||
.map((node) => ProsemirrorHelper.toPlainText(node))
|
||||
.join("");
|
||||
.join("")
|
||||
: mdSerializer.serialize(slice.content, {
|
||||
softBreak: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { action, observable } from "mobx";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { Node, Slice } from "prosemirror-model";
|
||||
import {
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
TextSelection,
|
||||
} from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { v4 } from "uuid";
|
||||
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import { codeLanguages } from "@shared/editor/lib/code";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
@@ -144,7 +144,7 @@ export default class PasteHandler extends Extension {
|
||||
type: MentionType.Document,
|
||||
modelId: document.id,
|
||||
label: document.titleWithDefault,
|
||||
id: v4(),
|
||||
id: uuidv4(),
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -189,7 +189,7 @@ export default class PasteHandler extends Extension {
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
label: collection.name,
|
||||
id: v4(),
|
||||
id: uuidv4(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import some from "lodash/some";
|
||||
import { action, observable } from "mobx";
|
||||
import {
|
||||
EditorState,
|
||||
NodeSelection,
|
||||
Selection,
|
||||
Plugin,
|
||||
TextSelection,
|
||||
} from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import Extension, { WidgetProps } 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 { SelectionToolbar } from "../components/SelectionToolbar";
|
||||
|
||||
export default class SelectionToolbarExtension extends Extension {
|
||||
get name() {
|
||||
return "selection-toolbar";
|
||||
}
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get plugins(): Plugin[] {
|
||||
return [
|
||||
new Plugin({
|
||||
view: () => ({
|
||||
update: this.handleUpdate,
|
||||
}),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@observable
|
||||
state: Selection | boolean = false;
|
||||
|
||||
private handleUpdate = action((view: EditorView) => {
|
||||
const { state } = view;
|
||||
this.state = this.calculateState(state);
|
||||
});
|
||||
|
||||
private calculateState(state: EditorState): Selection | boolean {
|
||||
const { selection, doc, schema } = state;
|
||||
|
||||
if (isMarkActive(schema.marks.link)(state)) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
if (
|
||||
(isNodeActive(schema.nodes.code_block)(state) ||
|
||||
isNodeActive(schema.nodes.code_fence)(state)) &&
|
||||
selection.from > 0
|
||||
) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
if (isInNotice(state) && selection.from > 0) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
if (!selection || selection.empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === "hr"
|
||||
) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
if (
|
||||
selection instanceof NodeSelection &&
|
||||
["image", "attachment", "embed"].includes(selection.node.type.name)
|
||||
) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
if (selection instanceof NodeSelection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectionText = doc.cut(selection.from, selection.to).textContent;
|
||||
if (selection instanceof TextSelection && !selectionText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const slice = selection.content();
|
||||
const fragment = slice.content;
|
||||
const nodes = (fragment as any).content;
|
||||
|
||||
if (some(nodes, (n) => n.content.size)) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
widget = (props: WidgetProps) => {
|
||||
const editorProps = this.editor.props;
|
||||
|
||||
return (
|
||||
<SelectionToolbar
|
||||
{...props}
|
||||
isActive={!!this.state}
|
||||
selection={this.state ? (this.state as Selection) : undefined}
|
||||
canUpdate={editorProps.canUpdate}
|
||||
canComment={editorProps.canComment}
|
||||
isTemplate={editorProps.template === true}
|
||||
onClickLink={editorProps.onClickLink}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { InputRule } from "@shared/editor/lib/InputRule";
|
||||
|
||||
const rightArrow = new InputRule(/->$/, "→");
|
||||
// Note that the suppression of pipe here prevents conflict with table creation rule.
|
||||
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
|
||||
const emdash = new InputRule(/(?:^|[^\|])(--\s)$/, "— ");
|
||||
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
|
||||
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
|
||||
const copyright = new InputRule(/\(c\)$/, "©️");
|
||||
|
||||
@@ -10,6 +10,7 @@ import Keys from "~/editor/extensions/Keys";
|
||||
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
|
||||
import PasteHandler from "~/editor/extensions/PasteHandler";
|
||||
import PreventTab from "~/editor/extensions/PreventTab";
|
||||
import SelectionToolbarExtension from "~/editor/extensions/SelectionToolbar";
|
||||
import SmartText from "~/editor/extensions/SmartText";
|
||||
|
||||
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
|
||||
@@ -24,6 +25,7 @@ export const withUIExtensions = (nodes: Nodes) => [
|
||||
MentionMenuExtension,
|
||||
FindAndReplaceExtension,
|
||||
HoverPreviewsExtension,
|
||||
SelectionToolbarExtension,
|
||||
// Order these default key handlers last
|
||||
PreventTab,
|
||||
Keys,
|
||||
|
||||
+81
-58
@@ -52,9 +52,16 @@ import Logger from "~/utils/Logger";
|
||||
import ComponentView from "./components/ComponentView";
|
||||
import EditorContext from "./components/EditorContext";
|
||||
import { NodeViewRenderer } from "./components/NodeViewRenderer";
|
||||
import SelectionToolbar from "./components/SelectionToolbar";
|
||||
|
||||
import WithTheme from "./components/WithTheme";
|
||||
import isNull from "lodash/isNull";
|
||||
import { isArray, map } from "lodash";
|
||||
import {
|
||||
LightboxImage,
|
||||
LightboxImageFactory,
|
||||
} from "@shared/editor/lib/Lightbox";
|
||||
import Lightbox from "~/components/Lightbox";
|
||||
import { anchorPlugin } from "@shared/editor/plugins/anchorPlugin";
|
||||
|
||||
export type Props = {
|
||||
/** An optional identifier for the editor context. It is used to persist local settings */
|
||||
@@ -144,10 +151,8 @@ type State = {
|
||||
isRTL: boolean;
|
||||
/** If the editor is currently focused */
|
||||
isEditorFocused: boolean;
|
||||
/** If the toolbar for a text selection is visible */
|
||||
selectionToolbarOpen: boolean;
|
||||
/** Position of image in doc that's being currently viewed in Lightbox */
|
||||
activeLightboxImgPos: number | null;
|
||||
/** Image that's being currently viewed in Lightbox */
|
||||
activeLightboxImage: LightboxImage | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -176,8 +181,7 @@ export class Editor extends React.PureComponent<
|
||||
state: State = {
|
||||
isRTL: false,
|
||||
isEditorFocused: false,
|
||||
selectionToolbarOpen: false,
|
||||
activeLightboxImgPos: null,
|
||||
activeLightboxImage: null,
|
||||
};
|
||||
|
||||
isInitialized = false;
|
||||
@@ -264,19 +268,12 @@ export class Editor extends React.PureComponent<
|
||||
this.calculateDir();
|
||||
}
|
||||
|
||||
if (
|
||||
!this.isBlurred &&
|
||||
!this.state.isEditorFocused &&
|
||||
!this.state.selectionToolbarOpen
|
||||
) {
|
||||
if (!this.isBlurred && !this.state.isEditorFocused) {
|
||||
this.isBlurred = true;
|
||||
this.props.onBlur?.();
|
||||
}
|
||||
|
||||
if (
|
||||
this.isBlurred &&
|
||||
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
|
||||
) {
|
||||
if (this.isBlurred && this.state.isEditorFocused) {
|
||||
this.isBlurred = false;
|
||||
this.props.onFocus?.();
|
||||
}
|
||||
@@ -410,6 +407,7 @@ export class Editor extends React.PureComponent<
|
||||
plugins: [
|
||||
...this.keymaps,
|
||||
...this.plugins,
|
||||
anchorPlugin(),
|
||||
dropCursor({
|
||||
color: this.props.theme.cursor,
|
||||
}),
|
||||
@@ -640,6 +638,16 @@ export class Editor extends React.PureComponent<
|
||||
*/
|
||||
public getImages = () => ProsemirrorHelper.getImages(this.view.state.doc);
|
||||
|
||||
public getLightboxImages = (): LightboxImage[] => {
|
||||
const lightboxNodes = ProsemirrorHelper.getLightboxNodes(
|
||||
this.view.state.doc
|
||||
);
|
||||
|
||||
return map(lightboxNodes, (node) =>
|
||||
LightboxImageFactory.createLightboxImage(this.view, node.pos)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the tasks/checkmarks in the current editor.
|
||||
*
|
||||
@@ -662,19 +670,36 @@ export class Editor extends React.PureComponent<
|
||||
public removeComment = (commentId: string) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
let markRemoved = false;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (!node.isInline) {
|
||||
return;
|
||||
if (markRemoved) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mark = node.marks.find(
|
||||
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
||||
);
|
||||
|
||||
if (mark) {
|
||||
tr.removeMark(pos, pos + node.nodeSize, mark);
|
||||
markRemoved = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isArray(node.attrs?.marks)) {
|
||||
const existingMarks = node.attrs.marks;
|
||||
const updatedMarks = existingMarks.filter(
|
||||
(mark: any) => mark.attrs.id !== commentId
|
||||
);
|
||||
const attrs = {
|
||||
...node.attrs,
|
||||
marks: updatedMarks,
|
||||
};
|
||||
tr.setNodeMarkup(pos, undefined, attrs);
|
||||
markRemoved = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
@@ -683,7 +708,7 @@ export class Editor extends React.PureComponent<
|
||||
/**
|
||||
* Update all marks related to a specific comment in the document.
|
||||
*
|
||||
* @param commentId The id of the comment to remove
|
||||
* @param commentId The id of the comment to update
|
||||
* @param attrs The attributes to update
|
||||
*/
|
||||
public updateComment = (
|
||||
@@ -692,10 +717,11 @@ export class Editor extends React.PureComponent<
|
||||
) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
let markUpdated = false;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (!node.isInline) {
|
||||
return;
|
||||
if (markUpdated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mark = node.marks.find(
|
||||
@@ -709,18 +735,36 @@ export class Editor extends React.PureComponent<
|
||||
...mark.attrs,
|
||||
...attrs,
|
||||
});
|
||||
|
||||
tr.removeMark(from, to, mark).addMark(from, to, newMark);
|
||||
markUpdated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isArray(node.attrs?.marks)) {
|
||||
const existingMarks = node.attrs.marks;
|
||||
const updatedMarks = existingMarks.map((mark: any) =>
|
||||
mark.type === "comment" && mark.attrs.id === commentId
|
||||
? { ...mark, attrs: { ...mark.attrs, ...attrs } }
|
||||
: mark
|
||||
);
|
||||
const newAttrs = {
|
||||
...node.attrs,
|
||||
marks: updatedMarks,
|
||||
};
|
||||
tr.setNodeMarkup(pos, undefined, newAttrs);
|
||||
markUpdated = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
};
|
||||
|
||||
public updateActiveLightbox = (pos: number | null) => {
|
||||
public updateActiveLightboxImage = (activeImage: LightboxImage | null) => {
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
activeLightboxImgPos: pos,
|
||||
activeLightboxImage: activeImage,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -775,23 +819,6 @@ export class Editor extends React.PureComponent<
|
||||
return false;
|
||||
};
|
||||
|
||||
private handleOpenSelectionToolbar = () => {
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
selectionToolbarOpen: true,
|
||||
}));
|
||||
};
|
||||
|
||||
private handleCloseSelectionToolbar = () => {
|
||||
if (!this.state.selectionToolbarOpen) {
|
||||
return;
|
||||
}
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
selectionToolbarOpen: false,
|
||||
}));
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
|
||||
this.props;
|
||||
@@ -821,18 +848,7 @@ export class Editor extends React.PureComponent<
|
||||
ref={this.elementRef}
|
||||
lang=""
|
||||
/>
|
||||
{this.view && (
|
||||
<SelectionToolbar
|
||||
rtl={isRTL}
|
||||
readOnly={readOnly}
|
||||
canUpdate={this.props.canUpdate}
|
||||
canComment={this.props.canComment}
|
||||
isTemplate={this.props.template === true}
|
||||
onOpen={this.handleOpenSelectionToolbar}
|
||||
onClose={this.handleCloseSelectionToolbar}
|
||||
onClickLink={this.props.onClickLink}
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.widgets &&
|
||||
Object.values(this.widgets).map((Widget, index) => (
|
||||
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
|
||||
@@ -843,10 +859,12 @@ export class Editor extends React.PureComponent<
|
||||
)}
|
||||
</Observer>
|
||||
</Flex>
|
||||
{this.state.activeLightboxImgPos && (
|
||||
{!isNull(this.state.activeLightboxImage) && (
|
||||
<Lightbox
|
||||
onUpdate={this.updateActiveLightbox}
|
||||
activePos={this.state.activeLightboxImgPos}
|
||||
images={this.getLightboxImages()}
|
||||
activeImage={this.state.activeLightboxImage}
|
||||
onUpdate={this.updateActiveLightboxImage}
|
||||
onClose={() => this.view.focus()}
|
||||
/>
|
||||
)}
|
||||
</EditorContext.Provider>
|
||||
@@ -862,10 +880,15 @@ const EditorContainer = styled(Styles)<{
|
||||
${(props) =>
|
||||
props.focusedCommentId &&
|
||||
css`
|
||||
#comment-${props.focusedCommentId} {
|
||||
span#comment-${props.focusedCommentId} {
|
||||
background: ${transparentize(0.5, props.theme.brand.marine)};
|
||||
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
||||
}
|
||||
a#comment-${props.focusedCommentId}
|
||||
~ span.component-image
|
||||
div.image-wrapper {
|
||||
outline: ${props.theme.commentMarkBackground} solid 2px;
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
|
||||
@@ -5,8 +5,12 @@ import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function attachmentMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: "replaceAttachment",
|
||||
|
||||
@@ -6,8 +6,12 @@ import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function dividerMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
const { schema } = state;
|
||||
|
||||
return [
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
IndentIcon,
|
||||
CopyIcon,
|
||||
Heading3Icon,
|
||||
TableMergeCellsIcon,
|
||||
TableSplitCellsIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import styled from "styled-components";
|
||||
@@ -34,6 +36,11 @@ import {
|
||||
isMobile as isMobileDevice,
|
||||
isTouchDevice,
|
||||
} from "@shared/utils/browser";
|
||||
import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
|
||||
export default function formattingMenuItems(
|
||||
state: EditorState,
|
||||
@@ -46,6 +53,7 @@ export default function formattingMenuItems(
|
||||
const isEmpty = state.selection.empty;
|
||||
const isMobile = isMobileDevice();
|
||||
const isTouch = isTouchDevice();
|
||||
const isTableCell = state.selection instanceof CellSelection;
|
||||
|
||||
const highlight = getMarksBetween(
|
||||
state.selection.from,
|
||||
@@ -166,11 +174,25 @@ export default function formattingMenuItems(
|
||||
icon: <BlockQuoteIcon />,
|
||||
active: isNodeActive(schema.nodes.blockquote),
|
||||
attrs: { level: 2 },
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
tooltip: dictionary.mergeCells,
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
tooltip: dictionary.splitCell,
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: !isCodeBlock,
|
||||
},
|
||||
{
|
||||
name: "checkbox_list",
|
||||
@@ -179,7 +201,7 @@ export default function formattingMenuItems(
|
||||
icon: <TodoListIcon />,
|
||||
keywords: "checklist checkbox task",
|
||||
active: isNodeActive(schema.nodes.checkbox_list),
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
|
||||
},
|
||||
{
|
||||
name: "bullet_list",
|
||||
@@ -187,7 +209,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `⇧+Ctrl+8`,
|
||||
icon: <BulletedListIcon />,
|
||||
active: isNodeActive(schema.nodes.bullet_list),
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
|
||||
},
|
||||
{
|
||||
name: "ordered_list",
|
||||
@@ -195,7 +217,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `⇧+Ctrl+9`,
|
||||
icon: <OrderedListIcon />,
|
||||
active: isNodeActive(schema.nodes.ordered_list),
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
|
||||
},
|
||||
{
|
||||
name: "outdentList",
|
||||
|
||||
@@ -6,16 +6,22 @@ import {
|
||||
AlignImageRightIcon,
|
||||
AlignImageCenterIcon,
|
||||
AlignFullWidthIcon,
|
||||
CommentIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
|
||||
export default function imageMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
const { schema } = state;
|
||||
const isLeftAligned = isNodeActive(schema.nodes.image, {
|
||||
layoutClass: "left-50",
|
||||
@@ -75,14 +81,32 @@ export default function imageMenuItems(
|
||||
visible: !!fetch,
|
||||
},
|
||||
{
|
||||
name: "replaceImage",
|
||||
tooltip: dictionary.replaceImage,
|
||||
icon: <ReplaceIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "replaceImage",
|
||||
label: dictionary.uploadImage,
|
||||
},
|
||||
{
|
||||
name: "editImageUrl",
|
||||
label: dictionary.editImageUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "deleteImage",
|
||||
tooltip: dictionary.deleteImage,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "commentOnImage",
|
||||
tooltip: dictionary.comment,
|
||||
shortcut: `${metaDisplay}+⌥+M`,
|
||||
icon: <CommentIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,8 +6,12 @@ import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function tableMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
const { schema } = state;
|
||||
const isFullWidth = isNodeActive(schema.nodes.table, {
|
||||
layout: TableLayout.fullWidth,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { TableSplitCellsIcon, TableMergeCellsIcon } from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function tableCellMenuItems(
|
||||
state: EditorState,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const { selection } = state;
|
||||
|
||||
// Only show menu items if we have a CellSelection
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: dictionary.mergeCells,
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: dictionary.splitCell,
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -25,10 +25,18 @@ import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
export default function tableColMenuItems(
|
||||
state: EditorState,
|
||||
index: number,
|
||||
rtl: boolean,
|
||||
dictionary: Dictionary
|
||||
readOnly: boolean,
|
||||
dictionary: Dictionary,
|
||||
options: {
|
||||
index: number;
|
||||
rtl: boolean;
|
||||
}
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { index, rtl } = options;
|
||||
const { schema, selection } = state;
|
||||
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
|
||||
@@ -19,9 +19,17 @@ import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
export default function tableRowMenuItems(
|
||||
state: EditorState,
|
||||
index: number,
|
||||
dictionary: Dictionary
|
||||
readOnly: boolean,
|
||||
dictionary: Dictionary,
|
||||
options: {
|
||||
index: number;
|
||||
}
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { index } = options;
|
||||
const { selection } = state;
|
||||
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMenuAction } from "./useMenuAction";
|
||||
import { ActionV2Separator, createActionV2 } from "~/actions";
|
||||
import {
|
||||
deleteCollection,
|
||||
editCollection,
|
||||
editCollectionPermissions,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
searchInCollection,
|
||||
createTemplate,
|
||||
archiveCollection,
|
||||
restoreCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
createDocument,
|
||||
exportCollection,
|
||||
importDocument,
|
||||
sortCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import { ActiveCollectionSection } from "~/actions/sections";
|
||||
import { InputIcon } from "outline-icons";
|
||||
import usePolicy from "./usePolicy";
|
||||
import useStores from "./useStores";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
/** Collection ID for which the actions are generated */
|
||||
collectionId: string;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
};
|
||||
|
||||
export function useCollectionMenuAction({ collectionId, onRename }: Props) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const collection = collections.get(collectionId);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
restoreCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
ActionV2Separator,
|
||||
createDocument,
|
||||
importDocument,
|
||||
ActionV2Separator,
|
||||
createActionV2({
|
||||
name: `${t("Rename")}…`,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <InputIcon />,
|
||||
visible: !!can.update && !!onRename,
|
||||
perform: () => requestAnimationFrame(() => onRename?.()),
|
||||
}),
|
||||
editCollection,
|
||||
editCollectionPermissions,
|
||||
createTemplate,
|
||||
sortCollection,
|
||||
exportCollection,
|
||||
archiveCollection,
|
||||
searchInCollection,
|
||||
ActionV2Separator,
|
||||
deleteCollection,
|
||||
],
|
||||
[t, can.createDocument, can.update, onRename]
|
||||
);
|
||||
|
||||
return useMenuAction(actions);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export default function useDictionary() {
|
||||
comment: t("Comment"),
|
||||
copy: t("Copy"),
|
||||
createLink: t("Create link"),
|
||||
editImageUrl: t("Edit image URL"),
|
||||
createLinkError: t("Sorry, an error occurred creating the link"),
|
||||
createNewDoc: t("Create a new doc"),
|
||||
createNewChildDoc: t("Create a new child doc"),
|
||||
@@ -108,6 +109,7 @@ export default function useDictionary() {
|
||||
untitled: t("Untitled"),
|
||||
none: t("None"),
|
||||
deleteEmbed: t("Delete embed"),
|
||||
uploadImage: t("Upload an image"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user