chore: Refactor upload placeholder (#5898)

This commit is contained in:
Tom Moor
2023-09-28 23:13:40 -04:00
committed by GitHub
parent f4fd9dae5f
commit 5397907599
7 changed files with 135 additions and 155 deletions

View File

@@ -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);
})

View File

@@ -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;

View File

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

View File

@@ -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 });
}

View File

@@ -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;
}

View File

@@ -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(<FileExtension title={action.add.file.name} />, 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

View File

@@ -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;