mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 848260b22e | |||
| a5ffeff42c | |||
| 98aa4d34c8 | |||
| 2f1e137062 |
@@ -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, {
|
||||
|
||||
@@ -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 useStores from "~/hooks/useStores";
|
||||
import withStores from "~/components/withStores";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { uploadFile, dataUrlToBlob } from "~/utils/files";
|
||||
|
||||
@@ -24,38 +24,41 @@ export type Props = {
|
||||
borderRadius?: number;
|
||||
};
|
||||
|
||||
const ImageUpload: React.FC<Props> = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
submitText,
|
||||
borderRadius = 150,
|
||||
children,
|
||||
}) => {
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
submitText || t("Crop image");
|
||||
@observer
|
||||
class ImageUpload extends React.Component<RootStore & Props> {
|
||||
@observable
|
||||
isUploading = false;
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isCropping, setIsCropping] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
@observable
|
||||
isCropping = false;
|
||||
|
||||
const avatarEditorRef = useRef<AvatarEditor>(null);
|
||||
@observable
|
||||
zoom = 1;
|
||||
|
||||
const onDropAccepted = async (files: File[]) => {
|
||||
setIsCropping(true);
|
||||
setFile(files[0]);
|
||||
@observable
|
||||
file: File;
|
||||
|
||||
avatarEditorRef = React.createRef<AvatarEditor>();
|
||||
|
||||
static defaultProps = {
|
||||
submitText: "Crop Image",
|
||||
borderRadius: 150,
|
||||
};
|
||||
|
||||
const handleCrop = () => {
|
||||
setIsUploading(true);
|
||||
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(uploadImage, 0);
|
||||
setTimeout(this.uploadImage, 0);
|
||||
};
|
||||
|
||||
const uploadImage = async () => {
|
||||
const canvas = avatarEditorRef.current?.getImage();
|
||||
uploadImage = async () => {
|
||||
const canvas = this.avatarEditorRef.current?.getImage();
|
||||
invariant(canvas, "canvas is not defined");
|
||||
const imageBlob = dataUrlToBlob(canvas.toDataURL());
|
||||
|
||||
@@ -65,87 +68,99 @@ const ImageUpload: React.FC<Props> = ({
|
||||
maxWidth: 512,
|
||||
});
|
||||
const attachment = await uploadFile(compressed, {
|
||||
name: file!.name,
|
||||
name: this.file.name,
|
||||
preset: AttachmentPreset.Avatar,
|
||||
});
|
||||
void onSuccess(attachment.url);
|
||||
void this.props.onSuccess(attachment.url);
|
||||
} catch (err) {
|
||||
onError(err.message);
|
||||
this.props.onError(err.message);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setIsCropping(false);
|
||||
this.isUploading = false;
|
||||
this.isCropping = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsUploading(false);
|
||||
setIsCropping(false);
|
||||
handleClose = () => {
|
||||
this.isUploading = false;
|
||||
this.isCropping = false;
|
||||
};
|
||||
|
||||
const handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLInputElement) {
|
||||
setZoom(parseFloat(target.value));
|
||||
this.zoom = parseFloat(target.value);
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
renderCropping() {
|
||||
const { ui, submitText } = this.props;
|
||||
|
||||
if (isCropping && file) {
|
||||
return renderCropping();
|
||||
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}
|
||||
/>
|
||||
<br />
|
||||
<ButtonLarge
|
||||
fullwidth
|
||||
onClick={this.handleCrop}
|
||||
disabled={this.isUploading}
|
||||
>
|
||||
{this.isUploading ? "Uploading…" : submitText}
|
||||
</ButtonLarge>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
accept={AttachmentValidation.avatarContentTypes.join(", ")}
|
||||
onDropAccepted={onDropAccepted}
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const AvatarEditorContainer = styled(Flex)`
|
||||
margin-bottom: 30px;
|
||||
@@ -176,4 +191,4 @@ const RangeInput = styled.input`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(ImageUpload);
|
||||
export default withStores(ImageUpload);
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^5.4.18",
|
||||
"vite": "^5.4.17",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
|
||||
Binary file not shown.
|
After 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 } 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,
|
||||
|
||||
@@ -1335,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?://(.*)$")],
|
||||
|
||||
@@ -86,6 +86,19 @@ function getDecorations({
|
||||
blocks.forEach((block) => {
|
||||
let startPos = block.pos + 1;
|
||||
const language = getRefractorLangForLanguage(block.node.attrs.language);
|
||||
|
||||
if (!language) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the language isn't registered yet, trigger loading it
|
||||
if (!refractor.registered(language)) {
|
||||
languagesToImport.add(language);
|
||||
return;
|
||||
} else {
|
||||
languagesToImport.delete(language);
|
||||
}
|
||||
|
||||
const lineDecorations = [];
|
||||
|
||||
if (!cache[block.pos] || !cache[block.pos].node.eq(block.node)) {
|
||||
@@ -114,48 +127,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 (!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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -893,8 +893,6 @@
|
||||
"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",
|
||||
@@ -1155,5 +1153,6 @@
|
||||
"You updated {{ timeAgo }}": "You updated {{ timeAgo }}",
|
||||
"{{ user }} updated {{ timeAgo }}": "{{ user }} updated {{ timeAgo }}",
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}"
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Uploading": "Uploading"
|
||||
}
|
||||
|
||||
@@ -15605,10 +15605,10 @@ vite-plugin-static-copy@^0.17.0:
|
||||
fs-extra "^11.1.0"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
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==
|
||||
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==
|
||||
dependencies:
|
||||
esbuild "^0.21.3"
|
||||
postcss "^8.4.43"
|
||||
|
||||
Reference in New Issue
Block a user