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",