mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be209157f9 | |||
| 89db519b72 | |||
| 31c412b4a6 | |||
| 199584428a | |||
| f22780e944 | |||
| a71381785c | |||
| a61b53aa74 |
@@ -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, {
|
||||
|
||||
@@ -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={<> </>}
|
||||
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={<> </>}
|
||||
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);
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -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,
|
||||
|
||||
@@ -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")};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -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
@@ -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,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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 }}"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user