Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit e32823e

Browse files
authored
Allow image pasting in plain mode in RTE (#11056)
* get rough funcitonality working * try to tidy up types * fix merge error * fix signature change error * type wrangling * use onBeforeInput listener * add onBeforeInput handler, add logic to onPaste * fix type error * bring plain text listeners in line with useInputEventProcessor * extract common function to util file, move tests * tidy comment * tidy comments * fix typo * add util tests * add text paste test
1 parent 47ab99f commit e32823e

File tree

6 files changed

+188
-104
lines changed

6 files changed

+188
-104
lines changed

src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,12 @@ export function PlainTextComposer({
5050
initialContent,
5151
leftComponent,
5252
rightComponent,
53+
eventRelation,
5354
}: PlainTextComposerProps): JSX.Element {
5455
const {
5556
ref: editorRef,
5657
autocompleteRef,
58+
onBeforeInput,
5759
onInput,
5860
onPaste,
5961
onKeyDown,
@@ -63,7 +65,7 @@ export function PlainTextComposer({
6365
onSelect,
6466
handleCommand,
6567
handleMention,
66-
} = usePlainTextListeners(initialContent, onChange, onSend);
68+
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation);
6769

6870
const composerFunctions = useComposerFunctions(editorRef, setContent);
6971
usePlainTextInitialization(initialContent, editorRef);
@@ -77,6 +79,7 @@ export function PlainTextComposer({
7779
className={classNames(className, { [`${className}-focused`]: isFocused })}
7880
onFocus={onFocus}
7981
onBlur={onFocus}
82+
onBeforeInput={onBeforeInput}
8083
onInput={onInput}
8184
onPaste={onPaste}
8285
onKeyDown={onKeyDown}

src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts

Lines changed: 3 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,7 @@ import { isCaretAtEnd, isCaretAtStart } from "../utils/selection";
3333
import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event";
3434
import { endEditing } from "../utils/editing";
3535
import Autocomplete from "../../Autocomplete";
36-
import { handleEventWithAutocomplete } from "./utils";
37-
import ContentMessages from "../../../../../ContentMessages";
38-
import { getBlobSafeMimeType } from "../../../../../utils/blobs";
39-
import { isNotNull } from "../../../../../Typeguards";
36+
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
4037

4138
export function useInputEventProcessor(
4239
onSend: () => void,
@@ -61,17 +58,8 @@ export function useInputEventProcessor(
6158
onSend();
6259
};
6360

64-
// this is required to handle edge case image pasting in Safari, see
65-
// https://github.com/vector-im/element-web/issues/25327 and it is caught by the
66-
// `beforeinput` listener attached to the composer
67-
const isInputEventForClipboard =
68-
event instanceof InputEvent && event.inputType === "insertFromPaste" && isNotNull(event.dataTransfer);
69-
const isClipboardEvent = event instanceof ClipboardEvent;
70-
71-
const shouldHandleAsClipboardEvent = isClipboardEvent || isInputEventForClipboard;
72-
73-
if (shouldHandleAsClipboardEvent) {
74-
const data = isClipboardEvent ? event.clipboardData : event.dataTransfer;
61+
if (isEventToHandleAsClipboardEvent(event)) {
62+
const data = event instanceof ClipboardEvent ? event.clipboardData : event.dataTransfer;
7563
const handled = handleClipboardEvent(event, data, roomContext, mxClient, eventRelation);
7664
return handled ? null : event;
7765
}
@@ -244,88 +232,3 @@ function handleInputEvent(event: InputEvent, send: Send, isCtrlEnterToSend: bool
244232

245233
return event;
246234
}
247-
248-
/**
249-
* Takes an event and handles image pasting. Returns a boolean to indicate if it has handled
250-
* the event or not. Must accept either clipboard or input events in order to prevent issue:
251-
* https://github.com/vector-im/element-web/issues/25327
252-
*
253-
* @param event - event to process
254-
* @param roomContext - room in which the event occurs
255-
* @param mxClient - current matrix client
256-
* @param eventRelation - used to send the event to the correct place eg timeline vs thread
257-
* @returns - boolean to show if the event was handled or not
258-
*/
259-
export function handleClipboardEvent(
260-
event: ClipboardEvent | InputEvent,
261-
data: DataTransfer | null,
262-
roomContext: IRoomState,
263-
mxClient: MatrixClient,
264-
eventRelation?: IEventRelation,
265-
): boolean {
266-
// Logic in this function follows that of `SendMessageComposer.onPaste`
267-
const { room, timelineRenderingType, replyToEvent } = roomContext;
268-
269-
function handleError(error: unknown): void {
270-
if (error instanceof Error) {
271-
console.log(error.message);
272-
} else if (typeof error === "string") {
273-
console.log(error);
274-
}
275-
}
276-
277-
if (event.type !== "paste" || data === null || room === undefined) {
278-
return false;
279-
}
280-
281-
// Prioritize text on the clipboard over files if RTF is present as Office on macOS puts a bitmap
282-
// in the clipboard as well as the content being copied. Modern versions of Office seem to not do this anymore.
283-
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
284-
// it puts the filename in as text/plain which we want to ignore.
285-
if (data.files.length && !data.types.includes("text/rtf")) {
286-
ContentMessages.sharedInstance()
287-
.sendContentListToRoom(Array.from(data.files), room.roomId, eventRelation, mxClient, timelineRenderingType)
288-
.catch(handleError);
289-
return true;
290-
}
291-
292-
// Safari `Insert from iPhone or iPad`
293-
// data.getData("text/html") returns a string like: <img src="blob:https://...">
294-
if (data.types.includes("text/html")) {
295-
const imgElementStr = data.getData("text/html");
296-
const parser = new DOMParser();
297-
const imgDoc = parser.parseFromString(imgElementStr, "text/html");
298-
299-
if (
300-
imgDoc.getElementsByTagName("img").length !== 1 ||
301-
!imgDoc.querySelector("img")?.src.startsWith("blob:") ||
302-
imgDoc.childNodes.length !== 1
303-
) {
304-
handleError("Failed to handle pasted content as Safari inserted content");
305-
return false;
306-
}
307-
const imgSrc = imgDoc.querySelector("img")!.src;
308-
309-
fetch(imgSrc)
310-
.then((response) => {
311-
response
312-
.blob()
313-
.then((imgBlob) => {
314-
const type = imgBlob.type;
315-
const safetype = getBlobSafeMimeType(type);
316-
const ext = type.split("/")[1];
317-
const parts = response.url.split("/");
318-
const filename = parts[parts.length - 1];
319-
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
320-
ContentMessages.sharedInstance()
321-
.sendContentToRoom(file, room.roomId, eventRelation, mxClient, replyToEvent)
322-
.catch(handleError);
323-
})
324-
.catch(handleError);
325-
})
326-
.catch(handleError);
327-
return true;
328-
}
329-
330-
return false;
331-
}

src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ limitations under the License.
1616

1717
import { KeyboardEvent, RefObject, SyntheticEvent, useCallback, useRef, useState } from "react";
1818
import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
19+
import { IEventRelation } from "matrix-js-sdk/src/matrix";
1920

2021
import { useSettingValue } from "../../../../../hooks/useSettings";
2122
import { IS_MAC, Key } from "../../../../../Keyboard";
2223
import Autocomplete from "../../Autocomplete";
23-
import { handleEventWithAutocomplete } from "./utils";
24+
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
2425
import { useSuggestion } from "./useSuggestion";
2526
import { isNotNull, isNotUndefined } from "../../../../../Typeguards";
27+
import { useRoomContext } from "../../../../../contexts/RoomContext";
28+
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
2629

2730
function isDivElement(target: EventTarget): target is HTMLDivElement {
2831
return target instanceof HTMLDivElement;
@@ -59,10 +62,12 @@ export function usePlainTextListeners(
5962
initialContent?: string,
6063
onChange?: (content: string) => void,
6164
onSend?: () => void,
65+
eventRelation?: IEventRelation,
6266
): {
6367
ref: RefObject<HTMLDivElement>;
6468
autocompleteRef: React.RefObject<Autocomplete>;
6569
content?: string;
70+
onBeforeInput(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
6671
onInput(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
6772
onPaste(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
6873
onKeyDown(event: KeyboardEvent<HTMLDivElement>): void;
@@ -72,6 +77,9 @@ export function usePlainTextListeners(
7277
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
7378
suggestion: MappedSuggestion | null;
7479
} {
80+
const roomContext = useRoomContext();
81+
const mxClient = useMatrixClientContext();
82+
7583
const ref = useRef<HTMLDivElement | null>(null);
7684
const autocompleteRef = useRef<Autocomplete | null>(null);
7785
const [content, setContent] = useState<string | undefined>(initialContent);
@@ -115,6 +123,27 @@ export function usePlainTextListeners(
115123
[setText, enterShouldSend],
116124
);
117125

126+
const onPaste = useCallback(
127+
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
128+
const { nativeEvent } = event;
129+
let imagePasteWasHandled = false;
130+
131+
if (isEventToHandleAsClipboardEvent(nativeEvent)) {
132+
const data =
133+
nativeEvent instanceof ClipboardEvent ? nativeEvent.clipboardData : nativeEvent.dataTransfer;
134+
imagePasteWasHandled = handleClipboardEvent(nativeEvent, data, roomContext, mxClient, eventRelation);
135+
}
136+
137+
// prevent default behaviour and skip call to onInput if the image paste event was handled
138+
if (imagePasteWasHandled) {
139+
event.preventDefault();
140+
} else {
141+
onInput(event);
142+
}
143+
},
144+
[eventRelation, mxClient, onInput, roomContext],
145+
);
146+
118147
const onKeyDown = useCallback(
119148
(event: KeyboardEvent<HTMLDivElement>) => {
120149
// we need autocomplete to take priority when it is open for using enter to select
@@ -149,8 +178,9 @@ export function usePlainTextListeners(
149178
return {
150179
ref,
151180
autocompleteRef,
181+
onBeforeInput: onPaste,
152182
onInput,
153-
onPaste: onInput,
183+
onPaste,
154184
onKeyDown,
155185
content,
156186
setContent: setText,

src/components/views/rooms/wysiwyg_composer/hooks/utils.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@ limitations under the License.
1515
*/
1616

1717
import { MutableRefObject, RefObject } from "react";
18+
import { IEventRelation, MatrixClient } from "matrix-js-sdk/src/matrix";
19+
import { WysiwygEvent } from "@matrix-org/matrix-wysiwyg";
1820

1921
import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
2022
import { IRoomState } from "../../../../structures/RoomView";
2123
import Autocomplete from "../../Autocomplete";
2224
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
2325
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
26+
import { getBlobSafeMimeType } from "../../../../../utils/blobs";
27+
import ContentMessages from "../../../../../ContentMessages";
28+
import { isNotNull } from "../../../../../Typeguards";
2429

2530
export function focusComposer(
2631
composerElement: MutableRefObject<HTMLElement | null>,
@@ -110,3 +115,108 @@ export function handleEventWithAutocomplete(
110115

111116
return handled;
112117
}
118+
119+
/**
120+
* Takes an event and handles image pasting. Returns a boolean to indicate if it has handled
121+
* the event or not. Must accept either clipboard or input events in order to prevent issue:
122+
* https://github.com/vector-im/element-web/issues/25327
123+
*
124+
* @param event - event to process
125+
* @param data - data from the event to process
126+
* @param roomContext - room in which the event occurs
127+
* @param mxClient - current matrix client
128+
* @param eventRelation - used to send the event to the correct place eg timeline vs thread
129+
* @returns - boolean to show if the event was handled or not
130+
*/
131+
export function handleClipboardEvent(
132+
event: ClipboardEvent | InputEvent,
133+
data: DataTransfer | null,
134+
roomContext: IRoomState,
135+
mxClient: MatrixClient,
136+
eventRelation?: IEventRelation,
137+
): boolean {
138+
// Logic in this function follows that of `SendMessageComposer.onPaste`
139+
const { room, timelineRenderingType, replyToEvent } = roomContext;
140+
141+
function handleError(error: unknown): void {
142+
if (error instanceof Error) {
143+
console.log(error.message);
144+
} else if (typeof error === "string") {
145+
console.log(error);
146+
}
147+
}
148+
149+
if (event.type !== "paste" || data === null || room === undefined) {
150+
return false;
151+
}
152+
153+
// Prioritize text on the clipboard over files if RTF is present as Office on macOS puts a bitmap
154+
// in the clipboard as well as the content being copied. Modern versions of Office seem to not do this anymore.
155+
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
156+
// it puts the filename in as text/plain which we want to ignore.
157+
if (data.files.length && !data.types.includes("text/rtf")) {
158+
ContentMessages.sharedInstance()
159+
.sendContentListToRoom(Array.from(data.files), room.roomId, eventRelation, mxClient, timelineRenderingType)
160+
.catch(handleError);
161+
return true;
162+
}
163+
164+
// Safari `Insert from iPhone or iPad`
165+
// data.getData("text/html") returns a string like: <img src="blob:https://...">
166+
if (data.types.includes("text/html")) {
167+
const imgElementStr = data.getData("text/html");
168+
const parser = new DOMParser();
169+
const imgDoc = parser.parseFromString(imgElementStr, "text/html");
170+
171+
if (
172+
imgDoc.getElementsByTagName("img").length !== 1 ||
173+
!imgDoc.querySelector("img")?.src.startsWith("blob:") ||
174+
imgDoc.childNodes.length !== 1
175+
) {
176+
handleError("Failed to handle pasted content as Safari inserted content");
177+
return false;
178+
}
179+
const imgSrc = imgDoc.querySelector("img")!.src;
180+
181+
fetch(imgSrc)
182+
.then((response) => {
183+
response
184+
.blob()
185+
.then((imgBlob) => {
186+
const type = imgBlob.type;
187+
const safetype = getBlobSafeMimeType(type);
188+
const ext = type.split("/")[1];
189+
const parts = response.url.split("/");
190+
const filename = parts[parts.length - 1];
191+
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
192+
ContentMessages.sharedInstance()
193+
.sendContentToRoom(file, room.roomId, eventRelation, mxClient, replyToEvent)
194+
.catch(handleError);
195+
})
196+
.catch(handleError);
197+
})
198+
.catch(handleError);
199+
return true;
200+
}
201+
202+
return false;
203+
}
204+
205+
/**
206+
* Util to determine if an input event or clipboard event must be handled as a clipboard event.
207+
* Due to https://github.com/vector-im/element-web/issues/25327, certain paste events
208+
* must be listenened for with an onBeforeInput handler and so will be caught as input events.
209+
*
210+
* @param event - the event to test, can be a WysiwygEvent if it comes from the rich text editor, or
211+
* input or clipboard events if from the plain text editor
212+
* @returns - true if event should be handled as a clipboard event
213+
*/
214+
export function isEventToHandleAsClipboardEvent(
215+
event: WysiwygEvent | InputEvent | ClipboardEvent,
216+
): event is InputEvent | ClipboardEvent {
217+
const isInputEventForClipboard =
218+
event instanceof InputEvent && event.inputType === "insertFromPaste" && isNotNull(event.dataTransfer);
219+
const isClipboardEvent = event instanceof ClipboardEvent;
220+
221+
return isClipboardEvent || isInputEventForClipboard;
222+
}

test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,4 +290,16 @@ describe("PlainTextComposer", () => {
290290

291291
expect(screen.getByTestId("autocomplete-wrapper")).toBeInTheDocument();
292292
});
293+
294+
it("Should allow pasting of text values", async () => {
295+
customRender();
296+
297+
const textBox = screen.getByRole("textbox");
298+
299+
await userEvent.click(textBox);
300+
await userEvent.type(textBox, "hello");
301+
await userEvent.paste(" world");
302+
303+
expect(textBox).toHaveTextContent("hello world");
304+
});
293305
});

0 commit comments

Comments
 (0)