mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bab2729669 | |||
| 90f9721b40 | |||
| dc474573c6 | |||
| a3910ce6d1 |
@@ -65,7 +65,7 @@
|
||||
],
|
||||
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
"lodash/import-scope": ["error", "method"],
|
||||
"lodash/import-scope": ["warn", "method"],
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"import/newline-after-import": 2,
|
||||
|
||||
@@ -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
|
||||
@@ -63,7 +63,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
app: ${{ steps.filter.outputs.app }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
@@ -82,7 +81,7 @@ jobs:
|
||||
- 'yarn.lock'
|
||||
|
||||
test:
|
||||
needs: [build, changes]
|
||||
needs: build
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -144,8 +143,8 @@ jobs:
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
|
||||
bundle-size:
|
||||
needs: [build, types, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
needs: [build, types]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -162,4 +161,3 @@ jobs:
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
webpackStatsFile: ./build/app/webpack-stats.json
|
||||
|
||||
+19
-179
@@ -3,32 +3,25 @@ name: Docker
|
||||
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:
|
||||
@@ -36,177 +29,24 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- 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 }}
|
||||
|
||||
+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.0
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -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-15
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -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({
|
||||
@@ -1230,7 +1205,6 @@ export const rootDocumentActions = [
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
copyDocumentAsPlainText,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
publishDocument,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -69,6 +69,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
readOnly={!can.update}
|
||||
userId={user.id}
|
||||
editorStyle={editorStyle}
|
||||
embedsDisabled
|
||||
/>
|
||||
<div ref={childRef} />
|
||||
</React.Suspense>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { PullRequestIcon } from "../Icons/PullRequestIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
@@ -114,8 +114,6 @@ const Modal: React.FC<Props> = ({
|
||||
<Small {...props}>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
// maxHeight needed for proper overflow behavior in Safari
|
||||
style={{ maxHeight: "65vh" }}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
@@ -261,7 +259,6 @@ const Small = styled.div`
|
||||
width: 75vw;
|
||||
min-width: 350px;
|
||||
max-width: 450px;
|
||||
max-height: 65vh;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -148,12 +148,7 @@ function InnerDocumentLink(
|
||||
const color = document?.color || node.color;
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDragDocument(
|
||||
node,
|
||||
depth,
|
||||
document,
|
||||
isEditing
|
||||
);
|
||||
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
|
||||
|
||||
// Drop to re-parent
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -275,8 +270,6 @@ function InnerDocumentLink(
|
||||
<div ref={dropToReparent}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
// @ts-expect-error react-router type is wrong, string component is fine.
|
||||
component={isEditing ? "div" : undefined}
|
||||
expanded={hasChildren ? isExpanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
@@ -292,7 +285,6 @@ function InnerDocumentLink(
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
isEditing={isEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
|
||||
@@ -39,7 +39,6 @@ export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
location?: Location;
|
||||
strict?: boolean;
|
||||
to: LocationDescriptor;
|
||||
component?: React.ComponentType;
|
||||
onBeforeClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -147,22 +146,17 @@ const NavLink = ({
|
||||
setPreActive(undefined);
|
||||
}, [currentLocation]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLAnchorElement>) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
},
|
||||
[navigateTo]
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={isActive ? "active" : "inactive"}
|
||||
ref={linkRef}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDown={(event) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
}}
|
||||
aria-current={(isActive && ariaCurrent) || undefined}
|
||||
className={className}
|
||||
style={style}
|
||||
|
||||
@@ -166,13 +166,11 @@ export function useDropToReorderStar(getIndex?: () => string) {
|
||||
* @param node The NavigationNode model to drag.
|
||||
* @param depth The depth of the node in the sidebar.
|
||||
* @param document The related Document model.
|
||||
* @param isEditing Whether the sidebar item is currently being edited.
|
||||
*/
|
||||
export function useDragDocument(
|
||||
node: NavigationNode,
|
||||
depth: number,
|
||||
document?: Document,
|
||||
isEditing?: boolean
|
||||
document?: Document
|
||||
) {
|
||||
const icon = document?.icon || node.icon || node.emoji;
|
||||
const color = document?.color || node.color;
|
||||
@@ -190,7 +188,7 @@ export function useDragDocument(
|
||||
icon: icon ? <Icon value={icon} color={color} /> : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
} as DragObject),
|
||||
canDrag: () => !!document?.isActive && !isEditing,
|
||||
canDrag: () => !!document?.isActive,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
|
||||
@@ -335,7 +335,6 @@ const TR = styled.div<{ $columns: string }>`
|
||||
grid-template-columns: ${({ $columns }) => `${$columns}`};
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
@@ -358,8 +357,7 @@ const TD = styled.span`
|
||||
padding: 10px 6px;
|
||||
font-size: 14px;
|
||||
text-wrap: wrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
|
||||
&:first-child {
|
||||
font-size: 15px;
|
||||
|
||||
+71
-1
@@ -1,3 +1,73 @@
|
||||
import Text from "@shared/components/Text";
|
||||
import styled, { css } from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** The type of text to render */
|
||||
type?: "secondary" | "tertiary" | "danger";
|
||||
/** The size of the text */
|
||||
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
|
||||
/** The direction of the text (defaults to ltr) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the text should be selectable (defaults to false) */
|
||||
selectable?: boolean;
|
||||
/** The font weight of the text */
|
||||
weight?: "xbold" | "bold" | "normal";
|
||||
/** Whether the text should be italic */
|
||||
italic?: boolean;
|
||||
/** Whether the text should be truncated with an ellipsis */
|
||||
ellipsis?: boolean;
|
||||
/** Whether the text should be monospaced */
|
||||
monospace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this component for all interface text that should not be selectable
|
||||
* by the user, this is the majority of UI text explainers, notes, headings.
|
||||
*/
|
||||
const Text = styled.span<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
: props.type === "tertiary"
|
||||
? props.theme.textTertiary
|
||||
: props.type === "danger"
|
||||
? props.theme.brand.red
|
||||
: props.theme.text};
|
||||
font-size: ${(props) =>
|
||||
props.size === "xlarge"
|
||||
? "26px"
|
||||
: props.size === "large"
|
||||
? "18px"
|
||||
: props.size === "medium"
|
||||
? "16px"
|
||||
: props.size === "small"
|
||||
? "14px"
|
||||
: props.size === "xsmall"
|
||||
? "13px"
|
||||
: "inherit"};
|
||||
|
||||
${(props) =>
|
||||
props.weight &&
|
||||
css`
|
||||
font-weight: ${props.weight === "xbold"
|
||||
? 600
|
||||
: props.weight === "bold"
|
||||
? 500
|
||||
: props.weight === "normal"
|
||||
? 400
|
||||
: "inherit"};
|
||||
`}
|
||||
|
||||
font-style: ${(props) => (props.italic ? "italic" : "normal")};
|
||||
font-family: ${(props) =>
|
||||
props.monospace ? props.theme.fontFamilyMono : "inherit"};
|
||||
|
||||
white-space: normal;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
|
||||
${(props) => props.ellipsis && ellipsis()}
|
||||
`;
|
||||
|
||||
export default Text;
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { EmailIcon, LinkIcon } from "outline-icons";
|
||||
import { LinkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 } from "uuid";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { determineMentionType, isURLMentionable } from "~/utils/mention";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
@@ -23,65 +15,34 @@ type Props = Omit<
|
||||
embeds: EmbedDescriptor[];
|
||||
};
|
||||
|
||||
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { integrations } = useStores();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
|
||||
let mentionType: MentionType | undefined;
|
||||
const url = pastedText ? new URL(pastedText) : undefined;
|
||||
|
||||
if (url) {
|
||||
const integration = integrations.find((intg: Integration) =>
|
||||
isURLMentionable({ url, integration: intg })
|
||||
);
|
||||
|
||||
mentionType = integration
|
||||
? determineMentionType({ url, integration })
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const embed = React.useMemo(() => {
|
||||
for (const e of embeds) {
|
||||
const matches = e.matcher(pastedText);
|
||||
const matches = e.matcher(props.pastedText);
|
||||
if (matches) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}, [embeds, pastedText]);
|
||||
}, [embeds, props.pastedText]);
|
||||
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "mention",
|
||||
title: t("Mention"),
|
||||
icon: <EmailIcon />,
|
||||
visible: !!mentionType,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: mentionType,
|
||||
label: pastedText,
|
||||
href: pastedText,
|
||||
modelId: v4(),
|
||||
actorId: user?.id,
|
||||
},
|
||||
appendSpace: true,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
] satisfies MenuItem[],
|
||||
[t, embed, mentionType, pastedText, user]
|
||||
() => [
|
||||
{
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
],
|
||||
[embed, t]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -91,7 +52,9 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
onClick={() => {
|
||||
props.onSelect?.(item);
|
||||
}}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
@@ -100,4 +63,6 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default PasteMenu;
|
||||
|
||||
@@ -174,12 +174,12 @@ export default function SelectionToolbar(props: Props) {
|
||||
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
|
||||
if ((readOnly && !canComment) || isDragging) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
const colIndex = getColumnIndex(state);
|
||||
const rowIndex = getRowIndex(state);
|
||||
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isList } from "@shared/editor/queries/isList";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
/**
|
||||
@@ -19,25 +18,17 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
new Plugin({
|
||||
key: new PluginKey("clipboardTextSerializer"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice, view) => {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
const isMultiline = slice.content.childCount > 1;
|
||||
|
||||
// This is a cheap way to determine if the content is "complex",
|
||||
// aka it has multiple marks or formatting. In which case we'll use
|
||||
// markdown formatting
|
||||
const hasMultipleListItems = slice.content.content
|
||||
.filter((node) => node.content.content.length > 1)
|
||||
.some((node) => isList(node, view.state.schema));
|
||||
const hasMultipleBlockTypes =
|
||||
[
|
||||
...new Set(
|
||||
slice.content.content
|
||||
.filter((node) => node.content.content.length > 1)
|
||||
.map((node) => node.type.name)
|
||||
),
|
||||
].length > 1;
|
||||
const copyAsMarkdown =
|
||||
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
|
||||
isMultiline ||
|
||||
slice.content.content.some(
|
||||
(node) => node.content.content.length > 1
|
||||
);
|
||||
|
||||
return copyAsMarkdown
|
||||
? mdSerializer.serialize(slice.content, {
|
||||
|
||||
@@ -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;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
import { PasteMenu } from "../components/PasteMenu";
|
||||
import PasteMenu from "../components/PasteMenu";
|
||||
|
||||
export default class PasteHandler extends Extension {
|
||||
state: {
|
||||
@@ -415,21 +415,6 @@ export default class PasteHandler extends Extension {
|
||||
});
|
||||
};
|
||||
|
||||
private insertMention = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const result = this.findPlaceholder(state, this.state.pastedText);
|
||||
|
||||
// Remove just the placeholder here.
|
||||
// Mention node will be created by SuggestionsMenu.
|
||||
if (result) {
|
||||
const tr = state.tr.deleteRange(result[0], result[1]);
|
||||
view.dispatch(
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private removePlaceholder = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
@@ -465,11 +450,6 @@ export default class PasteHandler extends Extension {
|
||||
this.insertEmbed();
|
||||
break;
|
||||
}
|
||||
case "mention": {
|
||||
this.hidePasteMenu();
|
||||
this.insertMention();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
import useIsMounted from "@shared/hooks/useIsMounted";
|
||||
import * as React from "react";
|
||||
|
||||
export default useIsMounted;
|
||||
/**
|
||||
* Hook to check if component is still mounted
|
||||
*
|
||||
* @returns {boolean} true if the component is mounted, false otherwise
|
||||
*/
|
||||
export default function useIsMounted() {
|
||||
const isMounted = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return React.useCallback(() => isMounted.current, []);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GlobeIcon,
|
||||
TeamIcon,
|
||||
BeakerIcon,
|
||||
BuildingBlocksIcon,
|
||||
SettingsIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
@@ -39,6 +40,7 @@ const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
|
||||
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
|
||||
const Profile = lazy(() => import("~/scenes/Settings/Profile"));
|
||||
const Security = lazy(() => import("~/scenes/Settings/Security"));
|
||||
const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
|
||||
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
|
||||
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
|
||||
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
|
||||
@@ -175,6 +177,14 @@ const useSettingsConfig = () => {
|
||||
icon: ExportIcon,
|
||||
},
|
||||
// Integrations
|
||||
{
|
||||
name: t("Self Hosted"),
|
||||
path: integrationSettingsPath("self-hosted"),
|
||||
component: SelfHosted,
|
||||
enabled: can.update && !isCloudHosted,
|
||||
group: t("Integrations"),
|
||||
icon: BuildingBlocksIcon,
|
||||
},
|
||||
{
|
||||
name: "Zapier",
|
||||
path: integrationSettingsPath("zapier"),
|
||||
|
||||
@@ -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";
|
||||
@@ -664,24 +663,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`,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { observable } from "mobx";
|
||||
import {
|
||||
import type {
|
||||
IntegrationService,
|
||||
type IntegrationSettings,
|
||||
type IntegrationType,
|
||||
IntegrationSettings,
|
||||
IntegrationType,
|
||||
} from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import Model from "~/models/base/Model";
|
||||
|
||||
@@ -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;
|
||||
@@ -209,9 +209,7 @@ function Invite({ onSubmit }: Props) {
|
||||
placeholder={`name@${predictedDomain}`}
|
||||
value={invite.email}
|
||||
required={index === 0}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
data-1p-ignore
|
||||
flex
|
||||
/>
|
||||
<StyledInput
|
||||
|
||||
@@ -39,10 +39,7 @@ export function LoginDialog() {
|
||||
maxLength={255}
|
||||
autoComplete="off"
|
||||
placeholder={t("subdomain")}
|
||||
{...register("subdomain", {
|
||||
required: true,
|
||||
pattern: /^[a-z\d-]{1,63}$/,
|
||||
})}
|
||||
{...register("subdomain", { required: true, pattern: /^[a-z\d-]+$/ })}
|
||||
>
|
||||
<Domain>.getoutline.com</Domain>
|
||||
</Input>
|
||||
|
||||
@@ -3,15 +3,6 @@ import { parseDomain } from "@shared/utils/domains";
|
||||
import env from "~/env";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
function validateAndEncodeSubdomain(subdomain: string): string {
|
||||
const encodedSubdomain = encodeURIComponent(subdomain);
|
||||
const urlPattern = /^[a-z\d-]{1,63}$/;
|
||||
if (!urlPattern.test(encodedSubdomain)) {
|
||||
throw new Error("Invalid subdomain");
|
||||
}
|
||||
return `https://${encodedSubdomain}.getoutline.com`;
|
||||
}
|
||||
|
||||
/**
|
||||
* If we're on a custom domain or a subdomain then the auth must point to the
|
||||
* apex (env.URL) for authentication so that the state cookie can be set and read.
|
||||
@@ -45,7 +36,7 @@ export async function navigateToSubdomain(subdomain: string) {
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "");
|
||||
const host = validateAndEncodeSubdomain(normalizedSubdomain);
|
||||
const host = `https://${normalizedSubdomain}.getoutline.com`;
|
||||
await Desktop.bridge?.addCustomHost(host);
|
||||
window.location.href = host;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import find from "lodash/find";
|
||||
import { observer } from "mobx-react";
|
||||
import { BuildingBlocksIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
type FormData = {
|
||||
drawIoUrl: string;
|
||||
gristUrl: string;
|
||||
};
|
||||
|
||||
function SelfHosted() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const integrationDiagrams = find(integrations.orderedData, {
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Diagrams,
|
||||
}) as Integration<IntegrationType.Embed> | undefined;
|
||||
|
||||
const integrationGrist = find(integrations.orderedData, {
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Grist,
|
||||
}) as Integration<IntegrationType.Embed> | undefined;
|
||||
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit: formHandleSubmit,
|
||||
formState,
|
||||
} = useForm<FormData>({
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
drawIoUrl: integrationDiagrams?.settings.url,
|
||||
gristUrl: integrationGrist?.settings.url,
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchPage({
|
||||
type: IntegrationType.Embed,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
React.useEffect(() => {
|
||||
reset({
|
||||
drawIoUrl: integrationDiagrams?.settings.url,
|
||||
gristUrl: integrationGrist?.settings.url,
|
||||
});
|
||||
}, [integrationDiagrams, integrationGrist, reset]);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
if (data.drawIoUrl) {
|
||||
await integrations.save({
|
||||
id: integrationDiagrams?.id,
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Diagrams,
|
||||
settings: {
|
||||
url: data.drawIoUrl,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await integrationDiagrams?.delete();
|
||||
}
|
||||
|
||||
if (data.gristUrl) {
|
||||
await integrations.save({
|
||||
id: integrationGrist?.id,
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Grist,
|
||||
settings: {
|
||||
url: data.gristUrl,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await integrationGrist?.delete();
|
||||
}
|
||||
|
||||
toast.success(t("Settings saved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[integrations, integrationDiagrams, integrationGrist, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title={t("Self Hosted")} icon={<BuildingBlocksIcon />}>
|
||||
<Heading>{t("Self Hosted")}</Heading>
|
||||
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<SettingRow
|
||||
label={t("Draw.io deployment")}
|
||||
name="drawIoUrl"
|
||||
description={t(
|
||||
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents."
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://app.diagrams.net/"
|
||||
pattern="https?://.*"
|
||||
{...register("drawIoUrl")}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("Grist deployment")}
|
||||
name="gristUrl"
|
||||
description={t("Add your self-hosted grist installation URL here.")}
|
||||
border={false}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://docs.getgrist.com/"
|
||||
pattern="https?://.*"
|
||||
{...register("gristUrl")}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(SelfHosted);
|
||||
@@ -4,7 +4,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Spinner from "@shared/components/Spinner";
|
||||
import {
|
||||
FileOperationFormat,
|
||||
FileOperationState,
|
||||
@@ -14,6 +13,7 @@ import FileOperation from "~/models/FileOperation";
|
||||
import { Action } from "~/components/Actions";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Spinner from "~/components/Spinner";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import AvatarEditor from "react-avatar-editor";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Dropzone from "react-dropzone";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Modal from "~/components/Modal";
|
||||
import withStores from "~/components/withStores";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { uploadFile, dataUrlToBlob } from "~/utils/files";
|
||||
|
||||
@@ -23,167 +24,132 @@ export type Props = {
|
||||
borderRadius?: number;
|
||||
};
|
||||
|
||||
const ImageUpload: React.FC<Props> = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
submitText,
|
||||
borderRadius,
|
||||
children,
|
||||
}) => {
|
||||
const { dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@observer
|
||||
class ImageUpload extends React.Component<RootStore & Props> {
|
||||
@observable
|
||||
isUploading = false;
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isCropping, setIsCropping] = useState(false);
|
||||
@observable
|
||||
isCropping = false;
|
||||
|
||||
const uploadImage = React.useCallback(
|
||||
async (blob: Blob, file: File) => {
|
||||
try {
|
||||
const compressed = await compressImage(blob, {
|
||||
maxHeight: 512,
|
||||
maxWidth: 512,
|
||||
});
|
||||
const attachment = await uploadFile(compressed, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Avatar,
|
||||
});
|
||||
void onSuccess(attachment.url);
|
||||
} catch (err) {
|
||||
onError(err.message);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setIsCropping(false);
|
||||
dialogs.closeAllModals();
|
||||
}
|
||||
},
|
||||
[dialogs, onSuccess, onError]
|
||||
);
|
||||
@observable
|
||||
zoom = 1;
|
||||
|
||||
const handleUpload = React.useCallback(
|
||||
(blob: Blob, file: File) => {
|
||||
setIsUploading(true);
|
||||
// allow the UI to update before converting the canvas to a Blob
|
||||
// for large images this can cause the page rendering to hang.
|
||||
setTimeout(() => uploadImage(blob, file), 0);
|
||||
},
|
||||
[uploadImage]
|
||||
);
|
||||
@observable
|
||||
file: File;
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setIsUploading(false);
|
||||
setIsCropping(false);
|
||||
}, []);
|
||||
avatarEditorRef = React.createRef<AvatarEditor>();
|
||||
|
||||
const onDropAccepted = React.useCallback(
|
||||
async (files: File[]) => {
|
||||
setIsCropping(true);
|
||||
dialogs.openModal({
|
||||
title: "",
|
||||
content: (
|
||||
<AvatarEditorDialog
|
||||
file={files[0]}
|
||||
onUpload={handleUpload}
|
||||
isUploading={isUploading}
|
||||
borderRadius={borderRadius ?? 150}
|
||||
submitText={submitText || t("Crop image")}
|
||||
/>
|
||||
),
|
||||
onClose: handleClose,
|
||||
static defaultProps = {
|
||||
submitText: "Crop Image",
|
||||
borderRadius: 150,
|
||||
};
|
||||
|
||||
onDropAccepted = async (files: File[]) => {
|
||||
this.isCropping = true;
|
||||
this.file = files[0];
|
||||
};
|
||||
|
||||
handleCrop = () => {
|
||||
this.isUploading = true;
|
||||
// allow the UI to update before converting the canvas to a Blob
|
||||
// for large images this can cause the page rendering to hang.
|
||||
setTimeout(this.uploadImage, 0);
|
||||
};
|
||||
|
||||
uploadImage = async () => {
|
||||
const canvas = this.avatarEditorRef.current?.getImage();
|
||||
invariant(canvas, "canvas is not defined");
|
||||
const imageBlob = dataUrlToBlob(canvas.toDataURL());
|
||||
|
||||
try {
|
||||
const compressed = await compressImage(imageBlob, {
|
||||
maxHeight: 512,
|
||||
maxWidth: 512,
|
||||
});
|
||||
},
|
||||
[
|
||||
t,
|
||||
dialogs,
|
||||
handleUpload,
|
||||
handleClose,
|
||||
isUploading,
|
||||
borderRadius,
|
||||
submitText,
|
||||
]
|
||||
);
|
||||
const attachment = await uploadFile(compressed, {
|
||||
name: this.file.name,
|
||||
preset: AttachmentPreset.Avatar,
|
||||
});
|
||||
void this.props.onSuccess(attachment.url);
|
||||
} catch (err) {
|
||||
this.props.onError(err.message);
|
||||
} finally {
|
||||
this.isUploading = false;
|
||||
this.isCropping = false;
|
||||
}
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: AttachmentValidation.avatarContentTypes.join(", "),
|
||||
onDropAccepted,
|
||||
});
|
||||
handleClose = () => {
|
||||
this.isUploading = false;
|
||||
this.isCropping = false;
|
||||
};
|
||||
|
||||
if (isCropping) {
|
||||
return null; // onDropAccepted would have opened a modal for cropping the image.
|
||||
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLInputElement) {
|
||||
this.zoom = parseFloat(target.value);
|
||||
}
|
||||
};
|
||||
|
||||
renderCropping() {
|
||||
const { ui, submitText } = this.props;
|
||||
return (
|
||||
<Modal isOpen onRequestClose={this.handleClose} title="">
|
||||
<Flex auto column align="center" justify="center">
|
||||
{this.isUploading && <LoadingIndicator />}
|
||||
<AvatarEditorContainer>
|
||||
<AvatarEditor
|
||||
ref={this.avatarEditorRef}
|
||||
image={this.file}
|
||||
width={250}
|
||||
height={250}
|
||||
border={25}
|
||||
borderRadius={this.props.borderRadius}
|
||||
color={
|
||||
ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]
|
||||
} // RGBA
|
||||
scale={this.zoom}
|
||||
rotate={0}
|
||||
/>
|
||||
</AvatarEditorContainer>
|
||||
<RangeInput
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2"
|
||||
step="0.01"
|
||||
defaultValue="1"
|
||||
onChange={this.handleZoom}
|
||||
/>
|
||||
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
|
||||
{this.isUploading ? "Uploading…" : submitText}
|
||||
</CropButton>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type AvatarEditorDialogProps = {
|
||||
file: File;
|
||||
onUpload: (blob: Blob, file: File) => void;
|
||||
isUploading: boolean;
|
||||
borderRadius: number;
|
||||
submitText: string;
|
||||
};
|
||||
|
||||
const AvatarEditorDialog: React.FC<AvatarEditorDialogProps> = observer(
|
||||
({ file, onUpload, isUploading, borderRadius, submitText }) => {
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const avatarEditorRef = useRef<AvatarEditor>(null);
|
||||
|
||||
const handleUpload = React.useCallback(() => {
|
||||
const canvas = avatarEditorRef.current?.getImage();
|
||||
invariant(canvas, "canvas is not defined");
|
||||
const blob = dataUrlToBlob(canvas.toDataURL());
|
||||
onUpload(blob, file);
|
||||
}, [file, onUpload]);
|
||||
|
||||
const handleZoom = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLInputElement) {
|
||||
setZoom(parseFloat(target.value));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
render() {
|
||||
if (this.isCropping) {
|
||||
return this.renderCropping();
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex auto column align="center" justify="center">
|
||||
{isUploading && <LoadingIndicator />}
|
||||
<AvatarEditorContainer>
|
||||
<AvatarEditor
|
||||
ref={avatarEditorRef}
|
||||
image={file}
|
||||
width={250}
|
||||
height={250}
|
||||
border={25}
|
||||
borderRadius={borderRadius}
|
||||
color={ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]} // RGBA
|
||||
scale={zoom}
|
||||
rotate={0}
|
||||
/>
|
||||
</AvatarEditorContainer>
|
||||
<RangeInput
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2"
|
||||
step="0.01"
|
||||
defaultValue="1"
|
||||
onChange={handleZoom}
|
||||
/>
|
||||
<br />
|
||||
<ButtonLarge fullwidth onClick={handleUpload} disabled={isUploading}>
|
||||
{isUploading ? `${t(`Uploading`)}…` : submitText}
|
||||
</ButtonLarge>
|
||||
</Flex>
|
||||
<Dropzone
|
||||
accept={AttachmentValidation.avatarContentTypes.join(", ")}
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
{this.props.children}
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const AvatarEditorContainer = styled(Flex)`
|
||||
margin-bottom: 30px;
|
||||
@@ -214,4 +180,8 @@ const RangeInput = styled.input`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(ImageUpload);
|
||||
const CropButton = styled(Button)`
|
||||
width: 300px;
|
||||
`;
|
||||
|
||||
export default withStores(ImageUpload);
|
||||
|
||||
@@ -5,12 +5,12 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Spinner from "@shared/components/Spinner";
|
||||
import { ImportState } from "@shared/types";
|
||||
import Import from "~/models/Import";
|
||||
import { Action } from "~/components/Actions";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Spinner from "~/components/Spinner";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -2,7 +2,6 @@ import compact from "lodash/compact";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Text from "@shared/components/Text";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import UserMenu from "~/menus/UserMenu";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
|
||||
@@ -29,7 +27,6 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
|
||||
export function MembersTable({ canManage, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const columns = React.useMemo<TableColumn<User>[]>(
|
||||
() =>
|
||||
@@ -41,20 +38,13 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
accessor: (user) => user.name,
|
||||
component: (user) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatar model={user} size={AvatarSize.Large} />{" "}
|
||||
<Flex column>
|
||||
<Text>
|
||||
{user.name} {currentUser.id === user.id && `(${t("You")})`}
|
||||
</Text>
|
||||
{isMobile && canManage && (
|
||||
<Text type="tertiary">{user.email}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Avatar model={user} size={AvatarSize.Large} /> {user.name}{" "}
|
||||
{currentUser.id === user.id && `(${t("You")})`}
|
||||
</Flex>
|
||||
),
|
||||
width: "4fr",
|
||||
},
|
||||
canManage && !isMobile
|
||||
canManage
|
||||
? {
|
||||
type: "data",
|
||||
id: "email",
|
||||
@@ -64,19 +54,17 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
width: "4fr",
|
||||
}
|
||||
: undefined,
|
||||
isMobile
|
||||
? undefined
|
||||
: {
|
||||
type: "data",
|
||||
id: "lastActiveAt",
|
||||
header: t("Last active"),
|
||||
accessor: (user) => user.lastActiveAt,
|
||||
component: (user) =>
|
||||
user.lastActiveAt ? (
|
||||
<Time dateTime={user.lastActiveAt} addSuffix />
|
||||
) : null,
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "lastActiveAt",
|
||||
header: t("Last active"),
|
||||
accessor: (user) => user.lastActiveAt,
|
||||
component: (user) =>
|
||||
user.lastActiveAt ? (
|
||||
<Time dateTime={user.lastActiveAt} addSuffix />
|
||||
) : null,
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "role",
|
||||
@@ -97,7 +85,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
|
||||
</Badges>
|
||||
),
|
||||
width: "80px",
|
||||
width: "2fr",
|
||||
},
|
||||
canManage
|
||||
? {
|
||||
@@ -109,7 +97,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
}
|
||||
: undefined,
|
||||
]),
|
||||
[t, currentUser, canManage, isMobile]
|
||||
[t, currentUser, canManage]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -306,7 +306,7 @@ export default class AuthStore extends Store<Team> {
|
||||
// if this logout was forced from an authenticated route then
|
||||
// save the current path so we can go back there once signed in
|
||||
if (savePath) {
|
||||
setPostLoginPath(window.location.pathname + window.location.search);
|
||||
setPostLoginPath(window.location.pathname);
|
||||
}
|
||||
|
||||
if (tryRevokingToken) {
|
||||
|
||||
@@ -26,7 +26,6 @@ import SharesStore from "./SharesStore";
|
||||
import StarsStore from "./StarsStore";
|
||||
import SubscriptionsStore from "./SubscriptionsStore";
|
||||
import UiStore from "./UiStore";
|
||||
import UnfurlsStore from "./UnfurlsStore";
|
||||
import UserMembershipsStore from "./UserMembershipsStore";
|
||||
import UsersStore from "./UsersStore";
|
||||
import ViewsStore from "./ViewsStore";
|
||||
@@ -56,7 +55,6 @@ export default class RootStore {
|
||||
searches: SearchesStore;
|
||||
shares: SharesStore;
|
||||
ui: UiStore;
|
||||
unfurls: UnfurlsStore;
|
||||
stars: StarsStore;
|
||||
subscriptions: SubscriptionsStore;
|
||||
users: UsersStore;
|
||||
@@ -87,7 +85,6 @@ export default class RootStore {
|
||||
this.registerStore(SharesStore);
|
||||
this.registerStore(StarsStore);
|
||||
this.registerStore(SubscriptionsStore);
|
||||
this.registerStore(UnfurlsStore);
|
||||
this.registerStore(UsersStore);
|
||||
this.registerStore(ViewsStore);
|
||||
this.registerStore(FileOperationsStore);
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { subMinutes } from "date-fns";
|
||||
import { action } from "mobx";
|
||||
import { UnfurlResourceType } from "@shared/types";
|
||||
import Unfurl from "~/models/Unfurl";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Logger from "~/utils/Logger";
|
||||
import RootStore from "./RootStore";
|
||||
import Store from "./base/Store";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class UnfurlsStore extends Store<Unfurl<any>> {
|
||||
actions = []; // no default actions allowed for unfurls.
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Unfurl);
|
||||
}
|
||||
|
||||
fetchUnfurl = async <UnfurlType extends UnfurlResourceType>({
|
||||
url,
|
||||
documentId,
|
||||
}: {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
}): Promise<Unfurl<UnfurlType> | undefined> => {
|
||||
const unfurl = this.get(url);
|
||||
|
||||
if (unfurl) {
|
||||
this.refetch({ unfurl: unfurl as Unfurl<UnfurlType>, documentId });
|
||||
return unfurl;
|
||||
}
|
||||
|
||||
return this.unfurl<UnfurlType>({ url, documentId });
|
||||
};
|
||||
|
||||
private refetch = <UnfurlType extends UnfurlResourceType>({
|
||||
unfurl,
|
||||
documentId,
|
||||
}: {
|
||||
unfurl: Unfurl<UnfurlType>;
|
||||
documentId?: string;
|
||||
}) => {
|
||||
const fiveMinutesAgo = subMinutes(new Date(), 5);
|
||||
|
||||
if (new Date(unfurl.fetchedAt) < fiveMinutesAgo) {
|
||||
void this.unfurl({ url: unfurl.id, documentId });
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
private unfurl = async <UnfurlType extends UnfurlResourceType>({
|
||||
url,
|
||||
documentId,
|
||||
}: {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
}): Promise<Unfurl<UnfurlType> | undefined> => {
|
||||
try {
|
||||
this.isFetching = true;
|
||||
|
||||
const data = await client.post("/urls.unfurl", {
|
||||
url,
|
||||
documentId,
|
||||
});
|
||||
|
||||
// unfurls can succeed with no data.
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.add({
|
||||
id: url,
|
||||
type: data.type,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
data,
|
||||
} as Unfurl<UnfurlType>);
|
||||
} catch (err) {
|
||||
Logger.error(`Failed to unfurl url ${url}`, err);
|
||||
return;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default UnfurlsStore;
|
||||
@@ -1,6 +1,5 @@
|
||||
import commandScore from "command-score";
|
||||
import invariant from "invariant";
|
||||
// eslint-disable-next-line lodash/import-scope
|
||||
import type { ObjectIterateeCustom } from "lodash";
|
||||
import deburr from "lodash/deburr";
|
||||
import filter from "lodash/filter";
|
||||
@@ -99,14 +98,7 @@ export default abstract class Store<T extends Model> {
|
||||
const normalized = deburr((query ?? "").trim().toLocaleLowerCase());
|
||||
|
||||
if (!normalized) {
|
||||
return this.orderedData
|
||||
.filter((item) => {
|
||||
if ("deletedAt" in item && item.deletedAt) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, options?.maxResults);
|
||||
return this.orderedData.slice(0, options?.maxResults);
|
||||
}
|
||||
|
||||
return this.orderedData
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationSettings,
|
||||
IntegrationType,
|
||||
MentionType,
|
||||
} from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
|
||||
export const isURLMentionable = ({
|
||||
url,
|
||||
integration,
|
||||
}: {
|
||||
url: URL;
|
||||
integration: Integration;
|
||||
}): boolean => {
|
||||
const { hostname, pathname } = url;
|
||||
const pathParts = pathname.split("/");
|
||||
|
||||
switch (integration.service) {
|
||||
case IntegrationService.GitHub: {
|
||||
const settings =
|
||||
integration.settings as IntegrationSettings<IntegrationType.Embed>;
|
||||
|
||||
return (
|
||||
hostname === "github.com" &&
|
||||
settings.github?.installation.account.name === pathParts[1] // ensure installed org/account name matches with the provided url.
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const determineMentionType = ({
|
||||
url,
|
||||
integration,
|
||||
}: {
|
||||
url: URL;
|
||||
integration: Integration;
|
||||
}): MentionType | undefined => {
|
||||
const { pathname } = url;
|
||||
const pathParts = pathname.split("/");
|
||||
|
||||
switch (integration.service) {
|
||||
case IntegrationService.GitHub: {
|
||||
const type = pathParts[3];
|
||||
return type === "pull"
|
||||
? MentionType.PullRequest
|
||||
: type === "issues"
|
||||
? MentionType.Issue
|
||||
: undefined;
|
||||
}
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
+3
-5
@@ -49,10 +49,8 @@ export function redirectTo(url: string) {
|
||||
/**
|
||||
* Check if the path is a valid path for redirect after login.
|
||||
*
|
||||
* @param input A path potentially including query string
|
||||
* @param path
|
||||
* @returns boolean indicating if the path is a valid redirect
|
||||
*/
|
||||
export const isAllowedLoginRedirect = (input: string) => {
|
||||
const path = input.split("?")[0];
|
||||
return !["/", "/create", "/home", "/logout", "/auth/"].includes(path);
|
||||
};
|
||||
export const isAllowedLoginRedirect = (path: string) =>
|
||||
!["/", "/create", "/home", "/logout", "/auth/"].includes(path);
|
||||
|
||||
+23
-24
@@ -48,11 +48,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.787.0",
|
||||
"@aws-sdk/lib-storage": "3.787.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.787.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.787.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.787.0",
|
||||
"@aws-sdk/client-s3": "3.777.0",
|
||||
"@aws-sdk/lib-storage": "3.777.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.777.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.777.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.775.0",
|
||||
"@babel/core": "^7.26.10",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
@@ -79,12 +79,12 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@notionhq/client": "^2.3.0",
|
||||
"@notionhq/client": "^2.2.16",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.0",
|
||||
"@radix-ui/react-visually-hidden": "^1.1.2",
|
||||
"@renderlesskit/react": "^0.11.0",
|
||||
"@sentry/node": "^7.120.3",
|
||||
"@sentry/react": "^7.120.3",
|
||||
@@ -114,7 +114,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.40.0",
|
||||
"diff": "^5.2.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"email-providers": "^1.14.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
@@ -141,7 +141,7 @@
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.21",
|
||||
"kbar": "0.1.0-beta.41",
|
||||
"koa": "^2.16.1",
|
||||
"koa": "^2.15.4",
|
||||
"koa-body": "^6.0.1",
|
||||
"koa-compress": "^5.1.1",
|
||||
"koa-helmet": "^6.1.0",
|
||||
@@ -153,7 +153,7 @@
|
||||
"koa-useragent": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mailparser": "^3.7.2",
|
||||
"mammoth": "^1.9.0",
|
||||
"mammoth": "^1.8.0",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^2.0.0",
|
||||
@@ -179,19 +179,19 @@
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
"polished": "^4.3.1",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-commands": "^1.7.0",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-inputrules": "^1.5.0",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.2",
|
||||
"prosemirror-model": "^1.25.0",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"prosemirror-view": "^1.39.1",
|
||||
"prosemirror-view": "^1.38.1",
|
||||
"query-string": "^7.1.3",
|
||||
"randomstring": "1.3.1",
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
@@ -210,7 +210,7 @@
|
||||
"react-merge-refs": "^2.1.1",
|
||||
"react-portal": "^4.2.2",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-virtualized-auto-sizer": "^1.0.21",
|
||||
"react-waypoint": "^10.3.0",
|
||||
"react-window": "^1.8.11",
|
||||
"reakit": "^1.3.11",
|
||||
@@ -219,7 +219,7 @@
|
||||
"refractor": "^3.6.0",
|
||||
"request-filtering-agent": "^1.1.2",
|
||||
"resolve-path": "^1.4.0",
|
||||
"rfc6902": "^5.1.2",
|
||||
"rfc6902": "^5.1.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"semver": "^7.7.1",
|
||||
@@ -232,7 +232,7 @@
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"socket.io-redis": "^6.1.1",
|
||||
"sonner": "^1.7.4",
|
||||
"sonner": "^1.7.1",
|
||||
"stoppable": "^1.1.0",
|
||||
"string-replace-to-array": "^2.1.1",
|
||||
"styled-components": "^5.3.11",
|
||||
@@ -248,8 +248,8 @@
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^5.4.18",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"vite": "^5.4.16",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
@@ -296,7 +296,7 @@
|
||||
"@types/markdown-it-emoji": "^2.0.4",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/natural-sort": "^0.0.24",
|
||||
"@types/node": "20.17.30",
|
||||
"@types/node": "20.17.27",
|
||||
"@types/node-fetch": "^2.6.9",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-oauth2": "^1.4.17",
|
||||
@@ -362,7 +362,7 @@
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.0.3",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
},
|
||||
@@ -377,6 +377,5 @@
|
||||
"rollup": "^4.5.1",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.83.0",
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
"version": "0.82.0"
|
||||
}
|
||||
@@ -94,7 +94,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
|
||||
/** Default user and team names metadata */
|
||||
let userName = profile.username;
|
||||
let teamName;
|
||||
let teamName = "Wiki";
|
||||
let userAvatarUrl: string = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`;
|
||||
let teamAvatarUrl: string | undefined = undefined;
|
||||
let subdomain = slugifyDomain(domain);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Endpoints } from "@octokit/types";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { Integration } from "@server/models";
|
||||
import { BaseIssueProvider } from "@server/utils/BaseIssueProvider";
|
||||
import { GitHub } from "./github";
|
||||
|
||||
// This is needed to handle Octokit paginate response type mismatch.
|
||||
type ReposForInstallation =
|
||||
Endpoints["GET /installation/repositories"]["response"]["data"]["repositories"];
|
||||
|
||||
export class GitHubIssueProvider extends BaseIssueProvider {
|
||||
constructor() {
|
||||
super(IntegrationService.GitHub);
|
||||
}
|
||||
|
||||
async fetchSources(
|
||||
integration: Integration<IntegrationType.Embed>
|
||||
): Promise<IssueSource[]> {
|
||||
const client = await GitHub.authenticateAsInstallation(
|
||||
integration.settings.github!.installation.id
|
||||
);
|
||||
|
||||
const sources: IssueSource[] = [];
|
||||
|
||||
for await (const response of client.requestRepos()) {
|
||||
const repos = response.data as unknown as ReposForInstallation;
|
||||
sources.push(
|
||||
...repos.map<IssueSource>((repo) => ({
|
||||
id: String(repo.id),
|
||||
name: repo.name,
|
||||
owner: { id: String(repo.owner.id), name: repo.owner.login },
|
||||
service: IntegrationService.GitHub,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import Router from "koa-router";
|
||||
import find from "lodash/find";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
@@ -89,27 +88,30 @@ router.get(
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Integration.createWithCtx(createContext({ user, transaction }), {
|
||||
service: IntegrationService.GitHub,
|
||||
type: IntegrationType.Embed,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
github: {
|
||||
installation: {
|
||||
id: installationId!,
|
||||
account: {
|
||||
id: installation.account?.id,
|
||||
name:
|
||||
// @ts-expect-error Property 'login' does not exist on type
|
||||
installation.account?.login,
|
||||
avatarUrl: installation.account?.avatar_url,
|
||||
await Integration.create(
|
||||
{
|
||||
service: IntegrationService.GitHub,
|
||||
type: IntegrationType.Embed,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
github: {
|
||||
installation: {
|
||||
id: installationId!,
|
||||
account: {
|
||||
id: installation.account?.id,
|
||||
name:
|
||||
// @ts-expect-error Property 'login' does not exist on type
|
||||
installation.account?.login,
|
||||
avatarUrl: installation.account?.avatar_url,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
{ transaction }
|
||||
);
|
||||
ctx.redirect(GitHubUtils.url);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -4,55 +4,36 @@ import {
|
||||
type OAuthWebFlowAuthOptions,
|
||||
type InstallationAuthOptions,
|
||||
} from "@octokit/auth-app";
|
||||
import { Endpoints, OctokitResponse } from "@octokit/types";
|
||||
import { Octokit } from "octokit";
|
||||
import pluralize from "pluralize";
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationType,
|
||||
JSONObject,
|
||||
UnfurlResourceType,
|
||||
} from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration, User } from "@server/models";
|
||||
import { UnfurlIssueAndPR, UnfurlSignature } from "@server/types";
|
||||
import { GitHubUtils } from "../shared/GitHubUtils";
|
||||
import { UnfurlSignature } from "@server/types";
|
||||
import env from "./env";
|
||||
|
||||
type PR =
|
||||
Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"];
|
||||
type Issue =
|
||||
Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}"]["response"]["data"];
|
||||
|
||||
const requestPlugin = (octokit: Octokit) => ({
|
||||
requestRepos: () =>
|
||||
octokit.paginate.iterator(
|
||||
octokit.rest.apps.listReposAccessibleToInstallation,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}
|
||||
),
|
||||
|
||||
requestPR: async (params: NonNullable<ReturnType<typeof GitHub.parseUrl>>) =>
|
||||
octokit.request(`GET /repos/{owner}/{repo}/pulls/{pull_number}`, {
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
pull_number: params.id,
|
||||
requestPR: async (params: ReturnType<typeof GitHub.parseUrl>) =>
|
||||
octokit.request(`GET /repos/{owner}/{repo}/pulls/{id}`, {
|
||||
owner: params?.owner,
|
||||
repo: params?.repo,
|
||||
id: params?.id,
|
||||
headers: {
|
||||
Accept: "application/vnd.github.text+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}),
|
||||
|
||||
requestIssue: async (
|
||||
params: NonNullable<ReturnType<typeof GitHub.parseUrl>>
|
||||
) =>
|
||||
octokit.request(`GET /repos/{owner}/{repo}/issues/{issue_number}`, {
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
issue_number: params.id,
|
||||
requestIssue: async (params: ReturnType<typeof GitHub.parseUrl>) =>
|
||||
octokit.request(`GET /repos/{owner}/{repo}/issues/{id}`, {
|
||||
owner: params?.owner,
|
||||
repo: params?.repo,
|
||||
id: params?.id,
|
||||
headers: {
|
||||
Accept: "application/vnd.github.text+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
@@ -75,14 +56,14 @@ const requestPlugin = (octokit: Octokit) => ({
|
||||
*/
|
||||
requestResource: async function requestResource(
|
||||
resource: ReturnType<typeof GitHub.parseUrl>
|
||||
): Promise<OctokitResponse<Issue | PR> | undefined> {
|
||||
): Promise<{ data?: JSONObject }> {
|
||||
switch (resource?.type) {
|
||||
case UnfurlResourceType.PR:
|
||||
return this.requestPR(resource) as Promise<OctokitResponse<PR>>;
|
||||
return this.requestPR(resource);
|
||||
case UnfurlResourceType.Issue:
|
||||
return this.requestIssue(resource) as Promise<OctokitResponse<Issue>>;
|
||||
return this.requestIssue(resource);
|
||||
default:
|
||||
return;
|
||||
return { data: undefined };
|
||||
}
|
||||
},
|
||||
|
||||
@@ -110,10 +91,7 @@ export class GitHub {
|
||||
|
||||
private static appOctokit: Octokit;
|
||||
|
||||
private static supportedResources = [
|
||||
UnfurlResourceType.Issue,
|
||||
UnfurlResourceType.PR,
|
||||
];
|
||||
private static supportedResources = Object.values(UnfurlResourceType);
|
||||
|
||||
/**
|
||||
* Parses a given URL and returns resource identifiers for GitHub specific URLs
|
||||
@@ -133,7 +111,7 @@ export class GitHub {
|
||||
const type = parts[3]
|
||||
? (pluralize.singular(parts[3]) as UnfurlResourceType)
|
||||
: undefined;
|
||||
const id = Number(parts[4]);
|
||||
const id = parts[4];
|
||||
|
||||
if (!type || !GitHub.supportedResources.includes(type)) {
|
||||
return;
|
||||
@@ -226,63 +204,14 @@ export class GitHub {
|
||||
const client = await GitHub.authenticateAsInstallation(
|
||||
integration.settings.github!.installation.id
|
||||
);
|
||||
|
||||
const res = await client.requestResource(resource);
|
||||
if (!res) {
|
||||
return { error: "Resource not found" };
|
||||
const { data } = await client.requestResource(resource);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
return GitHub.transformData(res.data, resource.type);
|
||||
return { ...data, type: resource.type };
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to fetch resource from GitHub", err);
|
||||
return { error: err.message || "Unknown error" };
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
private static transformData(data: Issue | PR, type: UnfurlResourceType) {
|
||||
if (type === UnfurlResourceType.Issue) {
|
||||
const issue = data as Issue;
|
||||
return {
|
||||
type: UnfurlResourceType.Issue,
|
||||
url: issue.html_url,
|
||||
id: `#${issue.number}`,
|
||||
title: issue.title,
|
||||
description: issue.body_text ?? null,
|
||||
author: {
|
||||
name: issue.user?.login ?? "",
|
||||
avatarUrl: issue.user?.avatar_url ?? "",
|
||||
},
|
||||
labels: issue.labels.map((label: { name: string; color: string }) => ({
|
||||
name: label.name,
|
||||
color: `#${label.color}`,
|
||||
})),
|
||||
state: {
|
||||
name: issue.state,
|
||||
color: GitHubUtils.getColorForStatus(issue.state),
|
||||
},
|
||||
createdAt: issue.created_at,
|
||||
transformed_unfurl: true,
|
||||
} satisfies UnfurlIssueAndPR;
|
||||
}
|
||||
|
||||
const pr = data as PR;
|
||||
const prState = pr.merged ? "merged" : pr.state;
|
||||
return {
|
||||
type: UnfurlResourceType.PR,
|
||||
url: pr.html_url,
|
||||
id: `#${pr.number}`,
|
||||
title: pr.title,
|
||||
description: pr.body,
|
||||
author: {
|
||||
name: pr.user.login,
|
||||
avatarUrl: pr.user.avatar_url,
|
||||
},
|
||||
state: {
|
||||
name: prState,
|
||||
color: GitHubUtils.getColorForStatus(prState),
|
||||
},
|
||||
createdAt: pr.created_at,
|
||||
transformed_unfurl: true,
|
||||
} satisfies UnfurlIssueAndPR;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { PluginManager, Hook } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import { GitHubIssueProvider } from "./GitHubIssueProvider";
|
||||
import router from "./api/github";
|
||||
import env from "./env";
|
||||
import { GitHub } from "./github";
|
||||
@@ -21,10 +20,6 @@ if (enabled) {
|
||||
type: Hook.API,
|
||||
value: router,
|
||||
},
|
||||
{
|
||||
type: Hook.IssueProvider,
|
||||
value: new GitHubIssueProvider(),
|
||||
},
|
||||
{
|
||||
type: Hook.UnfurlProvider,
|
||||
value: { unfurl: GitHub.unfurl, cacheExpiry: Minute.seconds },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { JSONObject, UnfurlResourceType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { UnfurlError, UnfurlSignature } from "@server/types";
|
||||
import { UnfurlSignature } from "@server/types";
|
||||
import fetch from "@server/utils/fetch";
|
||||
import env from "./env";
|
||||
|
||||
@@ -10,7 +10,7 @@ class Iframely {
|
||||
public static async requestResource(
|
||||
url: string,
|
||||
type = "oembed"
|
||||
): Promise<JSONObject | UnfurlError> {
|
||||
): Promise<JSONObject | undefined> {
|
||||
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
|
||||
|
||||
// Cloud Iframely requires /api path, while self-hosted does not.
|
||||
@@ -25,7 +25,7 @@ class Iframely {
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching data from Iframely for url: ${url}`, err);
|
||||
return { error: err.message || "Unknown error" };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,7 @@ class Iframely {
|
||||
*/
|
||||
public static unfurl: UnfurlSignature = async (url: string) => {
|
||||
const data = await Iframely.requestResource(url);
|
||||
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
|
||||
? ({ error: data.error } as UnfurlError)
|
||||
: { ...data, type: UnfurlResourceType.OEmbed };
|
||||
return { ...data, type: UnfurlResourceType.OEmbed };
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,9 @@ import {
|
||||
RichTextItemResponse,
|
||||
} from "@notionhq/client/build/src/api-endpoints";
|
||||
import { RateLimit } from "async-sema";
|
||||
import emojiRegex from "emoji-regex";
|
||||
import compact from "lodash/compact";
|
||||
import { z } from "zod";
|
||||
import { Second } from "@shared/utils/time";
|
||||
import { isUrl } from "@shared/utils/urls";
|
||||
import { NotionUtils } from "../shared/NotionUtils";
|
||||
import { Block, Page, PageType } from "../shared/types";
|
||||
import env from "./env";
|
||||
@@ -39,16 +37,7 @@ const AccessTokenResponseSchema = z.object({
|
||||
bot_id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
workspace_name: z.string().nullish(),
|
||||
workspace_icon: z
|
||||
.string()
|
||||
.nullish()
|
||||
.transform((val) => {
|
||||
const emojiRegexp = emojiRegex();
|
||||
if (val && (isUrl(val) || emojiRegexp.test(val))) {
|
||||
return val;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
workspace_icon: z.string().url().nullish(),
|
||||
});
|
||||
|
||||
export class NotionClient {
|
||||
|
||||
@@ -277,35 +277,6 @@ describe("#files.get", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok when avatar is requested using key", async () => {
|
||||
const user = await buildUser();
|
||||
const key = path.join("avatars", user.id, uuidV4());
|
||||
const attachment = await buildAttachment({
|
||||
key,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
contentType: "image/jpg",
|
||||
acl: "public-read",
|
||||
});
|
||||
|
||||
await attachment.destroy({
|
||||
hooks: false,
|
||||
});
|
||||
|
||||
ensureDirSync(
|
||||
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
|
||||
);
|
||||
|
||||
copyFileSync(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", "avatar.jpg"),
|
||||
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
|
||||
);
|
||||
|
||||
const res = await server.get(`/api/files.get?key=${key}`);
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.headers.get("Content-Disposition")).toEqual("attachment");
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok when avatar is requested using key", async () => {
|
||||
const user = await buildUser();
|
||||
const key = path.join("avatars", user.id, uuidV4());
|
||||
|
||||
@@ -78,13 +78,17 @@ router.get(
|
||||
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
|
||||
const skipAuthorize = isPublicBucket || isSignedRequest;
|
||||
const cacheHeader = "max-age=604800, immutable";
|
||||
const attachment = await Attachment.findByKey(key);
|
||||
|
||||
const attachment = await Attachment.findOne({
|
||||
where: { key },
|
||||
});
|
||||
|
||||
// Attachment is requested with a key, but it was not found
|
||||
if (!attachment && !!ctx.input.query.key) {
|
||||
throw NotFoundError();
|
||||
}
|
||||
|
||||
if (!skipAuthorize) {
|
||||
if (!attachment && !!ctx.input.query.key) {
|
||||
throw NotFoundError();
|
||||
}
|
||||
|
||||
authorize(actor, "read", attachment);
|
||||
}
|
||||
|
||||
@@ -96,7 +100,6 @@ router.get(
|
||||
ctx.set("Accept-Ranges", "bytes");
|
||||
ctx.set("Cache-Control", cacheHeader);
|
||||
ctx.set("Content-Type", contentType);
|
||||
ctx.set("Content-Security-Policy", "sandbox");
|
||||
ctx.attachment(fileName, {
|
||||
type: forceDownload
|
||||
? "attachment"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -264,6 +264,7 @@ describe("accountProvisioner", () => {
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
},
|
||||
team: {
|
||||
name: existingTeam.name,
|
||||
avatarUrl: existingTeam.avatarUrl,
|
||||
subdomain: faker.internet.domainWord(),
|
||||
},
|
||||
@@ -307,6 +308,7 @@ describe("accountProvisioner", () => {
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
},
|
||||
team: {
|
||||
name: team.name,
|
||||
avatarUrl: team.avatarUrl,
|
||||
subdomain: faker.internet.domainWord(),
|
||||
},
|
||||
|
||||
@@ -43,7 +43,7 @@ type Props = {
|
||||
*/
|
||||
teamId?: string;
|
||||
/** The displayed name of the team */
|
||||
name?: string;
|
||||
name: string;
|
||||
/** The domain name from the email of the user logging in */
|
||||
domain?: string;
|
||||
/** The preferred subdomain to provision for the team if not yet created */
|
||||
@@ -92,7 +92,6 @@ async function accountProvisioner({
|
||||
|
||||
try {
|
||||
result = await teamProvisioner({
|
||||
name: "Wiki",
|
||||
...teamParams,
|
||||
authenticationProvider: authenticationProviderParams,
|
||||
ip,
|
||||
|
||||
@@ -192,7 +192,7 @@ describe("userProvisioner", () => {
|
||||
|
||||
it("should prefer isAdmin argument over defaultUserRole", async () => {
|
||||
const team = await buildTeam({
|
||||
defaultUserRole: UserRole.Viewer,
|
||||
defaultUserRole: "viewer",
|
||||
});
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
|
||||
@@ -86,8 +86,7 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
|
||||
}
|
||||
return;
|
||||
},
|
||||
30,
|
||||
10000
|
||||
30
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
queryInterface.addColumn("integrations", "issueSources", {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
queryInterface.removeColumn("integrations", "issueSources");
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
QueryTypes,
|
||||
FindOptions,
|
||||
} from "sequelize";
|
||||
import {
|
||||
BeforeDestroy,
|
||||
@@ -165,20 +164,6 @@ class Attachment extends IdModel<
|
||||
|
||||
// static methods
|
||||
|
||||
/**
|
||||
* Find an attachment by its key.
|
||||
*
|
||||
* @param key - The key of the attachment to find.
|
||||
* @param options - Additional options for the query.
|
||||
* @returns A promise resolving to the attachment with the given key, or null if not found.
|
||||
*/
|
||||
static async findByKey(
|
||||
key: string,
|
||||
options?: FindOptions<Attachment>
|
||||
): Promise<Attachment | null> {
|
||||
return this.findOne({ where: { key }, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total size of all attachments for a given team.
|
||||
*
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
buildUser,
|
||||
buildGuestUser,
|
||||
} from "@server/test/factories";
|
||||
import Collection from "./Collection";
|
||||
import UserMembership from "./UserMembership";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -79,34 +78,6 @@ describe("#delete", () => {
|
||||
expect(newDocument?.deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should soft delete archived document in an archived collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
archivedAt: new Date(),
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
archivedAt: new Date(),
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await collection.addDocumentToStructure(document, 0);
|
||||
|
||||
await document.delete(user);
|
||||
const [newDocument, newCollection] = await Promise.all([
|
||||
Document.findByPk(document.id, {
|
||||
paranoid: false,
|
||||
}),
|
||||
Collection.findByPk(collection.id),
|
||||
]);
|
||||
|
||||
expect(newDocument?.lastModifiedById).toEqual(user.id);
|
||||
expect(newDocument?.deletedAt).toBeTruthy();
|
||||
expect(newCollection?.documentStructure).toEqual([]);
|
||||
});
|
||||
|
||||
it("should delete draft without collection", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDraftDocument();
|
||||
|
||||
@@ -1112,26 +1112,18 @@ class Document extends ArchivableModel<
|
||||
// Delete a document, archived or otherwise.
|
||||
delete = (user: User) =>
|
||||
this.sequelize.transaction(async (transaction: Transaction) => {
|
||||
let deleted = false;
|
||||
|
||||
if (!this.template && this.collectionId) {
|
||||
const collection = await Collection.findByPk(this.collectionId!, {
|
||||
if (!this.archivedAt && !this.template && this.collectionId) {
|
||||
// delete any children and remove from the document structure
|
||||
const collection = await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
if (!this.archivedAt || (this.archivedAt && collection?.archivedAt)) {
|
||||
await collection?.deleteDocument(this, { transaction });
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
await collection?.deleteDocument(this, { transaction });
|
||||
} else {
|
||||
await this.destroy({
|
||||
transaction,
|
||||
});
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
this.lastModifiedById = user.id;
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
IsIn,
|
||||
AfterDestroy,
|
||||
} from "sequelize-typescript";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import type { IntegrationSettings } from "@shared/types";
|
||||
import Collection from "@server/models/Collection";
|
||||
@@ -54,9 +53,6 @@ class Integration<T = unknown> extends ParanoidModel<
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
events: string[];
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
issueSources: IssueSource[] | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
|
||||
@@ -251,10 +251,6 @@ describe("NotificationHelper", () => {
|
||||
userId: subscribedUser.id,
|
||||
collectionId: document.collectionId!,
|
||||
});
|
||||
await buildSubscription({
|
||||
userId: subscribedUser.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import uniq from "lodash/uniq";
|
||||
import uniqBy from "lodash/uniqBy";
|
||||
import { Op } from "sequelize";
|
||||
import {
|
||||
NotificationEventType,
|
||||
@@ -188,6 +187,7 @@ export default class NotificationHelper {
|
||||
});
|
||||
} else {
|
||||
const subscriptions = await Subscription.findAll({
|
||||
attributes: ["userId"],
|
||||
where: {
|
||||
userId: {
|
||||
[Op.ne]: actorId,
|
||||
@@ -206,19 +206,17 @@ export default class NotificationHelper {
|
||||
],
|
||||
});
|
||||
|
||||
recipients = uniqBy(
|
||||
subscriptions.map((s) => s.user),
|
||||
(user) => user.id
|
||||
);
|
||||
recipients = subscriptions.map((s) => s.user);
|
||||
}
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
recipient.subscribedToEventType(notificationType)
|
||||
);
|
||||
|
||||
const filtered = [];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (
|
||||
recipient.isSuspended ||
|
||||
!recipient.subscribedToEventType(notificationType)
|
||||
) {
|
||||
if (recipient.isSuspended) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import compact from "lodash/compact";
|
||||
import flatten from "lodash/flatten";
|
||||
import isMatch from "lodash/isMatch";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import uniq from "lodash/uniq";
|
||||
import { Node, DOMSerializer, Fragment } from "prosemirror-model";
|
||||
import { Node, DOMSerializer, Fragment, Mark } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import styled, { ServerStyleSheet, ThemeProvider } from "styled-components";
|
||||
import { prosemirrorToYDoc } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import GlobalStyles from "@shared/styles/globals";
|
||||
import light from "@shared/styles/theme";
|
||||
import { MentionType, ProsemirrorData, UnfurlResponse } from "@shared/types";
|
||||
import { MentionType, ProsemirrorData } from "@shared/types";
|
||||
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
@@ -42,8 +43,6 @@ export type MentionAttrs = {
|
||||
modelId: string;
|
||||
actorId: string | undefined;
|
||||
id: string;
|
||||
href?: string;
|
||||
unfurl?: UnfurlResponse[keyof UnfurlResponse];
|
||||
};
|
||||
|
||||
@trace()
|
||||
@@ -62,7 +61,47 @@ export class ProsemirrorHelper {
|
||||
);
|
||||
}
|
||||
|
||||
const node = parser.parse(input);
|
||||
let node = parser.parse(input);
|
||||
|
||||
// in the editor embeds are created at runtime by converting links into
|
||||
// embeds where they match.Because we're converting to a CRDT structure on
|
||||
// the server we need to mimic this behavior.
|
||||
function urlsToEmbeds(node: Node): Node {
|
||||
if (node.type.name === "paragraph") {
|
||||
for (const textNode of node.content.content) {
|
||||
for (const embed of embeds) {
|
||||
if (
|
||||
textNode.text &&
|
||||
textNode.marks.some(
|
||||
(m: Mark) =>
|
||||
m.type.name === "link" && m.attrs.href === textNode.text
|
||||
) &&
|
||||
embed.matcher(textNode.text)
|
||||
) {
|
||||
return schema.nodes.embed.createAndFill({
|
||||
href: textNode.text,
|
||||
}) as Node;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.content) {
|
||||
const contentAsArray =
|
||||
node.content instanceof Fragment
|
||||
? node.content.content
|
||||
: node.content;
|
||||
// @ts-expect-error content
|
||||
node.content = Fragment.fromArray(contentAsArray.map(urlsToEmbeds));
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node) {
|
||||
node = urlsToEmbeds(node);
|
||||
}
|
||||
|
||||
return node ? prosemirrorToYDoc(node, fieldName) : new Y.Doc();
|
||||
}
|
||||
|
||||
@@ -196,7 +235,7 @@ export class ProsemirrorHelper {
|
||||
node.descendants((childNode: Node) => {
|
||||
if (
|
||||
childNode.type.name === "mention" &&
|
||||
isMatch(childNode.attrs, mention)
|
||||
isEqual(childNode.attrs, mention)
|
||||
) {
|
||||
foundMention = true;
|
||||
return false;
|
||||
|
||||
@@ -2,7 +2,7 @@ import invariant from "invariant";
|
||||
import filter from "lodash/filter";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { Collection, User, Team } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import { allow, can } from "./cancan";
|
||||
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
|
||||
|
||||
allow(User, "createCollection", Team, (actor, team) =>
|
||||
@@ -67,6 +67,15 @@ allow(
|
||||
}
|
||||
);
|
||||
|
||||
allow(User, "export", Collection, (actor, collection) =>
|
||||
and(
|
||||
//
|
||||
can(actor, "read", collection),
|
||||
!actor.isViewer,
|
||||
!actor.isGuest
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "share", Collection, (user, collection) => {
|
||||
if (
|
||||
!collection ||
|
||||
@@ -152,7 +161,7 @@ allow(
|
||||
}
|
||||
);
|
||||
|
||||
allow(User, ["update", "export", "archive"], Collection, (user, collection) =>
|
||||
allow(User, ["update", "archive"], Collection, (user, collection) =>
|
||||
and(
|
||||
!!collection,
|
||||
!!collection?.isActive,
|
||||
|
||||
+38
-54
@@ -71,63 +71,47 @@ const presentDocument = (
|
||||
|
||||
const presentPR = (
|
||||
data: Record<string, any>
|
||||
): UnfurlResponse[UnfurlResourceType.PR] => {
|
||||
// TODO: For backwards compatibility, remove once cache has expired in next release.
|
||||
if (data.transformed_unfurl) {
|
||||
delete data.transformed_unfurl;
|
||||
return data as UnfurlResponse[UnfurlResourceType.PR]; // this would have been transformed by the unfurl plugin.
|
||||
}
|
||||
|
||||
return {
|
||||
url: data.html_url,
|
||||
type: UnfurlResourceType.PR,
|
||||
id: `#${data.number}`,
|
||||
title: data.title,
|
||||
description: data.body,
|
||||
author: {
|
||||
name: data.user.login,
|
||||
avatarUrl: data.user.avatar_url,
|
||||
},
|
||||
state: {
|
||||
name: data.merged ? "merged" : data.state,
|
||||
color: GitHubUtils.getColorForStatus(data.merged ? "merged" : data.state),
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
};
|
||||
};
|
||||
): UnfurlResponse[UnfurlResourceType.PR] => ({
|
||||
url: data.html_url,
|
||||
type: UnfurlResourceType.PR,
|
||||
id: `#${data.number}`,
|
||||
title: data.title,
|
||||
description: data.body,
|
||||
author: {
|
||||
name: data.user.login,
|
||||
avatarUrl: data.user.avatar_url,
|
||||
},
|
||||
state: {
|
||||
name: data.merged ? "merged" : data.state,
|
||||
color: GitHubUtils.getColorForStatus(data.merged ? "merged" : data.state),
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
});
|
||||
|
||||
const presentIssue = (
|
||||
data: Record<string, any>
|
||||
): UnfurlResponse[UnfurlResourceType.Issue] => {
|
||||
// TODO: For backwards compatibility, remove once cache has expired in next release.
|
||||
if (data.transformed_unfurl) {
|
||||
delete data.transformed_unfurl;
|
||||
return data as UnfurlResponse[UnfurlResourceType.Issue]; // this would have been transformed by the unfurl plugin.
|
||||
}
|
||||
|
||||
return {
|
||||
url: data.html_url,
|
||||
type: UnfurlResourceType.Issue,
|
||||
id: `#${data.number}`,
|
||||
title: data.title,
|
||||
description: data.body_text,
|
||||
author: {
|
||||
name: data.user.login,
|
||||
avatarUrl: data.user.avatar_url,
|
||||
},
|
||||
labels: data.labels.map((label: { name: string; color: string }) => ({
|
||||
name: label.name,
|
||||
color: `#${label.color}`,
|
||||
})),
|
||||
state: {
|
||||
name: data.state,
|
||||
color: GitHubUtils.getColorForStatus(
|
||||
data.state === "closed" ? "done" : data.state
|
||||
),
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
};
|
||||
};
|
||||
): UnfurlResponse[UnfurlResourceType.Issue] => ({
|
||||
url: data.html_url,
|
||||
type: UnfurlResourceType.Issue,
|
||||
id: `#${data.number}`,
|
||||
title: data.title,
|
||||
description: data.body_text,
|
||||
author: {
|
||||
name: data.user.login,
|
||||
avatarUrl: data.user.avatar_url,
|
||||
},
|
||||
labels: data.labels.map((label: { name: string; color: string }) => ({
|
||||
name: label.name,
|
||||
color: `#${label.color}`,
|
||||
})),
|
||||
state: {
|
||||
name: data.state,
|
||||
color: GitHubUtils.getColorForStatus(
|
||||
data.state === "closed" ? "done" : data.state
|
||||
),
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
});
|
||||
|
||||
const presentLastOnlineInfoFor = (user: User) => {
|
||||
const locale = dateLocale(user.language);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Integration } from "@server/models";
|
||||
import BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import { IntegrationEvent, Event } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import CacheIssueSourcesTask from "../tasks/CacheIssueSourcesTask";
|
||||
|
||||
export default class IntegrationCreatedProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = ["integrations.create"];
|
||||
@@ -19,11 +18,6 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the available issue sources in the integration record.
|
||||
await CacheIssueSourcesTask.schedule({
|
||||
integrationId: integration.id,
|
||||
});
|
||||
|
||||
// Clear the cache of unfurled data for the team as it may be stale now.
|
||||
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Integration } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import BaseTask from "./BaseTask";
|
||||
|
||||
const plugins = PluginManager.getHooks(Hook.IssueProvider);
|
||||
|
||||
type Props = {
|
||||
integrationId: string;
|
||||
};
|
||||
|
||||
export default class CacheIssueSourcesTask extends BaseTask<Props> {
|
||||
async perform({ integrationId }: Props) {
|
||||
const integration = await Integration.findByPk(integrationId);
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = plugins.find((p) => p.value.service === integration.service);
|
||||
if (!plugin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = await plugin.value.fetchSources(integration);
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await integration.reload({ transaction, lock: transaction.LOCK.UPDATE });
|
||||
integration.issueSources = sources;
|
||||
await integration.save({ transaction });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -85,21 +85,16 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
|
||||
)
|
||||
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
for (const recipient of recipients) {
|
||||
await Notification.create(
|
||||
{
|
||||
event: NotificationEventType.CreateComment,
|
||||
userId: recipient.id,
|
||||
actorId: comment.createdById,
|
||||
teamId: document.teamId,
|
||||
commentId: comment.id,
|
||||
documentId: document.id,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
});
|
||||
for (const recipient of recipients) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.CreateComment,
|
||||
userId: recipient.id,
|
||||
actorId: comment.createdById,
|
||||
teamId: document.teamId,
|
||||
commentId: comment.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public get options() {
|
||||
|
||||
@@ -20,7 +20,7 @@ jest.mock("dns", () => ({
|
||||
|
||||
jest
|
||||
.spyOn(Iframely, "requestResource")
|
||||
.mockImplementation(() => Promise.resolve({}));
|
||||
.mockImplementation(() => Promise.resolve(undefined));
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
|
||||
@@ -98,12 +98,11 @@ router.post(
|
||||
}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const unfurl = await plugin.value.unfurl(url, actor);
|
||||
if (unfurl) {
|
||||
if ("error" in unfurl) {
|
||||
const data = await plugin.value.unfurl(url, actor);
|
||||
if (data) {
|
||||
if ("error" in data) {
|
||||
return (ctx.response.status = 204);
|
||||
} else {
|
||||
const data = unfurl as Unfurl;
|
||||
await CacheHelper.setData(
|
||||
CacheHelper.getUnfurlKey(actor.teamId, url),
|
||||
data,
|
||||
|
||||
+2
-18
@@ -10,7 +10,6 @@ import {
|
||||
JSONValue,
|
||||
UnfurlResourceType,
|
||||
ProsemirrorData,
|
||||
UnfurlResponse,
|
||||
} from "@shared/types";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
import { AccountProvisionerResult } from "./commands/accountProvisioner";
|
||||
@@ -577,27 +576,12 @@ export type CollectionJSONExport = {
|
||||
};
|
||||
};
|
||||
|
||||
export type UnfurlIssueAndPR = (
|
||||
| UnfurlResponse[UnfurlResourceType.Issue]
|
||||
| UnfurlResponse[UnfurlResourceType.PR]
|
||||
) & { transformed_unfurl: true };
|
||||
|
||||
export type Unfurl =
|
||||
| UnfurlIssueAndPR
|
||||
| {
|
||||
type: Exclude<
|
||||
UnfurlResourceType,
|
||||
UnfurlResourceType.Issue | UnfurlResourceType.PR
|
||||
>;
|
||||
[x: string]: JSONValue;
|
||||
};
|
||||
|
||||
export type UnfurlError = { error: string };
|
||||
export type Unfurl = { [x: string]: JSONValue; type: UnfurlResourceType };
|
||||
|
||||
export type UnfurlSignature = (
|
||||
url: string,
|
||||
actor?: User
|
||||
) => Promise<Unfurl | UnfurlError | undefined>;
|
||||
) => Promise<Unfurl | void>;
|
||||
|
||||
export type UninstallSignature = (integration: Integration) => Promise<void>;
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { IntegrationType, IssueTrackerIntegrationService } from "@shared/types";
|
||||
import { Integration } from "@server/models";
|
||||
|
||||
export abstract class BaseIssueProvider {
|
||||
service: IssueTrackerIntegrationService;
|
||||
|
||||
constructor(service: IssueTrackerIntegrationService) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
abstract fetchSources(
|
||||
integration: Integration<IntegrationType.Embed>
|
||||
): Promise<IssueSource[]>;
|
||||
}
|
||||
@@ -18,14 +18,11 @@ export class CacheHelper {
|
||||
* @param key Cache key
|
||||
* @param callback Callback to get the data if not found in cache
|
||||
* @param expiry Cache data expiry in seconds
|
||||
* @param lockTimeout Lock timeout in milliseconds
|
||||
* @returns The data from cache or the result of the callback
|
||||
*/
|
||||
public static async getDataOrSet<T>(
|
||||
key: string,
|
||||
callback: () => Promise<T | undefined>,
|
||||
expiry: number,
|
||||
lockTimeout: number = MutexLock.defaultLockTimeout
|
||||
expiry?: number
|
||||
): Promise<T | undefined> {
|
||||
let cache = await this.getData<T>(key);
|
||||
|
||||
@@ -38,7 +35,10 @@ export class CacheHelper {
|
||||
const lockKey = `lock:${key}`;
|
||||
try {
|
||||
try {
|
||||
lock = await MutexLock.lock.acquire([lockKey], lockTimeout);
|
||||
lock = await MutexLock.lock.acquire(
|
||||
[lockKey],
|
||||
MutexLock.defaultLockTimeout
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Could not acquire lock for ${key}`, err);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import Logger from "@server/logging/Logger";
|
||||
import type BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import type BaseTask from "@server/queues/tasks/BaseTask";
|
||||
import { UnfurlSignature, UninstallSignature } from "@server/types";
|
||||
import { BaseIssueProvider } from "./BaseIssueProvider";
|
||||
|
||||
export enum PluginPriority {
|
||||
VeryHigh = 0,
|
||||
@@ -26,7 +25,6 @@ export enum Hook {
|
||||
API = "api",
|
||||
AuthProvider = "authProvider",
|
||||
EmailTemplate = "emailTemplate",
|
||||
IssueProvider = "issueProvider",
|
||||
Processor = "processor",
|
||||
Task = "task",
|
||||
UnfurlProvider = "unfurl",
|
||||
@@ -41,7 +39,6 @@ type PluginValueMap = {
|
||||
[Hook.API]: Router;
|
||||
[Hook.AuthProvider]: { router: Router; id: string };
|
||||
[Hook.EmailTemplate]: typeof BaseEmail;
|
||||
[Hook.IssueProvider]: BaseIssueProvider;
|
||||
[Hook.Processor]: typeof BaseProcessor;
|
||||
[Hook.Task]: typeof BaseTask<any>;
|
||||
[Hook.Uninstall]: UninstallSignature;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import {
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildStar,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import { collectionIndexing, starIndexing } from "./indexing";
|
||||
|
||||
describe("collectionIndexing", () => {
|
||||
it("should generate index for collections without index", async () => {
|
||||
const team = await buildTeam();
|
||||
const collections = await Promise.all([
|
||||
buildCollection({
|
||||
teamId: team.id,
|
||||
}),
|
||||
buildCollection({
|
||||
teamId: team.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Set index to null to simulate no index
|
||||
collections[0].index = null;
|
||||
collections[1].index = null;
|
||||
await collections[0].save({ hooks: false });
|
||||
await collections[1].save({ hooks: false });
|
||||
|
||||
const result = await collectionIndexing(team.id, {});
|
||||
expect(Object.keys(result).length).toBe(2);
|
||||
expect(result[collections[0].id]).toBeTruthy();
|
||||
expect(result[collections[1].id]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should maintain existing indices", async () => {
|
||||
const team = await buildTeam();
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
index: "a1",
|
||||
});
|
||||
|
||||
const result = await collectionIndexing(team.id, {});
|
||||
expect(result[collection.id]).toBe("a1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("starIndexing", () => {
|
||||
it("should generate index for stars without index", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument();
|
||||
const stars = await Promise.all([
|
||||
buildStar({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
buildStar({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Set index to null to simulate no index
|
||||
stars[0].index = null;
|
||||
stars[1].index = null;
|
||||
await stars[0].save({ hooks: false });
|
||||
await stars[1].save({ hooks: false });
|
||||
|
||||
const result = await starIndexing(user.id);
|
||||
expect(Object.keys(result).length).toBe(2);
|
||||
expect(result[stars[0].id]).toBeTruthy();
|
||||
expect(result[stars[1].id]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should maintain existing indices", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument();
|
||||
const star = await buildStar({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
index: "a1",
|
||||
});
|
||||
|
||||
const result = await starIndexing(user.id);
|
||||
expect(result[star.id]).toBe("a1");
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,10 @@ export async function collectionIndexing(
|
||||
const collections = await Collection.findAll({
|
||||
where: {
|
||||
teamId,
|
||||
// no point in maintaining index of deleted collections.
|
||||
deletedAt: null,
|
||||
},
|
||||
attributes: ["id", "index", "name", "teamId"],
|
||||
attributes: ["id", "index", "name"],
|
||||
transaction,
|
||||
});
|
||||
|
||||
@@ -24,9 +26,7 @@ export async function collectionIndexing(
|
||||
for (const collection of sortable) {
|
||||
if (collection.index === null) {
|
||||
collection.index = fractionalIndex(previousIndex, null);
|
||||
promises.push(
|
||||
collection.save({ fields: ["index"], silent: true, transaction })
|
||||
);
|
||||
promises.push(collection.save({ fields: ["index"], transaction })); // save only index to prevent overwriting other unfetched fields.
|
||||
}
|
||||
|
||||
previousIndex = collection.index;
|
||||
@@ -67,7 +67,7 @@ export async function starIndexing(userId: string) {
|
||||
for (const star of sortable) {
|
||||
if (star.index === null) {
|
||||
star.index = fractionalIndex(previousIndex, null);
|
||||
promises.push(star.save({ silent: true }));
|
||||
promises.push(star.save());
|
||||
}
|
||||
|
||||
previousIndex = star.index;
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { ellipsis } from "../styles";
|
||||
|
||||
type Props = {
|
||||
/** The type of text to render */
|
||||
type?: "secondary" | "tertiary" | "danger";
|
||||
/** The size of the text */
|
||||
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
|
||||
/** The direction of the text (defaults to ltr) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the text should be selectable (defaults to false) */
|
||||
selectable?: boolean;
|
||||
/** The font weight of the text */
|
||||
weight?: "xbold" | "bold" | "normal";
|
||||
/** Whether the text should be italic */
|
||||
italic?: boolean;
|
||||
/** Whether the text should be truncated with an ellipsis */
|
||||
ellipsis?: boolean;
|
||||
/** Whether the text should be monospaced */
|
||||
monospace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this component for all interface text that should not be selectable
|
||||
* by the user, this is the majority of UI text explainers, notes, headings.
|
||||
*/
|
||||
const Text = styled.span<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
: props.type === "tertiary"
|
||||
? props.theme.textTertiary
|
||||
: props.type === "danger"
|
||||
? props.theme.brand.red
|
||||
: props.theme.text};
|
||||
font-size: ${(props) =>
|
||||
props.size === "xlarge"
|
||||
? "26px"
|
||||
: props.size === "large"
|
||||
? "18px"
|
||||
: props.size === "medium"
|
||||
? "16px"
|
||||
: props.size === "small"
|
||||
? "14px"
|
||||
: props.size === "xsmall"
|
||||
? "13px"
|
||||
: "inherit"};
|
||||
|
||||
${(props) =>
|
||||
props.weight &&
|
||||
css`
|
||||
font-weight: ${props.weight === "xbold"
|
||||
? 600
|
||||
: props.weight === "bold"
|
||||
? 500
|
||||
: props.weight === "normal"
|
||||
? 400
|
||||
: "inherit"};
|
||||
`}
|
||||
|
||||
font-style: ${(props) => (props.italic ? "italic" : "normal")};
|
||||
font-family: ${(props) =>
|
||||
props.monospace ? props.theme.fontFamilyMono : "inherit"};
|
||||
|
||||
white-space: normal;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
|
||||
${(props) => props.ellipsis && ellipsis()}
|
||||
`;
|
||||
|
||||
export default Text;
|
||||
@@ -17,10 +17,10 @@ type Props = ComponentProps & {
|
||||
|
||||
const Embed = (props: Props) => {
|
||||
const ref = React.useRef<HTMLIFrameElement>(null);
|
||||
const { node, isEditable, embedsDisabled, onChangeSize } = props;
|
||||
const { node, isEditable, onChangeSize } = props;
|
||||
const naturalWidth = 0;
|
||||
const naturalHeight = 400;
|
||||
const isResizable = !!onChangeSize && !embedsDisabled;
|
||||
const isResizable = !!onChangeSize;
|
||||
|
||||
const { width, height, setSize, handlePointerDown, dragging } = useDragResize(
|
||||
{
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { OpenIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Optional } from "utility-types";
|
||||
import { s } from "../../styles";
|
||||
import { sanitizeUrl } from "../../utils/urls";
|
||||
|
||||
type Props = Omit<
|
||||
Optional<React.ComponentProps<typeof Iframe>>,
|
||||
"children" | "style"
|
||||
> & {
|
||||
type Props = Omit<Optional<HTMLIFrameElement>, "children" | "style"> & {
|
||||
/** The URL to load in the iframe */
|
||||
src?: string;
|
||||
/** Whether to display a border, defaults to true */
|
||||
@@ -32,79 +30,85 @@ type PropsWithRef = Props & {
|
||||
forwardedRef: React.Ref<HTMLIFrameElement>;
|
||||
};
|
||||
|
||||
const Frame = ({
|
||||
border,
|
||||
style = {},
|
||||
forwardedRef,
|
||||
icon,
|
||||
title,
|
||||
canonicalUrl,
|
||||
isSelected,
|
||||
referrerPolicy,
|
||||
className = "",
|
||||
src,
|
||||
...rest
|
||||
}: PropsWithRef) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
@observer
|
||||
class Frame extends React.Component<PropsWithRef> {
|
||||
mounted: boolean;
|
||||
|
||||
useEffect(() => {
|
||||
// Set mounted flag
|
||||
mountedRef.current = true;
|
||||
@observable
|
||||
isLoaded = false;
|
||||
|
||||
// Load iframe after a small delay
|
||||
const timer = setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}, 0);
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
setTimeout(this.loadIframe, 0);
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
const showBottomBar = !!(icon || canonicalUrl);
|
||||
loadIframe = () => {
|
||||
if (!this.mounted) {
|
||||
return;
|
||||
}
|
||||
this.isLoaded = true;
|
||||
};
|
||||
|
||||
return (
|
||||
<Rounded
|
||||
style={style}
|
||||
$showBottomBar={showBottomBar}
|
||||
$border={border}
|
||||
className={
|
||||
isSelected ? `ProseMirror-selectednode ${className}` : className
|
||||
}
|
||||
>
|
||||
{isLoaded && (
|
||||
<Iframe
|
||||
ref={forwardedRef}
|
||||
$showBottomBar={showBottomBar}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-downloads allow-storage-access-by-user-activation"
|
||||
style={style}
|
||||
frameBorder="0"
|
||||
title="embed"
|
||||
loading="lazy"
|
||||
src={sanitizeUrl(src)}
|
||||
referrerPolicy={referrerPolicy}
|
||||
allowFullScreen
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
{showBottomBar && (
|
||||
<Bar>
|
||||
{icon} <Title>{title}</Title>
|
||||
{canonicalUrl && (
|
||||
<Open href={canonicalUrl} target="_blank" rel="noopener noreferrer">
|
||||
<OpenIcon size={18} /> Open
|
||||
</Open>
|
||||
)}
|
||||
</Bar>
|
||||
)}
|
||||
</Rounded>
|
||||
);
|
||||
};
|
||||
render() {
|
||||
const {
|
||||
border,
|
||||
style = {},
|
||||
forwardedRef,
|
||||
icon,
|
||||
title,
|
||||
canonicalUrl,
|
||||
isSelected,
|
||||
referrerPolicy,
|
||||
className = "",
|
||||
src,
|
||||
} = this.props;
|
||||
const showBottomBar = !!(icon || canonicalUrl);
|
||||
|
||||
return (
|
||||
<Rounded
|
||||
style={style}
|
||||
$showBottomBar={showBottomBar}
|
||||
$border={border}
|
||||
className={
|
||||
isSelected ? `ProseMirror-selectednode ${className}` : className
|
||||
}
|
||||
>
|
||||
{this.isLoaded && (
|
||||
<Iframe
|
||||
ref={forwardedRef}
|
||||
$showBottomBar={showBottomBar}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-downloads allow-storage-access-by-user-activation"
|
||||
style={style}
|
||||
frameBorder="0"
|
||||
title="embed"
|
||||
loading="lazy"
|
||||
src={sanitizeUrl(src)}
|
||||
referrerPolicy={referrerPolicy}
|
||||
allowFullScreen
|
||||
/>
|
||||
)}
|
||||
{showBottomBar && (
|
||||
<Bar>
|
||||
{icon} <Title>{title}</Title>
|
||||
{canonicalUrl && (
|
||||
<Open
|
||||
href={canonicalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<OpenIcon size={18} /> Open
|
||||
</Open>
|
||||
)}
|
||||
</Bar>
|
||||
)}
|
||||
</Rounded>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Iframe = styled.iframe<{ $showBottomBar: boolean }>`
|
||||
border-radius: ${(props) => (props.$showBottomBar ? "3px 3px 0 0" : "3px")};
|
||||
|
||||
@@ -1,49 +1,17 @@
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
DocumentIcon,
|
||||
EmailIcon,
|
||||
CollectionIcon,
|
||||
WarningIcon,
|
||||
} from "outline-icons";
|
||||
import { DocumentIcon, EmailIcon, CollectionIcon } from "outline-icons";
|
||||
import { Node } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Flex from "../../components/Flex";
|
||||
import Icon from "../../components/Icon";
|
||||
import { IssueStatusIcon } from "../../components/IssueStatusIcon";
|
||||
import { PullRequestIcon } from "../../components/PullRequestIcon";
|
||||
import Spinner from "../../components/Spinner";
|
||||
import Text from "../../components/Text";
|
||||
import useIsMounted from "../../hooks/useIsMounted";
|
||||
import useStores from "../../hooks/useStores";
|
||||
import theme from "../../styles/theme";
|
||||
import type {
|
||||
JSONValue,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "../../types";
|
||||
import { cn } from "../styles/utils";
|
||||
import { ComponentProps } from "../types";
|
||||
|
||||
type Attrs = {
|
||||
className: string;
|
||||
unfurl?: UnfurlResponse[keyof UnfurlResponse];
|
||||
} & Record<string, JSONValue>;
|
||||
|
||||
const getAttributesFromNode = (node: Node): Attrs => {
|
||||
const spec = node.type.spec.toDOM?.(node) as any as Record<
|
||||
string,
|
||||
JSONValue
|
||||
>[];
|
||||
const { class: className, "data-unfurl": unfurl, ...attrs } = spec[1];
|
||||
|
||||
return {
|
||||
className: className as Attrs["className"],
|
||||
unfurl: unfurl ? (JSON.parse(unfurl as any) as Attrs["unfurl"]) : undefined,
|
||||
...attrs,
|
||||
};
|
||||
const getAttributesFromNode = (node: Node) => {
|
||||
const spec = node.type.spec.toDOM?.(node) as any as Record<string, string>[];
|
||||
const { class: className, ...attrs } = spec[1];
|
||||
return { className, ...attrs };
|
||||
};
|
||||
|
||||
export const MentionUser = observer(function MentionUser_(
|
||||
@@ -52,7 +20,7 @@ export const MentionUser = observer(function MentionUser_(
|
||||
const { isSelected, node } = props;
|
||||
const { users } = useStores();
|
||||
const user = users.get(node.attrs.modelId);
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -74,7 +42,7 @@ export const MentionDocument = observer(function MentionDocument_(
|
||||
const { documents } = useStores();
|
||||
const doc = documents.get(node.attrs.modelId);
|
||||
const modelId = node.attrs.modelId;
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (modelId) {
|
||||
@@ -107,7 +75,7 @@ export const MentionCollection = observer(function MentionCollection_(
|
||||
const { collections } = useStores();
|
||||
const collection = collections.get(node.attrs.modelId);
|
||||
const modelId = node.attrs.modelId;
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (modelId) {
|
||||
@@ -132,183 +100,3 @@ export const MentionCollection = observer(function MentionCollection_(
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
type IssuePrProps = ComponentProps & {
|
||||
onChangeUnfurl: (
|
||||
unfurl:
|
||||
| UnfurlResponse[UnfurlResourceType.Issue]
|
||||
| UnfurlResponse[UnfurlResourceType.PR]
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const MentionIssue = observer((props: IssuePrProps) => {
|
||||
const { unfurls } = useStores();
|
||||
const isMounted = useIsMounted();
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
|
||||
|
||||
const { isSelected, node } = props;
|
||||
const {
|
||||
className,
|
||||
unfurl: unfurlAttr,
|
||||
...attrs
|
||||
} = getAttributesFromNode(node);
|
||||
|
||||
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchIssue = async () => {
|
||||
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
|
||||
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (unfurlModel) {
|
||||
onChangeUnfurl({
|
||||
...unfurlModel.data,
|
||||
description: null,
|
||||
} satisfies UnfurlResponse[UnfurlResourceType.Issue]);
|
||||
}
|
||||
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
void fetchIssue();
|
||||
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
|
||||
|
||||
if (!unfurl) {
|
||||
return !loaded ? (
|
||||
<MentionLoading className={className} />
|
||||
) : (
|
||||
<MentionError className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
const issue = unfurl as UnfurlResponse[UnfurlResourceType.Issue];
|
||||
|
||||
return (
|
||||
<a
|
||||
{...attrs}
|
||||
className={cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
})}
|
||||
href={attrs.href as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<Flex align="center" gap={6}>
|
||||
<IssueStatusIcon
|
||||
size={14}
|
||||
status={issue.state.name}
|
||||
color={issue.state.color}
|
||||
/>
|
||||
<Flex align="center" gap={4}>
|
||||
<Text>{issue.title}</Text>
|
||||
<Text type="tertiary">{issue.id}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
export const MentionPullRequest = observer((props: IssuePrProps) => {
|
||||
const { unfurls } = useStores();
|
||||
const isMounted = useIsMounted();
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
|
||||
|
||||
const { isSelected, node } = props;
|
||||
const {
|
||||
className,
|
||||
unfurl: unfurlAttr,
|
||||
...attrs
|
||||
} = getAttributesFromNode(node);
|
||||
|
||||
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchPR = async () => {
|
||||
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
|
||||
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (unfurlModel) {
|
||||
onChangeUnfurl({
|
||||
...unfurlModel.data,
|
||||
description: null,
|
||||
} satisfies UnfurlResponse[UnfurlResourceType.PR]);
|
||||
}
|
||||
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
void fetchPR();
|
||||
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
|
||||
|
||||
const sharedProps = {
|
||||
className: cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
}),
|
||||
};
|
||||
|
||||
if (!unfurl) {
|
||||
return !loaded ? (
|
||||
<MentionLoading {...sharedProps} />
|
||||
) : (
|
||||
<MentionError {...sharedProps} />
|
||||
);
|
||||
}
|
||||
|
||||
const pullRequest = unfurl as UnfurlResponse[UnfurlResourceType.PR];
|
||||
|
||||
return (
|
||||
<a
|
||||
{...attrs}
|
||||
{...sharedProps}
|
||||
href={attrs.href as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<Flex align="center" gap={6}>
|
||||
<PullRequestIcon
|
||||
size={14}
|
||||
status={pullRequest.state.name}
|
||||
color={pullRequest.state.color}
|
||||
/>
|
||||
<Flex align="center" gap={4}>
|
||||
<Text>{pullRequest.title}</Text>
|
||||
<Text type="tertiary">{pullRequest.id}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
const MentionLoading = ({ className }: { className: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<Spinner />
|
||||
<Text type="tertiary">{`${t("Loading")}…`}</Text>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const MentionError = ({ className }: { className: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<StyledWarningIcon size={20} color={theme.danger} />
|
||||
<Text type="secondary">{`${t("Error loading data")}`}</Text>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledWarningIcon = styled(WarningIcon)`
|
||||
margin: 0 -2px;
|
||||
`;
|
||||
|
||||
@@ -128,7 +128,6 @@ const mathStyle = (props: Props) => css`
|
||||
math-block .math-src .ProseMirror {
|
||||
width: 100%;
|
||||
display: block;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
math-block .katex-display {
|
||||
@@ -335,7 +334,7 @@ width: 100%;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
& > .ProseMirror {
|
||||
.ProseMirror {
|
||||
position: relative;
|
||||
outline: none;
|
||||
word-wrap: break-word;
|
||||
@@ -708,7 +707,6 @@ img.ProseMirror-separator {
|
||||
resize: none;
|
||||
user-select: text;
|
||||
margin: 0 auto !important;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.ProseMirror[contenteditable="false"] {
|
||||
@@ -1337,13 +1335,6 @@ mark {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-block[data-language=none],
|
||||
.code-block[data-language=markdown] {
|
||||
pre code {
|
||||
color: ${props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.code-block[data-language=mermaidjs] {
|
||||
margin: 0.75em 0;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BrowserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { Primitive } from "utility-types";
|
||||
@@ -666,7 +665,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
title: "Embed",
|
||||
keywords: "iframe webpage",
|
||||
placeholder: "Paste a URL to embed",
|
||||
icon: <BrowserIcon />,
|
||||
icon: <Img src="/images/embed.png" alt="Embed" />,
|
||||
defaultHidden: false,
|
||||
matchOnInput: false,
|
||||
regexMatch: [new RegExp("^https?://(.*)$")],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Node } from "prosemirror-model";
|
||||
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import refractor from "refractor/core";
|
||||
import { getLoaderForLanguage, getRefractorLangForLanguage } from "../lib/code";
|
||||
import { getPrismLangForLanguage } from "../lib/code";
|
||||
import { isRemoteTransaction } from "../lib/multiplayer";
|
||||
import { findBlockNodes } from "../queries/findChildren";
|
||||
|
||||
@@ -14,44 +14,6 @@ type ParsedNode = {
|
||||
};
|
||||
|
||||
const cache: Record<number, { node: Node; decorations: Decoration[] }> = {};
|
||||
const languagesToImport = new Set<string>();
|
||||
const languagePromises: Record<
|
||||
string,
|
||||
Promise<string | undefined> | undefined
|
||||
> = {};
|
||||
|
||||
async function loadLanguage(language: string) {
|
||||
if (!language || refractor.registered(language)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (languagePromises[language]) {
|
||||
return languagePromises[language];
|
||||
}
|
||||
|
||||
const loader = getLoaderForLanguage(language);
|
||||
if (!loader) {
|
||||
return;
|
||||
}
|
||||
|
||||
languagePromises[language] = loader()
|
||||
.then((syntax) => {
|
||||
refractor.register(syntax);
|
||||
return language;
|
||||
})
|
||||
.catch((err) => {
|
||||
// It will retry loading the language on the next render
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Failed to load language ${language} for code highlighting`,
|
||||
err
|
||||
);
|
||||
delete languagePromises[language]; // Remove failed promise from cache
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return languagePromises[language];
|
||||
}
|
||||
|
||||
function getDecorations({
|
||||
doc,
|
||||
@@ -95,8 +57,12 @@ function getDecorations({
|
||||
|
||||
blocks.forEach((block) => {
|
||||
let startPos = block.pos + 1;
|
||||
const language = block.node.attrs.language;
|
||||
const lang = getRefractorLangForLanguage(language);
|
||||
const language = getPrismLangForLanguage(block.node.attrs.language);
|
||||
|
||||
if (!language || !refractor.registered(language)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lineDecorations = [];
|
||||
|
||||
if (!cache[block.pos] || !cache[block.pos].node.eq(block.node)) {
|
||||
@@ -125,48 +91,35 @@ function getDecorations({
|
||||
);
|
||||
}
|
||||
|
||||
const nodes = refractor.highlight(block.node.textContent, language);
|
||||
const newDecorations = parseNodes(nodes)
|
||||
.map((node: ParsedNode) => {
|
||||
const from = startPos;
|
||||
const to = from + node.text.length;
|
||||
|
||||
startPos = to;
|
||||
|
||||
return {
|
||||
...node,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
})
|
||||
.filter((node) => node.classes && node.classes.length)
|
||||
.map((node) =>
|
||||
Decoration.inline(node.from, node.to, {
|
||||
class: node.classes.join(" "),
|
||||
})
|
||||
)
|
||||
.concat(lineDecorations);
|
||||
|
||||
cache[block.pos] = {
|
||||
node: block.node,
|
||||
decorations: lineDecorations,
|
||||
decorations: newDecorations,
|
||||
};
|
||||
|
||||
if (!lang) {
|
||||
// do nothing
|
||||
} else if (refractor.registered(lang)) {
|
||||
languagesToImport.delete(language);
|
||||
|
||||
const nodes = refractor.highlight(block.node.textContent, lang);
|
||||
const newDecorations = parseNodes(nodes)
|
||||
.map((node: ParsedNode) => {
|
||||
const from = startPos;
|
||||
const to = from + node.text.length;
|
||||
|
||||
startPos = to;
|
||||
|
||||
return {
|
||||
...node,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
})
|
||||
.filter((node) => node.classes && node.classes.length)
|
||||
.map((node) =>
|
||||
Decoration.inline(node.from, node.to, {
|
||||
class: node.classes.join(" "),
|
||||
})
|
||||
)
|
||||
.concat(lineDecorations);
|
||||
|
||||
cache[block.pos] = {
|
||||
node: block.node,
|
||||
decorations: newDecorations,
|
||||
};
|
||||
} else {
|
||||
languagesToImport.add(language);
|
||||
}
|
||||
}
|
||||
|
||||
cache[block.pos]?.decorations.forEach((decoration) => {
|
||||
cache[block.pos].decorations.forEach((decoration) => {
|
||||
decorations.push(decoration);
|
||||
});
|
||||
});
|
||||
@@ -180,7 +133,7 @@ function getDecorations({
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
|
||||
export function CodeHighlighting({
|
||||
export default function Prism({
|
||||
name,
|
||||
lineNumbers,
|
||||
}: {
|
||||
@@ -192,7 +145,7 @@ export function CodeHighlighting({
|
||||
let highlighted = false;
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey("codeHighlighting"),
|
||||
key: new PluginKey("prism"),
|
||||
state: {
|
||||
init: (_, { doc }) => DecorationSet.create(doc, []),
|
||||
apply: (transaction: Transaction, decorationSet, oldState, state) => {
|
||||
@@ -203,13 +156,11 @@ export function CodeHighlighting({
|
||||
|
||||
// @ts-expect-error accessing private field.
|
||||
const isPaste = transaction.meta?.paste;
|
||||
const langLoaded = transaction.getMeta("codeHighlighting")?.langLoaded;
|
||||
|
||||
if (
|
||||
!highlighted ||
|
||||
codeBlockChanged ||
|
||||
isPaste ||
|
||||
langLoaded ||
|
||||
isRemoteTransaction(transaction)
|
||||
) {
|
||||
highlighted = true;
|
||||
@@ -223,35 +174,15 @@ export function CodeHighlighting({
|
||||
if (!highlighted) {
|
||||
// we don't highlight code blocks on the first render as part of mounting
|
||||
// as it's expensive (relative to the rest of the document). Instead let
|
||||
// it render un-highlighted and then trigger a defered render of highlighting
|
||||
// it render un-highlighted and then trigger a defered render of Prism
|
||||
// by updating the plugins metadata
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
if (!view.isDestroyed) {
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta("codeHighlighting", { loaded: true })
|
||||
);
|
||||
view.dispatch(view.state.tr.setMeta("prism", { loaded: true }));
|
||||
}
|
||||
});
|
||||
}, 10);
|
||||
}
|
||||
return {
|
||||
update: () => {
|
||||
if (!languagesToImport.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.all([...languagesToImport].map(loadLanguage)).then(
|
||||
(language) => {
|
||||
if (language && languagesToImport.size) {
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta("codeHighlighting", {
|
||||
langLoaded: language,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
return {};
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getRefractorLangForLanguage, getLabelForLanguage } from "./code";
|
||||
import { getPrismLangForLanguage, getLabelForLanguage } from "./code";
|
||||
|
||||
describe("getRefractorLangForLanguage", () => {
|
||||
it("should return the correct lang identifier for a given language", () => {
|
||||
expect(getRefractorLangForLanguage("javascript")).toBe("javascript");
|
||||
expect(getRefractorLangForLanguage("mermaidjs")).toBe("mermaid");
|
||||
expect(getRefractorLangForLanguage("xml")).toBe("markup");
|
||||
expect(getRefractorLangForLanguage("unknown")).toBeUndefined();
|
||||
expect(getRefractorLangForLanguage("")).toBeUndefined();
|
||||
describe("getPrismLangForLanguage", () => {
|
||||
it("should return the correct Prism language identifier for a given language", () => {
|
||||
expect(getPrismLangForLanguage("javascript")).toBe("javascript");
|
||||
expect(getPrismLangForLanguage("mermaidjs")).toBe("mermaid");
|
||||
expect(getPrismLangForLanguage("xml")).toBe("markup");
|
||||
expect(getPrismLangForLanguage("unknown")).toBeUndefined();
|
||||
expect(getPrismLangForLanguage("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+62
-317
@@ -1,313 +1,69 @@
|
||||
import type { RefractorSyntax } from "refractor";
|
||||
import Storage from "../../utils/Storage";
|
||||
|
||||
const RecentlyUsedStorageKey = "rme-code-language";
|
||||
const RecentStorageKey = "rme-code-language";
|
||||
const StorageKey = "frequent-code-languages";
|
||||
const frequentLanguagesToGet = 5;
|
||||
const frequentLanguagesToTrack = 10;
|
||||
|
||||
type CodeLanguage = {
|
||||
lang: string;
|
||||
label: string;
|
||||
loader?: () => Promise<RefractorSyntax>;
|
||||
};
|
||||
|
||||
/**
|
||||
* List of supported code languages.
|
||||
*
|
||||
* Object key is the language identifier used in the editor, lang is the
|
||||
* language identifier used by Refractor. Note mismatches such as `markup` and
|
||||
* language identifier used by Prism. Note mismatches such as `markup` and
|
||||
* `mermaid`.
|
||||
*/
|
||||
export const codeLanguages: Record<string, CodeLanguage> = {
|
||||
export const codeLanguages = {
|
||||
none: { lang: "", label: "Plain text" },
|
||||
bash: {
|
||||
lang: "bash",
|
||||
label: "Bash",
|
||||
loader: () => import("refractor/lang/bash").then((m) => m.default),
|
||||
},
|
||||
clike: {
|
||||
lang: "clike",
|
||||
label: "C",
|
||||
loader: () => import("refractor/lang/clike").then((m) => m.default),
|
||||
},
|
||||
cpp: {
|
||||
lang: "cpp",
|
||||
label: "C++",
|
||||
loader: () => import("refractor/lang/cpp").then((m) => m.default),
|
||||
},
|
||||
csharp: {
|
||||
lang: "csharp",
|
||||
label: "C#",
|
||||
loader: () => import("refractor/lang/csharp").then((m) => m.default),
|
||||
},
|
||||
css: {
|
||||
lang: "css",
|
||||
label: "CSS",
|
||||
loader: () => import("refractor/lang/css").then((m) => m.default),
|
||||
},
|
||||
csv: {
|
||||
lang: "csv",
|
||||
label: "CSV",
|
||||
loader: () => import("refractor/lang/csv").then((m) => m.default),
|
||||
},
|
||||
docker: {
|
||||
lang: "docker",
|
||||
label: "Docker",
|
||||
loader: () => import("refractor/lang/docker").then((m) => m.default),
|
||||
},
|
||||
elixir: {
|
||||
lang: "elixir",
|
||||
label: "Elixir",
|
||||
loader: () => import("refractor/lang/elixir").then((m) => m.default),
|
||||
},
|
||||
erb: {
|
||||
lang: "erb",
|
||||
label: "ERB",
|
||||
loader: () => import("refractor/lang/erb").then((m) => m.default),
|
||||
},
|
||||
erlang: {
|
||||
lang: "erlang",
|
||||
label: "Erlang",
|
||||
loader: () => import("refractor/lang/erlang").then((m) => m.default),
|
||||
},
|
||||
go: {
|
||||
lang: "go",
|
||||
label: "Go",
|
||||
loader: () => import("refractor/lang/go").then((m) => m.default),
|
||||
},
|
||||
graphql: {
|
||||
lang: "graphql",
|
||||
label: "GraphQL",
|
||||
loader: () => import("refractor/lang/graphql").then((m) => m.default),
|
||||
},
|
||||
groovy: {
|
||||
lang: "groovy",
|
||||
label: "Groovy",
|
||||
loader: () => import("refractor/lang/groovy").then((m) => m.default),
|
||||
},
|
||||
haskell: {
|
||||
lang: "haskell",
|
||||
label: "Haskell",
|
||||
loader: () => import("refractor/lang/haskell").then((m) => m.default),
|
||||
},
|
||||
hcl: {
|
||||
lang: "hcl",
|
||||
label: "HCL",
|
||||
loader: () => import("refractor/lang/hcl").then((m) => m.default),
|
||||
},
|
||||
markup: {
|
||||
lang: "markup",
|
||||
label: "HTML",
|
||||
loader: () => import("refractor/lang/markup").then((m) => m.default),
|
||||
},
|
||||
ini: {
|
||||
lang: "ini",
|
||||
label: "INI",
|
||||
loader: () => import("refractor/lang/ini").then((m) => m.default),
|
||||
},
|
||||
java: {
|
||||
lang: "java",
|
||||
label: "Java",
|
||||
loader: () => import("refractor/lang/java").then((m) => m.default),
|
||||
},
|
||||
javascript: {
|
||||
lang: "javascript",
|
||||
label: "JavaScript",
|
||||
loader: () => import("refractor/lang/javascript").then((m) => m.default),
|
||||
},
|
||||
json: {
|
||||
lang: "json",
|
||||
label: "JSON",
|
||||
loader: () => import("refractor/lang/json").then((m) => m.default),
|
||||
},
|
||||
jsx: {
|
||||
lang: "jsx",
|
||||
label: "JSX",
|
||||
loader: () => import("refractor/lang/jsx").then((m) => m.default),
|
||||
},
|
||||
kotlin: {
|
||||
lang: "kotlin",
|
||||
label: "Kotlin",
|
||||
loader: () => import("refractor/lang/kotlin").then((m) => m.default),
|
||||
},
|
||||
kusto: {
|
||||
lang: "kusto",
|
||||
label: "Kusto",
|
||||
// @ts-expect-error Mermaid is not in types but exists
|
||||
loader: () => import("refractor/lang/kusto").then((m) => m.default),
|
||||
},
|
||||
lisp: {
|
||||
lang: "lisp",
|
||||
label: "Lisp",
|
||||
loader: () => import("refractor/lang/lisp").then((m) => m.default),
|
||||
},
|
||||
lua: {
|
||||
lang: "lua",
|
||||
label: "Lua",
|
||||
loader: () => import("refractor/lang/lua").then((m) => m.default),
|
||||
},
|
||||
makefile: {
|
||||
lang: "makefile",
|
||||
label: "Makefile",
|
||||
loader: () => import("refractor/lang/makefile").then((m) => m.default),
|
||||
},
|
||||
markdown: {
|
||||
lang: "markdown",
|
||||
label: "Markdown",
|
||||
loader: () => import("refractor/lang/markdown").then((m) => m.default),
|
||||
},
|
||||
mermaidjs: {
|
||||
lang: "mermaid",
|
||||
label: "Mermaid Diagram",
|
||||
// @ts-expect-error Mermaid is not in types but exists
|
||||
loader: () => import("refractor/lang/mermaid").then((m) => m.default),
|
||||
},
|
||||
nginx: {
|
||||
lang: "nginx",
|
||||
label: "Nginx",
|
||||
loader: () => import("refractor/lang/nginx").then((m) => m.default),
|
||||
},
|
||||
nix: {
|
||||
lang: "nix",
|
||||
label: "Nix",
|
||||
loader: () => import("refractor/lang/nix").then((m) => m.default),
|
||||
},
|
||||
objectivec: {
|
||||
lang: "objectivec",
|
||||
label: "Objective-C",
|
||||
loader: () => import("refractor/lang/objectivec").then((m) => m.default),
|
||||
},
|
||||
ocaml: {
|
||||
lang: "ocaml",
|
||||
label: "OCaml",
|
||||
loader: () => import("refractor/lang/ocaml").then((m) => m.default),
|
||||
},
|
||||
perl: {
|
||||
lang: "perl",
|
||||
label: "Perl",
|
||||
loader: () => import("refractor/lang/perl").then((m) => m.default),
|
||||
},
|
||||
php: {
|
||||
lang: "php",
|
||||
label: "PHP",
|
||||
loader: () => import("refractor/lang/php").then((m) => m.default),
|
||||
},
|
||||
powershell: {
|
||||
lang: "powershell",
|
||||
label: "Powershell",
|
||||
loader: () => import("refractor/lang/powershell").then((m) => m.default),
|
||||
},
|
||||
protobuf: {
|
||||
lang: "protobuf",
|
||||
label: "Protobuf",
|
||||
loader: () => import("refractor/lang/protobuf").then((m) => m.default),
|
||||
},
|
||||
python: {
|
||||
lang: "python",
|
||||
label: "Python",
|
||||
loader: () => import("refractor/lang/python").then((m) => m.default),
|
||||
},
|
||||
r: {
|
||||
lang: "r",
|
||||
label: "R",
|
||||
loader: () => import("refractor/lang/r").then((m) => m.default),
|
||||
},
|
||||
regex: {
|
||||
lang: "regex",
|
||||
label: "Regex",
|
||||
loader: () => import("refractor/lang/regex").then((m) => m.default),
|
||||
},
|
||||
ruby: {
|
||||
lang: "ruby",
|
||||
label: "Ruby",
|
||||
loader: () => import("refractor/lang/ruby").then((m) => m.default),
|
||||
},
|
||||
rust: {
|
||||
lang: "rust",
|
||||
label: "Rust",
|
||||
loader: () => import("refractor/lang/rust").then((m) => m.default),
|
||||
},
|
||||
scala: {
|
||||
lang: "scala",
|
||||
label: "Scala",
|
||||
loader: () => import("refractor/lang/scala").then((m) => m.default),
|
||||
},
|
||||
sass: {
|
||||
lang: "sass",
|
||||
label: "Sass",
|
||||
loader: () => import("refractor/lang/sass").then((m) => m.default),
|
||||
},
|
||||
scss: {
|
||||
lang: "scss",
|
||||
label: "SCSS",
|
||||
loader: () => import("refractor/lang/scss").then((m) => m.default),
|
||||
},
|
||||
"splunk-spl": {
|
||||
lang: "splunk-spl",
|
||||
label: "Splunk SPL",
|
||||
loader: () => import("refractor/lang/splunk-spl").then((m) => m.default),
|
||||
},
|
||||
sql: {
|
||||
lang: "sql",
|
||||
label: "SQL",
|
||||
loader: () => import("refractor/lang/sql").then((m) => m.default),
|
||||
},
|
||||
solidity: {
|
||||
lang: "solidity",
|
||||
label: "Solidity",
|
||||
loader: () => import("refractor/lang/solidity").then((m) => m.default),
|
||||
},
|
||||
swift: {
|
||||
lang: "swift",
|
||||
label: "Swift",
|
||||
loader: () => import("refractor/lang/swift").then((m) => m.default),
|
||||
},
|
||||
toml: {
|
||||
lang: "toml",
|
||||
label: "TOML",
|
||||
loader: () => import("refractor/lang/toml").then((m) => m.default),
|
||||
},
|
||||
tsx: {
|
||||
lang: "tsx",
|
||||
label: "TSX",
|
||||
loader: () => import("refractor/lang/tsx").then((m) => m.default),
|
||||
},
|
||||
typescript: {
|
||||
lang: "typescript",
|
||||
label: "TypeScript",
|
||||
loader: () => import("refractor/lang/typescript").then((m) => m.default),
|
||||
},
|
||||
vb: {
|
||||
lang: "vbnet",
|
||||
label: "Visual Basic",
|
||||
loader: () => import("refractor/lang/vbnet").then((m) => m.default),
|
||||
},
|
||||
verilog: {
|
||||
lang: "verilog",
|
||||
label: "Verilog",
|
||||
loader: () => import("refractor/lang/verilog").then((m) => m.default),
|
||||
},
|
||||
vhdl: {
|
||||
lang: "vhdl",
|
||||
label: "VHDL",
|
||||
loader: () => import("refractor/lang/vhdl").then((m) => m.default),
|
||||
},
|
||||
yaml: {
|
||||
lang: "yaml",
|
||||
label: "YAML",
|
||||
loader: () => import("refractor/lang/yaml").then((m) => m.default),
|
||||
},
|
||||
xml: {
|
||||
lang: "markup",
|
||||
label: "XML",
|
||||
loader: () => import("refractor/lang/markup").then((m) => m.default),
|
||||
},
|
||||
zig: {
|
||||
lang: "zig",
|
||||
label: "Zig",
|
||||
loader: () => import("refractor/lang/zig").then((m) => m.default),
|
||||
},
|
||||
bash: { lang: "bash", label: "Bash" },
|
||||
clike: { lang: "clike", label: "C" },
|
||||
cpp: { lang: "cpp", label: "C++" },
|
||||
csharp: { lang: "csharp", label: "C#" },
|
||||
css: { lang: "css", label: "CSS" },
|
||||
docker: { lang: "docker", label: "Docker" },
|
||||
elixir: { lang: "elixir", label: "Elixir" },
|
||||
erlang: { lang: "erlang", label: "Erlang" },
|
||||
go: { lang: "go", label: "Go" },
|
||||
graphql: { lang: "graphql", label: "GraphQL" },
|
||||
groovy: { lang: "groovy", label: "Groovy" },
|
||||
haskell: { lang: "haskell", label: "Haskell" },
|
||||
hcl: { lang: "hcl", label: "HCL" },
|
||||
markup: { lang: "markup", label: "HTML" },
|
||||
ini: { lang: "ini", label: "INI" },
|
||||
java: { lang: "java", label: "Java" },
|
||||
javascript: { lang: "javascript", label: "JavaScript" },
|
||||
json: { lang: "json", label: "JSON" },
|
||||
jsx: { lang: "jsx", label: "JSX" },
|
||||
kotlin: { lang: "kotlin", label: "Kotlin" },
|
||||
lisp: { lang: "lisp", label: "Lisp" },
|
||||
lua: { lang: "lua", label: "Lua" },
|
||||
mermaidjs: { lang: "mermaid", label: "Mermaid Diagram" },
|
||||
nginx: { lang: "nginx", label: "Nginx" },
|
||||
nix: { lang: "nix", label: "Nix" },
|
||||
objectivec: { lang: "objectivec", label: "Objective-C" },
|
||||
ocaml: { lang: "ocaml", label: "OCaml" },
|
||||
perl: { lang: "perl", label: "Perl" },
|
||||
php: { lang: "php", label: "PHP" },
|
||||
powershell: { lang: "powershell", label: "Powershell" },
|
||||
protobuf: { lang: "protobuf", label: "Protobuf" },
|
||||
python: { lang: "python", label: "Python" },
|
||||
r: { lang: "r", label: "R" },
|
||||
ruby: { lang: "ruby", label: "Ruby" },
|
||||
rust: { lang: "rust", label: "Rust" },
|
||||
scala: { lang: "scala", label: "Scala" },
|
||||
sass: { lang: "sass", label: "Sass" },
|
||||
scss: { lang: "scss", label: "SCSS" },
|
||||
sql: { lang: "sql", label: "SQL" },
|
||||
solidity: { lang: "solidity", label: "Solidity" },
|
||||
swift: { lang: "swift", label: "Swift" },
|
||||
toml: { lang: "toml", label: "TOML" },
|
||||
tsx: { lang: "tsx", label: "TSX" },
|
||||
typescript: { lang: "typescript", label: "TypeScript" },
|
||||
vb: { lang: "vb", label: "Visual Basic" },
|
||||
verilog: { lang: "verilog", label: "Verilog" },
|
||||
vhdl: { lang: "vhdl", label: "VHDL" },
|
||||
yaml: { lang: "yaml", label: "YAML" },
|
||||
xml: { lang: "markup", label: "XML" },
|
||||
zig: { lang: "zig", label: "Zig" },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -323,38 +79,27 @@ export const getLabelForLanguage = (language: string) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the Refractor language identifier for a given language.
|
||||
* Get the Prism language identifier for a given language.
|
||||
*
|
||||
* @param language The language identifier.
|
||||
* @returns The Refractor language identifier for the language.
|
||||
* @returns The Prism language identifier for the language.
|
||||
*/
|
||||
export const getRefractorLangForLanguage = (
|
||||
language: string
|
||||
): string | undefined =>
|
||||
export const getPrismLangForLanguage = (language: string): string | undefined =>
|
||||
codeLanguages[language as keyof typeof codeLanguages]?.lang;
|
||||
|
||||
/**
|
||||
* Get the loader function for a given language.
|
||||
*
|
||||
* @param language The language identifier.
|
||||
* @returns The loader function for the language, or undefined if not available.
|
||||
*/
|
||||
export const getLoaderForLanguage = (language: string) =>
|
||||
codeLanguages[language as keyof typeof codeLanguages]?.loader;
|
||||
|
||||
/**
|
||||
* Set the most recent code language used.
|
||||
*
|
||||
* @param language The language identifier.
|
||||
*/
|
||||
export const setRecentlyUsedCodeLanguage = (language: string) => {
|
||||
export const setRecentCodeLanguage = (language: string) => {
|
||||
const frequentLangs = (Storage.get(StorageKey) ?? {}) as Record<
|
||||
string,
|
||||
number
|
||||
>;
|
||||
|
||||
if (Object.keys(frequentLangs).length === 0) {
|
||||
const lastUsedLang = Storage.get(RecentlyUsedStorageKey);
|
||||
const lastUsedLang = Storage.get(RecentStorageKey);
|
||||
if (lastUsedLang) {
|
||||
frequentLangs[lastUsedLang] = 1;
|
||||
}
|
||||
@@ -376,7 +121,7 @@ export const setRecentlyUsedCodeLanguage = (language: string) => {
|
||||
}
|
||||
|
||||
Storage.set(StorageKey, Object.fromEntries(frequentLangEntries));
|
||||
Storage.set(RecentlyUsedStorageKey, language);
|
||||
Storage.set(RecentStorageKey, language);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -384,8 +129,8 @@ export const setRecentlyUsedCodeLanguage = (language: string) => {
|
||||
*
|
||||
* @returns The most recent code language used, or undefined if none is set.
|
||||
*/
|
||||
export const getRecentlyUsedCodeLanguage = () =>
|
||||
Storage.get(RecentlyUsedStorageKey) as keyof typeof codeLanguages | undefined;
|
||||
export const getRecentCodeLanguage = () =>
|
||||
Storage.get(RecentStorageKey) as keyof typeof codeLanguages | undefined;
|
||||
|
||||
/**
|
||||
* Get the most frequent code languages used.
|
||||
@@ -393,7 +138,7 @@ export const getRecentlyUsedCodeLanguage = () =>
|
||||
* @returns An array of the most frequent code languages used.
|
||||
*/
|
||||
export const getFrequentCodeLanguages = () => {
|
||||
const recentLang = Storage.get(RecentlyUsedStorageKey);
|
||||
const recentLang = Storage.get(RecentStorageKey);
|
||||
const frequentLangEntries = Object.entries(Storage.get(StorageKey) ?? {}) as [
|
||||
keyof typeof codeLanguages,
|
||||
number
|
||||
|
||||
+52
-87
@@ -47,66 +47,6 @@ export default class Code extends Mark {
|
||||
markType: this.editor.schema.marks.code_inline,
|
||||
})[0];
|
||||
|
||||
/**
|
||||
* Helper function to check if cursor is between backticks
|
||||
* and handle the code marking appropriately
|
||||
*/
|
||||
const handleTextBetweenBackticks = (
|
||||
view: EditorView,
|
||||
from: number,
|
||||
to: number,
|
||||
text: string | Slice
|
||||
) => {
|
||||
const { state } = view;
|
||||
|
||||
// Prevent access out of document bounds
|
||||
if (from === 0 || to === state.doc.nodeSize - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if we're adding a backtick character
|
||||
if (typeof text === "string" && text === "`") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we're between backticks
|
||||
if (
|
||||
state.doc.textBetween(from - 1, from) === "`" &&
|
||||
state.doc.textBetween(to, to + 1) === "`"
|
||||
) {
|
||||
const start = from - 1;
|
||||
const end = to + 1;
|
||||
|
||||
if (typeof text === "string") {
|
||||
// Handle text input
|
||||
view.dispatch(
|
||||
state.tr
|
||||
.delete(start, end)
|
||||
.insertText(text, start)
|
||||
.addMark(
|
||||
start,
|
||||
start + text.length,
|
||||
state.schema.marks.code_inline.create()
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Handle paste/slice
|
||||
view.dispatch(
|
||||
state.tr
|
||||
.replaceRange(start, end, text)
|
||||
.addMark(
|
||||
start,
|
||||
start + text.size,
|
||||
state.schema.marks.code_inline.create()
|
||||
)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return [
|
||||
codeCursorPlugin,
|
||||
new Plugin({
|
||||
@@ -119,11 +59,34 @@ export default class Code extends Mark {
|
||||
to: number,
|
||||
text: string
|
||||
) => {
|
||||
// Skip this handler during IME composition or it will prevent the
|
||||
if (view.composing) {
|
||||
const { state } = view;
|
||||
|
||||
// Prevent access out of document bounds
|
||||
if (from === 0 || to === state.doc.nodeSize - 1 || text === "`") {
|
||||
return false;
|
||||
}
|
||||
return handleTextBetweenBackticks(view, from, to, text);
|
||||
|
||||
if (
|
||||
from === to &&
|
||||
state.doc.textBetween(from - 1, from) === "`" &&
|
||||
state.doc.textBetween(to, to + 1) === "`"
|
||||
) {
|
||||
const start = from - 1;
|
||||
const end = to + 1;
|
||||
view.dispatch(
|
||||
state.tr
|
||||
.delete(start, end)
|
||||
.insertText(text, start)
|
||||
.addMark(
|
||||
start,
|
||||
start + text.length,
|
||||
state.schema.marks.code_inline.create()
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
// Pasting a character inside of two backticks will wrap the character
|
||||
@@ -131,7 +94,32 @@ export default class Code extends Mark {
|
||||
handlePaste: (view: EditorView, _event: Event, slice: Slice) => {
|
||||
const { state } = view;
|
||||
const { from, to } = state.selection;
|
||||
return handleTextBetweenBackticks(view, from, to, slice);
|
||||
|
||||
// Prevent access out of document bounds
|
||||
if (from === 0 || to === state.doc.nodeSize - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const start = from - 1;
|
||||
const end = to + 1;
|
||||
if (
|
||||
from === to &&
|
||||
state.doc.textBetween(start, from) === "`" &&
|
||||
state.doc.textBetween(to, end) === "`"
|
||||
) {
|
||||
view.dispatch(
|
||||
state.tr
|
||||
.replaceRange(start, end, slice)
|
||||
.addMark(
|
||||
start,
|
||||
start + slice.size,
|
||||
state.schema.marks.code_inline.create()
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
// Triple clicking inside of an inline code mark will select the entire
|
||||
@@ -155,29 +143,6 @@ export default class Code extends Mark {
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
// Handle composition end events for IME input
|
||||
handleDOMEvents: {
|
||||
compositionend: (view: EditorView) => {
|
||||
setTimeout(() => {
|
||||
const { $cursor } = view.state.selection as TextSelection;
|
||||
if (!$cursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = $cursor.pos - 1;
|
||||
const to = $cursor.pos;
|
||||
|
||||
// Process the composed text after IME composition completes
|
||||
handleTextBetweenBackticks(
|
||||
view,
|
||||
from,
|
||||
to,
|
||||
view.state.doc.textBetween(from, to)
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -9,6 +9,58 @@ import {
|
||||
} from "prosemirror-model";
|
||||
import { Command, Plugin, PluginKey, TextSelection } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import refractor from "refractor/core";
|
||||
import bash from "refractor/lang/bash";
|
||||
import clike from "refractor/lang/clike";
|
||||
import cpp from "refractor/lang/cpp";
|
||||
import csharp from "refractor/lang/csharp";
|
||||
import css from "refractor/lang/css";
|
||||
import docker from "refractor/lang/docker";
|
||||
import elixir from "refractor/lang/elixir";
|
||||
import erlang from "refractor/lang/erlang";
|
||||
import go from "refractor/lang/go";
|
||||
import graphql from "refractor/lang/graphql";
|
||||
import groovy from "refractor/lang/groovy";
|
||||
import haskell from "refractor/lang/haskell";
|
||||
import hcl from "refractor/lang/hcl";
|
||||
import ini from "refractor/lang/ini";
|
||||
import java from "refractor/lang/java";
|
||||
import javascript from "refractor/lang/javascript";
|
||||
import json from "refractor/lang/json";
|
||||
import jsx from "refractor/lang/jsx";
|
||||
import kotlin from "refractor/lang/kotlin";
|
||||
import lisp from "refractor/lang/lisp";
|
||||
import lua from "refractor/lang/lua";
|
||||
import markup from "refractor/lang/markup";
|
||||
// @ts-expect-error type definition is missing, but package exists
|
||||
import mermaid from "refractor/lang/mermaid";
|
||||
import nginx from "refractor/lang/nginx";
|
||||
import nix from "refractor/lang/nix";
|
||||
import objectivec from "refractor/lang/objectivec";
|
||||
import ocaml from "refractor/lang/ocaml";
|
||||
import perl from "refractor/lang/perl";
|
||||
import php from "refractor/lang/php";
|
||||
import powershell from "refractor/lang/powershell";
|
||||
import protobuf from "refractor/lang/protobuf";
|
||||
import python from "refractor/lang/python";
|
||||
import r from "refractor/lang/r";
|
||||
import ruby from "refractor/lang/ruby";
|
||||
import rust from "refractor/lang/rust";
|
||||
import sass from "refractor/lang/sass";
|
||||
import scala from "refractor/lang/scala";
|
||||
import scss from "refractor/lang/scss";
|
||||
import solidity from "refractor/lang/solidity";
|
||||
import sql from "refractor/lang/sql";
|
||||
import swift from "refractor/lang/swift";
|
||||
import toml from "refractor/lang/toml";
|
||||
import tsx from "refractor/lang/tsx";
|
||||
import typescript from "refractor/lang/typescript";
|
||||
import verilog from "refractor/lang/verilog";
|
||||
import vhdl from "refractor/lang/vhdl";
|
||||
import visualbasic from "refractor/lang/visual-basic";
|
||||
import yaml from "refractor/lang/yaml";
|
||||
import zig from "refractor/lang/zig";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Primitive } from "utility-types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
@@ -25,12 +77,9 @@ import {
|
||||
} from "../commands/codeFence";
|
||||
import { selectAll } from "../commands/selectAll";
|
||||
import toggleBlockType from "../commands/toggleBlockType";
|
||||
import { CodeHighlighting } from "../extensions/CodeHighlighting";
|
||||
import Mermaid from "../extensions/Mermaid";
|
||||
import {
|
||||
getRecentlyUsedCodeLanguage,
|
||||
setRecentlyUsedCodeLanguage,
|
||||
} from "../lib/code";
|
||||
import Prism from "../extensions/Prism";
|
||||
import { getRecentCodeLanguage, setRecentCodeLanguage } from "../lib/code";
|
||||
import { isCode } from "../lib/isCode";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
|
||||
@@ -41,6 +90,58 @@ import Node from "./Node";
|
||||
|
||||
const DEFAULT_LANGUAGE = "javascript";
|
||||
|
||||
[
|
||||
bash,
|
||||
cpp,
|
||||
css,
|
||||
clike,
|
||||
csharp,
|
||||
docker,
|
||||
elixir,
|
||||
erlang,
|
||||
go,
|
||||
graphql,
|
||||
groovy,
|
||||
haskell,
|
||||
hcl,
|
||||
ini,
|
||||
java,
|
||||
javascript,
|
||||
jsx,
|
||||
json,
|
||||
kotlin,
|
||||
lisp,
|
||||
lua,
|
||||
markup,
|
||||
mermaid,
|
||||
nginx,
|
||||
nix,
|
||||
objectivec,
|
||||
ocaml,
|
||||
perl,
|
||||
php,
|
||||
python,
|
||||
powershell,
|
||||
protobuf,
|
||||
r,
|
||||
ruby,
|
||||
rust,
|
||||
scala,
|
||||
sql,
|
||||
solidity,
|
||||
sass,
|
||||
scss,
|
||||
swift,
|
||||
toml,
|
||||
typescript,
|
||||
tsx,
|
||||
verilog,
|
||||
vhdl,
|
||||
visualbasic,
|
||||
yaml,
|
||||
zig,
|
||||
].forEach(refractor.register);
|
||||
|
||||
export default class CodeFence extends Node {
|
||||
constructor(options: {
|
||||
dictionary: Dictionary;
|
||||
@@ -111,10 +212,10 @@ export default class CodeFence extends Node {
|
||||
return {
|
||||
code_block: (attrs: Record<string, Primitive>) => {
|
||||
if (attrs?.language) {
|
||||
setRecentlyUsedCodeLanguage(attrs.language as string);
|
||||
setRecentCodeLanguage(attrs.language as string);
|
||||
}
|
||||
return toggleBlockType(type, schema.nodes.paragraph, {
|
||||
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
|
||||
language: getRecentCodeLanguage() ?? DEFAULT_LANGUAGE,
|
||||
...attrs,
|
||||
});
|
||||
},
|
||||
@@ -185,7 +286,7 @@ export default class CodeFence extends Node {
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
CodeHighlighting({
|
||||
Prism({
|
||||
name: this.name,
|
||||
lineNumbers: this.showLineNumbers,
|
||||
}),
|
||||
@@ -252,7 +353,7 @@ export default class CodeFence extends Node {
|
||||
inputRules({ type }: { type: NodeType }) {
|
||||
return [
|
||||
textblockTypeInputRule(/^```$/, type, () => ({
|
||||
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
|
||||
language: getRecentCodeLanguage() ?? DEFAULT_LANGUAGE,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -408,14 +408,10 @@ export default class Image extends SimpleImage {
|
||||
if (!(state.selection instanceof NodeSelection)) {
|
||||
return false;
|
||||
}
|
||||
let layoutClass: string | null = "full-width";
|
||||
if (state.selection.node.attrs.layoutClass === layoutClass) {
|
||||
layoutClass = null;
|
||||
}
|
||||
const attrs = {
|
||||
...state.selection.node.attrs,
|
||||
title: null,
|
||||
layoutClass,
|
||||
layoutClass: "full-width",
|
||||
};
|
||||
const { selection } = state;
|
||||
dispatch?.(state.tr.setNodeMarkup(selection.from, undefined, attrs));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import isMatch from "lodash/isMatch";
|
||||
import { Token } from "markdown-it";
|
||||
import {
|
||||
NodeSpec,
|
||||
@@ -16,12 +15,10 @@ import * as React from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import env from "../../env";
|
||||
import { MentionType, UnfurlResourceType, UnfurlResponse } from "../../types";
|
||||
import { MentionType } from "../../types";
|
||||
import {
|
||||
MentionCollection,
|
||||
MentionDocument,
|
||||
MentionIssue,
|
||||
MentionPullRequest,
|
||||
MentionUser,
|
||||
} from "../components/Mentions";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
@@ -53,12 +50,6 @@ export default class Mention extends Node {
|
||||
id: {
|
||||
default: undefined,
|
||||
},
|
||||
href: {
|
||||
default: undefined,
|
||||
},
|
||||
unfurl: {
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
inline: true,
|
||||
marks: "",
|
||||
@@ -82,10 +73,6 @@ export default class Mention extends Node {
|
||||
actorId: dom.dataset.actorid,
|
||||
label: dom.innerText,
|
||||
id: dom.id,
|
||||
href: dom.getAttribute("href"),
|
||||
unfurl: dom.dataset.unfurl
|
||||
? JSON.parse(dom.dataset.unfurl)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -100,18 +87,11 @@ export default class Mention extends Node {
|
||||
? undefined
|
||||
: node.attrs.type === MentionType.Document
|
||||
? `${env.URL}/doc/${node.attrs.modelId}`
|
||||
: node.attrs.type === MentionType.Collection
|
||||
? `${env.URL}/collection/${node.attrs.modelId}`
|
||||
: node.attrs.href,
|
||||
: `${env.URL}/collection/${node.attrs.modelId}`,
|
||||
"data-type": node.attrs.type,
|
||||
"data-id": node.attrs.modelId,
|
||||
"data-actorid": node.attrs.actorId,
|
||||
"data-url":
|
||||
node.attrs.type === MentionType.PullRequest ||
|
||||
node.attrs.type === MentionType.Issue
|
||||
? node.attrs.href
|
||||
: `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
|
||||
"data-unfurl": JSON.stringify(node.attrs.unfurl),
|
||||
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
|
||||
},
|
||||
toPlainText(node),
|
||||
],
|
||||
@@ -127,20 +107,6 @@ export default class Mention extends Node {
|
||||
return <MentionDocument {...props} />;
|
||||
case MentionType.Collection:
|
||||
return <MentionCollection {...props} />;
|
||||
case MentionType.Issue:
|
||||
return (
|
||||
<MentionIssue
|
||||
{...props}
|
||||
onChangeUnfurl={this.handleChangeUnfurl(props)}
|
||||
/>
|
||||
);
|
||||
case MentionType.PullRequest:
|
||||
return (
|
||||
<MentionPullRequest
|
||||
{...props}
|
||||
onChangeUnfurl={this.handleChangeUnfurl(props)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -183,42 +149,29 @@ export default class Mention extends Node {
|
||||
}
|
||||
|
||||
keys(): Record<string, Command> {
|
||||
const NavigableMention = [
|
||||
MentionType.Collection,
|
||||
MentionType.Document,
|
||||
MentionType.Issue,
|
||||
MentionType.PullRequest,
|
||||
];
|
||||
|
||||
return {
|
||||
Enter: (state) => {
|
||||
const { selection } = state;
|
||||
if (
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === this.name &&
|
||||
NavigableMention.includes(selection.node.attrs.type)
|
||||
(selection.node.attrs.type === MentionType.Document ||
|
||||
selection.node.attrs.type === MentionType.Collection)
|
||||
) {
|
||||
const mentionType = selection.node.attrs.type;
|
||||
const { modelId } = selection.node.attrs;
|
||||
|
||||
let link: string;
|
||||
const linkType =
|
||||
selection.node.attrs.type === MentionType.Document
|
||||
? "doc"
|
||||
: selection.node.attrs.type === MentionType.Collection
|
||||
? "collection"
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
mentionType === MentionType.Issue ||
|
||||
mentionType === MentionType.PullRequest
|
||||
) {
|
||||
link = selection.node.attrs.href;
|
||||
} else {
|
||||
const { modelId } = selection.node.attrs;
|
||||
|
||||
const linkType =
|
||||
selection.node.attrs.type === MentionType.Document
|
||||
? "doc"
|
||||
: "collection";
|
||||
|
||||
link = `/${linkType}/${modelId}`;
|
||||
if (!linkType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.editor.props.onClickLink?.(link);
|
||||
this.editor.props.onClickLink?.(`/${linkType}/${modelId}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -265,30 +218,4 @@ export default class Mention extends Node {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
handleChangeUnfurl =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(unfurl: UnfurlResponse[keyof UnfurlResponse]) => {
|
||||
const { view } = this.editor;
|
||||
const { tr } = view.state;
|
||||
|
||||
const label =
|
||||
unfurl.type === UnfurlResourceType.Issue ||
|
||||
unfurl.type === UnfurlResourceType.PR
|
||||
? unfurl.title
|
||||
: undefined;
|
||||
|
||||
const overrides: Record<string, unknown> = label ? { label } : {};
|
||||
overrides.unfurl = unfurl;
|
||||
|
||||
const pos = getPos();
|
||||
|
||||
if (!isMatch(node.attrs, overrides)) {
|
||||
const transaction = tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
...overrides,
|
||||
});
|
||||
view.dispatch(transaction);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NodeType } from "prosemirror-model";
|
||||
import { EditorState, NodeSelection } from "prosemirror-state";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { Primitive } from "utility-types";
|
||||
import { findParentNode } from "./findParentNode";
|
||||
|
||||
@@ -25,24 +25,12 @@ export const isNodeActive =
|
||||
return false;
|
||||
}
|
||||
|
||||
let nodeWithPos;
|
||||
const { from, to } = state.selection;
|
||||
|
||||
if (
|
||||
state.selection instanceof NodeSelection &&
|
||||
state.selection.node.type === type &&
|
||||
state.selection.node.hasMarkup(type, {
|
||||
...state.selection.node.attrs,
|
||||
...attrs,
|
||||
})
|
||||
) {
|
||||
nodeWithPos = { pos: from, node: state.selection.node };
|
||||
}
|
||||
|
||||
nodeWithPos ??= findParentNode(
|
||||
const nodeWithPos = findParentNode(
|
||||
(node) =>
|
||||
node.type === type &&
|
||||
(!attrs || node.hasMarkup(type, { ...node.attrs, ...attrs }))
|
||||
(!attrs ||
|
||||
Object.keys(attrs).every((key) => node.attrs[key] === attrs[key]))
|
||||
)(state.selection);
|
||||
|
||||
if (!nodeWithPos) {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Hook to check if component is still mounted
|
||||
*
|
||||
* @returns {boolean} true if the component is mounted, false otherwise
|
||||
*/
|
||||
export default function useIsMounted() {
|
||||
const isMounted = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return React.useCallback(() => isMounted.current, []);
|
||||
}
|
||||
@@ -11,17 +11,13 @@
|
||||
"Search in collection": "Hledat ve sbírce",
|
||||
"Star": "Přidat mezi oblíbené",
|
||||
"Unstar": "Odstranit z oblíbených",
|
||||
"Subscribe": "Přihlásit k odběru",
|
||||
"Subscribed to document notifications": "Přihlášen k odběru upozornění ohledně dokumentů",
|
||||
"Unsubscribe": "Odhlásit z odběru upozornění",
|
||||
"Unsubscribed from document notifications": "Ohlášen odběr upozornění ohledně dokumentů",
|
||||
"Archive": "Archiv",
|
||||
"Archive collection": "Archivovat sbírku",
|
||||
"Collection archived": "Sbírka archivována",
|
||||
"Archive collection": "Archive collection",
|
||||
"Collection archived": "Collection archived",
|
||||
"Archiving": "Probíhá archivace",
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archivací této sbírky dojde také k archivaci všech dokumentů v ní. Dokumenty z kolekce již nebudou viditelné ve výsledcích vyhledávání.",
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.",
|
||||
"Restore": "Obnovit",
|
||||
"Collection restored": "Sbírka obnovena",
|
||||
"Collection restored": "Collection restored",
|
||||
"Delete": "Odstranit",
|
||||
"Delete collection": "Odstranit sbírku",
|
||||
"New template": "Nová šablona",
|
||||
@@ -29,8 +25,8 @@
|
||||
"Mark as resolved": "Označit jako vyřešené",
|
||||
"Thread resolved": "Vlákno vyřešeno",
|
||||
"Mark as unresolved": "Označit jako nevyřešené",
|
||||
"View reactions": "Prohlédnout reakce",
|
||||
"Reactions": "Reakce",
|
||||
"View reactions": "View reactions",
|
||||
"Reactions": "Reactions",
|
||||
"Copy ID": "Kopírovat ID",
|
||||
"Clear IndexedDB cache": "Smazat mezipaměť IndexedDB",
|
||||
"IndexedDB cache cleared": "Mezipaměť indexedDB vymazána",
|
||||
@@ -40,7 +36,7 @@
|
||||
"Development": "Vývoj",
|
||||
"Open document": "Otevřít dokument",
|
||||
"New document": "Nový dokument",
|
||||
"New draft": "Nový koncept",
|
||||
"New draft": "New draft",
|
||||
"New from template": "Nový ze šablony",
|
||||
"New nested document": "Nový vnořený dokument",
|
||||
"Publish": "Zveřejnit",
|
||||
@@ -48,6 +44,10 @@
|
||||
"Publish document": "Zveřejnit dokument",
|
||||
"Unpublish": "Zrušit zveřejnění",
|
||||
"Unpublished {{ documentName }}": "Nepublikováno {{ documentName }}",
|
||||
"Subscribe": "Přihlásit k odběru",
|
||||
"Subscribed to document notifications": "Přihlášen k odběru upozornění ohledně dokumentů",
|
||||
"Unsubscribe": "Odhlásit z odběru upozornění",
|
||||
"Unsubscribed from document notifications": "Ohlášen odběr upozornění ohledně dokumentů",
|
||||
"Share this document": "Sdílet tento dokument",
|
||||
"HTML": "HTML",
|
||||
"PDF": "PDF",
|
||||
@@ -57,8 +57,6 @@
|
||||
"Download document": "Stáhnout dokument",
|
||||
"Copy as Markdown": "Kopírovat jako Markdown",
|
||||
"Markdown copied to clipboard": "Markdown zkopírován do schránky",
|
||||
"Copy as text": "Copy as text",
|
||||
"Text copied to clipboard": "Text copied to clipboard",
|
||||
"Copy public link": "Zkopírovat veřejný odkaz",
|
||||
"Link copied to clipboard": "Odkaz zkopírován do schránky",
|
||||
"Copy link": "Kopírovat odkaz",
|
||||
@@ -84,9 +82,9 @@
|
||||
"Move": "Přesunout",
|
||||
"Move to collection": "Přesunout do sbírky",
|
||||
"Move {{ documentType }}": "Přesunout {{ documentType }}",
|
||||
"Are you sure you want to archive this document?": "Opravdu chcete archivovat tento dokument?",
|
||||
"Are you sure you want to archive this document?": "Are you sure you want to archive this document?",
|
||||
"Document archived": "Dokument archivován",
|
||||
"Archiving this document will remove it from the collection and search results.": "Archivací tento dokument odstraníte z kolekce a výsledků vyhledávání.",
|
||||
"Archiving this document will remove it from the collection and search results.": "Archiving this document will remove it from the collection and search results.",
|
||||
"Delete {{ documentName }}": "Odstranit {{ documentName }}",
|
||||
"Permanently delete": "Trvale odstranit",
|
||||
"Permanently delete {{ documentName }}": "Trvale odstranit {{ documentName }}",
|
||||
@@ -97,12 +95,11 @@
|
||||
"Insights": "Přehledy",
|
||||
"Disable viewer insights": "Vypnout analytika nahlížení",
|
||||
"Enable viewer insights": "Zapnout analytika nahlížení",
|
||||
"Leave document": "Opustit dokument",
|
||||
"You have left the shared document": "Opustil jste sdílený dokument",
|
||||
"Could not leave document": "Nepodařilo se opustit dokument",
|
||||
"Leave document": "Leave document",
|
||||
"You have left the shared document": "You have left the shared document",
|
||||
"Could not leave document": "Could not leave document",
|
||||
"Home": "Domovská stránka",
|
||||
"Drafts": "Koncepty",
|
||||
"Search": "Hledat",
|
||||
"Trash": "Koš",
|
||||
"Settings": "Nastavení",
|
||||
"Profile": "Profil",
|
||||
@@ -140,7 +137,6 @@
|
||||
"Update role": "Aktualizovat roli",
|
||||
"Delete user": "Smazat uživatele",
|
||||
"Collection": "Sbírka",
|
||||
"Collections": "Sbírky",
|
||||
"Debug": "Odstranit vývojářskou chybu",
|
||||
"Document": "Dokument",
|
||||
"Documents": "Dokumenty",
|
||||
@@ -164,7 +160,7 @@
|
||||
"Saving": "Uložení",
|
||||
"Save": "Uložit",
|
||||
"Creating": "Vytváření",
|
||||
"Create": "Vytvořit",
|
||||
"Create": "Vytořit",
|
||||
"Collection deleted": "Kolekce odstraněna",
|
||||
"I’m sure – Delete": "Ano, smazat",
|
||||
"Deleting": "Mazání",
|
||||
@@ -177,14 +173,14 @@
|
||||
"Are you sure you want to permanently delete this entire comment thread?": "Jste si jisti, že chcete natrvalo odstranit vlákno komentářů?",
|
||||
"Are you sure you want to permanently delete this comment?": "Jste si jisti, že chcete natrvalo odstranit komentář?",
|
||||
"Confirm": "Potvrdit",
|
||||
"manage access": "spravovat přístup",
|
||||
"manage access": "manage access",
|
||||
"view and edit access": "přístup k prohlížení a úpravám",
|
||||
"view only access": "přístup pouze pro čtení",
|
||||
"no access": "bez přístupu",
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection": "Nemáte oprávnění přesunout {{ documentName }} do sbírky {{ collectionName }}",
|
||||
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection": "You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
|
||||
"Move document": "Přesunout dokument",
|
||||
"Moving": "Přesouvání",
|
||||
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "Přesunutím dokumentu <em>{{ title }}</em> do sbírky {{ newCollectionName }} se změní oprávnění pro všechny členy pracovního prostoru z <em>{{ prevPermission }}</em> na <em>{{ newPermission }}</em>.",
|
||||
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.",
|
||||
"Document is too large": "Dokument je příliš velký",
|
||||
"This document has reached the maximum size and can no longer be edited": "Tento dokument dosáhl maximální velikosti a nelze jej dále upravovat",
|
||||
"Authentication failed": "Ověření selhalo",
|
||||
@@ -198,17 +194,16 @@
|
||||
"Submenu": "Podmenu",
|
||||
"Collections could not be loaded, please reload the app": "Sbírky se nepodařilo načíst, prosím načtěte aplikaci znovu",
|
||||
"Default collection": "Výchozí sbírka",
|
||||
"Start view": "Domovská obrazovka",
|
||||
"Install now": "Nainstalovat",
|
||||
"Deleted Collection": "Odstraněná sbírka",
|
||||
"Untitled": "Bez názvu",
|
||||
"Unpin": "Zrušit připnutí",
|
||||
"{{ minutes }}m read": "{{ minutes }}m read",
|
||||
"Select a location to copy": "Vyberte místo, kam chcete zkopírovat",
|
||||
"Document copied": "Dokument byl zkopírován",
|
||||
"Couldn’t copy the document, try again?": "Dokument nelze zkopírovat, chcete to zkusit znovu?",
|
||||
"Select a location to copy": "Select a location to copy",
|
||||
"Document copied": "Document copied",
|
||||
"Couldn’t copy the document, try again?": "Couldn’t copy the document, try again?",
|
||||
"Include nested documents": "Zahrnout vnořené dokumenty",
|
||||
"Copy to <em>{{ location }}</em>": "Zkopírovat do <em>{{ location }}</em>",
|
||||
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
|
||||
"Search collections & documents": "Prohledat sbírky a dokumenty",
|
||||
"No results found": "Nebyly nalezeny žádné výsledky",
|
||||
"New": "Nový",
|
||||
@@ -294,6 +289,7 @@
|
||||
"Flags": "Vlajky",
|
||||
"Select a color": "Vybrat barvu",
|
||||
"Loading": "Načítání",
|
||||
"Search": "Hledat",
|
||||
"Permission": "Oprávnění",
|
||||
"View only": "Pouze zobrazit",
|
||||
"Can edit": "Může upravovat",
|
||||
@@ -308,14 +304,14 @@
|
||||
"Unknown": "Neznámý",
|
||||
"Mark all as read": "Označit vše jako přečtené",
|
||||
"You're all caught up": "Již nic nového",
|
||||
"{{ username }} reacted with {{ emoji }}": "{{ username }} reagoval s {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} a {{ secondUsername }} reagovali s {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} a {{ count }} reagovali s {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} a {{ count }} reagovali s {{ emoji }}",
|
||||
"Add reaction": "Přidat reakci",
|
||||
"Reaction picker": "Výběr reakce",
|
||||
"Could not load reactions": "Nepodařilo se načíst reakce",
|
||||
"Reaction": "Reakce",
|
||||
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
|
||||
"Add reaction": "Add reaction",
|
||||
"Reaction picker": "Reaction picker",
|
||||
"Could not load reactions": "Could not load reactions",
|
||||
"Reaction": "Reaction",
|
||||
"Results": "Výsledky",
|
||||
"No results for {{query}}": "Žádné výsledky pro {{query}}",
|
||||
"Manage": "Spravovat",
|
||||
@@ -333,7 +329,7 @@
|
||||
"Add or invite": "Přidat nebo pozvat",
|
||||
"Viewer": "Prohlížející",
|
||||
"Editor": "Editor",
|
||||
"Suggestions for invitation": "Návrhy na pozvánku",
|
||||
"Suggestions for invitation": "Suggestions for invitation",
|
||||
"No matches": "Žádné výsledky",
|
||||
"Can view": "Může prohlížet",
|
||||
"Everyone in the collection": "Všichni v kolekci",
|
||||
@@ -360,18 +356,19 @@
|
||||
"Anyone with the link can access because the parent document, <2>{{documentTitle}}</2>, is shared": "Kdokoli s odkazem má přístup, protože dokument dědí oprávnění po nadřazeném dokumentu <2>{{documentTitle}}</2>",
|
||||
"Allow anyone with the link to access": "Povolit přístup komukoliv s odkazem",
|
||||
"Publish to internet": "Zveřejnit na internetu",
|
||||
"Search engine indexing": "Indexace vyhledávání",
|
||||
"Disable this setting to discourage search engines from indexing the page": "Zakažte toto nastavení, abyste odradili vyhledávače od indexování stránky",
|
||||
"Search engine indexing": "Search engine indexing",
|
||||
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
|
||||
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Vložené dokumenty nejsou sdíleny na webu. Změnit sdílení pro povolení přístupu (toto bude v budoucnu výchozí chování)",
|
||||
"{{ userName }} was added to the document": "{{ userName }} byl přidán do dokumentu",
|
||||
"{{ count }} people added to the document": "{{ count }} lidí bylo přidáno do dokumentu",
|
||||
"{{ count }} people added to the document_plural": "{{ count }} lidí bylo přidáno do dokumentu",
|
||||
"{{ count }} groups added to the document": "{{ count }} skupin bylo přidáno do dokumentu",
|
||||
"{{ count }} groups added to the document_plural": "{{ count }} skupin bylo přidáno do dokumentu",
|
||||
"{{ userName }} was added to the document": "{{ userName }} was added to the document",
|
||||
"{{ count }} people added to the document": "{{ count }} people added to the document",
|
||||
"{{ count }} people added to the document_plural": "{{ count }} people added to the document",
|
||||
"{{ count }} groups added to the document": "{{ count }} groups added to the document",
|
||||
"{{ count }} groups added to the document_plural": "{{ count }} groups added to the document",
|
||||
"Logo": "Logo",
|
||||
"Archived collections": "Archivované sbírky",
|
||||
"Archived collections": "Archived collections",
|
||||
"New doc": "Nový dokument",
|
||||
"Empty": "Prázdné",
|
||||
"Collections": "Sbírky",
|
||||
"Collapse": "Sbalit",
|
||||
"Expand": "Rozbalit",
|
||||
"Document not supported – try Markdown, Plain text, HTML, or Word": "Dokument není podporován – zkuste Markdown, Plain text, HTML nebo Word",
|
||||
@@ -385,7 +382,7 @@
|
||||
"Up to date": "Aktuální",
|
||||
"{{ releasesBehind }} versions behind": "Zastaralá verze {{ releasesBehind }}",
|
||||
"{{ releasesBehind }} versions behind_plural": "Zastaralé verze {{ releasesBehind }}",
|
||||
"Change permissions?": "Změnit práva?",
|
||||
"Change permissions?": "Change permissions?",
|
||||
"{{ documentName }} cannot be moved within {{ parentDocumentName }}": "{{ documentName }} cannot be moved within {{ parentDocumentName }}",
|
||||
"You can't reorder documents in an alphabetically sorted collection": "Nemůžete změnit pořadí dokumentů v abecedně seřazené sbírce",
|
||||
"The {{ documentName }} cannot be moved here": "The {{ documentName }} cannot be moved here",
|
||||
@@ -427,8 +424,7 @@
|
||||
"Profile picture": "Profilový obrázek",
|
||||
"Create a new doc": "Vytvořit nový dokument",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
|
||||
"Keep as link": "Zachovat jako odkaz",
|
||||
"Mention": "Mention",
|
||||
"Keep as link": "Keep as link",
|
||||
"Embed": "Embed",
|
||||
"Add column after": "Přidat sloupec za",
|
||||
"Add column before": "Přidat sloupec před",
|
||||
@@ -462,7 +458,7 @@
|
||||
"Big heading": "Velký nadpis",
|
||||
"Medium heading": "Střední nadpis",
|
||||
"Small heading": "Malý nadpis",
|
||||
"Extra small heading": "Extra malý nadpis",
|
||||
"Extra small heading": "Extra small heading",
|
||||
"Heading": "Záhlaví",
|
||||
"Divider": "Dělící čára",
|
||||
"Image": "Obrázek",
|
||||
@@ -510,7 +506,6 @@
|
||||
"None": "None",
|
||||
"Could not import file": "Soubor nelze importovat",
|
||||
"Unsubscribed from document": "Upozornění vypnuta",
|
||||
"Unsubscribed from collection": "Unsubscribed from collection",
|
||||
"Account": "Účet",
|
||||
"API Keys": "API Keys",
|
||||
"Details": "Podrobnosti",
|
||||
@@ -520,6 +515,7 @@
|
||||
"Groups": "Skupiny",
|
||||
"Shared Links": "Sdílené odkazy",
|
||||
"Import": "Import",
|
||||
"Self Hosted": "Vlastní hostování",
|
||||
"Integrations": "Integrace",
|
||||
"Revoke token": "Odvolat tokeny",
|
||||
"Revoke": "Zrušit",
|
||||
@@ -537,15 +533,12 @@
|
||||
"{{ documentName }} restored": "{{ documentName }} restored",
|
||||
"Document options": "Možnosti dokumentů",
|
||||
"Choose a collection": "Vybrat sbírku",
|
||||
"Subscription inherited from collection": "Subscription inherited from collection",
|
||||
"Enable embeds": "Povolit embed vkládání",
|
||||
"Export options": "Možnosti exportu",
|
||||
"Group members": "Členové skupiny",
|
||||
"Edit group": "Upravit skupinu",
|
||||
"Delete group": "Odstranit skupinu",
|
||||
"Group options": "Nastavení skupin",
|
||||
"Cancel": "Zrušit",
|
||||
"Import menu options": "Import menu options",
|
||||
"Member options": "Uživatelská nastavení",
|
||||
"New document in <em>{{ collectionName }}</em>": "Nový dokument v <em>{{ collectionName }}</em>",
|
||||
"New child document": "Nový vložený dokument",
|
||||
@@ -576,7 +569,7 @@
|
||||
"created the collection": "vytvořil sbírku",
|
||||
"mentioned you in": "zmínil vás v",
|
||||
"left a comment on": "zanechal komentář k",
|
||||
"resolved a comment on": "vyřešil komentář dne",
|
||||
"resolved a comment on": "resolved a comment on",
|
||||
"shared": "sdíleno",
|
||||
"invited you to": "vás pozval/a do",
|
||||
"Choose a date": "Vybrat datum",
|
||||
@@ -605,7 +598,7 @@
|
||||
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} skupin s přístupem",
|
||||
"Archived by {{userName}}": "Archivoval {{userName}}",
|
||||
"Share": "Sdílet",
|
||||
"Overview": "Přehled",
|
||||
"Overview": "Overview",
|
||||
"Recently updated": "Nedávno aktualizováno",
|
||||
"Recently published": "Nedávno zveřejněné",
|
||||
"Least recently updated": "Naposledy aktualizováno",
|
||||
@@ -617,14 +610,15 @@
|
||||
"Add a reply": "Přidat odpověď",
|
||||
"Reply": "Odpovědět",
|
||||
"Post": "Odeslat",
|
||||
"Cancel": "Zrušit",
|
||||
"Upload image": "Nahrát obrázek",
|
||||
"No resolved comments": "Žádné vyřešené komentáře",
|
||||
"No comments yet": "Doposud žádné komentáře",
|
||||
"New comments": "New comments",
|
||||
"Sort comments": "Sort comments",
|
||||
"Most recent": "Most recent",
|
||||
"Order in doc": "Order in doc",
|
||||
"Resolved": "Vyřešené",
|
||||
"Sort comments": "Sort comments",
|
||||
"Resolved": "Resolved",
|
||||
"Show {{ count }} reply": "Show {{ count }} reply",
|
||||
"Show {{ count }} reply_plural": "Show {{ count }} replies",
|
||||
"Error updating comment": "Chyba při aktualizaci komentáře",
|
||||
@@ -705,11 +699,8 @@
|
||||
"No documents found for your filters.": "Pro zadaný požadavek nebyly nalezeny žádné dokumenty.",
|
||||
"You’ve not got any drafts at the moment.": "Momentálně nemáte žádné koncepty.",
|
||||
"Payment Required": "Vyžadována platba",
|
||||
"No access to this doc": "No access to this doc",
|
||||
"It doesn’t look like you have permission to access this document.": "It doesn’t look like you have permission to access this document.",
|
||||
"Please request access from the document owner.": "Please request access from the document owner.",
|
||||
"Not found": "Nenalezeno",
|
||||
"The page you’re looking for cannot be found. It might have been deleted or the link is incorrect.": "Stránka, kterou hledáte, nebyla nalezena. Možná byla odstraněna nebo odkaz není správný.",
|
||||
"Not Found": "Nenalezeno",
|
||||
"We were unable to find the page you’re looking for. Go to the <2>homepage</2>?": "Nepodařilo se nám najít stránku, kterou hledáte. Chcete přejít na <2>domovskou stránku</2>?",
|
||||
"Offline": "Offline",
|
||||
"We were unable to load the document while offline.": "V režimu offline se nepodařilo načíst dokument.",
|
||||
"Your account has been suspended": "Váš účet byl pozastaven",
|
||||
@@ -738,7 +729,7 @@
|
||||
"Inviting": "Pozvání",
|
||||
"Send Invites": "Odeslat pozvánky",
|
||||
"Open command menu": "Otevřít příkazovou řádku",
|
||||
"Forward": "Přeposlat",
|
||||
"Forward": "Forward",
|
||||
"Edit current document": "Upravit tento dokument",
|
||||
"Move current document": "Přesunout tento dokument",
|
||||
"Open document history": "Otevřít historii dokumentu",
|
||||
@@ -777,10 +768,10 @@
|
||||
"LaTeX block": "Blok LaTeX",
|
||||
"Inline code": "Vložený kód",
|
||||
"Inline LaTeX": "Vložený LaTeX",
|
||||
"Triggers": "Akce",
|
||||
"Mention users and more": "Zmínit uživatele a další",
|
||||
"Triggers": "Triggers",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Přidat blok",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Přihlásit se",
|
||||
"Continue with Email": "Pokračovat pomocí e-mailu",
|
||||
"Continue with {{ authProviderName }}": "Pokračovat s {{ authProviderName }}",
|
||||
@@ -802,7 +793,7 @@
|
||||
"Sorry, it looks like that sign-in link is no longer valid, please try requesting another.": "Je nám líto, zdá se, že tento odkaz pro přihlášení již není platný, zkuste prosím požádat o jiný.",
|
||||
"Your account has been suspended. To re-activate your account, please contact a workspace admin.": "Váš účet byl pozastaven. Chcete-li znovu aktivovat svůj účet, kontaktujte správce pracovního prostoru.",
|
||||
"This workspace has been suspended. Please contact support to restore access.": "Tento pracovní prostor byl pozastaven. Prosím kontaktujte podporu pro obnovení přístupu.",
|
||||
"Authentication failed – this login method was disabled by a workspace admin.": "Ověření se nezdařilo – tento způsob přihlášení byl zakázán správcem týmu.",
|
||||
"Authentication failed – this login method was disabled by a team admin.": "Ověření se nezdařilo – tento způsob přihlášení byl zakázán správcem týmu.",
|
||||
"The workspace you are trying to join requires an invite before you can create an account.<1></1>Please request an invite from your workspace admin and try again.": "Pracovní prostor, ke kterému se pokoušíte připojit, vyžaduje před vytvořením účtu pozvánku.<1></1> Požádejte správce pracovního prostoru o pozvánku a zkuste to znovu.",
|
||||
"Sorry, an unknown error occurred.": "Sorry, an unknown error occurred.",
|
||||
"Login": "Přihlášení",
|
||||
@@ -823,16 +814,17 @@
|
||||
"Or": "Nebo",
|
||||
"Already have an account? Go to <1>login</1>.": "Máte již účet? <1>Přihlaste se</1>.",
|
||||
"Any collection": "Jakákoli sbírka",
|
||||
"All time": "All time",
|
||||
"Any time": "Kdykoliv",
|
||||
"Past day": "Včera",
|
||||
"Past week": "Minulý týden",
|
||||
"Past month": "Minulý měsíc",
|
||||
"Past year": "Minulý rok",
|
||||
"Any time": "Kdykoliv",
|
||||
"Remove document filter": "Odebrat filtr dokumentů",
|
||||
"Any status": "Libovolný stav",
|
||||
"Remove search": "Odstranit vyhledávání",
|
||||
"Any author": "Jakýkoliv autor",
|
||||
"Author": "Autor",
|
||||
"We were unable to find the page you’re looking for.": "Nepodařilo se nám najít stránku, kterou hledáte.",
|
||||
"Search titles only": "Hledat pouze názvy",
|
||||
"Something went wrong": "Something went wrong",
|
||||
"Please try again or contact support if the problem persists": "Please try again or contact support if the problem persists",
|
||||
@@ -891,22 +883,16 @@
|
||||
"Search people": "Hledat uživatele",
|
||||
"No people matching your search": "Vašemu vyhledávání neodpovídají žádní lidé",
|
||||
"No people left to add": "Nezbývají žádní lidé, které by bylo možné přidat",
|
||||
"Date created": "Datum vytvoření",
|
||||
"Date created": "Date created",
|
||||
"Upload": "Nahrát",
|
||||
"Crop image": "Crop image",
|
||||
"Uploading": "Nahrávání",
|
||||
"How does this work?": "Jak to funguje?",
|
||||
"You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "Můžete importovat soubor zip, který byl dříve exportován z možnosti JSON v jiné instanci. V {{ appName }} otevřete <em>Export</em> v postranním panelu Nastavení a klikněte na <em>Exportovat data</em>.",
|
||||
"Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Přetáhněte soubor zip z možnosti exportu JSON v {{appName}} nebo kliknutím nahrajte",
|
||||
"Canceled": "Zrušeno",
|
||||
"Import canceled": "Import byl zrušen",
|
||||
"Are you sure you want to cancel this import?": "Jste si jisti, že chcete zrušit tento import?",
|
||||
"Canceling": "Probíhá zrušení",
|
||||
"Canceling this import will discard any progress made. This cannot be undone.": "Zrušením tohoto importu bude odstraněn jakýkoliv pokrok. Toto nelze vrátit zpět.",
|
||||
"{{ count }} document imported": "{{ count }} dokument importován",
|
||||
"{{ count }} document imported_plural": "{{ count }} dokumentů importováno",
|
||||
"You can import a zip file that was previously exported from an Outline installation – collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "Můžete importovat soubor zip, který byl dříve exportován z instalace Outline – budou importovány sbírky, dokumenty a obrázky. V aplikaci Outline otevřete <em>Export</em> na postranním panelu Nastavení a klikněte na <em>Exportovat data</em>.",
|
||||
"Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Přetáhněte soubor zip z možnosti exportu Markdown z {{appName}} nebo kliknutím nahrajte",
|
||||
"Where do I find the file?": "Kde najdu soubor?",
|
||||
"In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability.": "V Notion klikněte na <em>Nastavení a členové</em> v levém postranním panelu a otevřete Nastavení. Vyhledejte sekci Exportovat a klikněte na <em>Exportovat veškerý obsah pracovního prostoru</em>. Vyberte <em>HTML</em> jako formát pro nejlepší kompatibilitu dat.",
|
||||
"Drag and drop the zip file from Notion's HTML export option, or click to upload": "Přetáhněte soubor zip z exportu HTML aplikace Notion nebo kliknutím nahrajte",
|
||||
"Last active": "Poslední aktivita",
|
||||
"Guest": "Host",
|
||||
"Shared by": "Sdíleno uživatelem",
|
||||
@@ -919,8 +905,6 @@
|
||||
"Editors": "Editoři",
|
||||
"All status": "All status",
|
||||
"Active": "Aktivní",
|
||||
"Left": "Vlevo",
|
||||
"Right": "Vpravo",
|
||||
"Settings saved": "Nastavení uloženo",
|
||||
"Logo updated": "Logo aktualizováno",
|
||||
"Unable to upload new logo": "Nelze nahrát nové logo",
|
||||
@@ -938,10 +922,13 @@
|
||||
"Show your team’s logo on public pages like login and shared documents.": "Zobrazit logo vašeho týmu na veřejných stránkách, jako je přihlášení a sdílené dokumenty.",
|
||||
"Table of contents position": "Umístění obsahu tabulky",
|
||||
"The side to display the table of contents in relation to the main content.": "The side to display the table of contents in relation to the main content.",
|
||||
"Left": "Left",
|
||||
"Right": "Right",
|
||||
"Behavior": "Chování",
|
||||
"Subdomain": "Subdoména",
|
||||
"Your workspace will be accessible at": "Váš pracovní prostor bude přístupný na",
|
||||
"Choose a subdomain to enable a login page just for your team.": "Vyberte subdoménu a povolte přihlašovací stránku pouze pro svůj tým.",
|
||||
"Start view": "Domovská obrazovka",
|
||||
"This is the screen that workspace members will first see when they sign in.": "Toto je obrazovka, kterou členové pracovního prostoru uvidí jako první, když se přihlásí.",
|
||||
"Danger": "Nebezpečí",
|
||||
"You can delete this entire workspace including collections, documents, and users.": "Můžete odstranit celý tento pracovní prostor včetně kolekcí, dokumentů a uživatelů.",
|
||||
@@ -958,37 +945,38 @@
|
||||
"New group": "Nová skupina",
|
||||
"Groups can be used to organize and manage the people on your team.": "Skupiny lze použít k organizaci a správě lidí ve vašem týmu.",
|
||||
"No groups have been created yet": "Dosud nebyly vytvořeny žádné skupiny",
|
||||
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Rychle přeneste své stávající dokumenty, stránky a soubory z jiných nástrojů a služeb do {{appName}}. Jakékoli HTML, Markdown a textové dokumenty můžete také přetáhnout přímo do sbírky v aplikaci.",
|
||||
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)": "Importujte soubor zip s dokumenty Markdown (exportované z verze 0.67.0 nebo starší)",
|
||||
"Import data": "Importovat data",
|
||||
"Import a JSON data file exported from another {{ appName }} instance": "Importujte datový soubor JSON exportovaný z instance {{ appName }}",
|
||||
"Import pages exported from Notion": "Importujte stránky exportované z Notion",
|
||||
"Import pages from a Confluence instance": "Importujte stránky z aplikace Confluence",
|
||||
"Enterprise": "Podnik",
|
||||
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Rychle přeneste své stávající dokumenty, stránky a soubory z jiných nástrojů a služeb do {{appName}}. Jakékoli HTML, Markdown a textové dokumenty můžete také přetáhnout přímo do sbírky v aplikaci.",
|
||||
"Recent imports": "Nedávné importy",
|
||||
"Could not load members": "Could not load members",
|
||||
"Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.": "Zde je uveden každý, kdo se přihlásil do {{appName}}. Je možné, že existuje více uživatelů, kteří mají přístup přes {{signinMethods}}, ale ještě se nepřihlásili.",
|
||||
"Receive a notification whenever a new document is published": "Dostat upozornění, když bude publikován nový obsah",
|
||||
"Receive a notification whenever a new document is published": "Přijímat oznámení, když bude publikován nový obsah",
|
||||
"Document updated": "Dokument aktualizován",
|
||||
"Receive a notification when a document you are subscribed to is edited": "Dostat upozornění, když je dokument, k jehož odběru jste přihlášeni, upraven",
|
||||
"Receive a notification when a document you are subscribed to is edited": "Obdržet upozornění, když je dokument, k jehož odběru jste přihlášeni, upraven",
|
||||
"Comment posted": "Komentář byl zveřejněn",
|
||||
"Receive a notification when a document you are subscribed to or a thread you participated in receives a comment": "Dostat upozornění, když dokument, k jehož odběru jste přihlášeni, nebo vlákno, jehož jste se účastnili, obdrží komentář",
|
||||
"Receive a notification when a document you are subscribed to or a thread you participated in receives a comment": "Obdržet upozornění, když dokument, k jehož odběru jste přihlášeni, nebo vlákno, jehož jste se účastnili, obdrží komentář",
|
||||
"Mentioned": "Zmínky",
|
||||
"Receive a notification when someone mentions you in a document or comment": "Dostat upozornění, když se o vás někdo zmíní v dokumentu nebo komentáři",
|
||||
"Receive a notification when a comment thread you were involved in is resolved": "Dostat upozornění, když bude vyřešeno vlákno komentářů, do kterého jste byli zapojeni",
|
||||
"Receive a notification when someone mentions you in a document or comment": "Dostávat upozornění, když se o vás někdo zmíní v dokumentu nebo komentáři",
|
||||
"Receive a notification when a comment thread you were involved in is resolved": "Receive a notification when a comment thread you were involved in is resolved",
|
||||
"Collection created": "Sbírka vytvořena",
|
||||
"Receive a notification whenever a new collection is created": "Dostat upozornění, když bude vytvořena nová sbírka",
|
||||
"Receive a notification whenever a new collection is created": "Přijímat oznámení, když bude vytvořena nová sbírka",
|
||||
"Invite accepted": "Pozvánka přijata",
|
||||
"Receive a notification when someone you invited creates an account": "Dostat upozornění, když si někdo, koho jste pozvali, vytvoří účet",
|
||||
"Receive a notification when someone you invited creates an account": "Obdržet oznámení, když si někdo, koho jste pozvali, vytvoří účet",
|
||||
"Invited to document": "Pozván/a do dokumentu",
|
||||
"Receive a notification when a document is shared with you": "Dostat oznámení, když získáte přístup k dokumentu",
|
||||
"Invited to collection": "Pozván do kolekce",
|
||||
"Receive a notification when you are given access to a collection": "Dostat upozornění, když získáte přístup k nové kolekci",
|
||||
"Receive a notification when you are given access to a collection": "Dostávat upozornění, když získáte přístup k nové kolekci",
|
||||
"Export completed": "Export dokončen",
|
||||
"Receive a notification when an export you requested has been completed": "Dostat upozornění, když byl vámi požadovaný export dokončen",
|
||||
"Receive a notification when an export you requested has been completed": "Obdržet upozornění, když byl vámi požadovaný export dokončen",
|
||||
"Getting started": "Začínáme",
|
||||
"Tips on getting started with features and functionality": "Tipy, jak začít s funkcemi",
|
||||
"New features": "Nové funkce",
|
||||
"Receive an email when new features of note are added": "Dostat e-mail, když budou přidány nové funkce poznámky",
|
||||
"Receive an email when new features of note are added": "Obdržet e-mail, když budou přidány nové funkce poznámky",
|
||||
"Notifications saved": "Upozornění uložena",
|
||||
"Unsubscription successful. Your notification settings were updated": "Odhlášení bylo úspěšné. Nastavení oznámení bylo aktualizováno",
|
||||
"Manage when and where you receive email notifications.": "Spravujte, kdy a kde budete dostávat e-mailová upozornění.",
|
||||
@@ -1008,8 +996,8 @@
|
||||
"When enabled, documents have a separate editing mode. When disabled, documents are always editable when you have permission.": "Pokud je povoleno, dokumenty mají samostatný režim úprav. Pokud je zakázáno, dokumenty jsou vždy upravitelné, pokud máte oprávnění.",
|
||||
"Remember previous location": "Zapamatovat předchozí umístění",
|
||||
"Automatically return to the document you were last viewing when the app is re-opened.": "Automaticky se vracet k dokumentu, který jste si naposledy prohlédli před ukončením aplikace.",
|
||||
"Smart text replacements": "Chytré nahrazení textu",
|
||||
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Automatické formátování textu nahrazením symboly, pomlčkami, chytrými uvozovkami a dalšími typografickými prvky.",
|
||||
"Smart text replacements": "Smart text replacements",
|
||||
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.",
|
||||
"You may delete your account at any time, note that this is unrecoverable": "Účet můžete kdykoliv odstranit, tento krok je neobnovitelný",
|
||||
"Profile saved": "Profil uložen",
|
||||
"Profile picture updated": "Profilový obrázek byl úspěšně aktualizován",
|
||||
@@ -1044,6 +1032,10 @@
|
||||
"Allow editors to create new collections within the workspace": "Umožnit členům vytvářet v rámci pracoviště nové kolekce",
|
||||
"Workspace creation": "Vytvoření pracovního prostoru",
|
||||
"Allow editors to create new workspaces": "Povolit editorům vytvářet nové pracovní prostory",
|
||||
"Draw.io deployment": "Využití aplikace Draw.io",
|
||||
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Přidejte sem svou vlastní instalační adresu draw.io, abyste povolili automatické vkládání diagramů do dokumentů.",
|
||||
"Grist deployment": "Grist nasazení",
|
||||
"Add your self-hosted grist installation URL here.": "Zde přidejte vlastní instalační Grist URL adresu.",
|
||||
"Could not load shares": "Could not load shares",
|
||||
"Sharing is currently disabled.": "Sdílení je momentálně zakázáno.",
|
||||
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "Můžete globálně povolit a zakázat sdílení dokumentů v nastavení <em>zabezpečení</em>.",
|
||||
@@ -1094,8 +1086,6 @@
|
||||
"The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/": "URL adresa Matomo instance. Pokud používáte Matomo Cloud, adresa má na konci matomo.cloud/",
|
||||
"Site ID": "ID stránky",
|
||||
"An ID that uniquely identifies the website in your Matomo instance.": "An ID that uniquely identifies the website in your Matomo instance.",
|
||||
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?": "Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
|
||||
"Import pages from Notion": "Import pages from Notion",
|
||||
"Add to Slack": "Přidat do Slacku",
|
||||
"document published": "dokument zveřejněn",
|
||||
"document updated": "dokument aktualizován",
|
||||
@@ -1152,5 +1142,5 @@
|
||||
"{{ user }} updated {{ timeAgo }}": "{{ user }} aktualizoval před {{ timeAgo }}",
|
||||
"You created {{ timeAgo }}": "Vytvořili jste před {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} vytvořil před {{ timeAgo }}",
|
||||
"Error loading data": "Error loading data"
|
||||
}
|
||||
"Uploading": "Nahrávání"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user