Files
outline/app/components/ContentEditable.tsx
dependabot[bot] fbd16d4b9a chore(deps-dev): bump prettier from 2.1.2 to 2.8.8 (#5372)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2023-05-22 19:14:56 -07:00

205 lines
5.5 KiB
TypeScript

import isPrintableKeyEvent from "is-printable-key-event";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import useOnScreen from "~/hooks/useOnScreen";
type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
disabled?: boolean;
readOnly?: boolean;
onClick?: React.MouseEventHandler<HTMLDivElement>;
onChange?: (text: string) => void;
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
placeholder?: string;
maxLength?: number;
autoFocus?: boolean;
children?: React.ReactNode;
value: string;
};
export type RefHandle = {
focus: () => void;
focusAtStart: () => void;
focusAtEnd: () => void;
getComputedDirection: () => string;
};
/**
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
*/
const ContentEditable = React.forwardRef(
(
{
disabled,
onChange,
onInput,
onBlur,
onKeyDown,
value,
children,
className,
maxLength,
autoFocus,
placeholder,
readOnly,
dir,
onClick,
...rest
}: Props,
ref: React.RefObject<RefHandle>
) => {
const contentRef = React.useRef<HTMLSpanElement>(null);
const [innerValue, setInnerValue] = React.useState<string>(value);
const lastValue = React.useRef("");
React.useImperativeHandle(ref, () => ({
focus: () => {
if (contentRef.current) {
contentRef.current.focus();
// looks unnecessary but required because of https://github.com/outline/outline/issues/5198
if (!contentRef.current.innerText) {
placeCaret(contentRef.current, true);
}
}
},
focusAtStart: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, true);
}
},
focusAtEnd: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, false);
}
},
getComputedDirection: () => {
if (contentRef.current) {
return window.getComputedStyle(contentRef.current).direction;
}
return "ltr";
},
}));
const wrappedEvent =
(
callback:
| React.FocusEventHandler<HTMLSpanElement>
| React.FormEventHandler<HTMLSpanElement>
| React.KeyboardEventHandler<HTMLSpanElement>
| undefined
) =>
(event: any) => {
const text = contentRef.current?.innerText || "";
if (
maxLength &&
isPrintableKeyEvent(event) &&
text.length >= maxLength
) {
event?.preventDefault();
return;
}
if (text !== lastValue.current) {
lastValue.current = text;
onChange && onChange(text);
}
callback?.(event);
};
// This is to account for being within a React.Suspense boundary, in this
// case the component may be rendered with display: none. React 18 may solve
// this in the future by delaying useEffect hooks:
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
const isVisible = useOnScreen(contentRef);
React.useEffect(() => {
if (autoFocus && isVisible && !disabled && !readOnly) {
contentRef.current?.focus();
}
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
React.useEffect(() => {
if (value !== contentRef.current?.innerText) {
setInnerValue(value);
}
}, [value, contentRef]);
// Ensure only plain text can be pasted into input when pasting from another
// rich text source
const handlePaste = React.useCallback(
(event: React.ClipboardEvent<HTMLSpanElement>) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
window.document.execCommand("insertText", false, text);
},
[]
);
return (
<div className={className} dir={dir} onClick={onClick}>
<Content
ref={contentRef}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste}
data-placeholder={placeholder}
suppressContentEditableWarning
role="textbox"
{...rest}
>
{innerValue}
</Content>
{children}
</div>
);
}
);
function placeCaret(element: HTMLElement, atStart: boolean) {
if (
typeof window.getSelection !== "undefined" &&
typeof document.createRange !== "undefined"
) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(atStart);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
const Content = styled.span`
background: ${s("background")};
transition: ${s("backgroundTransition")};
color: ${s("text")};
-webkit-text-fill-color: ${s("text")};
outline: none;
resize: none;
cursor: text;
&:empty {
display: inline-block;
}
&:empty::before {
display: inline-block;
color: ${s("placeholder")};
-webkit-text-fill-color: ${s("placeholder")};
content: attr(data-placeholder);
pointer-events: none;
height: 0;
}
`;
export default ContentEditable;