feat: File attachments (#3031)

* stash

* refactor, working in non-collab + collab editor

* attachment styling

* Avoid crypto require in browser

* AttachmentIcon, handling unknown types

* Do not allow attachment creation for file sizes over limit

* Allow image as file attachment

* Upload placeholder styling

* lint

* Refactor: Do not use placeholder for file attachmentuploads

* Add loading spinner

* fix: Extra paragraphs around attachments on insert

* Bump editor

* fix build error

* Remove attachment placeholder when upload fails

* Remove unused styles

* fix: Attachments on shared pages

* Merge fixes
This commit is contained in:
Tom Moor
2022-03-06 13:58:58 -08:00
committed by GitHub
parent 8b0b383e9e
commit 631d600920
82 changed files with 846 additions and 322 deletions

View File

@@ -6,9 +6,9 @@ import ErrorBoundary from "~/components/ErrorBoundary";
import { Props as EditorProps } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useToasts from "~/hooks/useToasts";
import { uploadFile } from "~/utils/files";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { uploadFile } from "~/utils/uploadFile";
import { isHash } from "~/utils/urls";
const SharedEditor = React.lazy(
@@ -21,7 +21,12 @@ const SharedEditor = React.lazy(
export type Props = Optional<
EditorProps,
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "dictionary"
| "placeholder"
| "defaultValue"
| "onClickLink"
| "embeds"
| "dictionary"
| "onShowToast"
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
@@ -35,7 +40,7 @@ function Editor(props: Props, ref: React.Ref<any>) {
const { showToast } = useToasts();
const dictionary = useDictionary();
const onUploadImage = React.useCallback(
const onUploadFile = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
documentId: id,
@@ -90,7 +95,7 @@ function Editor(props: Props, ref: React.Ref<any>) {
<ErrorBoundary reloadOnChunkMissing>
<SharedEditor
ref={ref}
uploadImage={onUploadImage}
uploadFile={onUploadFile}
onShowToast={onShowToast}
embeds={embeds}
dictionary={dictionary}

View File

@@ -27,10 +27,10 @@ export type Props<T extends MenuItem = MenuItem> = {
dictionary: Dictionary;
view: EditorView;
search: string;
uploadImage?: (file: File) => Promise<string>;
onImageUploadStart?: () => void;
onImageUploadStop?: () => void;
onShowToast?: (message: string, id: string) => void;
uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void;
onFileUploadStop?: () => void;
onShowToast: (message: string, id: string) => void;
onLinkToolbarOpen?: () => void;
onClose: () => void;
onClearSearch: () => void;
@@ -178,7 +178,9 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
insertItem = (item: any) => {
switch (item.name) {
case "image":
return this.triggerImagePick();
return this.triggerFilePick("image/*");
case "attachment":
return this.triggerFilePick("*");
case "embed":
return this.triggerLinkInput(item);
case "link": {
@@ -212,7 +214,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const href = event.currentTarget.value;
const matches = this.state.insertItem.matcher(href);
if (!matches && this.props.onShowToast) {
if (!matches) {
this.props.onShowToast(
this.props.dictionary.embedInvalidLink,
ToastType.Error
@@ -258,8 +260,11 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
}
};
triggerImagePick = () => {
triggerFilePick = (accept: string) => {
if (this.inputRef.current) {
if (accept) {
this.inputRef.current.accept = accept;
}
this.inputRef.current.click();
}
};
@@ -268,14 +273,14 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
this.setState({ insertItem: item });
};
handleImagePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
handleFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(event);
const {
view,
uploadImage,
onImageUploadStart,
onImageUploadStop,
uploadFile,
onFileUploadStart,
onFileUploadStop,
onShowToast,
} = this.props;
const { state } = view;
@@ -283,17 +288,18 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
this.clearSearch();
if (!uploadImage) {
throw new Error("uploadImage prop is required to replace images");
if (!uploadFile) {
throw new Error("uploadFile prop is required to replace files");
}
if (parent) {
insertFiles(view, event, parent.pos, files, {
uploadImage,
onImageUploadStart,
onImageUploadStop,
uploadFile,
onFileUploadStart,
onFileUploadStop,
onShowToast,
dictionary: this.props.dictionary,
isAttachment: this.inputRef.current?.accept === "*",
});
}
@@ -409,7 +415,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const {
embeds = [],
search = "",
uploadImage,
uploadFile,
commands,
filterable = true,
} = this.props;
@@ -447,7 +453,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
}
// If no image upload callback has been passed, filter the image block out
if (!uploadImage && item.name === "image") {
if (!uploadFile && item.name === "image") {
return false;
}
@@ -470,7 +476,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
}
render() {
const { dictionary, isActive, uploadImage } = this.props;
const { dictionary, isActive, uploadFile } = this.props;
const items = this.filtered;
const { insertItem, ...positioning } = this.state;
@@ -537,13 +543,12 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
)}
</List>
)}
{uploadImage && (
{uploadFile && (
<VisuallyHidden>
<input
type="file"
ref={this.inputRef}
onChange={this.handleImagePicked}
accept="image/*"
onChange={this.handleFilePicked}
/>
</VisuallyHidden>
)}

View File

@@ -44,7 +44,7 @@ type Props = {
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast?: (message: string, code: string) => void;
onShowToast: (message: string, code: string) => void;
view: EditorView;
};

View File

@@ -15,7 +15,7 @@ type Props = {
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast?: (msg: string, code: string) => void;
onShowToast: (msg: string, code: string) => void;
onClose: () => void;
};

View File

@@ -36,7 +36,7 @@ type Props = {
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
onCreateLink?: (title: string) => Promise<string>;
onShowToast?: (msg: string, code: string) => void;
onShowToast: (msg: string, code: string) => void;
view: EditorView;
};

View File

@@ -28,6 +28,7 @@ import Strikethrough from "@shared/editor/marks/Strikethrough";
import Underline from "@shared/editor/marks/Underline";
// nodes
import Attachment from "@shared/editor/nodes/Attachment";
import Blockquote from "@shared/editor/nodes/Blockquote";
import BulletList from "@shared/editor/nodes/BulletList";
import CheckboxItem from "@shared/editor/nodes/CheckboxItem";
@@ -107,7 +108,7 @@ export type Props = {
/** Heading id to scroll to when the editor has loaded */
scrollTo?: string;
/** Callback for handling uploaded images, should return the url of uploaded file */
uploadImage?: (file: File) => Promise<string>;
uploadFile?: (file: File) => Promise<string>;
/** Callback when editor is blurred, as native input */
onBlur?: () => void;
/** Callback when editor is focused, as native input */
@@ -119,9 +120,9 @@ export type Props = {
/** Callback when user changes editor content */
onChange?: (value: () => string) => void;
/** Callback when a file upload begins */
onImageUploadStart?: () => void;
onFileUploadStart?: () => void;
/** Callback when a file upload ends */
onImageUploadStop?: () => void;
onFileUploadStop?: () => void;
/** Callback when a link is created, should return url to created document */
onCreateLink?: (title: string) => Promise<string>;
/** Callback when user searches for documents from link insert interface */
@@ -142,7 +143,7 @@ export type Props = {
/** Whether embeds should be rendered without an iframe */
embedsDisabled?: boolean;
/** Callback when a toast message is triggered (eg "link copied") */
onShowToast?: (message: string, code: ToastType) => void;
onShowToast: (message: string, code: ToastType) => void;
className?: string;
style?: React.CSSProperties;
};
@@ -177,10 +178,10 @@ export class Editor extends React.PureComponent<
defaultValue: "",
dir: "auto",
placeholder: "Write something nice…",
onImageUploadStart: () => {
onFileUploadStart: () => {
// no default behavior
},
onImageUploadStop: () => {
onFileUploadStop: () => {
// no default behavior
},
embeds: [],
@@ -318,7 +319,8 @@ export class Editor extends React.PureComponent<
createExtensions() {
const { dictionary } = this.props;
// adding nodes here? Update schema.ts for serialization on the server
// adding nodes here? Update server/editor/renderToHtml.ts for serialization
// on the server
return new ExtensionManager(
[
...[
@@ -341,6 +343,9 @@ export class Editor extends React.PureComponent<
new BulletList(),
new Embed({ embeds: this.props.embeds }),
new ListItem(),
new Attachment({
dictionary,
}),
new Notice({
dictionary,
}),
@@ -351,9 +356,9 @@ export class Editor extends React.PureComponent<
new HorizontalRule(),
new Image({
dictionary,
uploadImage: this.props.uploadImage,
onImageUploadStart: this.props.onImageUploadStart,
onImageUploadStop: this.props.onImageUploadStop,
uploadFile: this.props.uploadFile,
onFileUploadStart: this.props.onFileUploadStart,
onFileUploadStop: this.props.onFileUploadStop,
onShowToast: this.props.onShowToast,
}),
new Table(),
@@ -779,6 +784,7 @@ export class Editor extends React.PureComponent<
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
onShowToast={this.props.onShowToast}
/>
<LinkToolbar
view={this.view}
@@ -795,6 +801,7 @@ export class Editor extends React.PureComponent<
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
onShowToast={this.props.onShowToast}
isActive={this.state.emojiMenuOpen}
search={this.state.blockMenuSearch}
onClose={() => this.setState({ emojiMenuOpen: false })}
@@ -807,10 +814,10 @@ export class Editor extends React.PureComponent<
isActive={this.state.blockMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseBlockMenu}
uploadImage={this.props.uploadImage}
uploadFile={this.props.uploadFile}
onLinkToolbarOpen={this.handleOpenLinkMenu}
onImageUploadStart={this.props.onImageUploadStart}
onImageUploadStop={this.props.onImageUploadStop}
onFileUploadStart={this.props.onFileUploadStart}
onFileUploadStop={this.props.onFileUploadStop}
onShowToast={this.props.onShowToast}
embeds={this.props.embeds}
/>

View File

@@ -15,6 +15,7 @@ import {
WarningIcon,
InfoIcon,
LinkIcon,
AttachmentIcon,
} from "outline-icons";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
@@ -84,6 +85,12 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
shortcut: `${metaDisplay} k`,
keywords: "link url uri href",
},
{
name: "attachment",
title: dictionary.file,
icon: AttachmentIcon,
keywords: "file upload attach",
},
{
name: "table",
title: dictionary.table,
@@ -124,21 +131,21 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
name: "container_notice",
title: dictionary.infoNotice,
icon: InfoIcon,
keywords: "container_notice card information",
keywords: "notice card information",
attrs: { style: "info" },
},
{
name: "container_notice",
title: dictionary.warningNotice,
icon: WarningIcon,
keywords: "container_notice card error",
keywords: "notice card error",
attrs: { style: "warning" },
},
{
name: "container_notice",
title: dictionary.tipNotice,
icon: StarredIcon,
keywords: "container_notice card suggestion",
keywords: "notice card suggestion",
attrs: { style: "tip" },
},
];

View File

@@ -32,6 +32,7 @@ export default function useDictionary() {
alignImageDefault: t("Center large"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
file: t("File attachment"),
findOrCreateDoc: `${t("Find or create a doc")}`,
h1: t("Big heading"),
h2: t("Medium heading"),
@@ -39,7 +40,7 @@ export default function useDictionary() {
heading: t("Heading"),
hr: t("Divider"),
image: t("Image"),
imageUploadError: t("Sorry, an error occurred uploading the image"),
fileUploadError: t("Sorry, an error occurred uploading the file"),
imageCaptionPlaceholder: t("Write a caption"),
info: t("Info"),
infoNotice: t("Info notice"),

View File

@@ -1,4 +1,5 @@
import { computed } from "mobx";
import { bytesToHumanReadable } from "@shared/utils/files";
import BaseModal from "./BaseModel";
import User from "./User";
@@ -23,13 +24,7 @@ class FileOperation extends BaseModal {
@computed
get sizeInMB(): string {
const inKB = this.size / 1024;
if (inKB < 1024) {
return inKB.toFixed(2) + "KB";
}
return (inKB / 1024).toFixed(2) + "MB";
return bytesToHumanReadable(this.size);
}
}

View File

@@ -346,11 +346,11 @@ class DocumentScene extends React.Component<Props> {
updateIsDirtyDebounced = debounce(this.updateIsDirty, 500);
onImageUploadStart = () => {
onFileUploadStart = () => {
this.isUploading = true;
};
onImageUploadStop = () => {
onFileUploadStop = () => {
this.isUploading = false;
};
@@ -558,8 +558,8 @@ class DocumentScene extends React.Component<Props> {
defaultValue={value}
embedsDisabled={embedsDisabled}
onSynced={this.onSynced}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
onFileUploadStart={this.onFileUploadStart}
onFileUploadStop={this.onFileUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.onChangeTitle}

View File

@@ -16,7 +16,7 @@ import Subheading from "~/components/Subheading";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { uploadFile } from "~/utils/uploadFile";
import { uploadFile } from "~/utils/files";
import FileOperationListItem from "./components/FileOperationListItem";
function Import() {

View File

@@ -12,7 +12,7 @@ import LoadingIndicator from "~/components/LoadingIndicator";
import Modal from "~/components/Modal";
import withStores from "~/components/withStores";
import { compressImage } from "~/utils/compressImage";
import { uploadFile, dataUrlToBlob } from "~/utils/uploadFile";
import { uploadFile, dataUrlToBlob } from "~/utils/files";
const EMPTY_OBJECT = {};

View File

@@ -1,15 +1,21 @@
import * as Sentry from "@sentry/react";
import invariant from "invariant";
import { client } from "./ApiClient";
type Options = {
type UploadOptions = {
/** The user facing name of the file */
name?: string;
/** The document that this file was uploaded in, if any */
documentId?: string;
/** Whether the file should be public in cloud storage */
public?: boolean;
/** Callback will be passed a number between 0-1 as upload progresses */
onProgress?: (fractionComplete: number) => void;
};
export const uploadFile = async (
file: File | Blob,
options: Options = {
options: UploadOptions = {
name: "",
}
) => {
@@ -38,11 +44,28 @@ export const uploadFile = async (
formData.append("file", file);
}
const uploadResponse = await fetch(data.uploadUrl, {
method: "post",
body: formData,
// Using XMLHttpRequest instead of fetch because fetch doesn't support progress
let error;
const xhr = new XMLHttpRequest();
const success = await new Promise((resolve) => {
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && options.onProgress) {
options.onProgress(event.loaded / event.total);
}
});
xhr.addEventListener("error", (err) => (error = err));
xhr.addEventListener("loadend", () => {
resolve(xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 400);
});
xhr.open("POST", data.uploadUrl, true);
xhr.send(formData);
});
invariant(uploadResponse.ok, "Upload failed, try again?");
if (!success) {
Sentry.captureException(error);
throw new Error("Upload failed");
}
return attachment;
};

View File

@@ -73,6 +73,7 @@
"compressorjs": "^1.0.7",
"copy-to-clipboard": "^3.3.1",
"core-js": "^3.10.2",
"crypto-js": "^4.1.1",
"datadog-metrics": "^0.9.3",
"date-fns": "^2.25.0",
"dd-trace": "^0.32.2",
@@ -207,6 +208,7 @@
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
"@relative-ci/agent": "^3.0.0",
"@types/bull": "^3.15.5",
"@types/crypto-js": "^4.1.0",
"@types/datadog-metrics": "^0.6.2",
"@types/emoji-regex": "^9.2.0",
"@types/enzyme": "^3.10.10",

View File

@@ -12,6 +12,7 @@ import Strikethrough from "@shared/editor/marks/Strikethrough";
import Underline from "@shared/editor/marks/Underline";
// nodes
import Attachment from "@shared/editor/nodes/Attachment";
import Blockquote from "@shared/editor/nodes/Blockquote";
import BulletList from "@shared/editor/nodes/BulletList";
import CheckboxItem from "@shared/editor/nodes/CheckboxItem";
@@ -51,6 +52,7 @@ const extensions = new ExtensionManager([
new Embed(),
new ListItem(),
new Notice(),
new Attachment(),
new Heading(),
new HorizontalRule(),
new Image(),

View File

@@ -1,5 +1,6 @@
import { PluginSimple } from "markdown-it";
import createMarkdown from "@shared/editor/lib/markdown/rules";
import attachmentsRule from "@shared/editor/rules/attachments";
import breakRule from "@shared/editor/rules/breaks";
import checkboxRule from "@shared/editor/rules/checkboxes";
import embedsRule from "@shared/editor/rules/embeds";
@@ -18,6 +19,7 @@ const defaultRules = [
underlinesRule,
tablesRule,
noticesRule,
attachmentsRule,
emojiRule,
];

View File

@@ -22,9 +22,9 @@ import {
} from "sequelize-typescript";
import { v4 as uuidv4 } from "uuid";
import { languages } from "@shared/i18n";
import { stringToColor } from "@shared/utils/color";
import Logger from "@server/logging/logger";
import { DEFAULT_AVATAR_HOST } from "@server/utils/avatars";
import { palette } from "@server/utils/color";
import { publicS3Endpoint, uploadToS3FromUrl } from "@server/utils/s3";
import { ValidationError } from "../errors";
import ApiKey from "./ApiKey";
@@ -157,9 +157,7 @@ class User extends ParanoidModel {
}
get color() {
const idAsHex = crypto.createHash("md5").update(this.id).digest("hex");
const idAsNumber = parseInt(idAsHex, 16);
return palette[idAsNumber % palette.length];
return stringToColor(this.id);
}
// instance methods

View File

@@ -1,3 +1,4 @@
import { escapeRegExp } from "lodash";
import { Document } from "@server/models";
import Attachment from "@server/models/Attachment";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
@@ -16,8 +17,11 @@ async function replaceImageAttachments(text: string) {
const attachment = await Attachment.findByPk(id);
if (attachment) {
const accessUrl = await getSignedUrl(attachment.key);
text = text.replace(attachment.redirectUrl, accessUrl);
const signedUrl = await getSignedUrl(attachment.key, 3600);
text = text.replace(
new RegExp(escapeRegExp(attachment.redirectUrl), "g"),
signedUrl
);
}
})
);

View File

@@ -5,6 +5,7 @@ import { PublicEnv } from "@shared/types";
export default function present(env: Record<string, any>): PublicEnv {
return {
URL: env.URL.replace(/\/$/, ""),
AWS_S3_UPLOAD_BUCKET_URL: env.AWS_S3_UPLOAD_BUCKET_URL,
CDN_URL: (env.CDN_URL || "").replace(/\/$/, ""),
COLLABORATION_URL: (env.COLLABORATION_URL || env.URL)
.replace(/\/$/, "")

View File

@@ -1,6 +1,7 @@
import Router from "koa-router";
import { v4 as uuidv4 } from "uuid";
import { NotFoundError } from "@server/errors";
import { bytesToHumanReadable } from "@shared/utils/files";
import { NotFoundError, ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { Attachment, Document, Event } from "@server/models";
import { authorize } from "@server/policies";
@@ -15,12 +16,28 @@ const router = new Router();
const AWS_S3_ACL = process.env.AWS_S3_ACL || "private";
router.post("attachments.create", auth(), async (ctx) => {
const { name, documentId, contentType, size } = ctx.body;
const {
name,
documentId,
contentType = "application/octet-stream",
size,
} = ctx.body;
assertPresent(name, "name is required");
assertPresent(contentType, "contentType is required");
assertPresent(size, "size is required");
const { user } = ctx.state;
authorize(user, "createAttachment", user.team);
if (
process.env.AWS_S3_UPLOAD_MAX_SIZE &&
size > process.env.AWS_S3_UPLOAD_MAX_SIZE
) {
throw ValidationError(
`Sorry, this file is too large the maximum size is ${bytesToHumanReadable(
parseInt(process.env.AWS_S3_UPLOAD_MAX_SIZE, 10)
)}`
);
}
const s3Key = uuidv4();
const acl =
ctx.body.public === undefined

View File

@@ -1,19 +0,0 @@
import { darken } from "polished";
import theme from "@shared/theme";
export const palette = [
theme.brand.red,
theme.brand.blue,
theme.brand.purple,
theme.brand.pink,
theme.brand.marine,
theme.brand.green,
theme.brand.yellow,
darken(0.2, theme.brand.red),
darken(0.2, theme.brand.blue),
darken(0.2, theme.brand.purple),
darken(0.2, theme.brand.pink),
darken(0.2, theme.brand.marine),
darken(0.2, theme.brand.green),
darken(0.2, theme.brand.yellow),
];

View File

@@ -1,11 +1,13 @@
import { compact } from "lodash";
import { uniq, compact } from "lodash";
const attachmentRegex = /\/api\/attachments\.redirect\?id=(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi;
export default function parseAttachmentIds(text: string): string[] {
return compact(
[...text.matchAll(attachmentRegex)].map(
(match) => match.groups && match.groups.id
return uniq(
compact(
[...text.matchAll(attachmentRegex)].map(
(match) => match.groups && match.groups.id
)
)
);
}

View File

@@ -106,11 +106,12 @@ export const getPresignedPost = (
const params = {
Bucket: process.env.AWS_S3_UPLOAD_BUCKET_NAME,
Conditions: [
// @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'.
["content-length-range", 0, +process.env.AWS_S3_UPLOAD_MAX_SIZE],
process.env.AWS_S3_UPLOAD_MAX_SIZE
? ["content-length-range", 0, +process.env.AWS_S3_UPLOAD_MAX_SIZE]
: undefined,
["starts-with", "$Content-Type", contentType],
["starts-with", "$Cache-Control", ""],
],
].filter(Boolean),
Fields: {
key,
acl,
@@ -208,12 +209,12 @@ export const deleteFromS3 = (key: string) => {
.promise();
};
export const getSignedUrl = async (key: string) => {
export const getSignedUrl = async (key: string, expiresInMs = 60) => {
const isDocker = AWS_S3_UPLOAD_BUCKET_URL.match(/http:\/\/s3:/);
const params = {
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
Key: key,
Expires: 60,
Expires: expiresInMs,
};
const url = isDocker

View File

@@ -38,7 +38,7 @@ const createAndInsertLink = async function (
options: {
dictionary: any;
onCreateLink: (title: string) => Promise<string>;
onShowToast?: (message: string, code: string) => void;
onShowToast: (message: string, code: string) => void;
}
) {
const { dispatch, state } = view;

View File

@@ -1,19 +1,23 @@
import * as Sentry from "@sentry/react";
import { NodeSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { v4 as uuidv4 } from "uuid";
import uploadPlaceholderPlugin, {
findPlaceholder,
} from "../lib/uploadPlaceholder";
import findAttachmentById from "../queries/findAttachmentById";
import { ToastType } from "../types";
let uploadId = 0;
export type Options = {
dictionary: any;
/** Set to true to force images to become attachments */
isAttachment?: boolean;
/** Set to true to replace any existing image at the users selection */
replaceExisting?: boolean;
uploadImage: (file: File) => Promise<string>;
onImageUploadStart?: () => void;
onImageUploadStop?: () => void;
onShowToast?: (message: string, code: string) => void;
uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void;
onFileUploadStop?: () => void;
onShowToast: (message: string, code: string) => void;
};
const insertFiles = function (
@@ -23,85 +27,133 @@ const insertFiles = function (
files: File[],
options: Options
): void {
// filter to only include image files
const images = files.filter((file) => /image/i.test(file.type));
if (images.length === 0) {
return;
}
const {
dictionary,
uploadImage,
onImageUploadStart,
onImageUploadStop,
uploadFile,
onFileUploadStart,
onFileUploadStop,
onShowToast,
} = options;
if (!uploadImage) {
console.warn(
"uploadImage callback must be defined to handle image uploads."
);
if (!uploadFile) {
console.warn("uploadFile callback must be defined to handle uploads.");
return;
}
// okay, we have some dropped images and a handler lets stop this
// okay, we have some dropped files and a handler lets stop this
// event going any further up the stack
event.preventDefault();
// let the user know we're starting to process the images
if (onImageUploadStart) {
onImageUploadStart();
// let the user know we're starting to process the files
if (onFileUploadStart) {
onFileUploadStart();
}
const { schema } = view.state;
// we'll use this to track of how many images have succeeded or failed
// we'll use this to track of how many files have succeeded or failed
let complete = 0;
// the user might have dropped multiple images at once, we need to loop
for (const file of images) {
const id = `upload-${uploadId++}`;
// the user might have dropped multiple files at once, we need to loop
for (const file of files) {
const id = `upload-${uuidv4()}`;
const isImage = file.type.startsWith("image/") && !options.isAttachment;
const { tr } = view.state;
// insert a placeholder at this position, or mark an existing image as being
// replaced
tr.setMeta(uploadPlaceholderPlugin, {
add: {
id,
file,
pos,
replaceExisting: options.replaceExisting,
},
});
view.dispatch(tr);
if (isImage) {
// insert a placeholder at this position, or mark an existing file as being
// replaced
tr.setMeta(uploadPlaceholderPlugin, {
add: {
id,
file,
pos,
isImage,
replaceExisting: options.replaceExisting,
},
});
view.dispatch(tr);
} else {
const $pos = tr.doc.resolve(pos);
view.dispatch(
view.state.tr.replaceWith(
$pos.pos,
$pos.pos + ($pos.nodeAfter?.nodeSize || 0),
schema.nodes.attachment.create({
id,
title: file.name,
size: file.size,
})
)
);
}
// start uploading the image file to the server. Using "then" syntax
// start uploading the file to the server. Using "then" syntax
// to allow all placeholders to be entered at once with the uploads
// happening in the background in parallel.
uploadImage(file)
uploadFile(file)
.then((src) => {
// otherwise, insert it at the placeholder's position, and remove
// the placeholder itself
const newImg = new Image();
if (isImage) {
const newImg = new Image();
newImg.onload = () => {
const result = findPlaceholder(view.state, id);
newImg.onload = () => {
const result = findPlaceholder(view.state, id);
// if the content around the placeholder has been deleted
// then forget about inserting this file
if (result === null) {
return;
}
// if the content around the placeholder has been deleted
// then forget about inserting this image
const [from, to] = result;
view.dispatch(
view.state.tr
.replaceWith(
from,
to || from,
schema.nodes.image.create({ src })
)
.setMeta(uploadPlaceholderPlugin, { remove: { id } })
);
// If the users selection is still at the file then make sure to select
// the entire node once done. Otherwise, if the selection has moved
// elsewhere then we don't want to modify it
if (view.state.selection.from === from) {
view.dispatch(
view.state.tr.setSelection(
new NodeSelection(view.state.doc.resolve(from))
)
);
}
};
newImg.onerror = (error) => {
throw error;
};
newImg.src = src;
} else {
const result = findAttachmentById(view.state, id);
// if the attachment has been deleted then forget about updating it
if (result === null) {
return;
}
const [from, to] = result;
view.dispatch(
view.state.tr
.replaceWith(from, to || from, schema.nodes.image.create({ src }))
.setMeta(uploadPlaceholderPlugin, { remove: { id } })
view.state.tr.replaceWith(
from,
to || from,
schema.nodes.attachment.create({
href: src,
title: file.name,
size: file.size,
})
)
);
// If the users selection is still at the image then make sure to select
// If the users selection is still at the file then make sure to select
// the entire node once done. Otherwise, if the selection has moved
// elsewhere then we don't want to modify it
if (view.state.selection.from === from) {
@@ -111,34 +163,41 @@ const insertFiles = function (
)
);
}
};
newImg.onerror = (error) => {
throw error;
};
newImg.src = src;
}
})
.catch((error) => {
console.error(error);
Sentry.captureException(error);
// cleanup the placeholder if there is a failure
const transaction = view.state.tr.setMeta(uploadPlaceholderPlugin, {
remove: { id },
});
view.dispatch(transaction);
if (isImage) {
view.dispatch(
view.state.tr.setMeta(uploadPlaceholderPlugin, {
remove: { id },
})
);
} else {
const result = findAttachmentById(view.state, id);
// let the user know
if (onShowToast) {
onShowToast(dictionary.imageUploadError, ToastType.Error);
// if the attachment has been deleted then forget about updating it
if (result === null) {
return;
}
const [from, to] = result;
view.dispatch(view.state.tr.deleteRange(from, to || from));
}
onShowToast(
error.message || dictionary.fileUploadError,
ToastType.Error
);
})
.finally(() => {
complete++;
// once everything is done, let the user know
if (complete === images.length && onImageUploadStop) {
onImageUploadStop();
if (complete === files.length && onFileUploadStop) {
onFileUploadStop();
}
});
}

View File

@@ -0,0 +1,20 @@
import { OpenIcon } from "outline-icons";
import * as React from "react";
import { DefaultTheme, ThemeProps } from "styled-components";
import { EmbedProps as Props } from "../embeds";
import Widget from "./Widget";
export default function DisabledEmbed(props: Props & ThemeProps<DefaultTheme>) {
return (
<Widget
title={props.embed.title}
href={props.attrs.href}
icon={props.embed.icon(undefined)}
context={props.attrs.href.replace(/^https?:\/\//, "")}
isSelected={props.isSelected}
theme={props.theme}
>
<OpenIcon color="currentColor" size={20} />
</Widget>
);
}

View File

@@ -0,0 +1,42 @@
import { AttachmentIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { stringToColor } from "../../utils/color";
type Props = {
title: string;
size?: number;
};
export default function FileExtension(props: Props) {
const parts = props.title.split(".");
const extension = parts.length > 1 ? parts.pop() : undefined;
return (
<Icon
style={{ background: stringToColor(extension || "") }}
$size={props.size || 28}
>
{extension ? (
<span>{extension?.slice(0, 4)}</span>
) : (
<AttachmentIcon color="currentColor" />
)}
</Icon>
);
}
const Icon = styled.span<{ $size: number }>`
font-family: ${(props) => props.theme.fontFamilyMono};
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
text-transform: uppercase;
color: white;
text-align: center;
border-radius: 4px;
min-width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px;
`;

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { cdnPath } from "../../../utils/urls";
import { cdnPath } from "../../utils/urls";
type Props = {
alt: string;

View File

@@ -0,0 +1,98 @@
import * as React from "react";
import styled, { css, DefaultTheme, ThemeProps } from "styled-components";
type Props = {
icon: React.ReactNode;
title: React.ReactNode;
context?: React.ReactNode;
href: string;
isSelected: boolean;
children?: React.ReactNode;
};
export default function Widget(props: Props & ThemeProps<DefaultTheme>) {
return (
<Wrapper
className={
props.isSelected ? "ProseMirror-selectednode widget" : "widget"
}
href={props.href}
target="_blank"
rel="noreferrer nofollow"
>
{props.icon}
<Preview>
<Title>{props.title}</Title>
<Subtitle>{props.context}</Subtitle>
<Children>{props.children}</Children>
</Preview>
</Wrapper>
);
}
const Children = styled.div`
margin-left: auto;
height: 20px;
opacity: 0;
&:hover {
color: ${(props) => props.theme.text};
}
`;
const Title = styled.strong`
font-weight: 500;
font-size: 14px;
color: ${(props) => props.theme.text};
`;
const Preview = styled.div`
gap: 8px;
display: flex;
flex-direction: row;
flex-grow: 1;
align-items: center;
color: ${(props) => props.theme.textTertiary};
`;
const Subtitle = styled.span`
font-size: 13px;
color: ${(props) => props.theme.textTertiary} !important;
line-height: 0;
`;
const Wrapper = styled.a`
display: flex;
align-items: center;
gap: 6px;
background: ${(props) => props.theme.background};
color: ${(props) => props.theme.text} !important;
outline: 1px solid ${(props) => props.theme.divider};
white-space: nowrap;
border-radius: 8px;
padding: 6px 8px;
max-width: 840px;
cursor: default;
user-select: none;
text-overflow: ellipsis;
overflow: hidden;
${(props) =>
props.href &&
css`
&:hover,
&:active,
&:focus,
&:focus:not(.focus-visible) {
cursor: pointer !important;
text-decoration: none !important;
background: ${(props) => props.theme.secondaryBackground};
outline: 1px solid ${(props) => props.theme.divider};
${Children} {
opacity: 1;
}
}
`}
`;

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
export default class Abstract extends React.Component<Props> {

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("https://airtable.com/(?:embed/)?(shr.*)$");

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /(?:https?:\/\/)?(www\.bilibili\.com)\/video\/([\w\d]+)?(\?\S+)?/i;

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("https?://cawemo.com/(?:share|embed)/(.*)$");

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https://codepen.io/(.*?)/(pen|embed)/(.*)$");

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
export default class DBDiagram extends React.Component<Props> {

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
export default class Descript extends React.Component<Props> {

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import Frame from "../components/Frame";
import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = /^https:\/\/viewer\.diagrams\.net\/.*(title=\\w+)?/;

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https://framer.cloud/(.*)$");

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import Frame from "../components/Frame";
import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import Frame from "../components/Frame";
import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import Frame from "../components/Frame";
import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import Frame from "../components/Frame";
import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https?://drive.google.com/file/d/(.*)$");

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import Frame from "../components/Frame";
import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import Frame from "../components/Frame";
import Image from "../components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import ImageZoom from "react-medium-image-zoom";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const IFRAME_REGEX = /^https:\/\/(invis\.io\/.*)|(projects\.invisionapp\.com\/share\/.*)$/;

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /^https:\/\/(www\.)?(use)?loom.com\/(embed|share)\/(.*)$/;

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
export default class Lucidchart extends React.Component<Props> {

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https://marvelapp.com/([A-Za-z0-9-]{6})/?$");

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /^https:\/\/(realtimeboard|miro).com\/app\/board\/(.*)$/;

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https://prezi.com/view/(.*)$");

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
const URL_REGEX = new RegExp("https?://open.spotify.com/(.*)$");
import { EmbedProps as Props } from ".";

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /^https:\/\/trello.com\/(c|b)\/([^/]*)(.*)?$/;

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /(http|https)?:\/\/(www\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|)(\d+)(?:\/|\?)?([\d\w]+)?/;

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /^https?:\/\/whimsical.com\/[0-9a-zA-Z-_~]*-([a-zA-Z0-9]+)\/?$/;

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import Frame from "./components/Frame";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /(?:https?:\/\/)?(?:www\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([a-zA-Z0-9_-]{11})$/i;

View File

@@ -1,71 +0,0 @@
import { OpenIcon } from "outline-icons";
import * as React from "react";
import styled, { DefaultTheme, ThemeProps } from "styled-components";
import { EmbedProps as Props } from "../";
export default function Simple(props: Props & ThemeProps<DefaultTheme>) {
return (
<Wrapper
className={
props.isSelected
? "ProseMirror-selectednode disabled-embed"
: "disabled-embed"
}
href={props.attrs.href}
target="_blank"
rel="noreferrer nofollow"
>
{props.embed.icon(undefined)}
<Preview>
<Title>{props.embed.title}</Title>
<Subtitle>{props.attrs.href.replace(/^https?:\/\//, "")}</Subtitle>
<StyledOpenIcon color="currentColor" size={20} />
</Preview>
</Wrapper>
);
}
const StyledOpenIcon = styled(OpenIcon)`
margin-left: auto;
`;
const Title = styled.strong`
font-weight: 500;
font-size: 14px;
color: ${(props) => props.theme.text};
`;
const Preview = styled.div`
gap: 8px;
display: flex;
flex-direction: row;
flex-grow: 1;
align-items: center;
color: ${(props) => props.theme.textTertiary};
`;
const Subtitle = styled.span`
font-size: 13px;
color: ${(props) => props.theme.textTertiary} !important;
`;
const Wrapper = styled.a`
display: inline-flex;
align-items: flex-start;
gap: 4px;
box-sizing: border-box !important;
color: ${(props) => props.theme.text} !important;
background: ${(props) => props.theme.secondaryBackground};
white-space: nowrap;
border-radius: 8px;
padding: 6px 8px;
max-width: 840px;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
&:hover {
text-decoration: none !important;
outline: 2px solid ${(props) => props.theme.divider};
}
`;

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import styled from "styled-components";
import { EmbedDescriptor } from "@shared/editor/types";
import Image from "../components/Image";
import Abstract from "./Abstract";
import Airtable from "./Airtable";
import Bilibili from "./Bilibili";
@@ -35,7 +36,6 @@ import Typeform from "./Typeform";
import Vimeo from "./Vimeo";
import Whimsical from "./Whimsical";
import YouTube from "./YouTube";
import Image from "./components/Image";
export type EmbedProps = {
isSelected: boolean;

View File

@@ -1,5 +1,8 @@
import { EditorState, Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
import FileExtension from "../components/FileExtension";
// based on the example at: https://prosemirror.net/examples/upload/
const uploadPlaceholder = new Plugin({
@@ -31,7 +34,7 @@ const uploadPlaceholder = new Plugin({
);
set = set.add(tr.doc, [deco]);
}
} else {
} else if (action.add.isImage) {
const element = document.createElement("div");
element.className = "image placeholder";
@@ -40,6 +43,30 @@ const uploadPlaceholder = new Plugin({
element.appendChild(img);
const deco = Decoration.widget(action.add.pos, element, {
id: action.add.id,
});
set = set.add(tr.doc, [deco]);
} else {
const element = document.createElement("div");
element.className = "attachment placeholder";
const icon = document.createElement("div");
icon.className = "icon";
const component = <FileExtension title={action.add.file.name} />;
ReactDOM.render(component, icon);
element.appendChild(icon);
const text = document.createElement("span");
text.innerText = action.add.file.name;
element.appendChild(text);
const status = document.createElement("span");
status.innerText = "Uploading…";
status.className = "status";
element.appendChild(status);
const deco = Decoration.widget(action.add.pos, element, {
id: action.add.id,
});

View File

@@ -0,0 +1,114 @@
import Token from "markdown-it/lib/token";
import { DownloadIcon } from "outline-icons";
import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model";
import * as React from "react";
import { Trans } from "react-i18next";
import { bytesToHumanReadable } from "../../utils/files";
import toggleWrap from "../commands/toggleWrap";
import FileExtension from "../components/FileExtension";
import Widget from "../components/Widget";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import attachmentsRule from "../rules/attachments";
import { ComponentProps } from "../types";
import Node from "./Node";
export default class Attachment extends Node {
get name() {
return "attachment";
}
get rulePlugins() {
return [attachmentsRule];
}
get schema(): NodeSpec {
return {
attrs: {
id: {
default: null,
},
href: {
default: null,
},
title: {},
size: {},
},
group: "block",
defining: true,
atom: true,
parseDOM: [
{
priority: 100,
tag: "a.attachment",
getAttrs: (dom: HTMLAnchorElement) => {
return {
id: dom.id,
title: dom.innerText,
href: dom.getAttribute("href"),
size: parseInt(dom.dataset.size || "0", 10),
};
},
},
],
toDOM: (node) => {
return [
"a",
{
class: `attachment`,
id: node.attrs.id,
href: node.attrs.href,
download: node.attrs.title,
"data-size": node.attrs.size,
},
node.attrs.title,
];
},
};
}
component({ isSelected, theme, node }: ComponentProps) {
return (
<Widget
icon={<FileExtension title={node.attrs.title} />}
href={node.attrs.href}
title={node.attrs.title}
context={
node.attrs.href ? (
bytesToHumanReadable(node.attrs.size)
) : (
<>
<Trans>Uploading</Trans>
</>
)
}
isSelected={isSelected}
theme={theme}
>
{node.attrs.href && <DownloadIcon color="currentColor" size={20} />}
</Widget>
);
}
commands({ type }: { type: NodeType }) {
return (attrs: Record<string, any>) => toggleWrap(type, attrs);
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.ensureNewLine();
state.write(
`[${node.attrs.title} ${node.attrs.size}](${node.attrs.href})\n\n`
);
state.ensureNewLine();
}
parseMarkdown() {
return {
node: "attachment",
getAttrs: (tok: Token) => ({
href: tok.attrGet("href"),
title: tok.attrGet("title"),
size: tok.attrGet("size"),
}),
};
}
}

View File

@@ -197,12 +197,10 @@ export default class CodeFence extends Node {
const node = view.state.doc.nodeAt(result.pos);
if (node) {
copy(node.textContent);
if (this.options.onShowToast) {
this.options.onShowToast(
this.options.dictionary.codeCopied,
ToastType.Info
);
}
this.options.onShowToast(
this.options.dictionary.codeCopied,
ToastType.Info
);
}
}
};

View File

@@ -2,7 +2,7 @@ import Token from "markdown-it/lib/token";
import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import * as React from "react";
import Simple from "../embeds/components/Simple";
import DisabledEmbed from "../components/DisabledEmbed";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import embedsRule from "../rules/embeds";
import { ComponentProps } from "../types";
@@ -94,7 +94,7 @@ export default class Embed extends Node {
if (embedsDisabled) {
return (
<Simple
<DisabledEmbed
attrs={{ href: node.attrs.href, matches }}
embed={embed}
isEditable={isEditable}

View File

@@ -180,12 +180,10 @@ export default class Heading extends Node {
const urlWithoutHash = window.location.href.split("#")[0];
copy(urlWithoutHash + hash);
if (this.options.onShowToast) {
this.options.onShowToast(
this.options.dictionary.linkCopied,
ToastType.Info
);
}
this.options.onShowToast(
this.options.dictionary.linkCopied,
ToastType.Info
);
};
keys({ type, schema }: { type: NodeType; schema: Schema }) {

View File

@@ -36,7 +36,7 @@ const uploadPlugin = (options: Options) =>
paste(view, event: ClipboardEvent): boolean {
if (
(view.props.editable && !view.props.editable(view.state)) ||
!options.uploadImage
!options.uploadFile
) {
return false;
}
@@ -48,8 +48,9 @@ const uploadPlugin = (options: Options) =>
// check if we actually pasted any files
const files = Array.prototype.slice
.call(event.clipboardData.items)
.map((dt: any) => dt.getAsFile())
.filter((file: File) => file);
.filter((dt: DataTransferItem) => dt.kind !== "string")
.map((dt: DataTransferItem) => dt.getAsFile())
.filter(Boolean);
if (files.length === 0) {
return false;
@@ -67,15 +68,13 @@ const uploadPlugin = (options: Options) =>
drop(view, event: DragEvent): boolean {
if (
(view.props.editable && !view.props.editable(view.state)) ||
!options.uploadImage
!options.uploadFile
) {
return false;
}
// filter to only include image files
const files = getDataTransferFiles(event).filter((file) =>
/image/i.test(file.type)
);
const files = getDataTransferFiles(event);
if (files.length === 0) {
return false;
}
@@ -430,14 +429,14 @@ export default class Image extends Node {
replaceImage: () => (state: EditorState) => {
const { view } = this.editor;
const {
uploadImage,
onImageUploadStart,
onImageUploadStop,
uploadFile,
onFileUploadStart,
onFileUploadStop,
onShowToast,
} = this.editor.props;
if (!uploadImage) {
throw new Error("uploadImage prop is required to replace images");
if (!uploadFile) {
throw new Error("uploadFile prop is required to replace images");
}
// create an input element and click to trigger picker
@@ -447,9 +446,9 @@ export default class Image extends Node {
inputElement.onchange = (event: Event) => {
const files = getDataTransferFiles(event);
insertFiles(view, event, state.selection.from, files, {
uploadImage,
onImageUploadStart,
onImageUploadStop,
uploadFile,
onFileUploadStart,
onFileUploadStop,
onShowToast,
dictionary: this.options.dictionary,
replaceExisting: true,

View File

@@ -0,0 +1,23 @@
import { EditorState } from "prosemirror-state";
const findAttachmentById = function (
state: EditorState,
id: string
): [number, number] | null {
let result: [number, number] | null = null;
state.doc.descendants((node, pos) => {
if (result) {
return false;
}
if (node.type.name === "attachment" && node.attrs.id === id) {
result = [pos, pos + node.nodeSize];
return false;
}
return true;
});
return result;
};
export default findAttachmentById;

View File

@@ -0,0 +1,82 @@
import MarkdownIt from "markdown-it";
import Token from "markdown-it/lib/token";
import env from "../../env";
function isParagraph(token: Token) {
return token.type === "paragraph_open";
}
function isInline(token: Token) {
return token.type === "inline";
}
function isLinkOpen(token: Token) {
return token.type === "link_open";
}
function isLinkClose(token: Token) {
return token.type === "link_close";
}
function isAttachment(token: Token) {
const href = token.attrGet("href");
return (
href?.includes("attachments.redirect") ||
href?.startsWith(env.AWS_S3_UPLOAD_BUCKET_URL)
);
}
export default function linksToAttachments(md: MarkdownIt) {
md.core.ruler.after("breaks", "attachments", (state) => {
const tokens = state.tokens;
let insideLink;
for (let i = 0; i < tokens.length - 1; i++) {
// once we find an inline token look through it's children for links
if (isInline(tokens[i]) && isParagraph(tokens[i - 1])) {
const tokenChildren = tokens[i].children || [];
for (let j = 0; j < tokenChildren.length - 1; j++) {
const current = tokenChildren[j];
if (!current) {
continue;
}
if (isLinkOpen(current)) {
insideLink = current;
continue;
}
if (isLinkClose(current)) {
insideLink = null;
continue;
}
// of hey, we found a link lets check to see if it should be
// converted to a file attachment
if (insideLink && isAttachment(insideLink)) {
const { content } = current;
// convert to attachment token
const token = new Token("attachment", "a", 0);
token.attrSet("href", insideLink.attrGet("href") || "");
const parts = content.split(" ");
const size = parts.pop();
const title = parts.join(" ");
token.attrSet("size", size || "0");
token.attrSet("title", title);
// delete the inline link this makes the assumption that the
// attachment is the only thing in the para.
tokens.splice(i - 1, 3, token);
insideLink = null;
break;
}
}
}
}
return false;
});
}

View File

@@ -18,7 +18,7 @@ function isLinkClose(token: Token) {
return token.type === "link_close";
}
export default function (embeds: EmbedDescriptor[]) {
export default function linksToEmbeds(embeds: EmbedDescriptor[]) {
function isEmbed(token: Token, link: Token) {
const href = link.attrs ? link.attrs[0][1] : "";
const simpleLink = href === token.content;
@@ -70,7 +70,7 @@ export default function (embeds: EmbedDescriptor[]) {
}
// of hey, we found a link lets check to see if it should be
// considered to be an embed
// converted to an embed
if (insideLink) {
const result = isEmbed(current, insideLink);
if (result) {
@@ -82,7 +82,6 @@ export default function (embeds: EmbedDescriptor[]) {
// delete the inline link this makes the assumption that the
// embed is the only thing in the para.
// TODO: double check this
tokens.splice(i - 1, 3, token);
break;
}

View File

@@ -1,3 +1,3 @@
const EDITOR_VERSION = "11.21.3";
const EDITOR_VERSION = "12.0.0";
export default EDITOR_VERSION;

5
shared/env.ts Normal file
View File

@@ -0,0 +1,5 @@
import { PublicEnv } from "./types";
const env = typeof window === "undefined" ? process.env : window.env;
export default env as PublicEnv;

View File

@@ -194,6 +194,7 @@
"Center large": "Center large",
"Italic": "Italic",
"Sorry, that link wont work for this embed type": "Sorry, that link wont work for this embed type",
"File attachment": "File attachment",
"Find or create a doc": "Find or create a doc",
"Big heading": "Big heading",
"Medium heading": "Medium heading",
@@ -201,7 +202,7 @@
"Heading": "Heading",
"Divider": "Divider",
"Image": "Image",
"Sorry, an error occurred uploading the image": "Sorry, an error occurred uploading the image",
"Sorry, an error occurred uploading the file": "Sorry, an error occurred uploading the file",
"Write a caption": "Write a caption",
"Info": "Info",
"Info notice": "Info notice",

View File

@@ -6,6 +6,7 @@ export type PublicEnv = {
URL: string;
CDN_URL: string;
COLLABORATION_URL: string;
AWS_S3_UPLOAD_BUCKET_URL: string;
DEPLOYMENT: "hosted" | "";
ENVIRONMENT: "production" | "development";
SENTRY_DSN: string | undefined;

View File

@@ -1,2 +1,29 @@
export const validateColorHex = (color: string) =>
/(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(color);
import md5 from "crypto-js/md5";
import { darken } from "polished";
import theme from "../theme";
export const palette = [
theme.brand.red,
theme.brand.blue,
theme.brand.purple,
theme.brand.pink,
theme.brand.marine,
theme.brand.green,
theme.brand.yellow,
darken(0.2, theme.brand.red),
darken(0.2, theme.brand.blue),
darken(0.2, theme.brand.purple),
darken(0.2, theme.brand.pink),
darken(0.2, theme.brand.marine),
darken(0.2, theme.brand.green),
darken(0.2, theme.brand.yellow),
];
export const validateColorHex = (color: string) => {
return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(color);
};
export const stringToColor = (input: string) => {
const inputAsNumber = parseInt(md5(input).toString(), 16);
return palette[inputAsNumber % palette.length];
};

View File

@@ -0,0 +1,13 @@
import { bytesToHumanReadable } from "./files";
describe("bytesToHumanReadable", () => {
test("Outputs readable string", () => {
expect(bytesToHumanReadable(0)).toBe("0 Bytes");
expect(bytesToHumanReadable(500)).toBe("500 Bytes");
expect(bytesToHumanReadable(1000)).toBe("1 kB");
expect(bytesToHumanReadable(15000)).toBe("15 kB");
expect(bytesToHumanReadable(12345)).toBe("12.34 kB");
expect(bytesToHumanReadable(123456)).toBe("123.45 kB");
expect(bytesToHumanReadable(1234567)).toBe("1.23 MB");
});
});

21
shared/utils/files.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Converts bytes to human readable string for display
*
* @param bytes filesize in bytes
* @returns Human readable filesize as a string
*/
export const bytesToHumanReadable = (bytes: number) => {
const out = ("0".repeat((bytes.toString().length * 2) % 3) + bytes).match(
/.{3}/g
);
if (!out || bytes < 1000) {
return bytes + " Bytes";
}
const f = out[1].substring(0, 2);
return `${Number(out[0])}${f === "00" ? "" : `.${f}`} ${
" kMGTPEZY"[out.length]
}B`;
};

View File

@@ -2642,6 +2642,11 @@
"@types/keygrip" "*"
"@types/node" "*"
"@types/crypto-js@^4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.1.0.tgz#09ba1b49bcce62c9a8e6d5e50a3364aa98975578"
integrity sha512-DCFfy/vh2lG6qHSGezQ+Sn2Ulf/1Mx51dqOdmOKyW5nMK3maLlxeS3onC7r212OnBM2pBR95HkAmAjjF08YkxQ==
"@types/datadog-metrics@^0.6.2":
version "0.6.2"
resolved "https://registry.yarnpkg.com/@types/datadog-metrics/-/datadog-metrics-0.6.2.tgz#b3b2b9b4e7838cff07830472e8a8c8caa04514fa"
@@ -5695,6 +5700,16 @@ crypto-browserify@^3.11.0:
randombytes "^2.0.0"
randomfill "^1.0.3"
crypto-js@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.1.1.tgz#9e485bcf03521041bd85844786b83fb7619736cf"
integrity sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==
crypto-random-string@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
crypto-random-string@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"