diff --git a/app/editor/components/SelectionToolbar.tsx b/app/editor/components/SelectionToolbar.tsx index 0ba4d62b5..5d684e967 100644 --- a/app/editor/components/SelectionToolbar.tsx +++ b/app/editor/components/SelectionToolbar.tsx @@ -15,6 +15,7 @@ import useDictionary from "~/hooks/useDictionary"; import useEventListener from "~/hooks/useEventListener"; import useMobile from "~/hooks/useMobile"; import usePrevious from "~/hooks/usePrevious"; +import getAttachmentMenuItems from "../menus/attachment"; import getCodeMenuItems from "../menus/code"; import getDividerMenuItems from "../menus/divider"; import getFormattingMenuItems from "../menus/formatting"; @@ -66,7 +67,7 @@ function useIsActive(state: EditorState) { } if ( selection instanceof NodeSelection && - selection.node.type.name === "image" + ["image", "attachment"].includes(selection.node.type.name) ) { return true; } @@ -219,6 +220,9 @@ export default function SelectionToolbar(props: Props) { const range = getMarkRange(selection.$from, state.schema.marks.link); const isImageSelection = selection instanceof NodeSelection && selection.node.type.name === "image"; + const isAttachmentSelection = + selection instanceof NodeSelection && + selection.node.type.name === "attachment"; const isCodeSelection = isInCode(state, { onlyBlock: true }); let items: MenuItem[] = []; @@ -233,6 +237,8 @@ export default function SelectionToolbar(props: Props) { items = getTableRowMenuItems(state, rowIndex, dictionary); } else if (isImageSelection) { items = readOnly ? [] : getImageMenuItems(state, dictionary); + } else if (isAttachmentSelection) { + items = readOnly ? [] : getAttachmentMenuItems(state, dictionary); } else if (isDividerSelection) { items = getDividerMenuItems(state, dictionary); } else if (readOnly) { diff --git a/app/editor/menus/attachment.tsx b/app/editor/menus/attachment.tsx new file mode 100644 index 000000000..2246e1eab --- /dev/null +++ b/app/editor/menus/attachment.tsx @@ -0,0 +1,34 @@ +import { TrashIcon, DownloadIcon, ReplaceIcon } from "outline-icons"; +import { EditorState } from "prosemirror-state"; +import * as React from "react"; +import { MenuItem } from "@shared/editor/types"; +import { Dictionary } from "~/hooks/useDictionary"; + +export default function attachmentMenuItems( + state: EditorState, + dictionary: Dictionary +): MenuItem[] { + return [ + { + name: "replaceAttachment", + tooltip: dictionary.replaceAttachment, + icon: , + visible: true, + }, + { + name: "deleteAttachment", + tooltip: dictionary.deleteAttachment, + icon: , + visible: true, + }, + { + name: "separator", + }, + { + name: "downloadAttachment", + label: dictionary.download, + icon: , + visible: !!fetch, + }, + ]; +} diff --git a/app/hooks/useDictionary.ts b/app/hooks/useDictionary.ts index de8fbaa05..6a73e6750 100644 --- a/app/hooks/useDictionary.ts +++ b/app/hooks/useDictionary.ts @@ -28,6 +28,10 @@ export default function useDictionary() { deleteColumn: t("Delete column"), deleteRow: t("Delete row"), deleteTable: t("Delete table"), + deleteAttachment: t("Delete file"), + download: t("Download"), + downloadAttachment: t("Download file"), + replaceAttachment: t("Replace file"), deleteImage: t("Delete image"), downloadImage: t("Download image"), replaceImage: t("Replace image"), diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index f27adb852..bdebcc6c0 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -488,6 +488,12 @@ iframe.embed { } } +.attachment-replacement-uploading { + .widget { + opacity: 0.5; + } +} + .image-replacement-uploading { img { opacity: 0.5; diff --git a/shared/editor/components/Widget.tsx b/shared/editor/components/Widget.tsx index e21b0e03a..215171aae 100644 --- a/shared/editor/components/Widget.tsx +++ b/shared/editor/components/Widget.tsx @@ -11,6 +11,7 @@ type Props = { isSelected: boolean; children?: React.ReactNode; onMouseDown?: React.MouseEventHandler; + onClick?: React.MouseEventHandler; }; export default function Widget(props: Props & ThemeProps) { @@ -22,6 +23,7 @@ export default function Widget(props: Props & ThemeProps) { href={sanitizeUrl(props.href)} rel="noreferrer nofollow" onMouseDown={props.onMouseDown} + onClick={props.onClick} > {props.icon} diff --git a/shared/editor/lib/uploadPlaceholder.tsx b/shared/editor/lib/uploadPlaceholder.tsx index 7732f7105..29d8292ee 100644 --- a/shared/editor/lib/uploadPlaceholder.tsx +++ b/shared/editor/lib/uploadPlaceholder.tsx @@ -38,39 +38,39 @@ const uploadPlaceholder = new Plugin({ set = set.map(mapping, tr.doc); if (action?.add) { - if (action.add.isImage) { - if (action.add.replaceExisting) { - const $pos = tr.doc.resolve(action.add.pos); - - if ($pos.nodeAfter?.type.name === "image") { - const deco = Decoration.node( - $pos.pos, - $pos.pos + $pos.nodeAfter.nodeSize, - { - class: "image-replacement-uploading", - }, - { - id: action.add.id, - } - ); - set = set.add(tr.doc, [deco]); - } - } else { - const element = document.createElement("div"); - element.className = "image placeholder"; - - const img = document.createElement("img"); - img.src = URL.createObjectURL(action.add.file); - img.width = action.add.dimensions?.width; - img.height = action.add.dimensions?.height; - - element.appendChild(img); - - const deco = Decoration.widget(action.add.pos, element, { - id: action.add.id, - }); - set = set.add(tr.doc, [deco]); + if (action.add.replaceExisting) { + const $pos = tr.doc.resolve(action.add.pos); + const nodeAfter = $pos.nodeAfter; + if (!nodeAfter) { + return; } + + const deco = Decoration.node( + $pos.pos, + $pos.pos + nodeAfter.nodeSize, + { + class: `${nodeAfter.type.name}-replacement-uploading`, + }, + { + id: action.add.id, + } + ); + set = set.add(tr.doc, [deco]); + } else if (action.add.isImage) { + const element = document.createElement("div"); + element.className = "image placeholder"; + + const img = document.createElement("img"); + img.src = URL.createObjectURL(action.add.file); + img.width = action.add.dimensions?.width; + img.height = action.add.dimensions?.height; + + element.appendChild(img); + + const deco = Decoration.widget(action.add.pos, element, { + id: action.add.id, + }); + set = set.add(tr.doc, [deco]); } else if (action.add.isVideo) { const element = document.createElement("div"); element.className = "video placeholder"; diff --git a/shared/editor/nodes/Attachment.tsx b/shared/editor/nodes/Attachment.tsx index b7b3eb606..1bb747d3e 100644 --- a/shared/editor/nodes/Attachment.tsx +++ b/shared/editor/nodes/Attachment.tsx @@ -1,12 +1,13 @@ import Token from "markdown-it/lib/token"; import { DownloadIcon } from "outline-icons"; import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model"; -import { NodeSelection } from "prosemirror-state"; +import { Command, NodeSelection } from "prosemirror-state"; import * as React from "react"; import { Trans } from "react-i18next"; import { Primitive } from "utility-types"; -import { bytesToHumanReadable } from "../../utils/files"; +import { bytesToHumanReadable, getEventFiles } from "../../utils/files"; import { sanitizeUrl } from "../../utils/urls"; +import insertFiles from "../commands/insertFiles"; import toggleWrap from "../commands/toggleWrap"; import FileExtension from "../components/FileExtension"; import Widget from "../components/Widget"; @@ -69,7 +70,7 @@ export default class Attachment extends Node { } handleSelect = - ({ getPos }: { getPos: () => number }) => + ({ getPos }: ComponentProps) => () => { const { view } = this.editor; const $pos = view.state.doc.resolve(getPos()); @@ -78,13 +79,19 @@ export default class Attachment extends Node { }; component = (props: ComponentProps) => { - const { isSelected, theme, node } = props; + const { isSelected, isEditable, theme, node } = props; return ( } href={node.attrs.href} title={node.attrs.title} onMouseDown={this.handleSelect(props)} + onClick={(event) => { + if (isEditable) { + event.preventDefault(); + event.stopPropagation(); + } + }} context={ node.attrs.href ? ( bytesToHumanReadable(node.attrs.size || "0") @@ -97,13 +104,69 @@ export default class Attachment extends Node { isSelected={isSelected} theme={theme} > - {node.attrs.href && } + {node.attrs.href && !isEditable && } ); }; commands({ type }: { type: NodeType }) { - return (attrs: Record) => toggleWrap(type, attrs); + return { + createAttachment: (attrs: Record) => + toggleWrap(type, attrs), + deleteAttachment: (): Command => (state, dispatch) => { + dispatch?.(state.tr.deleteSelection()); + return true; + }, + replaceAttachment: (): Command => (state) => { + if (!(state.selection instanceof NodeSelection)) { + return false; + } + const { view } = this.editor; + const { node } = state.selection; + const { uploadFile, onFileUploadStart, onFileUploadStop } = + this.editor.props; + + if (!uploadFile) { + throw new Error("uploadFile prop is required to replace attachments"); + } + + if (node.type.name !== "attachment") { + return false; + } + + // create an input element and click to trigger picker + const inputElement = document.createElement("input"); + inputElement.type = "file"; + inputElement.onchange = (event) => { + const files = getEventFiles(event); + void insertFiles(view, event, state.selection.from, files, { + uploadFile, + onFileUploadStart, + onFileUploadStop, + dictionary: this.options.dictionary, + replaceExisting: true, + }); + }; + inputElement.click(); + return true; + }, + downloadAttachment: (): Command => (state) => { + if (!(state.selection instanceof NodeSelection)) { + return false; + } + const { node } = state.selection; + + // create a temporary link node and click it + const link = document.createElement("a"); + link.href = node.attrs.href; + document.body.appendChild(link); + link.click(); + + // cleanup + document.body.removeChild(link); + return true; + }, + }; } toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 32d239484..ee78c7916 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -306,6 +306,9 @@ "Delete column": "Delete column", "Delete row": "Delete row", "Delete table": "Delete table", + "Delete file": "Delete file", + "Download file": "Download file", + "Replace file": "Replace file", "Delete image": "Delete image", "Download image": "Download image", "Replace image": "Replace image",