mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8262c91319 | |||
| 41347970f5 | |||
| f0a04639ad | |||
| 12e4436e3a | |||
| 78383a1fbf | |||
| 29482c8307 | |||
| 9ea218af9b |
+5
-5
@@ -147,10 +147,6 @@ DISCORD_SERVER_ID=
|
||||
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
|
||||
DISCORD_SERVER_ROLES=
|
||||
|
||||
# –––––––––––––– IMPORTS ––––––––––––––
|
||||
NOTION_CLIENT_ID=
|
||||
NOTION_CLIENT_SECRET=
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# Base64 encoded private key and certificate for HTTPS termination. This is only
|
||||
@@ -205,10 +201,14 @@ SENTRY_TUNNEL=
|
||||
|
||||
# To support sending outgoing transactional emails such as "document updated" or
|
||||
# "you've been invited" you'll need to provide authentication for an SMTP server
|
||||
SMTP_SERVICE=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_REPLY_EMAIL=
|
||||
SMTP_TLS_CIPHERS=
|
||||
SMTP_SECURE=true
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
|
||||
@@ -15,8 +15,6 @@ requestInfoDefaultTitles:
|
||||
|
||||
requestInfoLabelToAdd: more information needed
|
||||
|
||||
requestInfoUserstoExclude:
|
||||
- tommoor
|
||||
|
||||
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ env:
|
||||
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
|
||||
REDIS_URL: redis://127.0.0.1:6379
|
||||
URL: http://localhost:3000
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=8000
|
||||
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
UTILS_SECRET: 123456
|
||||
SLACK_VERIFICATION_TOKEN: 123456
|
||||
|
||||
+21
-181
@@ -1,212 +1,52 @@
|
||||
name: Docker
|
||||
name: Docker build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
BASE_IMAGE_NAME: outlinewiki/outline-base
|
||||
|
||||
jobs:
|
||||
build-arm:
|
||||
runs-on: ubicloud-standard-8-arm
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.BASE_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
labels: ${{ steps.base_meta.outputs.labels }}
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
push: true
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}:latest
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Build and push main image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ env.IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-linux-arm64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-amd:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.BASE_IMAGE_NAME }}
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
labels: ${{ steps.base_meta.outputs.labels }}
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ env.IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-linux-amd64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubicloud-standard-8
|
||||
needs:
|
||||
- build-amd
|
||||
- build-arm
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
|
||||
@@ -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
|
||||
- run: yarn lint --fix
|
||||
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: 'Applied automatic fixes'
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
ARG BASE_IMAGE=outlinewiki/outline-base
|
||||
FROM ${BASE_IMAGE} AS base
|
||||
FROM outlinewiki/outline-base AS base
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
@@ -31,7 +30,7 @@ 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
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
||||
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
|
||||
+1
-4
@@ -1,14 +1,11 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:20 AS deps
|
||||
FROM node:20-slim AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
COPY ./patches ./patches
|
||||
|
||||
RUN apt-get update && apt-get install -y cmake
|
||||
ENV NODE_OPTIONS="--max-old-space-size=24000"
|
||||
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.83.0
|
||||
Licensed Work: Outline 0.82.1-3
|
||||
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-04-11
|
||||
Change Date: 2029-02-22
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -171,10 +171,6 @@
|
||||
"description": "smtp.example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_SERVICE": {
|
||||
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PORT": {
|
||||
"description": "1234 (optional)",
|
||||
"required": false
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Collection from "~/models/Collection";
|
||||
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
|
||||
import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
@@ -61,7 +62,7 @@ export const createCollection = createAction({
|
||||
keywords: "create",
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
|
||||
perform: ({ t, event, stores }) => {
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
stores.dialogs.openModal({
|
||||
@@ -77,10 +78,10 @@ export const editCollection = createAction({
|
||||
analyticsName: "Edit collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -103,10 +104,10 @@ export const editCollectionPermissions = createAction({
|
||||
analyticsName: "Collection permissions",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -134,7 +135,7 @@ export const searchInCollection = createAction({
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
visible: ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -149,7 +150,7 @@ export const searchInCollection = createAction({
|
||||
},
|
||||
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath({ collectionId: activeCollectionId }));
|
||||
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -159,7 +160,7 @@ export const starCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
visible: ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -169,7 +170,7 @@ export const starCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
perform: async ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -186,7 +187,7 @@ export const unstarCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
visible: ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -196,7 +197,7 @@ export const unstarCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
perform: async ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -338,13 +339,13 @@ export const deleteCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
visible: ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
perform: ({ activeCollectionId, t, stores }) => {
|
||||
perform: ({ activeCollectionId, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -372,7 +373,7 @@ export const createTemplate = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
LogoutIcon,
|
||||
CaseSensitiveIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -511,25 +510,6 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentAsPlainText = createAction({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
icon: <CaseSensitiveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
copy(document.toPlainText());
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentShareLink = createAction({
|
||||
name: ({ t }) => t("Copy public link"),
|
||||
section: ActiveDocumentSection,
|
||||
@@ -575,12 +555,7 @@ export const copyDocument = createAction({
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CopyIcon />,
|
||||
keywords: "clipboard",
|
||||
children: [
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
copyDocumentAsPlainText,
|
||||
],
|
||||
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
@@ -708,7 +683,6 @@ export const searchInDocument = createAction({
|
||||
name: ({ t }) => t("Search in document"),
|
||||
analyticsName: "Search document",
|
||||
section: ActiveDocumentSection,
|
||||
shortcut: [`Meta+/`],
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -718,7 +692,7 @@ export const searchInDocument = createAction({
|
||||
return !!document?.isActive;
|
||||
},
|
||||
perform: ({ activeDocumentId }) => {
|
||||
history.push(searchPath({ documentId: activeDocumentId }));
|
||||
history.push(searchPath(undefined, { documentId: activeDocumentId }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -831,15 +805,15 @@ export const openRandomDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const searchDocumentsForQuery = (query: string) =>
|
||||
export const searchDocumentsForQuery = (searchQuery: string) =>
|
||||
createAction({
|
||||
id: "search",
|
||||
name: ({ t }) =>
|
||||
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
|
||||
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
|
||||
analyticsName: "Search documents",
|
||||
section: DocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath({ query })),
|
||||
perform: () => history.push(searchPath(searchQuery)),
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
@@ -1230,14 +1204,12 @@ export const rootDocumentActions = [
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
copyDocumentAsPlainText,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
searchInDocument,
|
||||
duplicateDocument,
|
||||
leaveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
|
||||
@@ -50,7 +50,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
||||
name: searchQuery.query,
|
||||
analyticsName: "Navigate to recent search query",
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath({ query: searchQuery.query })),
|
||||
perform: () => history.push(searchPath(searchQuery.query)),
|
||||
});
|
||||
|
||||
export const navigateToDrafts = createAction({
|
||||
@@ -62,15 +62,6 @@ export const navigateToDrafts = createAction({
|
||||
visible: ({ location }) => location.pathname !== draftsPath(),
|
||||
});
|
||||
|
||||
export const navigateToSearch = createAction({
|
||||
name: ({ t }) => t("Search"),
|
||||
analyticsName: "Navigate to search",
|
||||
section: NavigationSection,
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath()),
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const navigateToArchive = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
analyticsName: "Navigate to archive",
|
||||
|
||||
@@ -2,8 +2,6 @@ import { ActionContext } from "~/types";
|
||||
|
||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||
|
||||
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
|
||||
|
||||
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
|
||||
const activeCollection = stores.collections.active;
|
||||
return `${t("Collection")} · ${activeCollection?.name}`;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Redirect } from "react-router-dom";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
import { logoutPath } from "~/utils/routeHelpers";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
|
||||
type Props = {
|
||||
@@ -32,7 +33,7 @@ const Authenticated = ({ children }: Props) => {
|
||||
}
|
||||
|
||||
void auth.logout(true);
|
||||
return <Redirect to="/" />;
|
||||
return <Redirect to={logoutPath()} />;
|
||||
};
|
||||
|
||||
export default observer(Authenticated);
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Switch,
|
||||
Route,
|
||||
useLocation,
|
||||
matchPath,
|
||||
Redirect,
|
||||
} from "react-router-dom";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
@@ -55,7 +48,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const can = usePolicy(ui.activeDocumentId);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
|
||||
const goToSearch = (ev: KeyboardEvent) => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
@@ -80,11 +72,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
return <Redirect to={postLoginPath} />;
|
||||
}
|
||||
|
||||
const sidebar = (
|
||||
<Fade>
|
||||
<Switch>
|
||||
|
||||
@@ -7,43 +7,17 @@ import User from "~/models/User";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar, { AvatarSize } from "./Avatar";
|
||||
|
||||
/**
|
||||
* Props for the AvatarWithPresence component
|
||||
*/
|
||||
type Props = {
|
||||
/** The user to display the avatar for */
|
||||
user: User;
|
||||
/** Whether the user is currently present in the document */
|
||||
isPresent: boolean;
|
||||
/** Whether the user is currently editing the document */
|
||||
isEditing: boolean;
|
||||
/** Whether the user is currently observing the document */
|
||||
isObserving: boolean;
|
||||
/** Whether this avatar represents the current user */
|
||||
isCurrentUser: boolean;
|
||||
/** Optional click handler for the avatar */
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
/** Size of the avatar, defaults to AvatarSize.Large */
|
||||
size?: AvatarSize;
|
||||
/** Optional inline styles to apply to the avatar wrapper */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* AvatarWithPresence component displays a user's avatar with visual indicators
|
||||
* for their current status (present, editing, observing).
|
||||
*
|
||||
* The component shows different visual states:
|
||||
* - Present users have full opacity
|
||||
* - Non-present users have reduced opacity
|
||||
* - Observing users have a colored border matching their user color
|
||||
* - Hovering shows a colored border
|
||||
*
|
||||
* A tooltip displays the user's name and current status.
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @returns React component
|
||||
*/
|
||||
function AvatarWithPresence({
|
||||
onClick,
|
||||
user,
|
||||
@@ -90,33 +64,16 @@ function AvatarWithPresence({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered container for tooltip content
|
||||
*/
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Props for the AvatarPresence styled component
|
||||
*/
|
||||
type AvatarWrapperProps = {
|
||||
/** Whether the user is currently present */
|
||||
$isPresent: boolean;
|
||||
/** Whether the user is currently observing */
|
||||
$isObserving: boolean;
|
||||
/** The user's color for border highlighting */
|
||||
$color: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Styled component that wraps the Avatar and provides visual indicators
|
||||
* for the user's presence status.
|
||||
*
|
||||
* - Adjusts opacity based on presence
|
||||
* - Adds colored borders for observing users
|
||||
* - Handles hover effects
|
||||
*/
|
||||
const AvatarPresence = styled.div<AvatarWrapperProps>`
|
||||
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
|
||||
@@ -80,10 +80,6 @@ const RealButton = styled(ActionButton)<RealProps>`
|
||||
} 0 0 0 1px inset;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: ${`rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.inputBorderFocused} 0 0 0 1px inset`};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${props.theme.textTertiary};
|
||||
background: none;
|
||||
|
||||
@@ -49,7 +49,7 @@ function Collaborators(props: Props) {
|
||||
() =>
|
||||
orderBy(
|
||||
filter(
|
||||
users.all,
|
||||
users.orderedData,
|
||||
(u) =>
|
||||
(presentIds.includes(u.id) ||
|
||||
document.collaboratorIds.includes(u.id)) &&
|
||||
@@ -58,7 +58,7 @@ function Collaborators(props: Props) {
|
||||
[(u) => presentIds.includes(u.id), "id"],
|
||||
["asc", "asc"]
|
||||
),
|
||||
[document.collaboratorIds, users.all, presentIds]
|
||||
[document.collaboratorIds, users.orderedData, presentIds]
|
||||
);
|
||||
|
||||
// load any users we don't yet have in memory
|
||||
|
||||
@@ -11,7 +11,6 @@ import Collection from "~/models/Collection";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Text from "./Text";
|
||||
@@ -25,7 +24,6 @@ type Props = {
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser({ rejectOnEmpty: true });
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const handleSave = React.useMemo(
|
||||
@@ -67,7 +65,6 @@ function CollectionDescription({ collection }: Props) {
|
||||
maxLength={CollectionValidation.maxDescriptionLength}
|
||||
canUpdate={can.update}
|
||||
readOnly={!can.update}
|
||||
userId={user.id}
|
||||
editorStyle={editorStyle}
|
||||
embedsDisabled
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ArrowIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
import Text from "~/components/Text";
|
||||
@@ -71,7 +70,7 @@ function CommandBarItem(
|
||||
""
|
||||
)}
|
||||
{sc.split("+").map((key) => (
|
||||
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
|
||||
<Key key={key}>{key}</Key>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -4,12 +4,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import {
|
||||
AuthenticationFailed,
|
||||
AuthorizationFailed,
|
||||
DocumentTooLarge,
|
||||
TooManyConnections,
|
||||
} from "@shared/collaboration/CloseEvents";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -20,21 +14,21 @@ function ConnectionStatus() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const codeToMessage = {
|
||||
[DocumentTooLarge.code]: {
|
||||
1009: {
|
||||
title: t("Document is too large"),
|
||||
body: t(
|
||||
"This document has reached the maximum size and can no longer be edited"
|
||||
),
|
||||
},
|
||||
[AuthenticationFailed.code]: {
|
||||
4401: {
|
||||
title: t("Authentication failed"),
|
||||
body: t("Please try logging out and back in again"),
|
||||
},
|
||||
[AuthorizationFailed.code]: {
|
||||
4403: {
|
||||
title: t("Authorization failed"),
|
||||
body: t("You may have lost access to this document, try reloading"),
|
||||
},
|
||||
[TooManyConnections.code]: {
|
||||
4503: {
|
||||
title: t("Too many users connected to document"),
|
||||
body: t("Your edits will sync once other users leave the document"),
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ 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;
|
||||
@@ -32,7 +31,6 @@ type Props = {
|
||||
const MenuItem = (
|
||||
{
|
||||
onClick,
|
||||
onPointerMove,
|
||||
children,
|
||||
active,
|
||||
selected,
|
||||
@@ -92,7 +90,6 @@ const MenuItem = (
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onPointerMove={disabled ? undefined : onPointerMove}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
@@ -161,9 +158,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
&: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);
|
||||
|
||||
|
||||
@@ -2,11 +2,16 @@ import { HomeIcon } from "outline-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { InputSelectNew, Option } from "~/components/InputSelectNew";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type DefaultCollectionInputSelectProps = {
|
||||
type DefaultCollectionInputSelectProps = Optional<
|
||||
React.ComponentProps<typeof InputSelect>
|
||||
> & {
|
||||
onSelectCollection: (collection: string) => void;
|
||||
defaultCollectionId: string | null;
|
||||
};
|
||||
@@ -14,6 +19,7 @@ type DefaultCollectionInputSelectProps = {
|
||||
const DefaultCollectionInputSelect = ({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
...rest
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
@@ -41,26 +47,36 @@ const DefaultCollectionInputSelect = ({
|
||||
void fetchData();
|
||||
}, [fetchError, t, fetching, collections]);
|
||||
|
||||
const options: Option[] = React.useMemo(
|
||||
const options = React.useMemo(
|
||||
() =>
|
||||
collections.nonPrivate.reduce(
|
||||
(acc, collection) => [
|
||||
...acc,
|
||||
{
|
||||
type: "item",
|
||||
label: collection.name,
|
||||
label: (
|
||||
<Flex align="center">
|
||||
<IconWrapper>
|
||||
<CollectionIcon collection={collection} />
|
||||
</IconWrapper>
|
||||
{collection.name}
|
||||
</Flex>
|
||||
),
|
||||
value: collection.id,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Home"),
|
||||
label: (
|
||||
<Flex align="center">
|
||||
<IconWrapper>
|
||||
<HomeIcon />
|
||||
</IconWrapper>
|
||||
{t("Home")}
|
||||
</Flex>
|
||||
),
|
||||
value: "home",
|
||||
icon: <HomeIcon />,
|
||||
},
|
||||
] satisfies Option[]
|
||||
]
|
||||
),
|
||||
[collections.nonPrivate, t]
|
||||
);
|
||||
@@ -70,14 +86,13 @@ const DefaultCollectionInputSelect = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelectNew
|
||||
options={options}
|
||||
<InputSelect
|
||||
value={defaultCollectionId ?? "home"}
|
||||
options={options}
|
||||
onChange={onSelectCollection}
|
||||
ariaLabel={t("Default collection")}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
short
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,10 +23,7 @@ function Dialogs() {
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
fullscreen={modal.fullscreen ?? false}
|
||||
onRequestClose={() => {
|
||||
modal.onClose?.();
|
||||
dialogs.closeModal(id);
|
||||
}}
|
||||
onRequestClose={() => dialogs.closeModal(id)}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode, NavigationNodeType } from "@shared/types";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
@@ -78,10 +78,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const recentlyViewedItemIds = documents.recentlyViewed
|
||||
.slice(0, 5)
|
||||
.map((item) => item.id);
|
||||
|
||||
const searchIndex = React.useMemo(
|
||||
() =>
|
||||
new FuzzySearch(items, ["title"], {
|
||||
@@ -130,18 +126,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
return searchTerm
|
||||
? searchIndex.search(searchTerm)
|
||||
: items
|
||||
.filter((item) => recentlyViewedItemIds.includes(item.id))
|
||||
.concat(
|
||||
items.filter((item) => item.type === NavigationNodeType.Collection)
|
||||
)
|
||||
.filter((item) => item.type === "collection")
|
||||
.flatMap(includeDescendants);
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
const baseDepth = nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
);
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
@@ -315,7 +304,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
expanded={isExpanded(index)}
|
||||
icon={renderedIcon}
|
||||
title={title}
|
||||
depth={(node.depth ?? 0) - baseDepth}
|
||||
depth={node.depth as number}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
|
||||
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const OFFSET = 12;
|
||||
const DISCLOSURE = 20;
|
||||
const ICON_SIZE = 24;
|
||||
|
||||
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
|
||||
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
|
||||
|
||||
return (
|
||||
<Node
|
||||
|
||||
+13
-39
@@ -6,9 +6,7 @@ import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
@@ -185,46 +183,22 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[updateComments]
|
||||
);
|
||||
|
||||
const paragraphs = React.useMemo(() => {
|
||||
if (props.readOnly && typeof props.value === "object") {
|
||||
return ProsemirrorHelper.getPlainParagraphs(props.value);
|
||||
}
|
||||
return undefined;
|
||||
}, [props.readOnly, props.value]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary component="div" reloadOnChunkMissing>
|
||||
<>
|
||||
{paragraphs ? (
|
||||
<EditorContainer
|
||||
rtl={props.dir === "rtl"}
|
||||
grow={props.grow}
|
||||
style={props.style}
|
||||
editorStyle={props.editorStyle}
|
||||
>
|
||||
<div className="ProseMirror">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} dir="auto">
|
||||
{paragraph.content?.map((content) => content.text)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</EditorContainer>
|
||||
) : (
|
||||
<LazyLoadedEditor
|
||||
key={props.extensions?.length || 0}
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={handleUploadFile}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
)}
|
||||
<LazyLoadedEditor
|
||||
key={props.extensions?.length || 0}
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={handleUploadFile}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.editorStyle?.paddingBottom && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import styled from "styled-components";
|
||||
import Text from "~/components/Text";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Empty = styled(Text).attrs({
|
||||
type: "tertiary",
|
||||
selectable: false,
|
||||
})``;
|
||||
const Empty = styled.p`
|
||||
color: ${s("textTertiary")};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default Empty;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import Button from "~/components/Button";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import Heading from "~/components/Heading";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
@@ -78,9 +77,9 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
{showTitle && (
|
||||
<>
|
||||
<PageTitle title={t("Module failed to load")} />
|
||||
<Heading>
|
||||
<h1>
|
||||
<Trans>Loading Failed</Trans>
|
||||
</Heading>
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
<Text as="p" type="secondary">
|
||||
@@ -102,9 +101,9 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
{showTitle && (
|
||||
<>
|
||||
<PageTitle title={t("Something Unexpected Happened")} />
|
||||
<Heading>
|
||||
<h1>
|
||||
<Trans>Something Unexpected Happened</Trans>
|
||||
</Heading>
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
<Text as="p" type="secondary">
|
||||
|
||||
@@ -234,7 +234,7 @@ const lineStyle = css`
|
||||
width: 1px;
|
||||
height: calc(50% - 14px + 8px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
|
||||
mix-blend-mode: multiply;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ const lineStyle = css`
|
||||
width: 1px;
|
||||
height: calc(50% - 14px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
|
||||
mix-blend-mode: multiply;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import styled from "styled-components";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
|
||||
const Fade = styled.span<{ timing?: number | string }>`
|
||||
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
|
||||
`;
|
||||
|
||||
export default Fade;
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
|
||||
/**
|
||||
* Fade in animation for a component.
|
||||
*
|
||||
* @param timing - The duration of the fade in animation, default is 250ms.
|
||||
*/
|
||||
const Fade = styled.span<{ timing?: number | string }>`
|
||||
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children?: JSX.Element | null;
|
||||
/** If true, children will be animated. */
|
||||
animate: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps children in a <Fade> if loading is true on mount.
|
||||
*/
|
||||
export const ConditionalFade = ({ animate, children }: Props) => {
|
||||
const [isAnimated] = React.useState(animate);
|
||||
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
|
||||
};
|
||||
|
||||
export default Fade;
|
||||
@@ -23,6 +23,7 @@ type Props = {
|
||||
options: TFilterOption[];
|
||||
selectedKeys: (string | null | undefined)[];
|
||||
defaultLabel?: string;
|
||||
selectedPrefix?: string;
|
||||
className?: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
showFilter?: boolean;
|
||||
@@ -34,6 +35,7 @@ const FilterOptions = ({
|
||||
options,
|
||||
selectedKeys = [],
|
||||
defaultLabel = "Filter options",
|
||||
selectedPrefix = "",
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
@@ -52,7 +54,9 @@ const FilterOptions = ({
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const selectedLabel = selectedItems.length
|
||||
? selectedItems.map((selected) => selected.label).join(", ")
|
||||
? selectedItems
|
||||
.map((selected) => `${selectedPrefix} ${selected.label}`)
|
||||
.join(", ")
|
||||
: "";
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
@@ -66,7 +70,7 @@ const FilterOptions = ({
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon}
|
||||
{option.icon && <Icon>{option.icon}</Icon>}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
@@ -159,16 +163,10 @@ const FilterOptions = ({
|
||||
const showFilterInput = showFilter || options.length > 10;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledButton
|
||||
{...props}
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
neutral
|
||||
disclosure
|
||||
>
|
||||
<StyledButton {...props} className={className} neutral disclosure>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
)}
|
||||
@@ -195,7 +193,7 @@ const FilterOptions = ({
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -233,7 +231,6 @@ const SearchInput = styled(Input)`
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("menuBackground")};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
${NativeInput} {
|
||||
@@ -270,9 +267,15 @@ export const StyledButton = styled(Button)`
|
||||
}
|
||||
|
||||
${Inner} {
|
||||
line-height: 28px;
|
||||
line-height: 24px;
|
||||
min-height: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled.div`
|
||||
margin-right: 8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
`;
|
||||
|
||||
export default FilterOptions;
|
||||
|
||||
@@ -73,7 +73,7 @@ const Backdrop = styled.div`
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${s("backdrop")} !important;
|
||||
z-index: ${depths.overlay};
|
||||
z-index: ${depths.modalOverlay};
|
||||
transition: opacity 200ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
@@ -9,7 +8,6 @@ import useEventListener from "~/hooks/useEventListener";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import LoadingIndicator from "../LoadingIndicator";
|
||||
import { CARD_MARGIN } from "./Components";
|
||||
import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
@@ -25,9 +23,9 @@ const POINTER_WIDTH = 22;
|
||||
type Props = {
|
||||
/** The HTML element that is being hovered over, or null if none. */
|
||||
element: HTMLElement | null;
|
||||
/** ID of the unfurl that will be shown in the hover preview. */
|
||||
unfurlId: string | null;
|
||||
/** Whether the preview data is being loaded. */
|
||||
/** Data to be previewed */
|
||||
data: Record<string, any> | null;
|
||||
/** Whether the preview data is being loaded */
|
||||
dataLoading: boolean;
|
||||
/** A callback on close of the hover preview. */
|
||||
onClose: () => void;
|
||||
@@ -38,155 +36,151 @@ enum Direction {
|
||||
DOWN,
|
||||
}
|
||||
|
||||
const HoverPreviewDesktop = observer(
|
||||
({ element, unfurlId, dataLoading, onClose }: Props) => {
|
||||
const { unfurls } = useStores();
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
|
||||
useHoverPosition({
|
||||
cardRef,
|
||||
element,
|
||||
isVisible,
|
||||
});
|
||||
const data = unfurlId ? unfurls.get(unfurlId)?.data : undefined;
|
||||
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
|
||||
useHoverPosition({
|
||||
cardRef,
|
||||
element,
|
||||
isVisible,
|
||||
});
|
||||
|
||||
const closePreview = React.useCallback(() => {
|
||||
setVisible(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
const closePreview = React.useCallback(() => {
|
||||
setVisible(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const stopCloseTimer = React.useCallback(() => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
timerClose.current = undefined;
|
||||
const stopCloseTimer = React.useCallback(() => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
timerClose.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCloseTimer = React.useCallback(() => {
|
||||
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
|
||||
}, [closePreview]);
|
||||
|
||||
// Open and close the preview when the element changes.
|
||||
React.useEffect(() => {
|
||||
if (element && data && !dataLoading) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
startCloseTimer();
|
||||
}
|
||||
}, [startCloseTimer, element, data, dataLoading]);
|
||||
|
||||
// Close the preview on Escape, scroll, or click outside.
|
||||
useOnClickOutside(cardRef, closePreview);
|
||||
useKeyDown("Escape", closePreview);
|
||||
useEventListener("scroll", closePreview, window, { capture: true });
|
||||
|
||||
// Ensure that the preview stays open while the user is hovering over the card.
|
||||
React.useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
|
||||
if (isVisible) {
|
||||
if (card) {
|
||||
card.addEventListener("mouseenter", stopCloseTimer);
|
||||
card.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCloseTimer = React.useCallback(() => {
|
||||
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
|
||||
}, [closePreview]);
|
||||
|
||||
// Open and close the preview when the element changes.
|
||||
React.useEffect(() => {
|
||||
if (element && data && !dataLoading) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
startCloseTimer();
|
||||
}
|
||||
}, [startCloseTimer, element, data, dataLoading]);
|
||||
|
||||
// Close the preview on Escape, scroll, or click outside.
|
||||
useOnClickOutside(cardRef, closePreview);
|
||||
useKeyDown("Escape", closePreview);
|
||||
useEventListener("scroll", closePreview, window, { capture: true });
|
||||
|
||||
// Ensure that the preview stays open while the user is hovering over the card.
|
||||
React.useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
|
||||
if (isVisible) {
|
||||
if (card) {
|
||||
card.addEventListener("mouseenter", stopCloseTimer);
|
||||
card.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (card) {
|
||||
card.removeEventListener("mouseenter", stopCloseTimer);
|
||||
card.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
stopCloseTimer();
|
||||
};
|
||||
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
|
||||
|
||||
if (dataLoading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return () => {
|
||||
if (card) {
|
||||
card.removeEventListener("mouseenter", stopCloseTimer);
|
||||
card.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transitionEnd: { pointerEvents: "auto" },
|
||||
}}
|
||||
>
|
||||
{data.type === UnfurlResourceType.Mention ? (
|
||||
<HoverPreviewMention
|
||||
ref={cardRef}
|
||||
name={data.name}
|
||||
avatarUrl={data.avatarUrl}
|
||||
color={data.color}
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
summary={data.summary}
|
||||
lastActivityByViewer={data.lastActivityByViewer}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Issue ? (
|
||||
<HoverPreviewIssue
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
author={data.author}
|
||||
labels={data.labels}
|
||||
state={data.state}
|
||||
createdAt={data.createdAt}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.PR ? (
|
||||
<HoverPreviewPullRequest
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
author={data.author}
|
||||
createdAt={data.createdAt}
|
||||
state={data.state}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
thumbnailUrl={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer
|
||||
top={pointerTop}
|
||||
left={pointerLeft}
|
||||
direction={pointerDir}
|
||||
/>
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
stopCloseTimer();
|
||||
};
|
||||
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
|
||||
|
||||
if (dataLoading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
);
|
||||
|
||||
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transitionEnd: { pointerEvents: "auto" },
|
||||
}}
|
||||
>
|
||||
{data.type === UnfurlResourceType.Mention ? (
|
||||
<HoverPreviewMention
|
||||
ref={cardRef}
|
||||
name={data.name}
|
||||
avatarUrl={data.avatarUrl}
|
||||
color={data.color}
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
summary={data.summary}
|
||||
lastActivityByViewer={data.lastActivityByViewer}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Issue ? (
|
||||
<HoverPreviewIssue
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
author={data.author}
|
||||
labels={data.labels}
|
||||
state={data.state}
|
||||
createdAt={data.createdAt}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.PR ? (
|
||||
<HoverPreviewPullRequest
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
author={data.author}
|
||||
createdAt={data.createdAt}
|
||||
state={data.state}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
thumbnailUrl={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer
|
||||
top={pointerTop}
|
||||
left={pointerLeft}
|
||||
direction={pointerDir}
|
||||
/>
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
@@ -196,7 +190,7 @@ function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
|
||||
<HoverPreviewDesktop
|
||||
{...rest}
|
||||
element={element}
|
||||
unfurlId={unfurlId}
|
||||
data={data}
|
||||
dataLoading={dataLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -45,10 +45,6 @@ export const NativeInput = styled.input<{
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&[readOnly] {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${s("placeholder")};
|
||||
@@ -130,14 +126,13 @@ export interface Props
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"prefix"
|
||||
> {
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea";
|
||||
labelHidden?: boolean;
|
||||
label?: string;
|
||||
flex?: boolean;
|
||||
short?: boolean;
|
||||
margin?: string | number;
|
||||
error?: string;
|
||||
rows?: number;
|
||||
/** Optional component that appears inside the input before the textarea and any icon */
|
||||
prefix?: React.ReactNode;
|
||||
/** Optional icon that appears inside the input before the textarea */
|
||||
@@ -181,7 +176,6 @@ function Input(
|
||||
if (ev.key === "Enter" && ev.metaKey) {
|
||||
if (props.onRequestSubmit) {
|
||||
props.onRequestSubmit(ev);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,11 +230,10 @@ function Input(
|
||||
])}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
hasIcon={!!icon}
|
||||
hasPrefix={!!prefix}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<NativeInput
|
||||
@@ -250,12 +243,11 @@ function Input(
|
||||
])}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
hasIcon={!!icon}
|
||||
hasPrefix={!!prefix}
|
||||
type={type}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
|
||||
@@ -60,8 +60,7 @@ function InputSearchPage({
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
history.push(
|
||||
searchPath({
|
||||
query: ev.currentTarget.value,
|
||||
searchPath(ev.currentTarget.value, {
|
||||
collectionId,
|
||||
ref: source,
|
||||
})
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { transparentize } from "polished";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import Separator from "./ContextMenu/Separator";
|
||||
import Flex from "./Flex";
|
||||
import { LabelText } from "./Input";
|
||||
import Scrollable from "./Scrollable";
|
||||
import { IconWrapper } from "./Sidebar/components/SidebarLink";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./primitives/Drawer";
|
||||
import {
|
||||
InputSelectRoot,
|
||||
InputSelectContent,
|
||||
InputSelectItem,
|
||||
InputSelectSeparator,
|
||||
InputSelectTrigger,
|
||||
type TriggerButtonProps,
|
||||
} from "./primitives/InputSelect";
|
||||
import {
|
||||
SelectItemIndicator,
|
||||
SelectItem as SelectItemWrapper,
|
||||
SelectButton,
|
||||
} from "./primitives/components/InputSelect";
|
||||
|
||||
type Separator = {
|
||||
/* Denotes a horizontal divider line to be rendered in the menu, */
|
||||
type: "separator";
|
||||
};
|
||||
|
||||
export type Item = {
|
||||
/* Denotes a selectable option in the menu. */
|
||||
type: "item";
|
||||
/* Representative text shown in the menu for this option. */
|
||||
label: string;
|
||||
/* Actual value of this option. */
|
||||
value: string;
|
||||
/* Additional info shown alongside the label. */
|
||||
description?: string;
|
||||
/* An icon shown alongside the label. */
|
||||
icon?: React.ReactElement;
|
||||
};
|
||||
|
||||
export type Option = Item | Separator;
|
||||
|
||||
type Props = {
|
||||
/* Options to display in the select menu. */
|
||||
options: Option[];
|
||||
/* Current chosen value. */
|
||||
value?: string;
|
||||
/* Callback when an option is selected. */
|
||||
onChange: (value: string) => void;
|
||||
/* ARIA label for accessibility. */
|
||||
ariaLabel: string;
|
||||
/* Label for the select menu. */
|
||||
label: string;
|
||||
/* When true, label is hidden in an accessible manner. */
|
||||
hideLabel?: boolean;
|
||||
/* When true, menu is disabled. */
|
||||
disabled?: boolean;
|
||||
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
|
||||
short?: boolean;
|
||||
} & TriggerButtonProps;
|
||||
|
||||
export function InputSelectNew(props: Props) {
|
||||
const {
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
label,
|
||||
hideLabel,
|
||||
disabled,
|
||||
short,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
const [localValue, setLocalValue] = React.useState(value);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const triggerRef =
|
||||
React.useRef<React.ElementRef<typeof InputSelectTrigger>>(null);
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
|
||||
|
||||
const isMobile = useMobile();
|
||||
|
||||
const placeholder = `Select a ${ariaLabel.toLowerCase()}`;
|
||||
const optionsHaveIcon = options.some(
|
||||
(opt) => opt.type === "item" && !!opt.icon
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
if (option.type === "separator") {
|
||||
return <InputSelectSeparator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelectItem key={option.value} value={option.value}>
|
||||
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
|
||||
</InputSelectItem>
|
||||
);
|
||||
},
|
||||
[optionsHaveIcon]
|
||||
);
|
||||
|
||||
const onValueChange = React.useCallback(
|
||||
async (val: string) => {
|
||||
setLocalValue(val);
|
||||
onChange(val);
|
||||
},
|
||||
[onChange, setLocalValue]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileSelect
|
||||
{...props}
|
||||
value={localValue}
|
||||
onChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper short={short}>
|
||||
<Label text={label} hidden={hideLabel ?? false} />
|
||||
<InputSelectRoot
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
value={localValue}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<InputSelectTrigger
|
||||
ref={triggerRef}
|
||||
placeholder={placeholder}
|
||||
{...triggerProps}
|
||||
/>
|
||||
<InputSelectContent
|
||||
ref={contentRef}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
{options.map(renderOption)}
|
||||
</InputSelectContent>
|
||||
</InputSelectRoot>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
type MobileSelectProps = Props & {
|
||||
placeholder: string;
|
||||
optionsHaveIcon: boolean;
|
||||
};
|
||||
|
||||
function MobileSelect(props: MobileSelectProps) {
|
||||
const {
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
label,
|
||||
hideLabel,
|
||||
disabled,
|
||||
short,
|
||||
placeholder,
|
||||
optionsHaveIcon,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
|
||||
|
||||
const selectedOption = React.useMemo(
|
||||
() =>
|
||||
value
|
||||
? options.find((opt) => opt.type === "item" && opt.value === value)
|
||||
: undefined,
|
||||
[value, options]
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (val: string) => {
|
||||
setOpen(false);
|
||||
onChange(val);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
if (option.type === "separator") {
|
||||
return <Separator />;
|
||||
}
|
||||
|
||||
const isSelected = option === selectedOption;
|
||||
|
||||
return (
|
||||
<SelectItemWrapper
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
data-state={isSelected ? "checked" : "unchecked"}
|
||||
>
|
||||
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
|
||||
{isSelected && <SelectItemIndicator />}
|
||||
</SelectItemWrapper>
|
||||
);
|
||||
},
|
||||
[handleSelect, selectedOption, optionsHaveIcon]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label text={label} hidden={hideLabel ?? false} />
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<SelectButton
|
||||
{...triggerProps}
|
||||
neutral
|
||||
disclosure
|
||||
data-placeholder={selectedOption ? false : ""}
|
||||
>
|
||||
{selectedOption ? (
|
||||
<Option
|
||||
option={selectedOption as Item}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
/>
|
||||
) : (
|
||||
<>{placeholder}</>
|
||||
)}
|
||||
</SelectButton>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
ref={contentRef}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
|
||||
<StyledScrollable hiddenScrollbars>
|
||||
{options.map(renderOption)}
|
||||
</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ text, hidden }: { text: string; hidden: boolean }) {
|
||||
const labelText = <LabelText>{text}</LabelText>;
|
||||
|
||||
return hidden ? (
|
||||
<VisuallyHidden.Root>{labelText}</VisuallyHidden.Root>
|
||||
) : (
|
||||
labelText
|
||||
);
|
||||
}
|
||||
|
||||
function Option({
|
||||
option,
|
||||
optionsHaveIcon,
|
||||
}: {
|
||||
option: Item;
|
||||
optionsHaveIcon: boolean;
|
||||
}) {
|
||||
const icon = optionsHaveIcon ? (
|
||||
option.icon ? (
|
||||
<IconWrapper>{option.icon}</IconWrapper>
|
||||
) : (
|
||||
<IconSpacer />
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<OptionContainer align="center">
|
||||
{icon}
|
||||
{option.label}
|
||||
{option.description && (
|
||||
<>
|
||||
|
||||
<Description type="tertiary" size="small" ellipsis>
|
||||
– {option.description}
|
||||
</Description>
|
||||
</>
|
||||
)}
|
||||
</OptionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.label<{ short?: boolean }>`
|
||||
display: block;
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
`;
|
||||
|
||||
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 IconSpacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
label: React.ReactNode | string;
|
||||
};
|
||||
|
||||
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
|
||||
<Flex column {...props}>
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export const Label = styled(Flex)`
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
display: inline-block;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
export default observer(Labeled);
|
||||
@@ -33,7 +33,6 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
|
||||
small?: boolean;
|
||||
/** Whether to enable keyboard navigation */
|
||||
keyboardNavigation?: boolean;
|
||||
ellipsis?: boolean;
|
||||
};
|
||||
|
||||
const ListItem = (
|
||||
@@ -46,7 +45,6 @@ const ListItem = (
|
||||
border,
|
||||
to,
|
||||
keyboardNavigation,
|
||||
ellipsis,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
@@ -85,9 +83,7 @@ const ListItem = (
|
||||
column={!compact}
|
||||
$selected={selected}
|
||||
>
|
||||
<Heading $small={small} $ellipsis={ellipsis}>
|
||||
{title}
|
||||
</Heading>
|
||||
<Heading $small={small}>{title}</Heading>
|
||||
{subtitle && (
|
||||
<Subtitle $small={small} $selected={selected}>
|
||||
{subtitle}
|
||||
@@ -109,7 +105,7 @@ const ListItem = (
|
||||
$border={border}
|
||||
$small={small}
|
||||
activeStyle={{
|
||||
background: theme.sidebarActiveBackground,
|
||||
background: theme.accent,
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
@@ -212,10 +208,10 @@ const Image = styled(Flex)`
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
|
||||
const Heading = styled.p<{ $small?: boolean }>`
|
||||
font-size: ${(props) => (props.$small ? 14 : 16)}px;
|
||||
font-weight: 500;
|
||||
${(props) => (props.$ellipsis !== false ? ellipsis() : "")}
|
||||
${ellipsis()}
|
||||
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
|
||||
margin: 0;
|
||||
`;
|
||||
@@ -223,13 +219,14 @@ const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
|
||||
const Content = styled(Flex)<{ $selected: boolean }>`
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
color: ${s("text")};
|
||||
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
|
||||
margin: 0;
|
||||
font-size: ${(props) => (props.$small ? 13 : 14)}px;
|
||||
color: ${s("textTertiary")};
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white50 : props.theme.textTertiary};
|
||||
margin-top: -2px;
|
||||
`;
|
||||
|
||||
@@ -237,7 +234,8 @@ export const Actions = styled(Flex)<{ $selected?: boolean }>`
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: ${s("textSecondary")};
|
||||
color: ${(props) =>
|
||||
props.$selected ? props.theme.white : props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
export default React.forwardRef(ListItem);
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { format as formatDate } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { locales } from "@shared/utils/date";
|
||||
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { useLocaleTime } from "~/hooks/useLocaleTime";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
|
||||
let callbacks: (() => void)[] = [];
|
||||
|
||||
// This is a shared timer that fires every minute, used for
|
||||
// updating all Time components across the page all at once.
|
||||
setInterval(() => {
|
||||
callbacks.forEach((cb) => cb());
|
||||
}, 1000 * 60);
|
||||
|
||||
function eachMinute(fn: () => void) {
|
||||
callbacks.push(fn);
|
||||
|
||||
return () => {
|
||||
callbacks = callbacks.filter((cb) => cb !== fn);
|
||||
};
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -12,12 +29,59 @@ export type Props = {
|
||||
format?: Partial<Record<keyof typeof locales, string>>;
|
||||
};
|
||||
|
||||
const LocaleTime: React.FC<Props> = ({ children, ...rest }: Props) => {
|
||||
const { tooltipContent, content } = useLocaleTime(rest);
|
||||
const LocaleTime: React.FC<Props> = ({
|
||||
addSuffix,
|
||||
children,
|
||||
dateTime,
|
||||
shorten,
|
||||
format,
|
||||
relative,
|
||||
}: Props) => {
|
||||
const userLocale = useUserLocale();
|
||||
const dateFormatLong: Record<string, string> = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
};
|
||||
const formatLocaleLong =
|
||||
(userLocale ? dateFormatLong[userLocale] : undefined) ??
|
||||
"MMMM do, yyyy h:mm a";
|
||||
// @ts-expect-error fallback to formatLocaleLong
|
||||
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const callback = React.useRef<() => void>();
|
||||
|
||||
React.useEffect(() => {
|
||||
callback.current = eachMinute(() => {
|
||||
setMinutesMounted((state) => ++state);
|
||||
});
|
||||
return () => {
|
||||
if (callback.current) {
|
||||
callback.current?.();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const date = new Date(Date.parse(dateTime));
|
||||
const locale = dateLocale(userLocale);
|
||||
const relativeContent = dateToRelative(date, {
|
||||
addSuffix,
|
||||
locale,
|
||||
shorten,
|
||||
});
|
||||
|
||||
const tooltipContent = formatDate(date, formatLocaleLong, {
|
||||
locale,
|
||||
});
|
||||
const content =
|
||||
relative !== false
|
||||
? relativeContent
|
||||
: formatDate(date, formatLocale, {
|
||||
locale,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent} placement="bottom">
|
||||
<time dateTime={rest.dateTime}>{children || content}</time>
|
||||
<time dateTime={dateTime}>{children || content}</time>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -147,7 +147,7 @@ const Backdrop = styled(Flex)<{ $fullscreen?: boolean }>`
|
||||
props.$fullscreen
|
||||
? transparentize(0.25, props.theme.background)
|
||||
: props.theme.modalBackdrop} !important;
|
||||
z-index: ${depths.overlay};
|
||||
z-index: ${depths.modalOverlay};
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
|
||||
@@ -48,15 +48,6 @@ function Notifications(
|
||||
notifications.approximateUnreadCount
|
||||
);
|
||||
}
|
||||
|
||||
// PWA badging
|
||||
if ("setAppBadge" in navigator) {
|
||||
if (notifications.approximateUnreadCount) {
|
||||
void navigator.setAppBadge(notifications.approximateUnreadCount);
|
||||
} else {
|
||||
void navigator.clearAppBadge();
|
||||
}
|
||||
}
|
||||
}, [notifications.approximateUnreadCount]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -96,7 +96,7 @@ export const Suggestions = observer(
|
||||
? users.notInDocument(document.id, query)
|
||||
: collection
|
||||
? users.notInCollection(collection.id, query)
|
||||
: users.activeOrInvited
|
||||
: users.orderedData
|
||||
).filter((u) => !u.isSuspended && u.id !== user.id);
|
||||
|
||||
if (isEmail(query)) {
|
||||
@@ -114,7 +114,7 @@ export const Suggestions = observer(
|
||||
}, [
|
||||
getSuggestionForEmail,
|
||||
users,
|
||||
users.activeOrInvited,
|
||||
users.orderedData,
|
||||
groups,
|
||||
groups.orderedData,
|
||||
document?.id,
|
||||
|
||||
@@ -54,8 +54,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const [hasPointerMoved, setPointerMoved] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
|
||||
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
@@ -116,10 +114,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
const handlePointerActivity = React.useCallback(() => {
|
||||
if (ui.sidebarIsClosed) {
|
||||
// clear the timeout when mouse exits
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
setHovering(document.hasFocus());
|
||||
setPointerMoved(true);
|
||||
}
|
||||
@@ -128,20 +122,12 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const handlePointerLeave = React.useCallback(
|
||||
(ev) => {
|
||||
if (hasPointerMoved) {
|
||||
// clear any previous timeout
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
// add a short delay when mouse exits the sidebar before closing
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setHovering(
|
||||
document.hasFocus() &&
|
||||
ev.pageX < width &&
|
||||
ev.pageY < window.innerHeight &&
|
||||
ev.pageY > 0
|
||||
);
|
||||
}, 500);
|
||||
setHovering(
|
||||
document.hasFocus() &&
|
||||
ev.pageX < width &&
|
||||
ev.pageY < window.innerHeight &&
|
||||
ev.pageY > 0
|
||||
);
|
||||
}
|
||||
},
|
||||
[width, hasPointerMoved]
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { CollectionValidation, DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -22,6 +21,7 @@ import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
@@ -12,7 +12,6 @@ import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
|
||||
+10
-11
@@ -73,13 +73,15 @@ function EditableTitle(
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onSubmit(trimmedValue);
|
||||
setOriginalValue(trimmedValue);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
if (document) {
|
||||
try {
|
||||
await onSubmit(trimmedValue);
|
||||
setOriginalValue(trimmedValue);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
[originalValue, value, onCancel, onSubmit]
|
||||
@@ -125,10 +127,7 @@ function EditableTitle(
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<span
|
||||
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
|
||||
className={rest.className}
|
||||
>
|
||||
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
@@ -6,11 +6,7 @@ import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
ImportState,
|
||||
} from "@shared/types";
|
||||
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Collection from "~/models/Collection";
|
||||
import Comment from "~/models/Comment";
|
||||
@@ -19,7 +15,6 @@ import FileOperation from "~/models/FileOperation";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import GroupUser from "~/models/GroupUser";
|
||||
import Import from "~/models/Import";
|
||||
import Membership from "~/models/Membership";
|
||||
import Notification from "~/models/Notification";
|
||||
import Pin from "~/models/Pin";
|
||||
@@ -105,7 +100,6 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
subscriptions,
|
||||
fileOperations,
|
||||
notifications,
|
||||
imports,
|
||||
} = this.props;
|
||||
|
||||
const currentUserId = auth?.user?.id;
|
||||
@@ -626,23 +620,6 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("imports.create", (event: PartialExcept<Import, "id">) => {
|
||||
imports.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("imports.update", (event: PartialExcept<Import, "id">) => {
|
||||
imports.add(event);
|
||||
|
||||
if (
|
||||
event.state === ImportState.Completed &&
|
||||
event.createdBy?.id === auth.user?.id
|
||||
) {
|
||||
toast.success(event.name, {
|
||||
description: this.props.t("Your import completed"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"subscriptions.create",
|
||||
(event: PartialExcept<Subscription, "id">) => {
|
||||
@@ -668,10 +645,6 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("users.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||
users.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"userMemberships.update",
|
||||
async (event: PartialExcept<UserMembership, "id">) => {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
import { Overlay } from "./components/Overlay";
|
||||
|
||||
/** Root Drawer component - all the other components are rendered inside it. */
|
||||
const Drawer = (props: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root {...props} />
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
/** Drawer's trigger. */
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
/** Drawer's content - renders the overlay and the actual content. */
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DrawerPrimitive.Portal>
|
||||
<DrawerPrimitive.Overlay asChild>
|
||||
<Overlay />
|
||||
</DrawerPrimitive.Overlay>
|
||||
<StyledContent ref={ref} {...rest}>
|
||||
{children}
|
||||
</StyledContent>
|
||||
</DrawerPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DrawerContent.displayName = DrawerPrimitive.Content.displayName;
|
||||
|
||||
/** Drawer's title shown in the center. */
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>((props, ref) => {
|
||||
const { hidden, children, ...rest } = props;
|
||||
|
||||
const title = (
|
||||
<TitleWrapper justify="center">
|
||||
<Text size="medium" weight="bold">
|
||||
{children}
|
||||
</Text>
|
||||
</TitleWrapper>
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerPrimitive.Title ref={ref} {...rest} asChild>
|
||||
{hidden ? (
|
||||
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
|
||||
) : (
|
||||
<>{title}</>
|
||||
)}
|
||||
</DrawerPrimitive.Title>
|
||||
);
|
||||
});
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(DrawerPrimitive.Content)`
|
||||
z-index: ${depths.menu};
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-width: 180px;
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
max-height: 90vh;
|
||||
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
`;
|
||||
|
||||
const TitleWrapper = styled(Flex)`
|
||||
padding: 8px 0;
|
||||
`;
|
||||
|
||||
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
|
||||
@@ -1,135 +0,0 @@
|
||||
import * as InputSelectPrimitive from "@radix-ui/react-select";
|
||||
import 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,
|
||||
SelectItem as SelectItemWrapper,
|
||||
SelectButton,
|
||||
} from "./components/InputSelect";
|
||||
|
||||
/** Root InputSelect component - all the other components are rendered inside it. */
|
||||
const InputSelectRoot = InputSelectPrimitive.Root;
|
||||
|
||||
/** InputSelect's trigger. */
|
||||
|
||||
export type TriggerButtonProps = {
|
||||
/** When true, "nude" variant of Button is rendered. */
|
||||
nude?: boolean;
|
||||
/** Optional css class names to pass to the trigger. */
|
||||
className?: string;
|
||||
} & Pick<ButtonProps<unknown>, "borderOnHover">;
|
||||
|
||||
type InputSelectTriggerProps = { placeholder: string } & TriggerButtonProps &
|
||||
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Trigger>;
|
||||
|
||||
const InputSelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof InputSelectPrimitive.Trigger>,
|
||||
InputSelectTriggerProps
|
||||
>((props, ref) => {
|
||||
const { placeholder, children, ...buttonProps } = props;
|
||||
|
||||
return (
|
||||
<InputSelectPrimitive.Trigger ref={ref} asChild>
|
||||
<SelectButton neutral disclosure {...buttonProps}>
|
||||
<InputSelectPrimitive.Value placeholder={placeholder} />
|
||||
</SelectButton>
|
||||
</InputSelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
InputSelectTrigger.displayName = InputSelectPrimitive.Trigger.displayName;
|
||||
|
||||
/** InputSelect's content - renders the options in a scrollable element. */
|
||||
type ContentProps = Omit<
|
||||
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Content>,
|
||||
"position"
|
||||
>;
|
||||
|
||||
const InputSelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof InputSelectPrimitive.Content>,
|
||||
ContentProps
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<InputSelectPrimitive.Portal>
|
||||
<StyledContent ref={ref} position={"popper"} {...rest}>
|
||||
<InputSelectPrimitive.Viewport style={{ overscrollBehavior: "none" }}>
|
||||
{children}
|
||||
</InputSelectPrimitive.Viewport>
|
||||
</StyledContent>
|
||||
</InputSelectPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
InputSelectContent.displayName = InputSelectPrimitive.Content.displayName;
|
||||
|
||||
/** Individual InputSelect option rendered in the menu. */
|
||||
const InputSelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof InputSelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Item>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<InputSelectPrimitive.Item ref={ref} {...rest} asChild>
|
||||
<SelectItemWrapper>
|
||||
<InputSelectPrimitive.ItemText>
|
||||
{children}
|
||||
</InputSelectPrimitive.ItemText>
|
||||
<InputSelectPrimitive.ItemIndicator asChild>
|
||||
<SelectItemIndicator />
|
||||
</InputSelectPrimitive.ItemIndicator>
|
||||
</SelectItemWrapper>
|
||||
</InputSelectPrimitive.Item>
|
||||
);
|
||||
});
|
||||
InputSelectItem.displayName = InputSelectPrimitive.Item.displayName;
|
||||
|
||||
/** Horizontal separator rendered between the options. */
|
||||
const InputSelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof InputSelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Separator>
|
||||
>((props, ref) => (
|
||||
<InputSelectPrimitive.Separator ref={ref} asChild>
|
||||
<Separator {...props} />
|
||||
</InputSelectPrimitive.Separator>
|
||||
));
|
||||
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(InputSelectPrimitive.Content)`
|
||||
z-index: ${depths.menu};
|
||||
min-width: var(--radix-select-trigger-width);
|
||||
max-width: 400px;
|
||||
min-height: 44px;
|
||||
max-height: 350px;
|
||||
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
transform-origin: 50% 0;
|
||||
|
||||
&[data-side="bottom"] {
|
||||
animation: ${fadeAndSlideDown} 200ms ease;
|
||||
}
|
||||
|
||||
&[data-side="top"] {
|
||||
animation: ${fadeAndSlideUp} 200ms ease;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export {
|
||||
InputSelectRoot,
|
||||
InputSelectTrigger,
|
||||
InputSelectContent,
|
||||
InputSelectItem,
|
||||
InputSelectSeparator,
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* Reusable components for InputSelect abstraction.
|
||||
*/
|
||||
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
export const SelectItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<ItemContainer
|
||||
ref={ref}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
gap={8}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
<IconSpacer />
|
||||
</ItemContainer>
|
||||
);
|
||||
});
|
||||
SelectItem.displayName = "SelectItem";
|
||||
|
||||
export const SelectItemIndicator = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>((props, ref) => (
|
||||
<IndicatorContainer ref={ref} {...props}>
|
||||
<CheckmarkIcon />
|
||||
</IndicatorContainer>
|
||||
));
|
||||
SelectItemIndicator.displayName = "SelectItemIndicator";
|
||||
|
||||
const IconSpacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const SelectButton = styled(Button)<{ $nude?: boolean }>`
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
width: 100%;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${s("buttonNeutralBackground")};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$nude &&
|
||||
css`
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
`}
|
||||
|
||||
${Inner} {
|
||||
line-height: 28px;
|
||||
padding-left: 12px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
svg {
|
||||
justify-self: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&[data-placeholder=""] {
|
||||
color: ${s("placeholder")};
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemContainer = styled(Flex)`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("textSecondary")};
|
||||
background: none;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
outline: 0;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: ${s("accentText")};
|
||||
background: ${s("accent")};
|
||||
|
||||
svg {
|
||||
color: ${s("accentText")};
|
||||
fill: ${s("accentText")};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state="checked"] {
|
||||
${IconSpacer} {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
padding-left: 8px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const IndicatorContainer = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
@@ -1,15 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
|
||||
export const Overlay = styled.div`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: ${s("backdrop")};
|
||||
z-index: ${depths.overlay};
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
&[data-state="open"] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
@@ -24,54 +24,6 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type KeyboardShortcutsProps = {
|
||||
popover: ReturnType<typeof usePopoverState>;
|
||||
handleOpen: ({ withReplace }: { withReplace: boolean }) => void;
|
||||
handleCaseSensitive: () => void;
|
||||
handleRegex: () => void;
|
||||
};
|
||||
|
||||
function useKeyboardShortcuts({
|
||||
popover,
|
||||
handleOpen,
|
||||
handleCaseSensitive,
|
||||
handleRegex,
|
||||
}: KeyboardShortcutsProps) {
|
||||
// Open popover
|
||||
useKeyDown(
|
||||
(ev) =>
|
||||
isModKey(ev) &&
|
||||
ev.code === "KeyF" &&
|
||||
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
|
||||
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
handleOpen({ withReplace: ev.altKey });
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
|
||||
// Enable/disable case sensitive search
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
handleCaseSensitive();
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
|
||||
// Enable/disable regex search
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
handleRegex();
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** Whether the find and replace popover is open */
|
||||
open: boolean;
|
||||
@@ -137,48 +89,42 @@ export default function FindAndReplace({
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
// Callbacks
|
||||
const selectInputText = React.useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
|
||||
}, []);
|
||||
|
||||
const selectInputReplaceText = React.useCallback(() => {
|
||||
setTimeout(() => {
|
||||
inputReplaceRef.current?.focus();
|
||||
inputReplaceRef.current?.setSelectionRange(
|
||||
0,
|
||||
inputReplaceRef.current?.value.length
|
||||
);
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const handleOpen = React.useCallback(
|
||||
({ withReplace }: { withReplace: boolean }) => {
|
||||
const shouldShowReplace = !readOnly && withReplace;
|
||||
|
||||
// If already open, switch focus to corresponding input text.
|
||||
if (popover.visible) {
|
||||
if (shouldShowReplace) {
|
||||
setShowReplace(true);
|
||||
selectInputReplaceText();
|
||||
} else {
|
||||
selectInputText();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyDown(
|
||||
(ev) =>
|
||||
isModKey(ev) &&
|
||||
!popover.visible &&
|
||||
ev.code === "KeyF" &&
|
||||
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
|
||||
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
selectionRef.current = window.getSelection()?.toString();
|
||||
popover.show();
|
||||
|
||||
if (shouldShowReplace) {
|
||||
setShowReplace(true);
|
||||
}
|
||||
},
|
||||
[popover, readOnly, selectInputText, selectInputReplaceText]
|
||||
}
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
setRegex((state) => !state);
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
setCaseSensitive((state) => !state);
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
|
||||
// Callbacks
|
||||
const handleMore = React.useCallback(() => {
|
||||
setShowReplace((state) => !state);
|
||||
setTimeout(() => inputReplaceRef.current?.focus(), 100);
|
||||
@@ -186,65 +132,68 @@ export default function FindAndReplace({
|
||||
|
||||
const handleCaseSensitive = React.useCallback(() => {
|
||||
setCaseSensitive((state) => {
|
||||
const isCaseSensitive = !state;
|
||||
const caseSensitive = !state;
|
||||
|
||||
editor.commands.find({
|
||||
text: searchTerm,
|
||||
caseSensitive: isCaseSensitive,
|
||||
caseSensitive,
|
||||
regexEnabled,
|
||||
});
|
||||
|
||||
return isCaseSensitive;
|
||||
return caseSensitive;
|
||||
});
|
||||
}, [regexEnabled, editor.commands, searchTerm]);
|
||||
|
||||
const handleRegex = React.useCallback(() => {
|
||||
setRegex((state) => {
|
||||
const isRegexEnabled = !state;
|
||||
const regexEnabled = !state;
|
||||
|
||||
editor.commands.find({
|
||||
text: searchTerm,
|
||||
caseSensitive,
|
||||
regexEnabled: isRegexEnabled,
|
||||
regexEnabled,
|
||||
});
|
||||
|
||||
return isRegexEnabled;
|
||||
return regexEnabled;
|
||||
});
|
||||
}, [caseSensitive, editor.commands, searchTerm]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
function nextPrevious() {
|
||||
function nextPrevious(ev: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (ev.shiftKey) {
|
||||
editor.commands.prevSearchMatch();
|
||||
} else {
|
||||
editor.commands.nextSearchMatch();
|
||||
}
|
||||
}
|
||||
function selectInputText() {
|
||||
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
|
||||
}
|
||||
|
||||
switch (ev.key) {
|
||||
case "Enter": {
|
||||
ev.preventDefault();
|
||||
nextPrevious();
|
||||
nextPrevious(ev);
|
||||
return;
|
||||
}
|
||||
case "g": {
|
||||
if (ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
nextPrevious();
|
||||
nextPrevious(ev);
|
||||
selectInputText();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "F3": {
|
||||
ev.preventDefault();
|
||||
nextPrevious();
|
||||
nextPrevious(ev);
|
||||
selectInputText();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor.commands, selectInputText]
|
||||
[editor.commands]
|
||||
);
|
||||
|
||||
const handleReplace = React.useCallback(
|
||||
@@ -294,15 +243,6 @@ export default function FindAndReplace({
|
||||
[handleReplace]
|
||||
);
|
||||
|
||||
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
|
||||
|
||||
useKeyboardShortcuts({
|
||||
popover,
|
||||
handleOpen,
|
||||
handleCaseSensitive,
|
||||
handleRegex,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = React.useMemo(
|
||||
() => ({
|
||||
position: "fixed",
|
||||
@@ -345,7 +285,7 @@ export default function FindAndReplace({
|
||||
<>
|
||||
<Tooltip
|
||||
content={t("Previous match")}
|
||||
shortcut="Shift+Enter"
|
||||
shortcut="shift+enter"
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge
|
||||
@@ -355,7 +295,7 @@ export default function FindAndReplace({
|
||||
<CaretUpIcon />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
|
||||
<Tooltip content={t("Next match")} shortcut="enter" placement="bottom">
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.nextSearchMatch()}
|
||||
@@ -414,11 +354,7 @@ export default function FindAndReplace({
|
||||
</StyledInput>
|
||||
{navigation}
|
||||
{!readOnly && (
|
||||
<Tooltip
|
||||
content={t("Replace options")}
|
||||
shortcut={`${altDisplay}+${metaDisplay}+f`}
|
||||
placement="bottom"
|
||||
>
|
||||
<Tooltip content={t("Replace options")} placement="bottom">
|
||||
<ButtonLarge onClick={handleMore}>
|
||||
<ReplaceIcon color={theme.textSecondary} />
|
||||
</ButtonLarge>
|
||||
@@ -440,28 +376,12 @@ export default function FindAndReplace({
|
||||
onRequestSubmit={handleReplaceAll}
|
||||
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
|
||||
/>
|
||||
<Tooltip
|
||||
content={t("Replace")}
|
||||
shortcut="Enter"
|
||||
placement="bottom"
|
||||
>
|
||||
<Button onClick={handleReplace} disabled={disabled} neutral>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={t("Replace all")}
|
||||
shortcut={`${metaDisplay}+Enter`}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={handleReplaceAll}
|
||||
disabled={disabled}
|
||||
neutral
|
||||
>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button onClick={handleReplace} disabled={disabled} neutral>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
<Button onClick={handleReplaceAll} disabled={disabled} neutral>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
|
||||
@@ -6,6 +6,7 @@ import styled, { css } from "styled-components";
|
||||
import { isCode } from "@shared/editor/lib/isCode";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
@@ -40,8 +41,7 @@ function usePosition({
|
||||
}) {
|
||||
const { view } = useEditor();
|
||||
const { selection } = view.state;
|
||||
const menuWidth = menuRef.current?.offsetWidth;
|
||||
const menuHeight = menuRef.current?.offsetHeight;
|
||||
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
|
||||
|
||||
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
|
||||
return defaultPosition;
|
||||
@@ -78,24 +78,13 @@ function usePosition({
|
||||
|
||||
// position at the top right of code blocks
|
||||
const codeBlock = findParentNode(isCode)(view.state.selection);
|
||||
const noticeBlock = findParentNode(
|
||||
(node) => node.type.name === "container_notice"
|
||||
)(view.state.selection);
|
||||
|
||||
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
|
||||
const position = codeBlock
|
||||
? codeBlock.pos
|
||||
: noticeBlock
|
||||
? noticeBlock.pos
|
||||
: null;
|
||||
|
||||
if (position !== null) {
|
||||
const element = view.nodeDOM(position);
|
||||
const bounds = (element as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.right - menuWidth;
|
||||
selectionBounds.right = bounds.right;
|
||||
}
|
||||
if (codeBlock && view.state.selection.empty) {
|
||||
const element = view.nodeDOM(codeBlock.pos);
|
||||
const bounds = (element as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.right - menuWidth;
|
||||
selectionBounds.right = bounds.right;
|
||||
}
|
||||
|
||||
// tables are an oddity, and need their own positioning logic
|
||||
@@ -199,8 +188,7 @@ 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,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { transparentize } from "polished";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
@@ -14,10 +13,6 @@ const Input = styled.input`
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => transparentize(0.5, props.theme.text)};
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ArrowIcon, CloseIcon, DocumentIcon, OpenIcon } from "outline-icons";
|
||||
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
|
||||
import { Mark } from "prosemirror-model";
|
||||
import { Selection } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { hideScrollbars, s } from "@shared/styles";
|
||||
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Input from "./Input";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
@@ -41,163 +32,142 @@ type Props = {
|
||||
view: EditorView;
|
||||
};
|
||||
|
||||
const LinkEditor: React.FC<Props> = ({
|
||||
mark,
|
||||
from,
|
||||
to,
|
||||
dictionary,
|
||||
onRemoveLink,
|
||||
onSelectLink,
|
||||
onClickLink,
|
||||
view,
|
||||
}) => {
|
||||
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
|
||||
const initialValue = getHref();
|
||||
const initialSelectionLength = to - from;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const discardRef = useRef(false);
|
||||
const [query, setQuery] = useState(initialValue);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const { documents } = useStores();
|
||||
type State = {
|
||||
value: string;
|
||||
previousValue: string;
|
||||
};
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
const results = trimmedQuery
|
||||
? documents.findByQuery(trimmedQuery, { maxResults: 25 })
|
||||
: [];
|
||||
class LinkEditor extends React.Component<Props, State> {
|
||||
discardInputValue = false;
|
||||
initialValue = this.href;
|
||||
initialSelectionLength = this.props.to - this.props.from;
|
||||
inputRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
const { request } = useRequest(
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query });
|
||||
res.data.documents.map(documents.add);
|
||||
}, [query])
|
||||
);
|
||||
state: State = {
|
||||
value: this.href,
|
||||
previousValue: "",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (trimmedQuery) {
|
||||
void request();
|
||||
get href(): string {
|
||||
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
window.addEventListener("keydown", this.handleGlobalKeyDown);
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
window.removeEventListener("keydown", this.handleGlobalKeyDown);
|
||||
|
||||
// If we discarded the changes then nothing to do
|
||||
if (this.discardInputValue) {
|
||||
return;
|
||||
}
|
||||
}, [trimmedQuery, request]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "k" && event.metaKey) {
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
// If the link is the same as it was when the editor opened, nothing to do
|
||||
if (this.state.value === this.initialValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleGlobalKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleGlobalKeyDown);
|
||||
// If the link is totally empty or only spaces then remove the mark
|
||||
const href = (this.state.value || "").trim();
|
||||
if (!href) {
|
||||
return this.handleRemoveLink();
|
||||
}
|
||||
|
||||
// If we discarded the changes then nothing to do
|
||||
if (discardRef.current) {
|
||||
return;
|
||||
}
|
||||
this.save(href, href);
|
||||
};
|
||||
|
||||
// If the link is the same as it was when the editor opened, nothing to do
|
||||
if (trimmedQuery === initialValue) {
|
||||
return;
|
||||
}
|
||||
handleGlobalKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "k" && event.metaKey) {
|
||||
this.inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
|
||||
// If the link is totally empty or only spaces then remove the mark
|
||||
if (!trimmedQuery) {
|
||||
return handleRemoveLink();
|
||||
}
|
||||
|
||||
save(trimmedQuery, trimmedQuery);
|
||||
};
|
||||
}, [trimmedQuery, initialValue]);
|
||||
|
||||
const save = (href: string, title?: string) => {
|
||||
save = (href: string, title?: string): void => {
|
||||
href = href.trim();
|
||||
|
||||
if (href.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
discardRef.current = true;
|
||||
this.discardInputValue = true;
|
||||
const { from, to } = this.props;
|
||||
href = sanitizeUrl(href) ?? "";
|
||||
|
||||
onSelectLink({ href, title, from, to });
|
||||
this.props.onSelectLink({ href, title, from, to });
|
||||
};
|
||||
|
||||
const moveSelectionToEnd = () => {
|
||||
const { state, dispatch } = view;
|
||||
const nextSelection = Selection.findFrom(state.tr.doc.resolve(to), 1, true);
|
||||
if (nextSelection) {
|
||||
dispatch(state.tr.setSelection(nextSelection));
|
||||
}
|
||||
view.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||
switch (event.key) {
|
||||
case "ArrowDown": {
|
||||
event.preventDefault();
|
||||
const maxIndex = results.length - 1;
|
||||
setSelectedIndex((current) => (current >= maxIndex ? 0 : current + 1));
|
||||
return;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
event.preventDefault();
|
||||
const maxIndex = results.length - 1;
|
||||
setSelectedIndex((current) => (current <= 0 ? maxIndex : current - 1));
|
||||
return;
|
||||
}
|
||||
case "Enter": {
|
||||
event.preventDefault();
|
||||
const { value } = this.state;
|
||||
|
||||
if (selectedIndex >= 0 && results[selectedIndex]) {
|
||||
const selectedDoc = results[selectedIndex];
|
||||
const href = selectedDoc.url;
|
||||
save(href, selectedDoc.title);
|
||||
} else {
|
||||
save(trimmedQuery, trimmedQuery);
|
||||
this.save(value, value);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
}
|
||||
|
||||
if (initialSelectionLength) {
|
||||
moveSelectionToEnd();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case "Escape": {
|
||||
event.preventDefault();
|
||||
|
||||
if (initialValue) {
|
||||
setQuery(initialValue);
|
||||
moveSelectionToEnd();
|
||||
if (this.initialValue) {
|
||||
this.setState({ value: this.initialValue }, this.moveSelectionToEnd);
|
||||
} else {
|
||||
handleRemoveLink();
|
||||
this.handleRemoveLink();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.target.value;
|
||||
setQuery(newValue);
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
handleSearch = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
): Promise<void> => {
|
||||
const value = event.target.value;
|
||||
|
||||
const handlePaste = () => {
|
||||
setTimeout(() => save(query, query), 0);
|
||||
};
|
||||
this.setState({
|
||||
value,
|
||||
});
|
||||
|
||||
const handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
try {
|
||||
onClickLink(getHref(), event);
|
||||
} catch (err) {
|
||||
toast.error(dictionary.openLinkError);
|
||||
if (trimmedValue) {
|
||||
try {
|
||||
this.setState({
|
||||
previousValue: trimmedValue,
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error("Error searching for link", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLink = () => {
|
||||
discardRef.current = true;
|
||||
handlePaste = (): void => {
|
||||
setTimeout(() => this.save(this.state.value, this.state.value), 0);
|
||||
};
|
||||
|
||||
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
this.props.onClickLink(this.href, event);
|
||||
} catch (err) {
|
||||
toast.error(this.props.dictionary.openLinkError);
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoveLink = (): void => {
|
||||
this.discardInputValue = true;
|
||||
|
||||
const { from, to, mark, view, onRemoveLink } = this.props;
|
||||
const { state, dispatch } = this.props.view;
|
||||
|
||||
const { state, dispatch } = view;
|
||||
if (mark) {
|
||||
dispatch(state.tr.removeMark(from, to, mark));
|
||||
}
|
||||
@@ -206,102 +176,57 @@ const LinkEditor: React.FC<Props> = ({
|
||||
view.focus();
|
||||
};
|
||||
|
||||
const isInternal = isInternalUrl(query);
|
||||
const hasResults = !!results.length;
|
||||
moveSelectionToEnd = () => {
|
||||
const { to, view } = this.props;
|
||||
const { state, dispatch } = view;
|
||||
const nextSelection = Selection.findFrom(state.tr.doc.resolve(to), 1, true);
|
||||
if (nextSelection) {
|
||||
dispatch(state.tr.setSelection(nextSelection));
|
||||
}
|
||||
view.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
render() {
|
||||
const { view, dictionary } = this.props;
|
||||
const { value } = this.state;
|
||||
const isInternal = isInternalUrl(value);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
placeholder={dictionary.searchOrPasteLink}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onChange={handleSearch}
|
||||
onFocus={handleSearch}
|
||||
autoFocus={getHref() === ""}
|
||||
ref={this.inputRef}
|
||||
value={value}
|
||||
placeholder={dictionary.enterLink}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onPaste={this.handlePaste}
|
||||
onChange={this.handleSearch}
|
||||
onFocus={this.handleSearch}
|
||||
autoFocus={this.href === ""}
|
||||
readOnly={!view.editable}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={isInternal ? dictionary.goToLink : dictionary.openLink}
|
||||
>
|
||||
<ToolbarButton onClick={handleOpenLink} disabled={!query}>
|
||||
<ToolbarButton onClick={this.handleOpenLink} disabled={!value}>
|
||||
{isInternal ? <ArrowIcon /> : <OpenIcon />}
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
{view.editable && (
|
||||
<Tooltip content={dictionary.removeLink}>
|
||||
<ToolbarButton onClick={handleRemoveLink}>
|
||||
<ToolbarButton onClick={this.handleRemoveLink}>
|
||||
<CloseIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Wrapper>
|
||||
<SearchResults $hasResults={hasResults}>
|
||||
<ResizingHeightContainer>
|
||||
{hasResults && (
|
||||
<>
|
||||
{results.map((doc, index) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={() => {
|
||||
save(doc.url, doc.title);
|
||||
if (initialSelectionLength) {
|
||||
moveSelectionToEnd();
|
||||
}
|
||||
}}
|
||||
onPointerMove={() => setSelectedIndex(index)}
|
||||
selected={index === selectedIndex}
|
||||
key={doc.id}
|
||||
subtitle={doc.collection?.name}
|
||||
title={doc.title}
|
||||
icon={
|
||||
doc.icon ? (
|
||||
<Icon value={doc.icon} color={doc.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
</SearchResults>
|
||||
</>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
pointer-events: all;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
|
||||
clip-path: inset(0px -100px -100px -100px);
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
left: 0;
|
||||
margin-top: -6px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: ${(props) => (props.$hasResults ? "6px" : "0")};
|
||||
max-height: 240px;
|
||||
pointer-events: all;
|
||||
|
||||
${hideScrollbars()}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 40px;
|
||||
border-radius: 0;
|
||||
max-height: 50vh;
|
||||
padding: 8px 8px 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(LinkEditor);
|
||||
export default LinkEditor;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
|
||||
import { DocumentIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
@@ -10,13 +10,11 @@ import Icon from "@shared/components/Icon";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
DocumentsSection,
|
||||
UserSection,
|
||||
CollectionsSection,
|
||||
} from "~/actions/sections";
|
||||
import { DocumentsSection, UserSection } from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
@@ -44,19 +42,23 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [items, setItems] = React.useState<MentionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const { auth, documents, users, collections } = useStores();
|
||||
const { auth, documents, users } = useStores();
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
const maxResultsInSection = search ? 25 : 5;
|
||||
|
||||
const { loading, request } = useRequest(
|
||||
const { loading, request } = useRequest<{
|
||||
documents: Document[];
|
||||
users: User[];
|
||||
}>(
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query: search });
|
||||
|
||||
res.data.documents.map(documents.add);
|
||||
res.data.users.map(users.add);
|
||||
res.data.collections.map(collections.add);
|
||||
return {
|
||||
documents: res.data.documents.map(documents.add),
|
||||
users: res.data.users.map(users.add),
|
||||
};
|
||||
}, [search, documents, users])
|
||||
);
|
||||
|
||||
@@ -125,34 +127,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
} as MentionItem)
|
||||
)
|
||||
)
|
||||
.concat(
|
||||
collections
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(collection) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: collection.icon ? (
|
||||
<Icon
|
||||
value={collection.icon}
|
||||
color={collection.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<CollectionIcon />
|
||||
),
|
||||
title: collection.name,
|
||||
section: CollectionsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
actorId,
|
||||
label: collection.name,
|
||||
},
|
||||
} as MentionItem)
|
||||
)
|
||||
)
|
||||
.concat([
|
||||
{
|
||||
name: "link",
|
||||
@@ -180,10 +154,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (item: MentionItem) => {
|
||||
if (
|
||||
item.attrs.type === MentionType.Document ||
|
||||
item.attrs.type === MentionType.Collection
|
||||
) {
|
||||
if (item.attrs.type === MentionType.Document) {
|
||||
return;
|
||||
}
|
||||
if (!documentId) {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
@@ -19,7 +18,6 @@ import getCodeMenuItems from "../menus/code";
|
||||
import getDividerMenuItems from "../menus/divider";
|
||||
import getFormattingMenuItems from "../menus/formatting";
|
||||
import getImageMenuItems from "../menus/image";
|
||||
import getNoticeMenuItems from "../menus/notice";
|
||||
import getReadOnlyMenuItems from "../menus/readOnly";
|
||||
import getTableMenuItems from "../menus/table";
|
||||
import getTableColMenuItems from "../menus/tableCol";
|
||||
@@ -57,10 +55,6 @@ function useIsActive(state: EditorState) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isInNotice(state) && selection.from > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!selection || selection.empty) {
|
||||
return false;
|
||||
}
|
||||
@@ -190,7 +184,6 @@ export default function SelectionToolbar(props: Props) {
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === "attachment";
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
|
||||
@@ -210,8 +203,6 @@ export default function SelectionToolbar(props: Props) {
|
||||
items = getDividerMenuItems(state, dictionary);
|
||||
} else if (readOnly) {
|
||||
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
|
||||
} else if (isNoticeSelection && selection.empty) {
|
||||
items = getNoticeMenuItems(state, readOnly, dictionary);
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
|
||||
}
|
||||
|
||||
@@ -645,11 +645,12 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const response = (
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
<>
|
||||
{currentHeading !== previousHeading && (
|
||||
<Header key={currentHeading}>{currentHeading}</Header>
|
||||
)}
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
@@ -658,7 +659,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
|
||||
previousHeading = currentHeading;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
@@ -12,8 +11,6 @@ export type Props = {
|
||||
disabled?: boolean;
|
||||
/** Callback when the item is clicked */
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
/** Callback when the item is hovered */
|
||||
onPointerMove?: (event: React.SyntheticEvent) => void;
|
||||
/** An optional icon for the item */
|
||||
icon?: React.ReactNode;
|
||||
/** The title of the item */
|
||||
@@ -28,7 +25,6 @@ function SuggestionsMenuItem({
|
||||
selected,
|
||||
disabled,
|
||||
onClick,
|
||||
onPointerMove,
|
||||
title,
|
||||
subtitle,
|
||||
shortcut,
|
||||
@@ -57,7 +53,6 @@ function SuggestionsMenuItem({
|
||||
ref={ref}
|
||||
active={selected}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onPointerMove={disabled ? undefined : onPointerMove}
|
||||
icon={icon}
|
||||
>
|
||||
{title}
|
||||
@@ -69,16 +64,12 @@ function SuggestionsMenuItem({
|
||||
|
||||
const Subtitle = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active
|
||||
? transparentize(0.35, props.theme.accentText)
|
||||
: props.theme.textTertiary};
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
const Shortcut = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active
|
||||
? transparentize(0.35, props.theme.accentText)
|
||||
: props.theme.textTertiary};
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
@@ -4,9 +4,9 @@ import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import stores from "~/stores";
|
||||
import HoverPreview from "~/components/HoverPreview";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
interface HoverPreviewsOptions {
|
||||
/** Delay before the target is considered "hovered" and callback is triggered. */
|
||||
@@ -16,11 +16,11 @@ interface HoverPreviewsOptions {
|
||||
export default class HoverPreviews extends Extension {
|
||||
state: {
|
||||
activeLinkElement: HTMLElement | null;
|
||||
unfurlId: string | null;
|
||||
data: Record<string, any> | null;
|
||||
dataLoading: boolean;
|
||||
} = observable({
|
||||
activeLinkElement: null,
|
||||
unfurlId: null,
|
||||
data: null,
|
||||
dataLoading: false,
|
||||
});
|
||||
|
||||
@@ -62,25 +62,19 @@ export default class HoverPreviews extends Extension {
|
||||
);
|
||||
|
||||
if (url) {
|
||||
const transformedUrl = url.startsWith("/")
|
||||
? env.URL + url
|
||||
: url;
|
||||
|
||||
this.state.dataLoading = true;
|
||||
|
||||
const unfurl = await stores.unfurls.fetchUnfurl({
|
||||
url: transformedUrl,
|
||||
documentId,
|
||||
});
|
||||
|
||||
if (unfurl) {
|
||||
try {
|
||||
const data = await client.post("/urls.unfurl", {
|
||||
url: url.startsWith("/") ? env.URL + url : url,
|
||||
documentId,
|
||||
});
|
||||
this.state.activeLinkElement = element;
|
||||
this.state.unfurlId = transformedUrl;
|
||||
} else {
|
||||
this.state.data = data;
|
||||
} catch (err) {
|
||||
this.state.activeLinkElement = null;
|
||||
} finally {
|
||||
this.state.dataLoading = false;
|
||||
}
|
||||
|
||||
this.state.dataLoading = false;
|
||||
}
|
||||
}),
|
||||
this.options.delay
|
||||
@@ -107,11 +101,10 @@ export default class HoverPreviews extends Extension {
|
||||
widget = () => (
|
||||
<HoverPreview
|
||||
element={this.state.activeLinkElement}
|
||||
unfurlId={this.state.unfurlId}
|
||||
data={this.state.data}
|
||||
dataLoading={this.state.dataLoading}
|
||||
onClose={action(() => {
|
||||
this.state.activeLinkElement = null;
|
||||
this.state.unfurlId = null;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { v4 } from "uuid";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import { codeLanguages } from "@shared/editor/lib/code";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
@@ -20,9 +20,8 @@ import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { IconType, MentionType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
import PasteMenu from "../components/PasteMenu";
|
||||
|
||||
@@ -88,7 +87,7 @@ export default class PasteHandler extends Extension {
|
||||
|
||||
// If the users selection is currently in a code block then paste
|
||||
// as plain text, ignore all formatting and HTML content.
|
||||
if (isInCode(state, { inclusive: true })) {
|
||||
if (isInCode(state)) {
|
||||
event.preventDefault();
|
||||
view.dispatch(state.tr.insertText(text));
|
||||
return true;
|
||||
@@ -122,8 +121,6 @@ export default class PasteHandler extends Extension {
|
||||
}
|
||||
|
||||
// Is the link a link to a document? If so, we can grab the title and insert it.
|
||||
const containsHash = text.includes("#");
|
||||
|
||||
if (isDocumentUrl(text)) {
|
||||
const slug = parseDocumentSlug(text);
|
||||
|
||||
@@ -135,7 +132,7 @@ export default class PasteHandler extends Extension {
|
||||
return;
|
||||
}
|
||||
if (document) {
|
||||
if (state.schema.nodes.mention && !containsHash) {
|
||||
if (state.schema.nodes.mention) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(
|
||||
state.selection.from,
|
||||
@@ -169,51 +166,6 @@ export default class PasteHandler extends Extension {
|
||||
this.insertLink(text);
|
||||
});
|
||||
}
|
||||
} else if (isCollectionUrl(text)) {
|
||||
const slug = parseCollectionSlug(text);
|
||||
|
||||
if (slug) {
|
||||
stores.collections
|
||||
.fetch(slug)
|
||||
.then((collection) => {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
if (collection) {
|
||||
if (state.schema.nodes.mention && !containsHash) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(
|
||||
state.selection.from,
|
||||
state.selection.to,
|
||||
state.schema.nodes.mention.create({
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
label: collection.name,
|
||||
id: v4(),
|
||||
})
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const { hash } = new URL(text);
|
||||
const hasEmoji =
|
||||
determineIconType(collection.icon) ===
|
||||
IconType.Emoji;
|
||||
|
||||
const title = `${
|
||||
hasEmoji ? collection.icon + " " : ""
|
||||
}${collection.name}`;
|
||||
|
||||
this.insertLink(`${collection.path}${hash}`, title);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
this.insertLink(text);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.insertLink(text);
|
||||
}
|
||||
@@ -228,7 +180,7 @@ export default class PasteHandler extends Extension {
|
||||
state.tr
|
||||
.replaceSelectionWith(
|
||||
state.schema.nodes.code_block.create({
|
||||
language: Object.keys(codeLanguages).includes(
|
||||
language: Object.keys(LANGUAGES).includes(
|
||||
vscodeMeta.mode
|
||||
)
|
||||
? vscodeMeta.mode
|
||||
|
||||
@@ -3,8 +3,8 @@ import { InputRule } from "@shared/editor/lib/InputRule";
|
||||
|
||||
const rightArrow = new InputRule(/->$/, "→");
|
||||
const emdash = new InputRule(/--$/, "—");
|
||||
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
|
||||
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
|
||||
const oneHalf = new InputRule(/(?:^|\s)1\/2$/, "½");
|
||||
const threeQuarters = new InputRule(/(?:^|\s)3\/4$/, "¾");
|
||||
const copyright = new InputRule(/\(c\)$/, "©️");
|
||||
const registered = new InputRule(/\(r\)$/, "®️");
|
||||
const trademarked = new InputRule(/\(tm\)$/, "™️");
|
||||
|
||||
@@ -23,7 +23,7 @@ export default class Suggestion extends Extension {
|
||||
this.options.trigger
|
||||
)}(${`[\\p{L}\/\\p{M}\\d${
|
||||
this.options.allowSpaces ? "\\s{1}" : ""
|
||||
}\\.\\-–_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
"u"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -843,7 +843,7 @@ const EditorContainer = styled(Styles)<{
|
||||
${(props) =>
|
||||
props.userId &&
|
||||
css`
|
||||
.mention[data-id="${props.userId}"] {
|
||||
.mention[data-id=${props.userId}] {
|
||||
color: ${props.theme.textHighlightForeground};
|
||||
background: ${props.theme.textHighlight};
|
||||
|
||||
|
||||
@@ -13,11 +13,13 @@ export default function attachmentMenuItems(
|
||||
name: "replaceAttachment",
|
||||
tooltip: dictionary.replaceAttachment,
|
||||
icon: <ReplaceIcon />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "deleteAttachment",
|
||||
tooltip: dictionary.deleteAttachment,
|
||||
icon: <TrashIcon />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
+12
-13
@@ -2,11 +2,8 @@ import { CopyIcon, ExpandedIcon } from "outline-icons";
|
||||
import { Node as ProseMirrorNode } from "prosemirror-model";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import {
|
||||
getFrequentCodeLanguages,
|
||||
codeLanguages,
|
||||
getLabelForLanguage,
|
||||
} from "@shared/editor/lib/code";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import { getFrequentCodeLanguages } from "@shared/editor/lib/code";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
@@ -17,19 +14,20 @@ export default function codeMenuItems(
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node();
|
||||
|
||||
const allLanguages = Object.entries(LANGUAGES) as [
|
||||
keyof typeof LANGUAGES,
|
||||
string
|
||||
][];
|
||||
const frequentLanguages = getFrequentCodeLanguages();
|
||||
|
||||
const frequentLangMenuItems = frequentLanguages.map((value) => {
|
||||
const label = codeLanguages[value]?.label;
|
||||
const label = LANGUAGES[value];
|
||||
return langToMenuItem({ node, value, label });
|
||||
});
|
||||
|
||||
const remainingLangMenuItems = Object.entries(codeLanguages)
|
||||
.filter(
|
||||
([value]) =>
|
||||
!frequentLanguages.includes(value as keyof typeof codeLanguages)
|
||||
)
|
||||
.map(([value, item]) => langToMenuItem({ node, value, label: item.label }));
|
||||
const remainingLangMenuItems = allLanguages
|
||||
.filter(([value]) => !frequentLanguages.includes(value))
|
||||
.map(([value, label]) => langToMenuItem({ node, value, label }));
|
||||
|
||||
const languageMenuItems = frequentLangMenuItems.length
|
||||
? [
|
||||
@@ -54,7 +52,8 @@ export default function codeMenuItems(
|
||||
visible: !readOnly,
|
||||
name: "code_block",
|
||||
icon: <ExpandedIcon />,
|
||||
label: getLabelForLanguage(node.attrs.language ?? "none"),
|
||||
// @ts-expect-error We have a fallback for incorrect mapping
|
||||
label: LANGUAGES[node.attrs.language ?? "none"],
|
||||
children: languageMenuItems,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -33,12 +33,14 @@ export default function imageMenuItems(
|
||||
name: "alignLeft",
|
||||
tooltip: dictionary.alignLeft,
|
||||
icon: <AlignImageLeftIcon />,
|
||||
visible: true,
|
||||
active: isLeftAligned,
|
||||
},
|
||||
{
|
||||
name: "alignCenter",
|
||||
tooltip: dictionary.alignCenter,
|
||||
icon: <AlignImageCenterIcon />,
|
||||
visible: true,
|
||||
active: (state) =>
|
||||
isNodeActive(schema.nodes.image)(state) &&
|
||||
!isLeftAligned(state) &&
|
||||
@@ -49,16 +51,19 @@ export default function imageMenuItems(
|
||||
name: "alignRight",
|
||||
tooltip: dictionary.alignRight,
|
||||
icon: <AlignImageRightIcon />,
|
||||
visible: true,
|
||||
active: isRightAligned,
|
||||
},
|
||||
{
|
||||
name: "alignFullWidth",
|
||||
tooltip: dictionary.alignFullWidth,
|
||||
icon: <AlignFullWidthIcon />,
|
||||
visible: true,
|
||||
active: isFullWidthAligned,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "downloadImage",
|
||||
@@ -70,11 +75,13 @@ export default function imageMenuItems(
|
||||
name: "replaceImage",
|
||||
tooltip: dictionary.replaceImage,
|
||||
icon: <ReplaceIcon />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "deleteImage",
|
||||
tooltip: dictionary.deleteImage,
|
||||
icon: <TrashIcon />,
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import {
|
||||
DoneIcon,
|
||||
ExpandedIcon,
|
||||
InfoIcon,
|
||||
StarredIcon,
|
||||
WarningIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { NoticeTypes } from "@shared/editor/nodes/Notice";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function noticeMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean | undefined,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node(-1);
|
||||
const currentStyle = node?.attrs.style as NoticeTypes;
|
||||
|
||||
const mapping = {
|
||||
[NoticeTypes.Info]: dictionary.infoNotice,
|
||||
[NoticeTypes.Warning]: dictionary.warningNotice,
|
||||
[NoticeTypes.Success]: dictionary.successNotice,
|
||||
[NoticeTypes.Tip]: dictionary.tipNotice,
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
name: "container_notice",
|
||||
visible: !readOnly,
|
||||
label: mapping[currentStyle],
|
||||
icon: <ExpandedIcon />,
|
||||
children: [
|
||||
{
|
||||
name: NoticeTypes.Info,
|
||||
icon: <InfoIcon />,
|
||||
label: dictionary.infoNotice,
|
||||
active: () => currentStyle === NoticeTypes.Info,
|
||||
},
|
||||
{
|
||||
name: NoticeTypes.Success,
|
||||
icon: <DoneIcon />,
|
||||
label: dictionary.successNotice,
|
||||
active: () => currentStyle === NoticeTypes.Success,
|
||||
},
|
||||
{
|
||||
name: NoticeTypes.Warning,
|
||||
icon: <WarningIcon />,
|
||||
label: dictionary.warningNotice,
|
||||
active: () => currentStyle === NoticeTypes.Warning,
|
||||
},
|
||||
{
|
||||
name: NoticeTypes.Tip,
|
||||
icon: <StarredIcon />,
|
||||
label: dictionary.tipNotice,
|
||||
active: () => currentStyle === NoticeTypes.Tip,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Hook that provides a dictionary of translated UI strings.
|
||||
*
|
||||
* @returns An object containing all translated UI strings used throughout the application
|
||||
*/
|
||||
export default function useDictionary() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { format as formatDate } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
|
||||
let callbacks: (() => void)[] = [];
|
||||
|
||||
// This is a shared timer that fires every minute, used for
|
||||
// updating all Time components across the page all at once.
|
||||
setInterval(() => {
|
||||
callbacks.forEach((cb) => cb());
|
||||
}, 1000 * 60);
|
||||
|
||||
function eachMinute(fn: () => void) {
|
||||
callbacks.push(fn);
|
||||
|
||||
return () => {
|
||||
callbacks = callbacks.filter((cb) => cb !== fn);
|
||||
};
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
dateTime: string;
|
||||
addSuffix?: boolean;
|
||||
shorten?: boolean;
|
||||
relative?: boolean;
|
||||
format?: Partial<Record<keyof typeof locales, string>>;
|
||||
};
|
||||
|
||||
export const useLocaleTime = ({
|
||||
addSuffix,
|
||||
dateTime,
|
||||
shorten,
|
||||
format,
|
||||
relative,
|
||||
}: Props) => {
|
||||
const userLocale = useUserLocale();
|
||||
const dateFormatLong: Record<string, string> = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
};
|
||||
const formatLocaleLong =
|
||||
(userLocale ? dateFormatLong[userLocale] : undefined) ??
|
||||
"MMMM do, yyyy h:mm a";
|
||||
// @ts-expect-error fallback to formatLocaleLong
|
||||
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const callback = React.useRef<() => void>();
|
||||
|
||||
React.useEffect(() => {
|
||||
callback.current = eachMinute(() => {
|
||||
setMinutesMounted((state) => ++state);
|
||||
});
|
||||
return () => {
|
||||
if (callback.current) {
|
||||
callback.current?.();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const date = new Date(Date.parse(dateTime));
|
||||
const locale = dateLocale(userLocale);
|
||||
const relativeContent = dateToRelative(date, {
|
||||
addSuffix,
|
||||
locale,
|
||||
shorten,
|
||||
});
|
||||
|
||||
const tooltipContent = formatDate(date, formatLocaleLong, {
|
||||
locale,
|
||||
});
|
||||
const content =
|
||||
relative !== false
|
||||
? relativeContent
|
||||
: formatDate(date, formatLocale, {
|
||||
locale,
|
||||
});
|
||||
|
||||
return {
|
||||
content,
|
||||
tooltipContent,
|
||||
};
|
||||
};
|
||||
@@ -1,15 +1,6 @@
|
||||
import * as React from "react";
|
||||
import useWindowSize from "./useWindowSize";
|
||||
|
||||
/**
|
||||
* Hook to calculate the maximum height for an element based on its position and viewport size.
|
||||
*
|
||||
* @param options Configuration options
|
||||
* @param options.elementRef A ref pointing to the element to calculate max height for
|
||||
* @param options.maxViewportPercentage The maximum height of the element as a percentage of the viewport
|
||||
* @param options.margin The margin to apply to the positioning
|
||||
* @returns Object containing the calculated maxHeight and a function to recalculate it
|
||||
*/
|
||||
const useMaxHeight = ({
|
||||
elementRef,
|
||||
maxViewportPercentage = 90,
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Hook to check if a media query matches the current viewport.
|
||||
*
|
||||
* @param query The CSS media query to check against
|
||||
* @returns boolean indicating whether the media query matches
|
||||
*/
|
||||
export default function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(false);
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { breakpoints } from "@shared/styles";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
/**
|
||||
* Hook to detect if the current viewport is mobile-sized.
|
||||
*
|
||||
* @returns boolean indicating whether the current viewport is mobile-sized
|
||||
*/
|
||||
export default function useMobile(): boolean {
|
||||
return useMediaQuery(`(max-width: ${breakpoints.tablet - 1}px)`);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ export default function usePolicy(entity?: string | Model | null) {
|
||||
? entity
|
||||
: entity.id
|
||||
: "";
|
||||
const policy = policies.get(entityId);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -29,11 +28,11 @@ export default function usePolicy(entity?: string | Model | null) {
|
||||
) {
|
||||
// The policy for this model is missing and we have an authenticated session, attempt to
|
||||
// reload relationships for this model.
|
||||
if (!policy && user) {
|
||||
if (!policies.get(entity.id) && user) {
|
||||
void entity.loadRelations();
|
||||
}
|
||||
}
|
||||
}, [policies, policy, user, entity]);
|
||||
}, [policies, user, entity]);
|
||||
|
||||
return policies.abilities(entityId);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Hook to access URL query parameters from the current location.
|
||||
*
|
||||
* @returns URLSearchParams object containing the current URL query parameters
|
||||
*/
|
||||
export default function useQuery() {
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
@@ -24,14 +24,6 @@ export default function useQueryNotices() {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case QueryNotices.UnsubscribeCollection: {
|
||||
toast.success(
|
||||
t("Unsubscribed from collection", {
|
||||
type: "success",
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}, [t, notice]);
|
||||
|
||||
@@ -2,11 +2,6 @@ import { MobXProviderContext } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import RootStore from "~/stores";
|
||||
|
||||
/**
|
||||
* Hook to access the MobX stores from the React context.
|
||||
*
|
||||
* @returns The root store containing all application stores
|
||||
*/
|
||||
export default function useStores() {
|
||||
return React.useContext(MobXProviderContext) as typeof RootStore;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Hook that executes a callback when the component unmounts.
|
||||
*
|
||||
* @param callback Function to be called on component unmount
|
||||
*/
|
||||
const useUnmount = (callback: (...args: Array<any>) => any) => {
|
||||
const ref = React.useRef(callback);
|
||||
ref.current = callback;
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Hook to get the current viewport height, accounting for mobile virtual keyboards.
|
||||
* Uses the VisualViewport API when available, falling back to window.innerHeight.
|
||||
*
|
||||
* @returns The current viewport height in pixels
|
||||
*/
|
||||
export default function useViewportHeight(): number | void {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility
|
||||
// Note: No support in Firefox at time of writing, however this mainly exists
|
||||
|
||||
@@ -13,13 +13,6 @@ const defaultOptions = {
|
||||
throttle: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to track the window's scroll position.
|
||||
*
|
||||
* @param options Configuration options
|
||||
* @param options.throttle Time in milliseconds to throttle the scroll event
|
||||
* @returns Object containing the current scroll position (x, y coordinates)
|
||||
*/
|
||||
export default function useWindowScrollPosition(options: {
|
||||
throttle: number;
|
||||
}): {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CrossIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Import from "~/models/Import";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
type Props = {
|
||||
/** Import to which actions will be applied. */
|
||||
importModel: Import;
|
||||
/** Callback to handle import cancellation. */
|
||||
onCancel: () => Promise<void>;
|
||||
/** Callback to handle import deletion. */
|
||||
onDelete: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const ImportMenu = observer(
|
||||
({ importModel, onCancel, onDelete }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(importModel);
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
type: "button",
|
||||
title: t("Cancel"),
|
||||
visible: can.cancel,
|
||||
icon: <CrossIcon />,
|
||||
dangerous: true,
|
||||
onClick: onCancel,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Delete"),
|
||||
visible: can.delete,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
onClick: onDelete,
|
||||
},
|
||||
] satisfies MenuItem[],
|
||||
[t, can.delete, can.cancel, onCancel, onDelete]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Import menu options")}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -24,6 +24,7 @@ const NotificationMenu: React.FC = () => {
|
||||
{
|
||||
type: "button",
|
||||
title: t("Notification settings"),
|
||||
visible: true,
|
||||
onClick: () => performAction(navigateToNotificationSettings, context),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -28,6 +28,7 @@ function TableOfContentsMenu() {
|
||||
const i = [
|
||||
{
|
||||
type: "heading",
|
||||
visible: true,
|
||||
title: t("Contents"),
|
||||
},
|
||||
...headings.map((heading) => ({
|
||||
|
||||
@@ -92,11 +92,6 @@ export default class Collection extends ParanoidModel {
|
||||
@observable
|
||||
archivedBy?: User;
|
||||
|
||||
@computed
|
||||
get searchContent(): string {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
/** Returns whether the collection is empty, or undefined if not loaded. */
|
||||
@computed
|
||||
get isEmpty(): boolean | undefined {
|
||||
|
||||
+2
-22
@@ -17,7 +17,6 @@ import {
|
||||
NavigationNodeType,
|
||||
NotificationEventType,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
@@ -189,10 +188,9 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
@observable
|
||||
collaboratorIds: string[];
|
||||
|
||||
@Relation(() => User)
|
||||
@observable
|
||||
createdBy: User | undefined;
|
||||
|
||||
@Relation(() => User)
|
||||
@observable
|
||||
updatedBy: User | undefined;
|
||||
|
||||
@@ -601,7 +599,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
*/
|
||||
getSummary = (blocks = 4) => ({
|
||||
...this.data,
|
||||
content: this.data.content?.slice(0, blocks),
|
||||
content: this.data.content.slice(0, blocks),
|
||||
});
|
||||
|
||||
@computed
|
||||
@@ -664,24 +662,6 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
return markdown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the plain text representation of the document derived from the ProseMirror data.
|
||||
*
|
||||
* @returns The plain text representation of the document as a string.
|
||||
*/
|
||||
toPlainText = () => {
|
||||
const extensionManager = new ExtensionManager(withComments(richExtensions));
|
||||
const schema = new Schema({
|
||||
nodes: extensionManager.nodes,
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
const text = ProsemirrorHelper.toPlainText(
|
||||
Node.fromJSON(schema, this.data),
|
||||
schema
|
||||
);
|
||||
return text;
|
||||
};
|
||||
|
||||
download = (contentType: ExportContentType) =>
|
||||
client.post(
|
||||
`/documents.export`,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class FileOperation extends Model {
|
||||
static modelName = "FileOperation";
|
||||
@@ -28,7 +27,6 @@ class FileOperation extends Model {
|
||||
|
||||
format: FileOperationFormat;
|
||||
|
||||
@Relation(() => User)
|
||||
user: User;
|
||||
|
||||
@computed
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { observable } from "mobx";
|
||||
import { ImportableIntegrationService, ImportState } from "@shared/types";
|
||||
import ImportsStore from "~/stores/ImportsStore";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterChange } from "./decorators/Lifecycle";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class Import extends Model {
|
||||
static modelName = "Import";
|
||||
|
||||
store: ImportsStore;
|
||||
|
||||
/** The name of the import. */
|
||||
name: string;
|
||||
|
||||
/** Descriptive error message when the import errors out. */
|
||||
error: string | null;
|
||||
|
||||
/** The current state of the import. */
|
||||
@Field
|
||||
@observable
|
||||
state: ImportState;
|
||||
|
||||
/** The external service from which the import is created. */
|
||||
service: ImportableIntegrationService;
|
||||
|
||||
/** The count of documents created in the import. */
|
||||
@observable
|
||||
documentCount: number;
|
||||
|
||||
/** The user who created the import. */
|
||||
@Relation(() => User, {})
|
||||
createdBy: User;
|
||||
|
||||
/** The ID of the user who created the import. */
|
||||
createdById: string;
|
||||
|
||||
/**
|
||||
* Cancel the import – this will stop the import process and mark it as
|
||||
* cancelled at the first opportunity.
|
||||
*/
|
||||
cancel = async () => this.store.cancel(this);
|
||||
|
||||
// hooks
|
||||
|
||||
@AfterChange
|
||||
static removePolicies(model: Import, previousAttributes: Partial<Import>) {
|
||||
if (previousAttributes.state && previousAttributes.state !== model.state) {
|
||||
const { policies } = model.store.rootStore;
|
||||
policies.remove(model.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Import;
|
||||
@@ -4,7 +4,6 @@ import { isRTL } from "@shared/utils/rtl";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class Revision extends Model {
|
||||
@@ -20,10 +19,6 @@ class Revision extends Model {
|
||||
/** The document title when the revision was created */
|
||||
title: string;
|
||||
|
||||
/** An optional name for the revision */
|
||||
@Field
|
||||
name: string | null;
|
||||
|
||||
/** Prosemirror data of the content when revision was created */
|
||||
data: ProsemirrorData;
|
||||
|
||||
|
||||
+2
-8
@@ -1,13 +1,12 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import { observable } from "mobx";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
import { Searchable } from "./interfaces/Searchable";
|
||||
|
||||
class Share extends Model implements Searchable {
|
||||
class Share extends Model {
|
||||
static modelName = "Share";
|
||||
|
||||
@Field
|
||||
@@ -66,11 +65,6 @@ class Share extends Model implements Searchable {
|
||||
/** The user that shared the document. */
|
||||
@Relation(() => User, { onDelete: "null" })
|
||||
createdBy: User;
|
||||
|
||||
@computed
|
||||
get searchContent(): string[] {
|
||||
return [this.document?.title ?? this.documentTitle];
|
||||
}
|
||||
}
|
||||
|
||||
export default Share;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { observable } from "mobx";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import Model from "./base/Model";
|
||||
|
||||
class Unfurl<UnfurlType extends UnfurlResourceType> extends Model {
|
||||
static modelName = "Unfurl";
|
||||
|
||||
@observable
|
||||
type: UnfurlType;
|
||||
|
||||
@observable
|
||||
data: UnfurlResponse[UnfurlType];
|
||||
|
||||
@observable
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export default Unfurl;
|
||||
+14
-14
@@ -43,6 +43,12 @@ export default abstract class Model {
|
||||
this: Model,
|
||||
options: { withoutPolicies?: boolean } = {}
|
||||
): Promise<any> {
|
||||
const relations = getRelationsForModelClass(
|
||||
this.constructor as typeof Model
|
||||
);
|
||||
if (!relations) {
|
||||
return;
|
||||
}
|
||||
// this is to ensure that multiple loads don’t happen in parallel
|
||||
if (this.loadingRelations) {
|
||||
return this.loadingRelations;
|
||||
@@ -50,20 +56,14 @@ export default abstract class Model {
|
||||
|
||||
const promises = [];
|
||||
|
||||
const relations = getRelationsForModelClass(
|
||||
this.constructor as typeof Model
|
||||
);
|
||||
|
||||
if (relations) {
|
||||
for (const properties of relations.values()) {
|
||||
const store = this.store.rootStore.getStoreForModelName(
|
||||
properties.relationClassResolver().modelName
|
||||
);
|
||||
if ("fetch" in store) {
|
||||
const id = this[properties.idKey];
|
||||
if (id) {
|
||||
promises.push(store.fetch(id as string));
|
||||
}
|
||||
for (const properties of relations.values()) {
|
||||
const store = this.store.rootStore.getStoreForModelName(
|
||||
properties.relationClassResolver().modelName
|
||||
);
|
||||
if ("fetch" in store) {
|
||||
const id = this[properties.idKey];
|
||||
if (id) {
|
||||
promises.push(store.fetch(id as string));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import { observable } from "mobx";
|
||||
import Model from "./Model";
|
||||
|
||||
export default abstract class ParanoidModel extends Model {
|
||||
@observable
|
||||
deletedAt: string | undefined;
|
||||
|
||||
@computed
|
||||
get isDeleted(): boolean {
|
||||
return !!this.deletedAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
|
||||
import DocumentNew from "~/scenes/DocumentNew";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import AuthenticatedLayout from "~/components/AuthenticatedLayout";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
@@ -83,7 +83,7 @@ function AuthenticatedRoutes() {
|
||||
<Route exact path={`/doc/${slug}/insights`} component={Document} />
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route exact path={`${searchPath()}/:query?`} component={Search} />
|
||||
<Route exact path={`${searchPath()}/:term?`} component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
<SettingsRoutes />
|
||||
<Route component={Error404} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { RouteComponentProps, Switch } from "react-router-dom";
|
||||
import DocumentNew from "~/scenes/DocumentNew";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
|
||||
@@ -17,6 +17,7 @@ import { s } from "@shared/styles";
|
||||
import { StatusFilter } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
import Search from "~/scenes/Search";
|
||||
import { Action } from "~/components/Actions";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
|
||||
@@ -40,7 +41,6 @@ import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
|
||||
import Error404 from "../Errors/Error404";
|
||||
import Actions from "./components/Actions";
|
||||
import DropToImport from "./components/DropToImport";
|
||||
import Empty from "./components/Empty";
|
||||
@@ -139,7 +139,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
|
||||
|
||||
if (!collection && error) {
|
||||
return <Error404 />;
|
||||
return <Search notFound />;
|
||||
}
|
||||
|
||||
const hasOverview = can.update || collection?.hasDescription;
|
||||
|
||||
@@ -9,8 +9,8 @@ import { s } from "@shared/styles";
|
||||
import { NavigationNode, PublicTeam, TOCPosition } from "@shared/types";
|
||||
import type { Theme } from "~/stores/UiStore";
|
||||
import DocumentModel from "~/models/Document";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import ErrorOffline from "~/scenes/Errors/ErrorOffline";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
import {
|
||||
DocumentContextProvider,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { InputSelectNew, Option } from "~/components/InputSelectNew";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
@@ -28,80 +28,66 @@ const CommentSortMenu = () => {
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const value = viewingResolved ? "resolved" : preferredSortType;
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(val: string) => {
|
||||
if (val === "resolved") {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const handleSortTypeChange = (type: CommentSortType) => {
|
||||
if (type !== preferredSortType) {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
type === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
}
|
||||
};
|
||||
|
||||
const sortType = val as CommentSortType;
|
||||
if (sortType !== preferredSortType) {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
sortType === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
}
|
||||
const showResolved = () => {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
};
|
||||
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: undefined,
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
},
|
||||
[history, location, sidebarContext, user, preferredSortType]
|
||||
);
|
||||
|
||||
const options: Option[] = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Most recent"),
|
||||
value: CommentSortType.MostRecent,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Order in doc"),
|
||||
value: CommentSortType.OrderInDocument,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Resolved"),
|
||||
value: "resolved",
|
||||
},
|
||||
] satisfies Option[],
|
||||
[t]
|
||||
);
|
||||
const showUnresolved = () => {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: undefined,
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
style={{ margin: 0 }}
|
||||
ariaLabel={t("Sort comments")}
|
||||
label={t("Sort comments")}
|
||||
hideLabel
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
if (ev === "resolved") {
|
||||
showResolved();
|
||||
} else {
|
||||
handleSortTypeChange(ev as CommentSortType);
|
||||
showUnresolved();
|
||||
}
|
||||
}}
|
||||
borderOnHover
|
||||
options={[
|
||||
{ value: CommentSortType.MostRecent, label: t("Most recent") },
|
||||
{ value: CommentSortType.OrderInDocument, label: t("Order in doc") },
|
||||
{
|
||||
divider: true,
|
||||
value: "resolved",
|
||||
label: t("Resolved"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Select = styled(InputSelectNew)`
|
||||
const Select = styled(InputSelect)`
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
@@ -64,7 +63,7 @@ function CommentThread({
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
|
||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
||||
|
||||
const can = usePolicy(document);
|
||||
|
||||
@@ -157,9 +156,9 @@ function CommentThread({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focused && autoFocus) {
|
||||
setAutoFocusOff();
|
||||
setAutoFocus(false);
|
||||
}
|
||||
}, [focused, autoFocus, setAutoFocusOff]);
|
||||
}, [focused, autoFocus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focused) {
|
||||
@@ -274,7 +273,7 @@ function CommentThread({
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
{!focused && !recessed && !draft && canReply && (
|
||||
<Reply onClick={setAutoFocusOn}>{t("Reply")}…</Reply>
|
||||
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}…</Reply>
|
||||
)}
|
||||
</Thread>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user