From 631d600920702c8426ca3cc2c5ab8c9858c5590e Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 6 Mar 2022 13:58:58 -0800 Subject: [PATCH] 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 --- app/components/Editor.tsx | 13 +- app/editor/components/CommandMenu.tsx | 49 +++-- app/editor/components/LinkEditor.tsx | 2 +- app/editor/components/LinkToolbar.tsx | 2 +- app/editor/components/SelectionToolbar.tsx | 2 +- app/editor/index.tsx | 33 +-- app/editor/menus/block.ts | 13 +- app/hooks/useDictionary.ts | 3 +- app/models/FileOperation.ts | 9 +- app/scenes/Document/components/Document.tsx | 8 +- app/scenes/Settings/Import.tsx | 2 +- .../Settings/components/ImageUpload.tsx | 2 +- app/utils/{uploadFile.ts => files.ts} | 35 +++- package.json | 2 + server/editor/index.ts | 2 + server/editor/renderToHtml.ts | 2 + server/models/User.ts | 6 +- server/presenters/document.ts | 8 +- server/presenters/env.ts | 1 + server/routes/api/attachments.ts | 23 +- server/utils/color.ts | 19 -- server/utils/parseAttachmentIds.ts | 10 +- server/utils/s3.ts | 11 +- shared/editor/commands/createAndInsertLink.ts | 2 +- shared/editor/commands/insertFiles.ts | 197 ++++++++++++------ shared/editor/components/DisabledEmbed.tsx | 20 ++ shared/editor/components/FileExtension.tsx | 42 ++++ .../editor/{embeds => }/components/Frame.tsx | 0 .../editor/{embeds => }/components/Image.tsx | 2 +- shared/editor/components/Widget.tsx | 98 +++++++++ shared/editor/embeds/Abstract.tsx | 2 +- shared/editor/embeds/Airtable.tsx | 2 +- shared/editor/embeds/Bilibili.tsx | 2 +- shared/editor/embeds/Cawemo.tsx | 2 +- shared/editor/embeds/ClickUp.tsx | 2 +- shared/editor/embeds/Codepen.tsx | 2 +- shared/editor/embeds/DBDiagram.tsx | 2 +- shared/editor/embeds/Descript.tsx | 2 +- shared/editor/embeds/Diagrams.tsx | 4 +- shared/editor/embeds/Figma.tsx | 2 +- shared/editor/embeds/Framer.tsx | 2 +- shared/editor/embeds/GoogleCalendar.tsx | 2 +- shared/editor/embeds/GoogleDataStudio.tsx | 4 +- shared/editor/embeds/GoogleDocs.tsx | 4 +- shared/editor/embeds/GoogleDrawings.tsx | 4 +- shared/editor/embeds/GoogleDrive.tsx | 4 +- shared/editor/embeds/GoogleSheets.tsx | 4 +- shared/editor/embeds/GoogleSlides.tsx | 4 +- shared/editor/embeds/InVision.tsx | 2 +- shared/editor/embeds/Loom.tsx | 2 +- shared/editor/embeds/Lucidchart.tsx | 2 +- shared/editor/embeds/Marvel.tsx | 2 +- shared/editor/embeds/Mindmeister.tsx | 2 +- shared/editor/embeds/Miro.tsx | 2 +- shared/editor/embeds/ModeAnalytics.tsx | 2 +- shared/editor/embeds/Pitch.tsx | 2 +- shared/editor/embeds/Prezi.tsx | 2 +- shared/editor/embeds/Spotify.tsx | 2 +- shared/editor/embeds/Trello.tsx | 2 +- shared/editor/embeds/Typeform.tsx | 2 +- shared/editor/embeds/Vimeo.tsx | 2 +- shared/editor/embeds/Whimsical.tsx | 2 +- shared/editor/embeds/YouTube.tsx | 2 +- shared/editor/embeds/components/Simple.tsx | 71 ------- shared/editor/embeds/index.tsx | 2 +- ...adPlaceholder.ts => uploadPlaceholder.tsx} | 29 ++- shared/editor/nodes/Attachment.tsx | 114 ++++++++++ shared/editor/nodes/CodeFence.ts | 10 +- shared/editor/nodes/Embed.tsx | 4 +- shared/editor/nodes/Heading.ts | 10 +- shared/editor/nodes/Image.tsx | 29 ++- shared/editor/queries/findAttachmentById.ts | 23 ++ shared/editor/rules/attachments.ts | 82 ++++++++ shared/editor/rules/embeds.ts | 5 +- shared/editor/version.ts | 2 +- shared/env.ts | 5 + shared/i18n/locales/en_US/translation.json | 3 +- shared/types.ts | 1 + shared/utils/color.ts | 31 ++- shared/utils/files.test.ts | 13 ++ shared/utils/files.ts | 21 ++ yarn.lock | 15 ++ 82 files changed, 846 insertions(+), 322 deletions(-) rename app/utils/{uploadFile.ts => files.ts} (56%) delete mode 100644 server/utils/color.ts create mode 100644 shared/editor/components/DisabledEmbed.tsx create mode 100644 shared/editor/components/FileExtension.tsx rename shared/editor/{embeds => }/components/Frame.tsx (100%) rename shared/editor/{embeds => }/components/Image.tsx (84%) create mode 100644 shared/editor/components/Widget.tsx delete mode 100644 shared/editor/embeds/components/Simple.tsx rename shared/editor/lib/{uploadPlaceholder.ts => uploadPlaceholder.tsx} (67%) create mode 100644 shared/editor/nodes/Attachment.tsx create mode 100644 shared/editor/queries/findAttachmentById.ts create mode 100644 shared/editor/rules/attachments.ts create mode 100644 shared/env.ts create mode 100644 shared/utils/files.test.ts create mode 100644 shared/utils/files.ts diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 22406cf6b..63d2fe9e0 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -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) { 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) { = { dictionary: Dictionary; view: EditorView; search: string; - uploadImage?: (file: File) => Promise; - onImageUploadStart?: () => void; - onImageUploadStop?: () => void; - onShowToast?: (message: string, id: string) => void; + uploadFile?: (file: File) => Promise; + onFileUploadStart?: () => void; + onFileUploadStop?: () => void; + onShowToast: (message: string, id: string) => void; onLinkToolbarOpen?: () => void; onClose: () => void; onClearSearch: () => void; @@ -178,7 +178,9 @@ class CommandMenu extends React.Component, 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 extends React.Component, 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 extends React.Component, 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 extends React.Component, State> { this.setState({ insertItem: item }); }; - handleImagePicked = (event: React.ChangeEvent) => { + handleFilePicked = (event: React.ChangeEvent) => { const files = getDataTransferFiles(event); const { view, - uploadImage, - onImageUploadStart, - onImageUploadStop, + uploadFile, + onFileUploadStart, + onFileUploadStop, onShowToast, } = this.props; const { state } = view; @@ -283,17 +288,18 @@ class CommandMenu extends React.Component, 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 extends React.Component, State> { const { embeds = [], search = "", - uploadImage, + uploadFile, commands, filterable = true, } = this.props; @@ -447,7 +453,7 @@ class CommandMenu extends React.Component, 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 extends React.Component, 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 extends React.Component, State> { )} )} - {uploadImage && ( + {uploadFile && ( )} diff --git a/app/editor/components/LinkEditor.tsx b/app/editor/components/LinkEditor.tsx index d98813e2e..28b0aea58 100644 --- a/app/editor/components/LinkEditor.tsx +++ b/app/editor/components/LinkEditor.tsx @@ -44,7 +44,7 @@ type Props = { href: string, event: React.MouseEvent ) => void; - onShowToast?: (message: string, code: string) => void; + onShowToast: (message: string, code: string) => void; view: EditorView; }; diff --git a/app/editor/components/LinkToolbar.tsx b/app/editor/components/LinkToolbar.tsx index 20f1fdf88..49dd3dcb8 100644 --- a/app/editor/components/LinkToolbar.tsx +++ b/app/editor/components/LinkToolbar.tsx @@ -15,7 +15,7 @@ type Props = { href: string, event: React.MouseEvent ) => void; - onShowToast?: (msg: string, code: string) => void; + onShowToast: (msg: string, code: string) => void; onClose: () => void; }; diff --git a/app/editor/components/SelectionToolbar.tsx b/app/editor/components/SelectionToolbar.tsx index 198125236..f71f14a09 100644 --- a/app/editor/components/SelectionToolbar.tsx +++ b/app/editor/components/SelectionToolbar.tsx @@ -36,7 +36,7 @@ type Props = { event: MouseEvent | React.MouseEvent ) => void; onCreateLink?: (title: string) => Promise; - onShowToast?: (msg: string, code: string) => void; + onShowToast: (msg: string, code: string) => void; view: EditorView; }; diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 69122ea85..4af42b285 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -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; + uploadFile?: (file: File) => Promise; /** 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; /** 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} /> 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} /> diff --git a/app/editor/menus/block.ts b/app/editor/menus/block.ts index 392d2758d..82eb2df87 100644 --- a/app/editor/menus/block.ts +++ b/app/editor/menus/block.ts @@ -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" }, }, ]; diff --git a/app/hooks/useDictionary.ts b/app/hooks/useDictionary.ts index ce3db1171..e692d5379 100644 --- a/app/hooks/useDictionary.ts +++ b/app/hooks/useDictionary.ts @@ -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"), diff --git a/app/models/FileOperation.ts b/app/models/FileOperation.ts index 725f656c7..36689a663 100644 --- a/app/models/FileOperation.ts +++ b/app/models/FileOperation.ts @@ -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); } } diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index 881db17ec..8c71a976f 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -346,11 +346,11 @@ class DocumentScene extends React.Component { updateIsDirtyDebounced = debounce(this.updateIsDirty, 500); - onImageUploadStart = () => { + onFileUploadStart = () => { this.isUploading = true; }; - onImageUploadStop = () => { + onFileUploadStop = () => { this.isUploading = false; }; @@ -558,8 +558,8 @@ class DocumentScene extends React.Component { 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} diff --git a/app/scenes/Settings/Import.tsx b/app/scenes/Settings/Import.tsx index ebd50cee1..30f2f8ea5 100644 --- a/app/scenes/Settings/Import.tsx +++ b/app/scenes/Settings/Import.tsx @@ -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() { diff --git a/app/scenes/Settings/components/ImageUpload.tsx b/app/scenes/Settings/components/ImageUpload.tsx index 7ab5e0f52..11b116686 100644 --- a/app/scenes/Settings/components/ImageUpload.tsx +++ b/app/scenes/Settings/components/ImageUpload.tsx @@ -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 = {}; diff --git a/app/utils/uploadFile.ts b/app/utils/files.ts similarity index 56% rename from app/utils/uploadFile.ts rename to app/utils/files.ts index 2797cdef6..8cb7b1b7d 100644 --- a/app/utils/uploadFile.ts +++ b/app/utils/files.ts @@ -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; }; diff --git a/package.json b/package.json index bd107010d..aeb14dc71 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/editor/index.ts b/server/editor/index.ts index 30969f9c6..3cb88bbcd 100644 --- a/server/editor/index.ts +++ b/server/editor/index.ts @@ -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(), diff --git a/server/editor/renderToHtml.ts b/server/editor/renderToHtml.ts index 062d418e6..e151ca7f3 100644 --- a/server/editor/renderToHtml.ts +++ b/server/editor/renderToHtml.ts @@ -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, ]; diff --git a/server/models/User.ts b/server/models/User.ts index a68025ba0..58e0a80a8 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -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 diff --git a/server/presenters/document.ts b/server/presenters/document.ts index d81b5a232..f84e46985 100644 --- a/server/presenters/document.ts +++ b/server/presenters/document.ts @@ -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 + ); } }) ); diff --git a/server/presenters/env.ts b/server/presenters/env.ts index dc355e4ce..98e6aa9ea 100644 --- a/server/presenters/env.ts +++ b/server/presenters/env.ts @@ -5,6 +5,7 @@ import { PublicEnv } from "@shared/types"; export default function present(env: Record): 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(/\/$/, "") diff --git a/server/routes/api/attachments.ts b/server/routes/api/attachments.ts index 4d24629b0..63090ef85 100644 --- a/server/routes/api/attachments.ts +++ b/server/routes/api/attachments.ts @@ -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 diff --git a/server/utils/color.ts b/server/utils/color.ts deleted file mode 100644 index b36b0d161..000000000 --- a/server/utils/color.ts +++ /dev/null @@ -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), -]; diff --git a/server/utils/parseAttachmentIds.ts b/server/utils/parseAttachmentIds.ts index d69be094c..c1f8b86e3 100644 --- a/server/utils/parseAttachmentIds.ts +++ b/server/utils/parseAttachmentIds.ts @@ -1,11 +1,13 @@ -import { compact } from "lodash"; +import { uniq, compact } from "lodash"; const attachmentRegex = /\/api\/attachments\.redirect\?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 + ) ) ); } diff --git a/server/utils/s3.ts b/server/utils/s3.ts index 6b3e70719..d0592be08 100644 --- a/server/utils/s3.ts +++ b/server/utils/s3.ts @@ -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 diff --git a/shared/editor/commands/createAndInsertLink.ts b/shared/editor/commands/createAndInsertLink.ts index d2e9f10f1..196396caa 100644 --- a/shared/editor/commands/createAndInsertLink.ts +++ b/shared/editor/commands/createAndInsertLink.ts @@ -38,7 +38,7 @@ const createAndInsertLink = async function ( options: { dictionary: any; onCreateLink: (title: string) => Promise; - onShowToast?: (message: string, code: string) => void; + onShowToast: (message: string, code: string) => void; } ) { const { dispatch, state } = view; diff --git a/shared/editor/commands/insertFiles.ts b/shared/editor/commands/insertFiles.ts index ca320836d..9b4f7172f 100644 --- a/shared/editor/commands/insertFiles.ts +++ b/shared/editor/commands/insertFiles.ts @@ -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; - onImageUploadStart?: () => void; - onImageUploadStop?: () => void; - onShowToast?: (message: string, code: string) => void; + uploadFile?: (file: File) => Promise; + 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(); } }); } diff --git a/shared/editor/components/DisabledEmbed.tsx b/shared/editor/components/DisabledEmbed.tsx new file mode 100644 index 000000000..71576cadd --- /dev/null +++ b/shared/editor/components/DisabledEmbed.tsx @@ -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) { + return ( + + + + ); +} diff --git a/shared/editor/components/FileExtension.tsx b/shared/editor/components/FileExtension.tsx new file mode 100644 index 000000000..f4f799b71 --- /dev/null +++ b/shared/editor/components/FileExtension.tsx @@ -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 ( + + {extension ? ( + {extension?.slice(0, 4)} + ) : ( + + )} + + ); +} + +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; +`; diff --git a/shared/editor/embeds/components/Frame.tsx b/shared/editor/components/Frame.tsx similarity index 100% rename from shared/editor/embeds/components/Frame.tsx rename to shared/editor/components/Frame.tsx diff --git a/shared/editor/embeds/components/Image.tsx b/shared/editor/components/Image.tsx similarity index 84% rename from shared/editor/embeds/components/Image.tsx rename to shared/editor/components/Image.tsx index 14245c3ba..29b0b60a5 100644 --- a/shared/editor/embeds/components/Image.tsx +++ b/shared/editor/components/Image.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { cdnPath } from "../../../utils/urls"; +import { cdnPath } from "../../utils/urls"; type Props = { alt: string; diff --git a/shared/editor/components/Widget.tsx b/shared/editor/components/Widget.tsx new file mode 100644 index 000000000..bf0bc057f --- /dev/null +++ b/shared/editor/components/Widget.tsx @@ -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) { + return ( + + {props.icon} + + {props.title} + {props.context} + {props.children} + + + ); +} + +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; + } + } + `} +`; diff --git a/shared/editor/embeds/Abstract.tsx b/shared/editor/embeds/Abstract.tsx index 4133b1c95..28f01a850 100644 --- a/shared/editor/embeds/Abstract.tsx +++ b/shared/editor/embeds/Abstract.tsx @@ -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 { diff --git a/shared/editor/embeds/Airtable.tsx b/shared/editor/embeds/Airtable.tsx index 37fff32bb..532c3836d 100644 --- a/shared/editor/embeds/Airtable.tsx +++ b/shared/editor/embeds/Airtable.tsx @@ -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.*)$"); diff --git a/shared/editor/embeds/Bilibili.tsx b/shared/editor/embeds/Bilibili.tsx index 8d7f5916c..06d8ecd9f 100644 --- a/shared/editor/embeds/Bilibili.tsx +++ b/shared/editor/embeds/Bilibili.tsx @@ -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; diff --git a/shared/editor/embeds/Cawemo.tsx b/shared/editor/embeds/Cawemo.tsx index 37d45593f..b59e42b53 100644 --- a/shared/editor/embeds/Cawemo.tsx +++ b/shared/editor/embeds/Cawemo.tsx @@ -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)/(.*)$"); diff --git a/shared/editor/embeds/ClickUp.tsx b/shared/editor/embeds/ClickUp.tsx index c0585905f..a5b71daab 100644 --- a/shared/editor/embeds/ClickUp.tsx +++ b/shared/editor/embeds/ClickUp.tsx @@ -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( diff --git a/shared/editor/embeds/Codepen.tsx b/shared/editor/embeds/Codepen.tsx index efe65d6cb..0271f52ab 100644 --- a/shared/editor/embeds/Codepen.tsx +++ b/shared/editor/embeds/Codepen.tsx @@ -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)/(.*)$"); diff --git a/shared/editor/embeds/DBDiagram.tsx b/shared/editor/embeds/DBDiagram.tsx index ed1832961..31fa524ac 100644 --- a/shared/editor/embeds/DBDiagram.tsx +++ b/shared/editor/embeds/DBDiagram.tsx @@ -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 { diff --git a/shared/editor/embeds/Descript.tsx b/shared/editor/embeds/Descript.tsx index 31964d4cc..32a0de8c8 100644 --- a/shared/editor/embeds/Descript.tsx +++ b/shared/editor/embeds/Descript.tsx @@ -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 { diff --git a/shared/editor/embeds/Diagrams.tsx b/shared/editor/embeds/Diagrams.tsx index db4c0f53c..f115c71dc 100644 --- a/shared/editor/embeds/Diagrams.tsx +++ b/shared/editor/embeds/Diagrams.tsx @@ -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+)?/; diff --git a/shared/editor/embeds/Figma.tsx b/shared/editor/embeds/Figma.tsx index 9fbf7c74e..355bfc1c8 100644 --- a/shared/editor/embeds/Figma.tsx +++ b/shared/editor/embeds/Figma.tsx @@ -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( diff --git a/shared/editor/embeds/Framer.tsx b/shared/editor/embeds/Framer.tsx index 06ab7d679..4589dde3d 100644 --- a/shared/editor/embeds/Framer.tsx +++ b/shared/editor/embeds/Framer.tsx @@ -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/(.*)$"); diff --git a/shared/editor/embeds/GoogleCalendar.tsx b/shared/editor/embeds/GoogleCalendar.tsx index 422ebf0b3..8dc6e185a 100644 --- a/shared/editor/embeds/GoogleCalendar.tsx +++ b/shared/editor/embeds/GoogleCalendar.tsx @@ -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( diff --git a/shared/editor/embeds/GoogleDataStudio.tsx b/shared/editor/embeds/GoogleDataStudio.tsx index 180792b6a..1a65c6933 100644 --- a/shared/editor/embeds/GoogleDataStudio.tsx +++ b/shared/editor/embeds/GoogleDataStudio.tsx @@ -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( diff --git a/shared/editor/embeds/GoogleDocs.tsx b/shared/editor/embeds/GoogleDocs.tsx index 845b640f9..7d86db401 100644 --- a/shared/editor/embeds/GoogleDocs.tsx +++ b/shared/editor/embeds/GoogleDocs.tsx @@ -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/(.*)$"); diff --git a/shared/editor/embeds/GoogleDrawings.tsx b/shared/editor/embeds/GoogleDrawings.tsx index 96ff53e29..a86c21ada 100644 --- a/shared/editor/embeds/GoogleDrawings.tsx +++ b/shared/editor/embeds/GoogleDrawings.tsx @@ -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( diff --git a/shared/editor/embeds/GoogleDrive.tsx b/shared/editor/embeds/GoogleDrive.tsx index 89fc8603a..916dab23c 100644 --- a/shared/editor/embeds/GoogleDrive.tsx +++ b/shared/editor/embeds/GoogleDrive.tsx @@ -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/(.*)$"); diff --git a/shared/editor/embeds/GoogleSheets.tsx b/shared/editor/embeds/GoogleSheets.tsx index 64adf9152..7416bb6a9 100644 --- a/shared/editor/embeds/GoogleSheets.tsx +++ b/shared/editor/embeds/GoogleSheets.tsx @@ -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/(.*)$"); diff --git a/shared/editor/embeds/GoogleSlides.tsx b/shared/editor/embeds/GoogleSlides.tsx index 7b8f4ea3c..9bc99e95b 100644 --- a/shared/editor/embeds/GoogleSlides.tsx +++ b/shared/editor/embeds/GoogleSlides.tsx @@ -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/(.*)$"); diff --git a/shared/editor/embeds/InVision.tsx b/shared/editor/embeds/InVision.tsx index f223d42ca..f12a7e08a 100644 --- a/shared/editor/embeds/InVision.tsx +++ b/shared/editor/embeds/InVision.tsx @@ -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\/.*)$/; diff --git a/shared/editor/embeds/Loom.tsx b/shared/editor/embeds/Loom.tsx index 1dbc8f8b6..ee9fbfe9c 100644 --- a/shared/editor/embeds/Loom.tsx +++ b/shared/editor/embeds/Loom.tsx @@ -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)\/(.*)$/; diff --git a/shared/editor/embeds/Lucidchart.tsx b/shared/editor/embeds/Lucidchart.tsx index 83523b8f7..f2981eeae 100644 --- a/shared/editor/embeds/Lucidchart.tsx +++ b/shared/editor/embeds/Lucidchart.tsx @@ -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 { diff --git a/shared/editor/embeds/Marvel.tsx b/shared/editor/embeds/Marvel.tsx index 4f36e234c..0fb8b54ca 100644 --- a/shared/editor/embeds/Marvel.tsx +++ b/shared/editor/embeds/Marvel.tsx @@ -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})/?$"); diff --git a/shared/editor/embeds/Mindmeister.tsx b/shared/editor/embeds/Mindmeister.tsx index 2762bca71..823caaeaf 100644 --- a/shared/editor/embeds/Mindmeister.tsx +++ b/shared/editor/embeds/Mindmeister.tsx @@ -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( diff --git a/shared/editor/embeds/Miro.tsx b/shared/editor/embeds/Miro.tsx index 56b1ed46d..d0df3a222 100644 --- a/shared/editor/embeds/Miro.tsx +++ b/shared/editor/embeds/Miro.tsx @@ -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\/(.*)$/; diff --git a/shared/editor/embeds/ModeAnalytics.tsx b/shared/editor/embeds/ModeAnalytics.tsx index 2ae6b0c7b..1b61ee6fc 100644 --- a/shared/editor/embeds/ModeAnalytics.tsx +++ b/shared/editor/embeds/ModeAnalytics.tsx @@ -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( diff --git a/shared/editor/embeds/Pitch.tsx b/shared/editor/embeds/Pitch.tsx index dc4a94002..23231584a 100644 --- a/shared/editor/embeds/Pitch.tsx +++ b/shared/editor/embeds/Pitch.tsx @@ -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( diff --git a/shared/editor/embeds/Prezi.tsx b/shared/editor/embeds/Prezi.tsx index 3a970b49a..06193c008 100644 --- a/shared/editor/embeds/Prezi.tsx +++ b/shared/editor/embeds/Prezi.tsx @@ -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/(.*)$"); diff --git a/shared/editor/embeds/Spotify.tsx b/shared/editor/embeds/Spotify.tsx index 616c3d4ac..c366d63e1 100644 --- a/shared/editor/embeds/Spotify.tsx +++ b/shared/editor/embeds/Spotify.tsx @@ -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 "."; diff --git a/shared/editor/embeds/Trello.tsx b/shared/editor/embeds/Trello.tsx index 78f3d006a..87829a4ff 100644 --- a/shared/editor/embeds/Trello.tsx +++ b/shared/editor/embeds/Trello.tsx @@ -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)\/([^/]*)(.*)?$/; diff --git a/shared/editor/embeds/Typeform.tsx b/shared/editor/embeds/Typeform.tsx index a0ab08801..4b7b96991 100644 --- a/shared/editor/embeds/Typeform.tsx +++ b/shared/editor/embeds/Typeform.tsx @@ -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( diff --git a/shared/editor/embeds/Vimeo.tsx b/shared/editor/embeds/Vimeo.tsx index e7b3fd73d..f7f18607e 100644 --- a/shared/editor/embeds/Vimeo.tsx +++ b/shared/editor/embeds/Vimeo.tsx @@ -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]+)?/; diff --git a/shared/editor/embeds/Whimsical.tsx b/shared/editor/embeds/Whimsical.tsx index 3e71dc9aa..7e68127ce 100644 --- a/shared/editor/embeds/Whimsical.tsx +++ b/shared/editor/embeds/Whimsical.tsx @@ -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]+)\/?$/; diff --git a/shared/editor/embeds/YouTube.tsx b/shared/editor/embeds/YouTube.tsx index 44127a865..c3a873ef8 100644 --- a/shared/editor/embeds/YouTube.tsx +++ b/shared/editor/embeds/YouTube.tsx @@ -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; diff --git a/shared/editor/embeds/components/Simple.tsx b/shared/editor/embeds/components/Simple.tsx deleted file mode 100644 index 5243f9cb9..000000000 --- a/shared/editor/embeds/components/Simple.tsx +++ /dev/null @@ -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) { - return ( - - {props.embed.icon(undefined)} - - {props.embed.title} - {props.attrs.href.replace(/^https?:\/\//, "")} - - - - ); -} - -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}; - } -`; diff --git a/shared/editor/embeds/index.tsx b/shared/editor/embeds/index.tsx index 59cf7423f..4ac1d5a99 100644 --- a/shared/editor/embeds/index.tsx +++ b/shared/editor/embeds/index.tsx @@ -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; diff --git a/shared/editor/lib/uploadPlaceholder.ts b/shared/editor/lib/uploadPlaceholder.tsx similarity index 67% rename from shared/editor/lib/uploadPlaceholder.ts rename to shared/editor/lib/uploadPlaceholder.tsx index 21b88f7a5..07c910e97 100644 --- a/shared/editor/lib/uploadPlaceholder.ts +++ b/shared/editor/lib/uploadPlaceholder.tsx @@ -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 = ; + 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, }); diff --git a/shared/editor/nodes/Attachment.tsx b/shared/editor/nodes/Attachment.tsx new file mode 100644 index 000000000..66c5a17cf --- /dev/null +++ b/shared/editor/nodes/Attachment.tsx @@ -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 ( + } + href={node.attrs.href} + title={node.attrs.title} + context={ + node.attrs.href ? ( + bytesToHumanReadable(node.attrs.size) + ) : ( + <> + Uploading… + + ) + } + isSelected={isSelected} + theme={theme} + > + {node.attrs.href && } + + ); + } + + commands({ type }: { type: NodeType }) { + return (attrs: Record) => 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"), + }), + }; + } +} diff --git a/shared/editor/nodes/CodeFence.ts b/shared/editor/nodes/CodeFence.ts index 754bb1565..392e4103f 100644 --- a/shared/editor/nodes/CodeFence.ts +++ b/shared/editor/nodes/CodeFence.ts @@ -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 + ); } } }; diff --git a/shared/editor/nodes/Embed.tsx b/shared/editor/nodes/Embed.tsx index d50de1109..bdda25f13 100644 --- a/shared/editor/nodes/Embed.tsx +++ b/shared/editor/nodes/Embed.tsx @@ -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 ( - 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, diff --git a/shared/editor/queries/findAttachmentById.ts b/shared/editor/queries/findAttachmentById.ts new file mode 100644 index 000000000..91968b977 --- /dev/null +++ b/shared/editor/queries/findAttachmentById.ts @@ -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; diff --git a/shared/editor/rules/attachments.ts b/shared/editor/rules/attachments.ts new file mode 100644 index 000000000..328c96d01 --- /dev/null +++ b/shared/editor/rules/attachments.ts @@ -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; + }); +} diff --git a/shared/editor/rules/embeds.ts b/shared/editor/rules/embeds.ts index 1089ce8f5..11c43837e 100644 --- a/shared/editor/rules/embeds.ts +++ b/shared/editor/rules/embeds.ts @@ -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; } diff --git a/shared/editor/version.ts b/shared/editor/version.ts index 215a3e5a7..b087359ec 100644 --- a/shared/editor/version.ts +++ b/shared/editor/version.ts @@ -1,3 +1,3 @@ -const EDITOR_VERSION = "11.21.3"; +const EDITOR_VERSION = "12.0.0"; export default EDITOR_VERSION; diff --git a/shared/env.ts b/shared/env.ts new file mode 100644 index 000000000..695e28be9 --- /dev/null +++ b/shared/env.ts @@ -0,0 +1,5 @@ +import { PublicEnv } from "./types"; + +const env = typeof window === "undefined" ? process.env : window.env; + +export default env as PublicEnv; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 65411e7bd..eea46e646 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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", diff --git a/shared/types.ts b/shared/types.ts index 03a8b5b16..76d45f8a9 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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; diff --git a/shared/utils/color.ts b/shared/utils/color.ts index d74372825..db17349fc 100644 --- a/shared/utils/color.ts +++ b/shared/utils/color.ts @@ -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]; +}; diff --git a/shared/utils/files.test.ts b/shared/utils/files.test.ts new file mode 100644 index 000000000..4555f0689 --- /dev/null +++ b/shared/utils/files.test.ts @@ -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"); + }); +}); diff --git a/shared/utils/files.ts b/shared/utils/files.ts new file mode 100644 index 000000000..9ab5f160f --- /dev/null +++ b/shared/utils/files.ts @@ -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`; +}; diff --git a/yarn.lock b/yarn.lock index 2f661ff1a..7bb7b8c28 100644 --- a/yarn.lock +++ b/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"