Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve Embed image button UX #2567

Merged
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/Files/Input.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getFormattedFiles } from "./helpers";
const FilesInput = ({ onChange, children, acceptedFiles }) => {
function handleFilesChange(e, files) {
const formattedFiles = getFormattedFiles(files);
onChange(formattedFiles);
onChange(formattedFiles, files);
}

return (
Expand Down
31 changes: 31 additions & 0 deletions src/components/MarkdownEditor/CustomImageCommand.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from "react";
import { ReactComponent as ImageSVG } from "./assets/image.svg";
import ModalAttachFiles from "src/components/ModalAttachFiles";
import useModalContext from "src/hooks/utils/useModalContext";
import { saveFileToMde } from "./helpers";

export function useCustomImageCommand(saveImage) {
const [handleOpenModal, handleCloseModal] = useModalContext();

const imageCommand = {
name: "image",
icon: () => <ImageSVG />,
execute({ initialState, textApi, l18n }) {
const handleOnChange = async function (_, files) {
handleCloseModal();
for (const file of files) {
await saveFileToMde(file[1], saveImage, initialState, textApi, l18n);
}
};
handleOpenModal(ModalAttachFiles, {
title: "Upload an image",
onChange: handleOnChange,
onClose: handleCloseModal
});
}
};

return {
imageCommand
};
}
61 changes: 21 additions & 40 deletions src/components/MarkdownEditor/MarkdownEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,10 @@ import { toolbarCommands, getCommandIcon } from "./commands";
import Markdown from "../Markdown";
import styles from "./MarkdownEditor.module.css";
import "./styles.css";
import { digestPayload } from "src/helpers";
import ReactMde, { getDefaultCommandMap } from "react-mde";
import customSaveImageCommand from "./customSaveImageCommand";

/** displayBlobSolution receives a pair of [ArrayBuffer, File] and returns a blob URL to display the image on preview. */
const displayBlobSolution = (f) => {
const [result] = f;
const bytes = new Uint8Array(result);
const blob = new Blob([bytes], { type: "image/png" });
const urlCreator = window.URL || window.webkitURL;
const imageUrl = urlCreator.createObjectURL(blob);
return imageUrl;
};

/** getFormattedFile receives a pair of [ArrayBuffer, File] and returns an object in the exact format we need to submit to the backend */
function getFormattedFile(f) {
const [result, file] = f;
const payload = btoa(result);
return {
name: file.name,
mime: file.type,
size: file.size,
payload,
digest: digestPayload(payload)
};
}
import { useCustomImageCommand } from "./CustomImageCommand";
import { getFormattedFile, displayBlobSolution } from "./helpers";

const MarkdownEditor = React.memo(function MarkdownEditor({
onChange,
Expand All @@ -47,8 +25,8 @@ const MarkdownEditor = React.memo(function MarkdownEditor({
const [tab, setTab] = useState("write");
const { themeName } = useTheme();
const isDarkTheme = themeName === DEFAULT_DARK_THEME_NAME;

const testId = props["data-testid"];

useEffect(() => {
const textarea = document.getElementsByClassName("mde-text")[0];
if (textarea) {
Expand All @@ -57,6 +35,22 @@ const MarkdownEditor = React.memo(function MarkdownEditor({
}
}, [tab, placeholder, testId]);

const saveImage = function ({ serverImage, displayImage }) {
try {
const fileToUpload = getFormattedFile(serverImage);
const urlToDisplay = displayBlobSolution(displayImage);
mapBlobToFile.set(urlToDisplay, fileToUpload);
return {
name: fileToUpload.name,
url: urlToDisplay
};
} catch (e) {
console.error(e);
}
};

const { imageCommand } = useCustomImageCommand(saveImage);

const attachFilesCommand = {
name: "attach-files",
icon: () => filesInput,
Expand All @@ -78,20 +72,6 @@ const MarkdownEditor = React.memo(function MarkdownEditor({
/>
);

const save = function ({ serverImage, displayImage }) {
try {
const fileToUpload = getFormattedFile(serverImage);
const urlToDisplay = displayBlobSolution(displayImage);
mapBlobToFile.set(urlToDisplay, fileToUpload);
return {
name: fileToUpload.name,
url: urlToDisplay
};
} catch (e) {
console.error(e);
}
};

return (
<div className={classNames(styles.container, className)}>
<ReactMde
Expand All @@ -103,6 +83,7 @@ const MarkdownEditor = React.memo(function MarkdownEditor({
commands={{
...getDefaultCommandMap(),
"attach-files": attachFilesCommand,
image: imageCommand,
"save-image": customSaveImageCommand
}}
toolbarCommands={toolbarCommands(allowImgs)}
Expand All @@ -111,7 +92,7 @@ const MarkdownEditor = React.memo(function MarkdownEditor({
paste={
allowImgs
? {
saveImage: save
saveImage: saveImage
}
: null
}
Expand Down
8 changes: 7 additions & 1 deletion src/components/MarkdownEditor/commands.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const commandTypes = {
link: "link",
quote: "quote",
code: "code",
image: "image"
image: "image",
saveImage: "save-image"
};

export const toolbarCommands = (allowImgs) =>
Expand Down Expand Up @@ -91,6 +92,11 @@ export const commands = [
command: commandTypes.numberedList,
tooltipText: "Add a numbered list",
Icon: NumberedListSVG
},
{
command: commandTypes.saveImage,
tooltipText: "Add an image",
Icon: ImageSVG
}
];

Expand Down
92 changes: 3 additions & 89 deletions src/components/MarkdownEditor/customSaveImageCommand.js
Original file line number Diff line number Diff line change
@@ -1,50 +1,4 @@
function getBreaksNeededForEmptyLineBefore(text = "", startPosition = 0) {
if (startPosition === 0) return 0;

// - If we're in the first line, no breaks are needed
// - Otherwise there must be 2 breaks before the previous character.

// Depending on how many breaks exist already, we may need to insert 0, 1 or 2 breaks
let neededBreaks = 2;
let isInFirstLine = true;
for (let i = startPosition - 1; i >= 0 && neededBreaks >= 0; i--) {
switch (text.charCodeAt(i)) {
case 32: // blank space
continue;
case 10: // line break
neededBreaks--;
isInFirstLine = false;
break;
default:
return neededBreaks;
}
}
return isInFirstLine ? 0 : neededBreaks;
}

export function readFileAsync(file, as = "arrayBuffer") {
return new Promise((resolve, reject) => {
const reader = new FileReader();

reader.onload = () => {
if (typeof reader.result === "string" && as !== "binary") {
throw new Error("reader.result is expected to be an ArrayBuffer");
}
if (typeof reader.result !== "string" && as === "binary") {
throw new Error("reader.result is expected to be an BinaryString");
}
resolve([reader.result, file]);
};

reader.onerror = reject;

if (as === "binary") {
reader.readAsBinaryString(file);
} else {
reader.readAsArrayBuffer(file);
}
});
}
import { saveFileToMde } from "./helpers";

function dataTransferToArray(items) {
const result = [];
Expand Down Expand Up @@ -83,48 +37,8 @@ const customSaveImageCommand = {
? dataTransferToArray(event.dataTransfer.items)
: fileListToArray(event.target.files);

for (const index in items) {
const breaksBeforeCount = getBreaksNeededForEmptyLineBefore(
initialState.text,
initialState.selection.start
);
const breaksBefore = Array(breaksBeforeCount + 1).join("\n");

const placeHolder = `${breaksBefore}![${l18n.uploadingImage}]()`;

textApi.replaceSelection(placeHolder);

const blob = items[index];
// this is the format we have to send to the server
const serverImage = await readFileAsync(blob, "binary");
// this is the format we have can show a blob
const displayImage = await readFileAsync(blob);
const image = await saveImage({ serverImage, displayImage });
const newState = textApi.getState();

const uploadingText = newState.text.substr(
initialState.selection.start,
placeHolder.length
);

if (uploadingText === placeHolder) {
// In this case, the user did not touch the placeholder. Good user
// we will replace it with the real one that came from the server
textApi.setSelectionRange({
start: initialState.selection.start,
end: initialState.selection.start + placeHolder.length
});

const realImageMarkdown = `${breaksBefore}![${image.name}](${image.url})`;

const selectionDelta = realImageMarkdown.length - placeHolder.length;

textApi.replaceSelection(realImageMarkdown);
textApi.setSelectionRange({
start: newState.selection.start + selectionDelta,
end: newState.selection.end + selectionDelta
});
}
for (const blob of items) {
await saveFileToMde(blob, saveImage, initialState, textApi, l18n);
}
}
};
Expand Down
121 changes: 121 additions & 0 deletions src/components/MarkdownEditor/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { digestPayload } from "src/helpers";

function readFileAsync(file, as = "arrayBuffer") {
return new Promise((resolve, reject) => {
const reader = new FileReader();

reader.onload = () => {
if (typeof reader.result === "string" && as !== "binary") {
throw new Error("reader.result is expected to be an ArrayBuffer");
}
if (typeof reader.result !== "string" && as === "binary") {
throw new Error("reader.result is expected to be an BinaryString");
}
resolve([reader.result, file]);
};

reader.onerror = reject;

if (as === "binary") {
reader.readAsBinaryString(file);
} else {
reader.readAsArrayBuffer(file);
}
});
}

/** displayBlobSolution receives a pair of [ArrayBuffer, File] and returns a blob URL to display the image on preview. */
export function displayBlobSolution(f) {
const [result] = f;
const bytes = new Uint8Array(result);
const blob = new Blob([bytes], { type: "image/png" });
const urlCreator = window.URL || window.webkitURL;
const imageUrl = urlCreator.createObjectURL(blob);
return imageUrl;
}

/** getFormattedFile receives a pair of [ArrayBuffer, File] and returns an object in the exact format we need to submit to the backend */
export function getFormattedFile(f) {
const [result, file] = f;
const payload = btoa(result);
return {
name: file.name,
mime: file.type,
size: file.size,
payload,
digest: digestPayload(payload)
};
}

function getBreaksNeededForEmptyLineBefore(text = "", startPosition = 0) {
if (startPosition === 0) return 0;

// - If we're in the first line, no breaks are needed
// - Otherwise there must be 2 breaks before the previous character.

// Depending on how many breaks exist already, we may need to insert 0, 1 or 2 breaks
let neededBreaks = 2;
let isInFirstLine = true;
for (let i = startPosition - 1; i >= 0 && neededBreaks >= 0; i--) {
switch (text.charCodeAt(i)) {
case 32: // blank space
continue;
case 10: // line break
neededBreaks--;
isInFirstLine = false;
break;
default:
return neededBreaks;
}
}
return isInFirstLine ? 0 : neededBreaks;
}

export async function saveFileToMde(
blob,
saveImage,
initialState,
textApi,
l18n
) {
const breaksBeforeCount = getBreaksNeededForEmptyLineBefore(
initialState.text,
initialState.selection.start
);
const breaksBefore = Array(breaksBeforeCount + 1).join("\n");

const placeHolder = `${breaksBefore}![${l18n.uploadingImage}]()`;

textApi.replaceSelection(placeHolder);

// this is the format we have to send to the server
const serverImage = await readFileAsync(blob, "binary");
// this is the format we have can show a blob
const displayImage = await readFileAsync(blob);
const image = await saveImage({ serverImage, displayImage });
const newState = textApi.getState();

const uploadingText = newState.text.substr(
initialState.selection.start,
placeHolder.length
);

if (uploadingText === placeHolder) {
// In this case, the user did not touch the placeholder. Good user
// we will replace it with the real one that came from the server
textApi.setSelectionRange({
start: initialState.selection.start,
end: initialState.selection.start + placeHolder.length
});

const realImageMarkdown = `${breaksBefore}![${image.name}](${image.url})`;

const selectionDelta = realImageMarkdown.length - placeHolder.length;

textApi.replaceSelection(realImageMarkdown);
textApi.setSelectionRange({
start: newState.selection.start + selectionDelta,
end: newState.selection.end + selectionDelta
});
}
}