diff --git a/shared/editor/commands/insertFiles.ts b/shared/editor/commands/insertFiles.ts index 4fa0bfc45..39e71dbc5 100644 --- a/shared/editor/commands/insertFiles.ts +++ b/shared/editor/commands/insertFiles.ts @@ -1,12 +1,10 @@ import * as Sentry from "@sentry/react"; -import { NodeSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { v4 as uuidv4 } from "uuid"; import FileHelper from "../lib/FileHelper"; import uploadPlaceholderPlugin, { findPlaceholder, } from "../lib/uploadPlaceholder"; -import findAttachmentById from "../queries/findAttachmentById"; export type Options = { /** Dictionary object containing translation strings */ @@ -61,7 +59,6 @@ const insertFiles = function ( // we'll use this to track of how many files have succeeded or failed let complete = 0; - let attachmentPlaceholdersSet = false; const filesToUpload = files.map((file) => ({ id: `upload-${uuidv4()}`, @@ -76,55 +73,14 @@ const insertFiles = function ( for (const upload of filesToUpload) { const { tr } = view.state; - if (upload.isImage) { - // insert a placeholder at this position, or mark an existing file as being - // replaced - tr.setMeta(uploadPlaceholderPlugin, { - add: { - id: upload.id, - file: upload.file, - pos, - isImage: true, - replaceExisting: options.replaceExisting, - }, - }); - view.dispatch(tr); - } else if (upload.isVideo) { - // insert a placeholder at this position, or mark an existing file as being - // replaced - tr.setMeta(uploadPlaceholderPlugin, { - add: { - id: upload.id, - file: upload.file, - pos, - isVideo: true, - }, - }); - view.dispatch(tr); - } else if (!attachmentPlaceholdersSet) { - // Skip if the editor does not support attachments. - if (!view.state.schema.nodes.attachment) { - continue; - } - - const attachmentsToUpload = filesToUpload.filter( - (i) => i.isImage === false - ); - - view.dispatch( - tr.insert( - pos, - attachmentsToUpload.map((attachment) => - schema.nodes.attachment.create({ - id: attachment.id, - title: attachment.file.name ?? dictionary.untitled, - size: attachment.file.size, - }) - ) - ) - ); - attachmentPlaceholdersSet = true; - } + tr.setMeta(uploadPlaceholderPlugin, { + add: { + pos, + ...upload, + replaceExisting: options.replaceExisting, + }, + }); + view.dispatch(tr); // start uploading the file to the server. Using "then" syntax // to allow all placeholders to be entered at once with the uploads @@ -135,9 +91,6 @@ const insertFiles = function ( const newImg = new Image(); newImg.onload = () => { const result = findPlaceholder(view.state, upload.id); - - // if the content around the placeholder has been deleted - // then forget about inserting this file if (result === null) { return; } @@ -152,17 +105,6 @@ const insertFiles = function ( ) .setMeta(uploadPlaceholderPlugin, { remove: { id: upload.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 = () => { @@ -172,9 +114,6 @@ const insertFiles = function ( newImg.src = src; } else if (upload.isVideo) { const result = findPlaceholder(view.state, upload.id); - - // if the content around the placeholder has been deleted - // then forget about inserting this file if (result === null) { return; } @@ -197,71 +136,41 @@ const insertFiles = function ( ) .setMeta(uploadPlaceholderPlugin, { remove: { id: upload.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)) - ) - ); - } } else { - const result = findAttachmentById(view.state, upload.id); - - // if the attachment has been deleted then forget about updating it + const result = findPlaceholder(view.state, upload.id); if (result === null) { return; } const [from, to] = result; - view.dispatch( - view.state.tr.replaceWith( - from, - to || from, - schema.nodes.attachment.create({ - href: src, - title: upload.file.name ?? dictionary.untitled, - size: upload.file.size, - }) - ) - ); - // 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)) + view.dispatch( + view.state.tr + .replaceWith( + from, + to || from, + schema.nodes.attachment.create({ + href: src, + title: upload.file.name ?? dictionary.untitled, + size: upload.file.size, + }) ) - ); - } + .setMeta(uploadPlaceholderPlugin, { remove: { id: upload.id } }) + ); } }) .catch((error) => { Sentry.captureException(error); + // eslint-disable-next-line no-console + console.error(error); + // cleanup the placeholder if there is a failure - if (upload.isImage || upload.isVideo) { - view.dispatch( - view.state.tr.setMeta(uploadPlaceholderPlugin, { - remove: { id: upload.id }, - }) - ); - } else { - const result = findAttachmentById(view.state, upload.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.deleteRange(from, to || from)); - } + view.dispatch( + view.state.tr.setMeta(uploadPlaceholderPlugin, { + remove: { id: upload.id }, + }) + ); onShowToast(error.message || dictionary.fileUploadError); }) diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index 79890b4ea..056826ddc 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -427,6 +427,42 @@ li { } } +.file.placeholder { + display: flex; + align-items: center; + background: ${props.theme.background}; + box-shadow: 0 0 0 1px ${props.theme.divider}; + white-space: nowrap; + border-radius: 8px; + padding: 6px 8px; + max-width: 840px; + cursor: default; + + margin-top: 0.5em; + margin-bottom: 0.5em; + + .title, + .subtitle { + margin-left: 8px; + } + + .title { + font-weight: 600; + font-size: 14px; + color: ${props.theme.text}; + } + + .subtitle { + font-size: 13px; + color: ${props.theme.textTertiary}; + line-height: 0; + } + + span { + font-family: ${props.theme.fontFamilyMono}; + } +} + .image-replacement-uploading { img { opacity: 0.5; diff --git a/shared/editor/extensions/Mermaid.ts b/shared/editor/extensions/Mermaid.ts index 12db17aef..15c660c30 100644 --- a/shared/editor/extensions/Mermaid.ts +++ b/shared/editor/extensions/Mermaid.ts @@ -11,6 +11,7 @@ import { import { Decoration, DecorationSet } from "prosemirror-view"; import { v4 as uuidv4 } from "uuid"; import { isCode } from "../lib/isCode"; +import { isRemoteTransaction } from "../lib/multiplayer"; import { findBlockNodes, NodeWithPos } from "../queries/findChildren"; type MermaidState = { @@ -251,7 +252,6 @@ export default function Mermaid({ const previousNodeName = oldState.selection.$head.parent.type.name; const codeBlockChanged = transaction.docChanged && [nodeName, previousNodeName].includes(name); - const ySyncEdit = !!transaction.getMeta("y-sync$"); const themeMeta = transaction.getMeta("theme"); const mermaidMeta = transaction.getMeta("mermaid"); const themeToggled = themeMeta?.isDark !== undefined; @@ -260,7 +260,12 @@ export default function Mermaid({ pluginState.isDark = themeMeta.isDark; } - if (mermaidMeta || themeToggled || codeBlockChanged || ySyncEdit) { + if ( + mermaidMeta || + themeToggled || + codeBlockChanged || + isRemoteTransaction(transaction) + ) { return getNewState({ doc: transaction.doc, name, diff --git a/shared/editor/extensions/Prism.ts b/shared/editor/extensions/Prism.ts index a0fc017ff..e81700b9b 100644 --- a/shared/editor/extensions/Prism.ts +++ b/shared/editor/extensions/Prism.ts @@ -4,6 +4,7 @@ import { Node } from "prosemirror-model"; import { Plugin, PluginKey, Transaction } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import refractor from "refractor/core"; +import { isRemoteTransaction } from "../lib/multiplayer"; import { findBlockNodes } from "../queries/findChildren"; export const LANGUAGES = { @@ -199,9 +200,12 @@ export default function Prism({ const previousNodeName = oldState.selection.$head.parent.type.name; const codeBlockChanged = transaction.docChanged && [nodeName, previousNodeName].includes(name); - const ySyncEdit = !!transaction.getMeta("y-sync$"); - if (!highlighted || codeBlockChanged || ySyncEdit) { + if ( + !highlighted || + codeBlockChanged || + isRemoteTransaction(transaction) + ) { highlighted = true; return getDecorations({ doc: transaction.doc, name, lineNumbers }); } diff --git a/shared/editor/lib/multiplayer.ts b/shared/editor/lib/multiplayer.ts new file mode 100644 index 000000000..685b2b66d --- /dev/null +++ b/shared/editor/lib/multiplayer.ts @@ -0,0 +1,15 @@ +import { ySyncPluginKey } from "@getoutline/y-prosemirror"; +import { Transaction } from "prosemirror-state"; + +/** + * Checks if a transaction is a remote transaction + * + * @param tr The Prosemirror transaction + * @returns true if the transaction is a remote transaction + */ +export function isRemoteTransaction(tr: Transaction): boolean { + const meta = tr.getMeta(ySyncPluginKey); + + // This logic seems to be flipped? But it's correct. + return !!meta?.isChangeOrigin; +} diff --git a/shared/editor/lib/uploadPlaceholder.tsx b/shared/editor/lib/uploadPlaceholder.tsx index 5c55fa60f..26ebb4b39 100644 --- a/shared/editor/lib/uploadPlaceholder.tsx +++ b/shared/editor/lib/uploadPlaceholder.tsx @@ -1,5 +1,9 @@ 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"; +import { isRemoteTransaction } from "./multiplayer"; import { recreateTransform } from "./prosemirror-recreate-transform"; // based on the example at: https://prosemirror.net/examples/upload/ @@ -9,10 +13,15 @@ const uploadPlaceholder = new Plugin({ return DecorationSet.empty; }, apply(tr, set: DecorationSet) { - const ySyncEdit = !!tr.getMeta("y-sync$"); let mapping = tr.mapping; - if (ySyncEdit) { + // See if the transaction adds or removes any placeholders – the placeholder display is + // different depending on if we're uploading an image, video or plain file + const action = tr.getMeta(this); + + // Note: We always rebuild the mapping if the transaction comes from this plugin as otherwise + // with the default mapping decorations are wiped out when you upload multiple files at a time. + if (isRemoteTransaction(tr) || action) { try { mapping = recreateTransform(tr.before, tr.doc, { complexSteps: true, @@ -27,9 +36,6 @@ const uploadPlaceholder = new Plugin({ set = set.map(mapping, tr.doc); - // See if the transaction adds or removes any placeholders - const action = tr.getMeta(this); - if (action?.add) { if (action.add.isImage) { if (action.add.replaceExisting) { @@ -62,9 +68,7 @@ const uploadPlaceholder = new Plugin({ }); set = set.add(tr.doc, [deco]); } - } - - if (action.add.isVideo) { + } else if (action.add.isVideo) { const element = document.createElement("div"); element.className = "video placeholder"; @@ -75,6 +79,29 @@ const uploadPlaceholder = new Plugin({ element.appendChild(video); + 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 = "file placeholder"; + + const icon = document.createElement("div"); + const title = document.createElement("div"); + title.className = "title"; + title.innerText = action.add.file.name; + + const subtitle = document.createElement("div"); + subtitle.className = "subtitle"; + subtitle.innerText = "Uploading…"; + + ReactDOM.render(, icon); + + element.appendChild(icon); + element.appendChild(title); + element.appendChild(subtitle); + const deco = Decoration.widget(action.add.pos, element, { id: action.add.id, }); @@ -99,6 +126,13 @@ const uploadPlaceholder = new Plugin({ export default uploadPlaceholder; +/** + * Find the position of a placeholder by its ID + * + * @param state The editor state + * @param id The placeholder ID + * @returns The placeholder position as a tuple of [from, to] or null if not found + */ export function findPlaceholder( state: EditorState, id: string diff --git a/shared/editor/queries/findAttachmentById.ts b/shared/editor/queries/findAttachmentById.ts deleted file mode 100644 index 91968b977..000000000 --- a/shared/editor/queries/findAttachmentById.ts +++ /dev/null @@ -1,23 +0,0 @@ -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;