chore: Refactor upload placeholder (#5898)
This commit is contained in:
@@ -1,12 +1,10 @@
|
|||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
import { NodeSelection } from "prosemirror-state";
|
|
||||||
import { EditorView } from "prosemirror-view";
|
import { EditorView } from "prosemirror-view";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import FileHelper from "../lib/FileHelper";
|
import FileHelper from "../lib/FileHelper";
|
||||||
import uploadPlaceholderPlugin, {
|
import uploadPlaceholderPlugin, {
|
||||||
findPlaceholder,
|
findPlaceholder,
|
||||||
} from "../lib/uploadPlaceholder";
|
} from "../lib/uploadPlaceholder";
|
||||||
import findAttachmentById from "../queries/findAttachmentById";
|
|
||||||
|
|
||||||
export type Options = {
|
export type Options = {
|
||||||
/** Dictionary object containing translation strings */
|
/** 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
|
// we'll use this to track of how many files have succeeded or failed
|
||||||
let complete = 0;
|
let complete = 0;
|
||||||
let attachmentPlaceholdersSet = false;
|
|
||||||
|
|
||||||
const filesToUpload = files.map((file) => ({
|
const filesToUpload = files.map((file) => ({
|
||||||
id: `upload-${uuidv4()}`,
|
id: `upload-${uuidv4()}`,
|
||||||
@@ -76,55 +73,14 @@ const insertFiles = function (
|
|||||||
for (const upload of filesToUpload) {
|
for (const upload of filesToUpload) {
|
||||||
const { tr } = view.state;
|
const { tr } = view.state;
|
||||||
|
|
||||||
if (upload.isImage) {
|
tr.setMeta(uploadPlaceholderPlugin, {
|
||||||
// insert a placeholder at this position, or mark an existing file as being
|
add: {
|
||||||
// replaced
|
pos,
|
||||||
tr.setMeta(uploadPlaceholderPlugin, {
|
...upload,
|
||||||
add: {
|
replaceExisting: options.replaceExisting,
|
||||||
id: upload.id,
|
},
|
||||||
file: upload.file,
|
});
|
||||||
pos,
|
view.dispatch(tr);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// start uploading the 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
|
// to allow all placeholders to be entered at once with the uploads
|
||||||
@@ -135,9 +91,6 @@ const insertFiles = function (
|
|||||||
const newImg = new Image();
|
const newImg = new Image();
|
||||||
newImg.onload = () => {
|
newImg.onload = () => {
|
||||||
const result = findPlaceholder(view.state, upload.id);
|
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) {
|
if (result === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -152,17 +105,6 @@ const insertFiles = function (
|
|||||||
)
|
)
|
||||||
.setMeta(uploadPlaceholderPlugin, { remove: { id: upload.id } })
|
.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 = () => {
|
newImg.onerror = () => {
|
||||||
@@ -172,9 +114,6 @@ const insertFiles = function (
|
|||||||
newImg.src = src;
|
newImg.src = src;
|
||||||
} else if (upload.isVideo) {
|
} else if (upload.isVideo) {
|
||||||
const result = findPlaceholder(view.state, upload.id);
|
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) {
|
if (result === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -197,71 +136,41 @@ const insertFiles = function (
|
|||||||
)
|
)
|
||||||
.setMeta(uploadPlaceholderPlugin, { remove: { id: upload.id } })
|
.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 {
|
} else {
|
||||||
const result = findAttachmentById(view.state, upload.id);
|
const result = findPlaceholder(view.state, upload.id);
|
||||||
|
|
||||||
// if the attachment has been deleted then forget about updating it
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [from, to] = result;
|
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
|
view.dispatch(
|
||||||
// the entire node once done. Otherwise, if the selection has moved
|
view.state.tr
|
||||||
// elsewhere then we don't want to modify it
|
.replaceWith(
|
||||||
if (view.state.selection.from === from) {
|
from,
|
||||||
view.dispatch(
|
to || from,
|
||||||
view.state.tr.setSelection(
|
schema.nodes.attachment.create({
|
||||||
new NodeSelection(view.state.doc.resolve(from))
|
href: src,
|
||||||
|
title: upload.file.name ?? dictionary.untitled,
|
||||||
|
size: upload.file.size,
|
||||||
|
})
|
||||||
)
|
)
|
||||||
);
|
.setMeta(uploadPlaceholderPlugin, { remove: { id: upload.id } })
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
// cleanup the placeholder if there is a failure
|
// cleanup the placeholder if there is a failure
|
||||||
if (upload.isImage || upload.isVideo) {
|
view.dispatch(
|
||||||
view.dispatch(
|
view.state.tr.setMeta(uploadPlaceholderPlugin, {
|
||||||
view.state.tr.setMeta(uploadPlaceholderPlugin, {
|
remove: { id: upload.id },
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
onShowToast(error.message || dictionary.fileUploadError);
|
onShowToast(error.message || dictionary.fileUploadError);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
.image-replacement-uploading {
|
||||||
img {
|
img {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { isCode } from "../lib/isCode";
|
import { isCode } from "../lib/isCode";
|
||||||
|
import { isRemoteTransaction } from "../lib/multiplayer";
|
||||||
import { findBlockNodes, NodeWithPos } from "../queries/findChildren";
|
import { findBlockNodes, NodeWithPos } from "../queries/findChildren";
|
||||||
|
|
||||||
type MermaidState = {
|
type MermaidState = {
|
||||||
@@ -251,7 +252,6 @@ export default function Mermaid({
|
|||||||
const previousNodeName = oldState.selection.$head.parent.type.name;
|
const previousNodeName = oldState.selection.$head.parent.type.name;
|
||||||
const codeBlockChanged =
|
const codeBlockChanged =
|
||||||
transaction.docChanged && [nodeName, previousNodeName].includes(name);
|
transaction.docChanged && [nodeName, previousNodeName].includes(name);
|
||||||
const ySyncEdit = !!transaction.getMeta("y-sync$");
|
|
||||||
const themeMeta = transaction.getMeta("theme");
|
const themeMeta = transaction.getMeta("theme");
|
||||||
const mermaidMeta = transaction.getMeta("mermaid");
|
const mermaidMeta = transaction.getMeta("mermaid");
|
||||||
const themeToggled = themeMeta?.isDark !== undefined;
|
const themeToggled = themeMeta?.isDark !== undefined;
|
||||||
@@ -260,7 +260,12 @@ export default function Mermaid({
|
|||||||
pluginState.isDark = themeMeta.isDark;
|
pluginState.isDark = themeMeta.isDark;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mermaidMeta || themeToggled || codeBlockChanged || ySyncEdit) {
|
if (
|
||||||
|
mermaidMeta ||
|
||||||
|
themeToggled ||
|
||||||
|
codeBlockChanged ||
|
||||||
|
isRemoteTransaction(transaction)
|
||||||
|
) {
|
||||||
return getNewState({
|
return getNewState({
|
||||||
doc: transaction.doc,
|
doc: transaction.doc,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Node } from "prosemirror-model";
|
|||||||
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
|
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
|
||||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||||
import refractor from "refractor/core";
|
import refractor from "refractor/core";
|
||||||
|
import { isRemoteTransaction } from "../lib/multiplayer";
|
||||||
import { findBlockNodes } from "../queries/findChildren";
|
import { findBlockNodes } from "../queries/findChildren";
|
||||||
|
|
||||||
export const LANGUAGES = {
|
export const LANGUAGES = {
|
||||||
@@ -199,9 +200,12 @@ export default function Prism({
|
|||||||
const previousNodeName = oldState.selection.$head.parent.type.name;
|
const previousNodeName = oldState.selection.$head.parent.type.name;
|
||||||
const codeBlockChanged =
|
const codeBlockChanged =
|
||||||
transaction.docChanged && [nodeName, previousNodeName].includes(name);
|
transaction.docChanged && [nodeName, previousNodeName].includes(name);
|
||||||
const ySyncEdit = !!transaction.getMeta("y-sync$");
|
|
||||||
|
|
||||||
if (!highlighted || codeBlockChanged || ySyncEdit) {
|
if (
|
||||||
|
!highlighted ||
|
||||||
|
codeBlockChanged ||
|
||||||
|
isRemoteTransaction(transaction)
|
||||||
|
) {
|
||||||
highlighted = true;
|
highlighted = true;
|
||||||
return getDecorations({ doc: transaction.doc, name, lineNumbers });
|
return getDecorations({ doc: transaction.doc, name, lineNumbers });
|
||||||
}
|
}
|
||||||
|
|||||||
15
shared/editor/lib/multiplayer.ts
Normal file
15
shared/editor/lib/multiplayer.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { EditorState, Plugin } from "prosemirror-state";
|
import { EditorState, Plugin } from "prosemirror-state";
|
||||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
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";
|
import { recreateTransform } from "./prosemirror-recreate-transform";
|
||||||
|
|
||||||
// based on the example at: https://prosemirror.net/examples/upload/
|
// based on the example at: https://prosemirror.net/examples/upload/
|
||||||
@@ -9,10 +13,15 @@ const uploadPlaceholder = new Plugin({
|
|||||||
return DecorationSet.empty;
|
return DecorationSet.empty;
|
||||||
},
|
},
|
||||||
apply(tr, set: DecorationSet) {
|
apply(tr, set: DecorationSet) {
|
||||||
const ySyncEdit = !!tr.getMeta("y-sync$");
|
|
||||||
let mapping = tr.mapping;
|
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 {
|
try {
|
||||||
mapping = recreateTransform(tr.before, tr.doc, {
|
mapping = recreateTransform(tr.before, tr.doc, {
|
||||||
complexSteps: true,
|
complexSteps: true,
|
||||||
@@ -27,9 +36,6 @@ const uploadPlaceholder = new Plugin({
|
|||||||
|
|
||||||
set = set.map(mapping, tr.doc);
|
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) {
|
||||||
if (action.add.isImage) {
|
if (action.add.isImage) {
|
||||||
if (action.add.replaceExisting) {
|
if (action.add.replaceExisting) {
|
||||||
@@ -62,9 +68,7 @@ const uploadPlaceholder = new Plugin({
|
|||||||
});
|
});
|
||||||
set = set.add(tr.doc, [deco]);
|
set = set.add(tr.doc, [deco]);
|
||||||
}
|
}
|
||||||
}
|
} else if (action.add.isVideo) {
|
||||||
|
|
||||||
if (action.add.isVideo) {
|
|
||||||
const element = document.createElement("div");
|
const element = document.createElement("div");
|
||||||
element.className = "video placeholder";
|
element.className = "video placeholder";
|
||||||
|
|
||||||
@@ -75,6 +79,29 @@ const uploadPlaceholder = new Plugin({
|
|||||||
|
|
||||||
element.appendChild(video);
|
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, {
|
const deco = Decoration.widget(action.add.pos, element, {
|
||||||
id: action.add.id,
|
id: action.add.id,
|
||||||
});
|
});
|
||||||
@@ -99,6 +126,13 @@ const uploadPlaceholder = new Plugin({
|
|||||||
|
|
||||||
export default uploadPlaceholder;
|
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(
|
export function findPlaceholder(
|
||||||
state: EditorState,
|
state: EditorState,
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -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;
|
|
||||||
Reference in New Issue
Block a user