/* Copyright 2018-2024 New Vector Ltd. Copyright 2015, 2016 OpenMarket Ltd 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 React, { createRef } from "react"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; enum Phases { Display = "display", Edit = "edit", } interface IProps { onValueChanged?: (value: string, shouldSubmit: boolean) => void; initialValue: string; label: string; placeholder: string; className: string; labelClassName?: string; placeholderClassName: string; // Overrides blurToSubmit if true blurToCancel?: boolean; // Will cause onValueChanged(value, true) to fire on blur blurToSubmit: boolean; editable: boolean; } interface IState { phase: Phases; } export default class EditableText extends React.Component { // we track value as an JS object field rather than in React state // as React doesn't play nice with contentEditable. public value = ""; private placeholder = false; private editableDiv = createRef(); public static defaultProps: Partial = { onValueChanged() {}, initialValue: "", label: "", placeholder: "", editable: true, className: "mx_EditableText", placeholderClassName: "mx_EditableText_placeholder", blurToSubmit: false, }; public constructor(props: IProps) { super(props); this.state = { phase: Phases.Display, }; } public componentDidUpdate(prevProps: Readonly): void { if (prevProps.initialValue !== this.props.initialValue) { this.value = this.props.initialValue; if (this.editableDiv.current) { this.showPlaceholder(!this.value); } } } public componentDidMount(): void { this.value = this.props.initialValue; if (this.editableDiv.current) { this.showPlaceholder(!this.value); } } private showPlaceholder = (show: boolean): void => { if (!this.editableDiv.current) return; if (show) { this.editableDiv.current.textContent = this.props.placeholder; this.editableDiv.current.setAttribute( "class", this.props.className + " " + this.props.placeholderClassName, ); this.placeholder = true; this.value = ""; } else { this.editableDiv.current.textContent = this.value; this.editableDiv.current.setAttribute("class", this.props.className); this.placeholder = false; } }; private cancelEdit = (): void => { this.setState({ phase: Phases.Display, }); this.value = this.props.initialValue; this.showPlaceholder(!this.value); this.onValueChanged(false); this.editableDiv.current?.blur(); }; private onValueChanged = (shouldSubmit: boolean): void => { this.props.onValueChanged?.(this.value, shouldSubmit); }; private onKeyDown = (ev: React.KeyboardEvent): void => { if (this.placeholder) { this.showPlaceholder(false); } const action = getKeyBindingsManager().getAccessibilityAction(ev); switch (action) { case KeyBindingAction.Enter: ev.stopPropagation(); ev.preventDefault(); break; } }; private onKeyUp = (ev: React.KeyboardEvent): void => { if (!(ev.target as HTMLDivElement).textContent) { this.showPlaceholder(true); } else if (!this.placeholder) { this.value = (ev.target as HTMLDivElement).textContent ?? ""; } const action = getKeyBindingsManager().getAccessibilityAction(ev); switch (action) { case KeyBindingAction.Escape: this.cancelEdit(); break; case KeyBindingAction.Enter: this.onFinish(ev); break; } }; private onClickDiv = (): void => { if (!this.props.editable) return; this.setState({ phase: Phases.Edit, }); }; private onFocus = (ev: React.FocusEvent): void => { const node = ev.target.childNodes[0]; if (node) { const range = document.createRange(); range.setStart(node, 0); range.setEnd(node, ev.target.childNodes.length); const sel = window.getSelection()!; sel.removeAllRanges(); sel.addRange(range); } }; private onFinish = ( ev: React.KeyboardEvent | React.FocusEvent, shouldSubmit = false, ): void => { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent); const submit = action === KeyBindingAction.Enter || shouldSubmit; this.setState( { phase: Phases.Display, }, () => { if (this.value !== this.props.initialValue) { self.onValueChanged(submit); } }, ); }; private onBlur = (ev: React.FocusEvent): void => { const sel = window.getSelection()!; sel.removeAllRanges(); if (this.props.blurToCancel) { this.cancelEdit(); } else { this.onFinish(ev, this.props.blurToSubmit); } this.showPlaceholder(!this.value); }; public render(): React.ReactNode { const { className, editable, initialValue, label, labelClassName } = this.props; let editableEl; if (!editable || (this.state.phase === Phases.Display && (label || labelClassName) && !this.value)) { // show the label editableEl = (
{label || initialValue}
); } else { // show the content editable div, but manually manage its contents as react and contentEditable don't play nice together editableEl = (
); } return editableEl; } }