Compare commits

...

13 Commits

Author SHA1 Message Date
Tom Moor be209157f9 fix: Line numbers flash in on load
fix: Text color of plain text and markdown code blocks
2025-04-12 21:10:29 -04:00
Tom Moor 89db519b72 Replace embed icon (#8947) 2025-04-12 19:40:08 +00:00
codegen-sh[bot] 31c412b4a6 refactor: Convert ImageUpload component to functional (#8944)
* refactor: Convert ImageUpload component to functional

* fix: Fix linting issues by removing trailing whitespace and unused imports

* Applied automatic fixes

* translations

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-12 19:26:38 +00:00
Tom Moor 199584428a fix: Markdown copy should not occur for single node situations (#8946) 2025-04-12 12:15:52 -07:00
Tom Moor f22780e944 Move editor syntax highlighting to async (#8934)
* Move editor syntax highlighting to async, add a bunch more languages

* Remove vestigial referenecs to Prism

* fix: bundle-size job not triggering

* Add webpackStatsFile
2025-04-12 10:55:47 -07:00
dependabot[bot] a71381785c chore(deps): bump vite from 5.4.17 to 5.4.18 (#8941)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.17 to 5.4.18.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.18/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.18/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-12 10:55:27 -07:00
Tom Moor a61b53aa74 Require collection manage permissions to export (#8942) 2025-04-12 10:55:15 -07:00
Tom Moor 45f0885533 fix: bundle-size CI (#8940) 2025-04-12 10:07:48 -07:00
Hemachandar 9c9657f4cc eslint: Increase severity to error for lodash imports (#8932) 2025-04-11 17:56:28 -07:00
Tom Moor c3de0cf0ec v0.83.0 (#8928) 2025-04-10 19:53:08 -07:00
Hemachandar f7b00e72f1 Implement UnfurlsStore (#8920)
* Implement UnfurlsStore

* simplify lookup

* refetch unfurl after X elapsed time

* compute fetchedAt in client
2025-04-10 18:24:32 -07:00
Hemachandar e499881110 fix: Update collection 'documentStructure' when archived document is deleted (#8922) 2025-04-10 18:11:30 -07:00
Tom Moor 016c8c802c Finalize moving docker publish to GH actions (#8927) 2025-04-10 18:10:10 -07:00
27 changed files with 766 additions and 484 deletions
+1 -1
View File
@@ -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": ["warn", "method"],
"lodash/import-scope": ["error", "method"],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
+5 -3
View File
@@ -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=8000
NODE_OPTIONS: --max-old-space-size=8192
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
@@ -63,6 +63,7 @@ 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
@@ -81,7 +82,7 @@ jobs:
- 'yarn.lock'
test:
needs: build
needs: [build, changes]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
@@ -143,7 +144,7 @@ jobs:
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types]
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
steps:
@@ -161,3 +162,4 @@ jobs:
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
webpackStatsFile: ./build/app/webpack-stats.json
+179 -19
View File
@@ -3,25 +3,32 @@ name: Docker
on:
push:
tags:
- 'v*'
- "v*"
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-and-push:
runs-on: ubuntu-latest
build-arm:
runs-on: ubicloud-standard-8-arm
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:
@@ -29,24 +36,177 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
uses: docker/build-push-action@v5
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
push: true
tags: ${{ env.BASE_IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
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
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- 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 main image
uses: docker/build-push-action@v5
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
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 }}
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
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 }}
+3 -2
View File
@@ -1,5 +1,6 @@
ARG APP_PATH=/opt/outline
FROM outlinewiki/outline-base AS base
ARG BASE_IMAGE=outlinewiki/outline-base
FROM ${BASE_IMAGE} AS base
ARG APP_PATH
WORKDIR $APP_PATH
@@ -30,7 +31,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" && \
+4 -1
View File
@@ -1,11 +1,14 @@
ARG APP_PATH=/opt/outline
FROM node:20-slim AS deps
FROM node:20 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
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.82.0
Licensed Work: Outline 0.83.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-02-15
Change Date: 2029-04-11
Change License: Apache License, Version 2.0
+146 -140
View File
@@ -1,4 +1,5 @@
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";
@@ -8,6 +9,7 @@ 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";
@@ -23,9 +25,9 @@ const POINTER_WIDTH = 22;
type Props = {
/** The HTML element that is being hovered over, or null if none. */
element: HTMLElement | null;
/** Data to be previewed */
data: Record<string, any> | null;
/** Whether the preview data is being loaded */
/** ID of the unfurl that will be shown in the hover preview. */
unfurlId: string | null;
/** Whether the preview data is being loaded. */
dataLoading: boolean;
/** A callback on close of the hover preview. */
onClose: () => void;
@@ -36,151 +38,155 @@ enum Direction {
DOWN,
}
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 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;
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 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 stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}
}, []);
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("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);
}
}
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
if (dataLoading) {
return <LoadingIndicator />;
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
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>
);
}
);
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) {
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
@@ -190,7 +196,7 @@ function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
<HoverPreviewDesktop
{...rest}
element={element}
data={data}
unfurlId={unfurlId}
dataLoading={dataLoading}
/>
);
@@ -1,5 +1,6 @@
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";
/**
@@ -18,17 +19,25 @@ export default class ClipboardTextSerializer extends Extension {
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice) => {
clipboardTextSerializer: (slice, view) => {
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 ||
slice.content.content.some(
(node) => node.content.content.length > 1
);
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
+20 -13
View File
@@ -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;
data: Record<string, any> | null;
unfurlId: string | null;
dataLoading: boolean;
} = observable({
activeLinkElement: null,
data: null,
unfurlId: null,
dataLoading: false,
});
@@ -62,19 +62,25 @@ export default class HoverPreviews extends Extension {
);
if (url) {
const transformedUrl = url.startsWith("/")
? env.URL + url
: url;
this.state.dataLoading = true;
try {
const data = await client.post("/urls.unfurl", {
url: url.startsWith("/") ? env.URL + url : url,
documentId,
});
const unfurl = await stores.unfurls.fetchUnfurl({
url: transformedUrl,
documentId,
});
if (unfurl) {
this.state.activeLinkElement = element;
this.state.data = data;
} catch (err) {
this.state.unfurlId = transformedUrl;
} else {
this.state.activeLinkElement = null;
} finally {
this.state.dataLoading = false;
}
this.state.dataLoading = false;
}
}),
this.options.delay
@@ -101,10 +107,11 @@ export default class HoverPreviews extends Extension {
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
data={this.state.data}
unfurlId={this.state.unfurlId}
dataLoading={this.state.dataLoading}
onClose={action(() => {
this.state.activeLinkElement = null;
this.state.unfurlId = null;
})}
/>
);
+18
View File
@@ -0,0 +1,18 @@
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;
+90 -105
View File
@@ -1,19 +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 Dropzone from "react-dropzone";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import { AttachmentValidation } from "@shared/validations";
import RootStore from "~/stores/RootStore";
import ButtonLarge from "~/components/ButtonLarge";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import Modal from "~/components/Modal";
import withStores from "~/components/withStores";
import useStores from "~/hooks/useStores";
import { compressImage } from "~/utils/compressImage";
import { uploadFile, dataUrlToBlob } from "~/utils/files";
@@ -24,41 +24,38 @@ export type Props = {
borderRadius?: number;
};
@observer
class ImageUpload extends React.Component<RootStore & Props> {
@observable
isUploading = false;
const ImageUpload: React.FC<Props> = ({
onSuccess,
onError,
submitText,
borderRadius = 150,
children,
}) => {
const { ui } = useStores();
const { t } = useTranslation();
submitText || t("Crop image");
@observable
isCropping = false;
const [isUploading, setIsUploading] = useState(false);
const [isCropping, setIsCropping] = useState(false);
const [zoom, setZoom] = useState(1);
const [file, setFile] = useState<File | null>(null);
@observable
zoom = 1;
const avatarEditorRef = useRef<AvatarEditor>(null);
@observable
file: File;
avatarEditorRef = React.createRef<AvatarEditor>();
static defaultProps = {
submitText: "Crop Image",
borderRadius: 150,
const onDropAccepted = async (files: File[]) => {
setIsCropping(true);
setFile(files[0]);
};
onDropAccepted = async (files: File[]) => {
this.isCropping = true;
this.file = files[0];
};
handleCrop = () => {
this.isUploading = true;
const handleCrop = () => {
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(this.uploadImage, 0);
setTimeout(uploadImage, 0);
};
uploadImage = async () => {
const canvas = this.avatarEditorRef.current?.getImage();
const uploadImage = async () => {
const canvas = avatarEditorRef.current?.getImage();
invariant(canvas, "canvas is not defined");
const imageBlob = dataUrlToBlob(canvas.toDataURL());
@@ -68,99 +65,87 @@ class ImageUpload extends React.Component<RootStore & Props> {
maxWidth: 512,
});
const attachment = await uploadFile(compressed, {
name: this.file.name,
name: file!.name,
preset: AttachmentPreset.Avatar,
});
void this.props.onSuccess(attachment.url);
void onSuccess(attachment.url);
} catch (err) {
this.props.onError(err.message);
onError(err.message);
} finally {
this.isUploading = false;
this.isCropping = false;
setIsUploading(false);
setIsCropping(false);
}
};
handleClose = () => {
this.isUploading = false;
this.isCropping = false;
const handleClose = () => {
setIsUploading(false);
setIsCropping(false);
};
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
this.zoom = parseFloat(target.value);
setZoom(parseFloat(target.value));
}
};
renderCropping() {
const { ui, submitText } = this.props;
return (
<Modal
onRequestClose={this.handleClose}
fullscreen={false}
title={<>&nbsp;</>}
isOpen
>
<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}
const renderCropping = () => (
<Modal
onRequestClose={handleClose}
fullscreen={false}
title={<>&nbsp;</>}
isOpen
>
<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}
/>
<br />
<ButtonLarge
fullwidth
onClick={this.handleCrop}
disabled={this.isUploading}
>
{this.isUploading ? "Uploading…" : submitText}
</ButtonLarge>
</Flex>
</Modal>
);
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={handleZoom}
/>
<br />
<ButtonLarge fullwidth onClick={handleCrop} disabled={isUploading}>
{isUploading ? `${t(`Uploading`)}` : submitText}
</ButtonLarge>
</Flex>
</Modal>
);
if (isCropping && file) {
return renderCropping();
}
render() {
if (this.isCropping) {
return this.renderCropping();
}
return (
<Dropzone
accept={AttachmentValidation.avatarContentTypes.join(", ")}
onDropAccepted={this.onDropAccepted}
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<input {...getInputProps()} />
{this.props.children}
</div>
)}
</Dropzone>
);
}
}
return (
<Dropzone
accept={AttachmentValidation.avatarContentTypes.join(", ")}
onDropAccepted={onDropAccepted}
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<input {...getInputProps()} />
{children}
</div>
)}
</Dropzone>
);
};
const AvatarEditorContainer = styled(Flex)`
margin-bottom: 30px;
@@ -191,4 +176,4 @@ const RangeInput = styled.input`
}
`;
export default withStores(ImageUpload);
export default observer(ImageUpload);
+3
View File
@@ -26,6 +26,7 @@ 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";
@@ -55,6 +56,7 @@ export default class RootStore {
searches: SearchesStore;
shares: SharesStore;
ui: UiStore;
unfurls: UnfurlsStore;
stars: StarsStore;
subscriptions: SubscriptionsStore;
users: UsersStore;
@@ -85,6 +87,7 @@ 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);
+85
View File
@@ -0,0 +1,85 @@
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
View File
@@ -1,5 +1,6 @@
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";
+3 -3
View File
@@ -248,7 +248,7 @@
"uuid": "^8.3.2",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^5.4.17",
"vite": "^5.4.18",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
@@ -377,5 +377,5 @@
"rollup": "^4.5.1",
"prismjs": "1.30.0"
},
"version": "0.82.0"
}
"version": "0.83.0"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

+29
View File
@@ -11,6 +11,7 @@ import {
buildUser,
buildGuestUser,
} from "@server/test/factories";
import Collection from "./Collection";
import UserMembership from "./UserMembership";
beforeEach(() => {
@@ -78,6 +79,34 @@ 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();
+13 -5
View File
@@ -1112,18 +1112,26 @@ class Document extends ArchivableModel<
// Delete a document, archived or otherwise.
delete = (user: User) =>
this.sequelize.transaction(async (transaction: Transaction) => {
if (!this.archivedAt && !this.template && this.collectionId) {
// delete any children and remove from the document structure
const collection = await Collection.findByPk(this.collectionId, {
let deleted = false;
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
});
await collection?.deleteDocument(this, { transaction });
} else {
if (!this.archivedAt || (this.archivedAt && collection?.archivedAt)) {
await collection?.deleteDocument(this, { transaction });
deleted = true;
}
}
if (!deleted) {
await this.destroy({
transaction,
});
deleted = true;
}
this.lastModifiedById = user.id;
+2 -11
View File
@@ -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, can } from "./cancan";
import { allow } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, "createCollection", Team, (actor, team) =>
@@ -67,15 +67,6 @@ allow(
}
);
allow(User, "export", Collection, (actor, collection) =>
and(
//
can(actor, "read", collection),
!actor.isViewer,
!actor.isGuest
)
);
allow(User, "share", Collection, (user, collection) => {
if (
!collection ||
@@ -161,7 +152,7 @@ allow(
}
);
allow(User, ["update", "archive"], Collection, (user, collection) =>
allow(User, ["update", "export", "archive"], Collection, (user, collection) =>
and(
!!collection,
!!collection?.isActive,
+7
View File
@@ -1335,6 +1335,13 @@ 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;
+2 -1
View File
@@ -1,3 +1,4 @@
import { BrowserIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { Primitive } from "utility-types";
@@ -665,7 +666,7 @@ const embeds: EmbedDescriptor[] = [
title: "Embed",
keywords: "iframe webpage",
placeholder: "Paste a URL to embed",
icon: <Img src="/images/embed.png" alt="Embed" />,
icon: <BrowserIcon />,
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 { getPrismLangForLanguage } from "../lib/code";
import { getRefractorLangForLanguage } from "../lib/code";
import { isRemoteTransaction } from "../lib/multiplayer";
import { findBlockNodes } from "../queries/findChildren";
@@ -14,6 +14,34 @@ type ParsedNode = {
};
const cache: Record<number, { node: Node; decorations: Decoration[] }> = {};
const languagesToImport = new Set<string>();
async function loadLanguage(language: string) {
if (!language || refractor.registered(language)) {
return;
}
try {
// @ts-expect-error we are adding a module to the window object to work
// around the fact that refractor doesn't export ESM but import expects it.
// See the rules of dynamic imports:
// https://github.com/rollup/plugins/blob/e1a5ef99f1578eb38a8c87563cb9651db228f3bd/packages/dynamic-import-vars/README.md#limitations
window.module ??= {};
return import(`../../../node_modules/refractor/lang/${language}.js`).then(
() => {
refractor.register(window.module.exports);
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
);
}
return;
}
function getDecorations({
doc,
@@ -57,12 +85,7 @@ function getDecorations({
blocks.forEach((block) => {
let startPos = block.pos + 1;
const language = getPrismLangForLanguage(block.node.attrs.language);
if (!language || !refractor.registered(language)) {
return;
}
const language = getRefractorLangForLanguage(block.node.attrs.language);
const lineDecorations = [];
if (!cache[block.pos] || !cache[block.pos].node.eq(block.node)) {
@@ -91,35 +114,48 @@ 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: newDecorations,
decorations: lineDecorations,
};
if (!language) {
// do nothing
} else if (refractor.registered(language)) {
languagesToImport.delete(language);
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: newDecorations,
};
} else {
languagesToImport.add(language);
}
}
cache[block.pos].decorations.forEach((decoration) => {
cache[block.pos]?.decorations.forEach((decoration) => {
decorations.push(decoration);
});
});
@@ -133,7 +169,7 @@ function getDecorations({
return DecorationSet.create(doc, decorations);
}
export default function Prism({
export function CodeHighlighting({
name,
lineNumbers,
}: {
@@ -145,7 +181,7 @@ export default function Prism({
let highlighted = false;
return new Plugin({
key: new PluginKey("prism"),
key: new PluginKey("codeHighlighting"),
state: {
init: (_, { doc }) => DecorationSet.create(doc, []),
apply: (transaction: Transaction, decorationSet, oldState, state) => {
@@ -156,11 +192,13 @@ export default function Prism({
// @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;
@@ -174,15 +212,34 @@ export default function Prism({
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 Prism
// it render un-highlighted and then trigger a defered render of highlighting
// by updating the plugins metadata
setTimeout(() => {
requestAnimationFrame(() => {
if (!view.isDestroyed) {
view.dispatch(view.state.tr.setMeta("prism", { loaded: true }));
view.dispatch(
view.state.tr.setMeta("codeHighlighting", { loaded: true })
);
}
}, 10);
});
}
return {};
return {
update: () => {
if (!languagesToImport.size) {
return;
}
void Promise.all([...languagesToImport].map(loadLanguage)).then(
(language) =>
languagesToImport.size
? view.dispatch(
view.state.tr.setMeta("codeHighlighting", {
langLoaded: language,
})
)
: null
);
},
};
},
props: {
decorations(state) {
+8 -8
View File
@@ -1,12 +1,12 @@
import { getPrismLangForLanguage, getLabelForLanguage } from "./code";
import { getRefractorLangForLanguage, getLabelForLanguage } from "./code";
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();
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();
});
});
+20 -11
View File
@@ -1,6 +1,6 @@
import Storage from "../../utils/Storage";
const RecentStorageKey = "rme-code-language";
const RecentlyUsedStorageKey = "rme-code-language";
const StorageKey = "frequent-code-languages";
const frequentLanguagesToGet = 5;
const frequentLanguagesToTrack = 10;
@@ -9,7 +9,7 @@ const frequentLanguagesToTrack = 10;
* List of supported code languages.
*
* Object key is the language identifier used in the editor, lang is the
* language identifier used by Prism. Note mismatches such as `markup` and
* language identifier used by Refractor. Note mismatches such as `markup` and
* `mermaid`.
*/
export const codeLanguages = {
@@ -19,8 +19,10 @@ export const codeLanguages = {
cpp: { lang: "cpp", label: "C++" },
csharp: { lang: "csharp", label: "C#" },
css: { lang: "css", label: "CSS" },
csv: { lang: "csv", label: "CSV" },
docker: { lang: "docker", label: "Docker" },
elixir: { lang: "elixir", label: "Elixir" },
erb: { lang: "erb", label: "ERB" },
erlang: { lang: "erlang", label: "Erlang" },
go: { lang: "go", label: "Go" },
graphql: { lang: "graphql", label: "GraphQL" },
@@ -34,8 +36,11 @@ export const codeLanguages = {
json: { lang: "json", label: "JSON" },
jsx: { lang: "jsx", label: "JSX" },
kotlin: { lang: "kotlin", label: "Kotlin" },
kusto: { lang: "kusto", label: "Kusto" },
lisp: { lang: "lisp", label: "Lisp" },
lua: { lang: "lua", label: "Lua" },
makefile: { lang: "makefile", label: "Makefile" },
markdown: { lang: "markdown", label: "Markdown" },
mermaidjs: { lang: "mermaid", label: "Mermaid Diagram" },
nginx: { lang: "nginx", label: "Nginx" },
nix: { lang: "nix", label: "Nix" },
@@ -47,11 +52,13 @@ export const codeLanguages = {
protobuf: { lang: "protobuf", label: "Protobuf" },
python: { lang: "python", label: "Python" },
r: { lang: "r", label: "R" },
regex: { lang: "regex", label: "Regex" },
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" },
"splunk-spl": { lang: "splunk-spl", label: "Splunk SPL" },
sql: { lang: "sql", label: "SQL" },
solidity: { lang: "solidity", label: "Solidity" },
swift: { lang: "swift", label: "Swift" },
@@ -79,12 +86,14 @@ export const getLabelForLanguage = (language: string) => {
};
/**
* Get the Prism language identifier for a given language.
* Get the Refractor language identifier for a given language.
*
* @param language The language identifier.
* @returns The Prism language identifier for the language.
* @returns The Refractor language identifier for the language.
*/
export const getPrismLangForLanguage = (language: string): string | undefined =>
export const getRefractorLangForLanguage = (
language: string
): string | undefined =>
codeLanguages[language as keyof typeof codeLanguages]?.lang;
/**
@@ -92,14 +101,14 @@ export const getPrismLangForLanguage = (language: string): string | undefined =>
*
* @param language The language identifier.
*/
export const setRecentCodeLanguage = (language: string) => {
export const setRecentlyUsedCodeLanguage = (language: string) => {
const frequentLangs = (Storage.get(StorageKey) ?? {}) as Record<
string,
number
>;
if (Object.keys(frequentLangs).length === 0) {
const lastUsedLang = Storage.get(RecentStorageKey);
const lastUsedLang = Storage.get(RecentlyUsedStorageKey);
if (lastUsedLang) {
frequentLangs[lastUsedLang] = 1;
}
@@ -121,7 +130,7 @@ export const setRecentCodeLanguage = (language: string) => {
}
Storage.set(StorageKey, Object.fromEntries(frequentLangEntries));
Storage.set(RecentStorageKey, language);
Storage.set(RecentlyUsedStorageKey, language);
};
/**
@@ -129,8 +138,8 @@ export const setRecentCodeLanguage = (language: string) => {
*
* @returns The most recent code language used, or undefined if none is set.
*/
export const getRecentCodeLanguage = () =>
Storage.get(RecentStorageKey) as keyof typeof codeLanguages | undefined;
export const getRecentlyUsedCodeLanguage = () =>
Storage.get(RecentlyUsedStorageKey) as keyof typeof codeLanguages | undefined;
/**
* Get the most frequent code languages used.
@@ -138,7 +147,7 @@ export const getRecentCodeLanguage = () =>
* @returns An array of the most frequent code languages used.
*/
export const getFrequentCodeLanguages = () => {
const recentLang = Storage.get(RecentStorageKey);
const recentLang = Storage.get(RecentlyUsedStorageKey);
const frequentLangEntries = Object.entries(Storage.get(StorageKey) ?? {}) as [
keyof typeof codeLanguages,
number
+9 -110
View File
@@ -9,58 +9,6 @@ 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";
@@ -77,9 +25,12 @@ 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 Prism from "../extensions/Prism";
import { getRecentCodeLanguage, setRecentCodeLanguage } from "../lib/code";
import {
getRecentlyUsedCodeLanguage,
setRecentlyUsedCodeLanguage,
} from "../lib/code";
import { isCode } from "../lib/isCode";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
@@ -90,58 +41,6 @@ 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;
@@ -212,10 +111,10 @@ export default class CodeFence extends Node {
return {
code_block: (attrs: Record<string, Primitive>) => {
if (attrs?.language) {
setRecentCodeLanguage(attrs.language as string);
setRecentlyUsedCodeLanguage(attrs.language as string);
}
return toggleBlockType(type, schema.nodes.paragraph, {
language: getRecentCodeLanguage() ?? DEFAULT_LANGUAGE,
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
...attrs,
});
},
@@ -286,7 +185,7 @@ export default class CodeFence extends Node {
get plugins() {
return [
Prism({
CodeHighlighting({
name: this.name,
lineNumbers: this.showLineNumbers,
}),
@@ -353,7 +252,7 @@ export default class CodeFence extends Node {
inputRules({ type }: { type: NodeType }) {
return [
textblockTypeInputRule(/^```$/, type, () => ({
language: getRecentCodeLanguage() ?? DEFAULT_LANGUAGE,
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
})),
];
}
+3 -2
View File
@@ -893,6 +893,8 @@
"No people left to add": "No people left to add",
"Date created": "Date created",
"Upload": "Upload",
"Crop image": "Crop image",
"Uploading": "Uploading",
"How does this work?": "How does this work?",
"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>.": "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>.",
"Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload",
@@ -1153,6 +1155,5 @@
"You updated {{ timeAgo }}": "You updated {{ timeAgo }}",
"{{ user }} updated {{ timeAgo }}": "{{ user }} updated {{ timeAgo }}",
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Uploading": "Uploading"
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}"
}
+4 -4
View File
@@ -15605,10 +15605,10 @@ vite-plugin-static-copy@^0.17.0:
fs-extra "^11.1.0"
picocolors "^1.0.0"
vite@^5.4.17:
version "5.4.17"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.17.tgz#4bf61dd4cdbf64b0d6661f5dba76954cc81d5082"
integrity sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==
vite@^5.4.18:
version "5.4.18"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.18.tgz#b5af357f9d5ebb2e0c085779b7a37a77f09168a4"
integrity sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==
dependencies:
esbuild "^0.21.3"
postcss "^8.4.43"