mirror of
https://github.com/outline/outline.git
synced 2025-12-21 10:39:41 -06:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function useDictionary() {
|
||||
alignImageDefault: t("Center large"),
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t 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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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(/\/$/, "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
20
shared/editor/components/DisabledEmbed.tsx
Normal file
20
shared/editor/components/DisabledEmbed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
shared/editor/components/FileExtension.tsx
Normal file
42
shared/editor/components/FileExtension.tsx
Normal 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;
|
||||
`;
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { cdnPath } from "../../../utils/urls";
|
||||
import { cdnPath } from "../../utils/urls";
|
||||
|
||||
type Props = {
|
||||
alt: string;
|
||||
98
shared/editor/components/Widget.tsx
Normal file
98
shared/editor/components/Widget.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.*)$");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)/(.*)$");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)/(.*)$");
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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+)?/;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/(.*)$");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/(.*)$");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/(.*)$");
|
||||
|
||||
@@ -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/(.*)$");
|
||||
|
||||
@@ -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/(.*)$");
|
||||
|
||||
@@ -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\/.*)$/;
|
||||
|
||||
@@ -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)\/(.*)$/;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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})/?$");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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\/(.*)$/;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/(.*)$");
|
||||
|
||||
@@ -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 ".";
|
||||
|
||||
@@ -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)\/([^/]*)(.*)?$/;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]+)?/;
|
||||
|
||||
@@ -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]+)\/?$/;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
114
shared/editor/nodes/Attachment.tsx
Normal file
114
shared/editor/nodes/Attachment.tsx
Normal 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"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
23
shared/editor/queries/findAttachmentById.ts
Normal file
23
shared/editor/queries/findAttachmentById.ts
Normal 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;
|
||||
82
shared/editor/rules/attachments.ts
Normal file
82
shared/editor/rules/attachments.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
5
shared/env.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PublicEnv } from "./types";
|
||||
|
||||
const env = typeof window === "undefined" ? process.env : window.env;
|
||||
|
||||
export default env as PublicEnv;
|
||||
@@ -194,6 +194,7 @@
|
||||
"Center large": "Center large",
|
||||
"Italic": "Italic",
|
||||
"Sorry, that link won’t work for this embed type": "Sorry, that link won’t 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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
13
shared/utils/files.test.ts
Normal file
13
shared/utils/files.test.ts
Normal 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
21
shared/utils/files.ts
Normal 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`;
|
||||
};
|
||||
15
yarn.lock
15
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user