Files
element-web/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts
Michael Telatynski 7eb969bbc2 Apply lint rule @typescript-eslint/no-empty-object-type (#29159)
* Apply lint rule @typescript-eslint/no-empty-object-type

To avoid the footgun that is https://www.totaltypescript.com/the-empty-object-type-in-typescript

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-02-04 13:41:34 +00:00

215 lines
8.7 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { MutableRefObject, RefObject } from "react";
import { IEventRelation, MatrixClient } from "matrix-js-sdk/src/matrix";
import { WysiwygEvent } from "@vector-im/matrix-wysiwyg";
import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
import { IRoomState } from "../../../../structures/RoomView";
import Autocomplete from "../../Autocomplete";
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
import { getBlobSafeMimeType } from "../../../../../utils/blobs";
import ContentMessages from "../../../../../ContentMessages";
import { isNotNull } from "../../../../../Typeguards";
export function focusComposer(
composerElement: MutableRefObject<HTMLElement | null>,
renderingType: TimelineRenderingType,
roomContext: Pick<IRoomState, "timelineRenderingType">,
timeoutId: MutableRefObject<number | null>,
): void {
if (renderingType === roomContext.timelineRenderingType) {
// Immediately set the focus, so if you start typing it
// will appear in the composer
composerElement.current?.focus();
// If we call focus immediate, the focus _is_ in the right
// place, but the cursor is invisible, presumably because
// some other event is still processing.
// The following line ensures that the cursor is actually
// visible in composer.
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
timeoutId.current = window.setTimeout(() => composerElement.current?.focus(), 200);
}
}
export function setCursorPositionAtTheEnd(element: HTMLElement): void {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = document.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
element.focus();
}
/**
* When the autocomplete modal is open we need to be able to properly
* handle events that are dispatched. This allows the user to move the selection
* in the autocomplete and select using enter.
*
* @param autocompleteRef - a ref to the autocomplete of interest
* @param event - the keyboard event that has been dispatched
* @returns boolean - whether or not the autocomplete has handled the event
*/
export function handleEventWithAutocomplete(
autocompleteRef: RefObject<Autocomplete>,
// we get a React Keyboard event from plain text composer, a Keyboard Event from the rich text composer
event: KeyboardEvent | React.KeyboardEvent<HTMLDivElement>,
): boolean {
const autocompleteIsOpen = autocompleteRef?.current && !autocompleteRef.current.state.hide;
if (!autocompleteIsOpen) {
return false;
}
let handled = false;
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
const component = autocompleteRef.current;
if (component && component.countCompletions() > 0) {
switch (autocompleteAction) {
case KeyBindingAction.ForceCompleteAutocomplete:
case KeyBindingAction.CompleteAutocomplete:
autocompleteRef.current.onConfirmCompletion();
handled = true;
break;
case KeyBindingAction.PrevSelectionInAutocomplete:
autocompleteRef.current.moveSelection(-1);
handled = true;
break;
case KeyBindingAction.NextSelectionInAutocomplete:
autocompleteRef.current.moveSelection(1);
handled = true;
break;
case KeyBindingAction.CancelAutocomplete:
autocompleteRef.current.onEscape(event);
handled = true;
break;
default:
break; // don't return anything, allow event to pass through
}
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
return handled;
}
/**
* Takes an event and handles image pasting. Returns a boolean to indicate if it has handled
* the event or not. Must accept either clipboard or input events in order to prevent issue:
* https://github.com/vector-im/element-web/issues/25327
*
* @param event - event to process
* @param data - data from the event to process
* @param roomContext - room in which the event occurs
* @param mxClient - current matrix client
* @param eventRelation - used to send the event to the correct place eg timeline vs thread
* @returns - boolean to show if the event was handled or not
*/
export function handleClipboardEvent(
event: ClipboardEvent | InputEvent,
data: DataTransfer | null,
roomContext: Pick<IRoomState, "room" | "timelineRenderingType" | "replyToEvent">,
mxClient: MatrixClient,
eventRelation?: IEventRelation,
): boolean {
// Logic in this function follows that of `SendMessageComposer.onPaste`
const { room, timelineRenderingType, replyToEvent } = roomContext;
function handleError(error: unknown): void {
if (error instanceof Error) {
console.log(error.message);
} else if (typeof error === "string") {
console.log(error);
}
}
if (event.type !== "paste" || data === null || room === undefined) {
return false;
}
// Prioritize text on the clipboard over files if RTF is present as Office on macOS puts a bitmap
// in the clipboard as well as the content being copied. Modern versions of Office seem to not do this anymore.
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
// it puts the filename in as text/plain which we want to ignore.
if (data.files.length && !data.types.includes("text/rtf")) {
ContentMessages.sharedInstance()
.sendContentListToRoom(Array.from(data.files), room.roomId, eventRelation, mxClient, timelineRenderingType)
.catch(handleError);
return true;
}
// Safari `Insert from iPhone or iPad`
// data.getData("text/html") returns a string like: <img src="blob:https://...">
if (data.types.includes("text/html")) {
const imgElementStr = data.getData("text/html");
const parser = new DOMParser();
const imgDoc = parser.parseFromString(imgElementStr, "text/html");
if (
imgDoc.getElementsByTagName("img").length !== 1 ||
!imgDoc.querySelector("img")?.src.startsWith("blob:") ||
imgDoc.childNodes.length !== 1
) {
handleError("Failed to handle pasted content as Safari inserted content");
return false;
}
const imgSrc = imgDoc.querySelector("img")!.src;
fetch(imgSrc)
.then((response) => {
response
.blob()
.then((imgBlob) => {
const type = imgBlob.type;
const safetype = getBlobSafeMimeType(type);
const ext = type.split("/")[1];
const parts = response.url.split("/");
const filename = parts[parts.length - 1];
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
ContentMessages.sharedInstance()
.sendContentToRoom(file, room.roomId, eventRelation, mxClient, replyToEvent)
.catch(handleError);
})
.catch(handleError);
})
.catch(handleError);
return true;
}
return false;
}
/**
* Util to determine if an input event or clipboard event must be handled as a clipboard event.
* Due to https://github.com/vector-im/element-web/issues/25327, certain paste events
* must be listenened for with an onBeforeInput handler and so will be caught as input events.
*
* @param event - the event to test, can be a WysiwygEvent if it comes from the rich text editor, or
* input or clipboard events if from the plain text editor
* @returns - true if event should be handled as a clipboard event
*/
export function isEventToHandleAsClipboardEvent(
event: WysiwygEvent | InputEvent | ClipboardEvent,
): event is InputEvent | ClipboardEvent {
const isInputEventForClipboard =
event instanceof InputEvent && event.inputType === "insertFromPaste" && isNotNull(event.dataTransfer);
const isClipboardEvent = event instanceof ClipboardEvent;
return isClipboardEvent || isInputEventForClipboard;
}