feat: Image Node; Resizer
parent
30b9814181
commit
2aedb1b6c0
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
type ShowFlashMessage,
|
||||
useFlashMessageContext,
|
||||
} from '../context/FlashMessageContext';
|
||||
|
||||
export default function useFlashMessage(): ShowFlashMessage {
|
||||
return useFlashMessageContext();
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {useCallback, useMemo, useState} from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import Modal from './../ui/Modal';
|
||||
|
||||
export default function useModal(): [
|
||||
JSX.Element | null,
|
||||
(title: string, showModal: (onClose: () => void) => JSX.Element) => void,
|
||||
] {
|
||||
const [modalContent, setModalContent] = useState<null | {
|
||||
closeOnClickOutside: boolean;
|
||||
content: JSX.Element;
|
||||
title: string;
|
||||
}>(null);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setModalContent(null);
|
||||
}, []);
|
||||
|
||||
const modal = useMemo(() => {
|
||||
if (modalContent === null) {
|
||||
return null;
|
||||
}
|
||||
const {title, content, closeOnClickOutside} = modalContent;
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
closeOnClickOutside={closeOnClickOutside}>
|
||||
{content}
|
||||
</Modal>
|
||||
);
|
||||
}, [modalContent, onClose]);
|
||||
|
||||
const showModal = useCallback(
|
||||
(
|
||||
title: string,
|
||||
// eslint-disable-next-line no-shadow
|
||||
getContent: (onClose: () => void) => JSX.Element,
|
||||
closeOnClickOutside = false,
|
||||
) => {
|
||||
setModalContent({
|
||||
closeOnClickOutside,
|
||||
content: getContent(onClose),
|
||||
title,
|
||||
});
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return [modal, showModal];
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {useCallback, useEffect, useRef} from 'react';
|
||||
|
||||
const getElement = (): HTMLElement => {
|
||||
let element = document.getElementById('report-container');
|
||||
|
||||
if (element === null) {
|
||||
element = document.createElement('div');
|
||||
element.id = 'report-container';
|
||||
element.style.position = 'fixed';
|
||||
element.style.top = '50%';
|
||||
element.style.left = '50%';
|
||||
element.style.fontSize = '32px';
|
||||
element.style.transform = 'translate(-50%, -50px)';
|
||||
element.style.padding = '20px';
|
||||
element.style.background = 'rgba(240, 240, 240, 0.4)';
|
||||
element.style.borderRadius = '20px';
|
||||
|
||||
if (document.body) {
|
||||
document.body.appendChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
export default function useReport(): (
|
||||
arg0: string,
|
||||
) => ReturnType<typeof setTimeout> {
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const cleanup = useCallback(() => {
|
||||
if (timer.current !== null) {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
|
||||
if (document.body) {
|
||||
document.body.removeChild(getElement());
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return cleanup;
|
||||
}, [cleanup]);
|
||||
|
||||
return useCallback(
|
||||
(content) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(content);
|
||||
const element = getElement();
|
||||
if (timer.current !== null) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
element.innerHTML = content;
|
||||
timer.current = setTimeout(cleanup, 1000);
|
||||
return timer.current;
|
||||
},
|
||||
[cleanup],
|
||||
);
|
||||
}
|
@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import type {Position} from './InlineImageNode';
|
||||
import type {BaseSelection, LexicalEditor, NodeKey} from 'lexical';
|
||||
|
||||
import './InlineImageNode.css';
|
||||
|
||||
import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
|
||||
import {LexicalNestedComposer} from '@lexical/react/LexicalNestedComposer';
|
||||
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
|
||||
import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection';
|
||||
import {mergeRegister} from '@lexical/utils';
|
||||
import {
|
||||
$getNodeByKey,
|
||||
$getSelection,
|
||||
$isNodeSelection,
|
||||
$setSelection,
|
||||
CLICK_COMMAND,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
DRAGSTART_COMMAND,
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
KEY_DELETE_COMMAND,
|
||||
KEY_ENTER_COMMAND,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical';
|
||||
import * as React from 'react';
|
||||
import {Suspense, useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
import useModal from '../../hooks/useModal';
|
||||
import LinkPlugin from '../../plugins/LinkPlugin';
|
||||
import Button from '../../ui/Button';
|
||||
import ContentEditable from '../../ui/ContentEditable';
|
||||
import {DialogActions} from '../../ui/Dialog';
|
||||
import Select from '../../ui/Select';
|
||||
import TextInput from '../../ui/TextInput';
|
||||
import {$isInlineImageNode, InlineImageNode} from './InlineImageNode';
|
||||
|
||||
const imageCache = new Set();
|
||||
|
||||
function useSuspenseImage(src: string) {
|
||||
if (!imageCache.has(src)) {
|
||||
throw new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
img.onload = () => {
|
||||
imageCache.add(src);
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function LazyImage({
|
||||
altText,
|
||||
className,
|
||||
imageRef,
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
position,
|
||||
}: {
|
||||
altText: string;
|
||||
className: string | null;
|
||||
height: 'inherit' | number;
|
||||
imageRef: {current: null | HTMLImageElement};
|
||||
src: string;
|
||||
width: 'inherit' | number;
|
||||
position: Position;
|
||||
}): JSX.Element {
|
||||
useSuspenseImage(src);
|
||||
return (
|
||||
<img
|
||||
className={className || undefined}
|
||||
src={src}
|
||||
alt={altText}
|
||||
ref={imageRef}
|
||||
data-position={position}
|
||||
style={{
|
||||
display: 'block',
|
||||
height,
|
||||
width,
|
||||
}}
|
||||
draggable="false"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateInlineImageDialog({
|
||||
activeEditor,
|
||||
nodeKey,
|
||||
onClose,
|
||||
}: {
|
||||
activeEditor: LexicalEditor;
|
||||
nodeKey: NodeKey;
|
||||
onClose: () => void;
|
||||
}): JSX.Element {
|
||||
const editorState = activeEditor.getEditorState();
|
||||
const node = editorState.read(
|
||||
() => $getNodeByKey(nodeKey) as InlineImageNode,
|
||||
);
|
||||
const [altText, setAltText] = useState(node.getAltText());
|
||||
const [showCaption, setShowCaption] = useState(node.getShowCaption());
|
||||
const [position, setPosition] = useState<Position>(node.getPosition());
|
||||
|
||||
const handleShowCaptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setShowCaption(e.target.checked);
|
||||
};
|
||||
|
||||
const handlePositionChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setPosition(e.target.value as Position);
|
||||
};
|
||||
|
||||
const handleOnConfirm = () => {
|
||||
const payload = {altText, position, showCaption};
|
||||
if (node) {
|
||||
activeEditor.update(() => {
|
||||
node.update(payload);
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{marginBottom: '1em'}}>
|
||||
<TextInput
|
||||
label="Alt Text"
|
||||
placeholder="Descriptive alternative text"
|
||||
onChange={setAltText}
|
||||
value={altText}
|
||||
data-test-id="image-modal-alt-text-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
style={{marginBottom: '1em', width: '208px'}}
|
||||
value={position}
|
||||
label="Position"
|
||||
name="position"
|
||||
id="position-select"
|
||||
onChange={handlePositionChange}>
|
||||
<option value="left">Left</option>
|
||||
<option value="right">Right</option>
|
||||
<option value="full">Full Width</option>
|
||||
</Select>
|
||||
|
||||
<div className="Input__wrapper">
|
||||
<input
|
||||
id="caption"
|
||||
type="checkbox"
|
||||
checked={showCaption}
|
||||
onChange={handleShowCaptionChange}
|
||||
/>
|
||||
<label htmlFor="caption">Show Caption</label>
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
data-test-id="image-modal-file-upload-btn"
|
||||
onClick={() => handleOnConfirm()}>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InlineImageComponent({
|
||||
src,
|
||||
altText,
|
||||
nodeKey,
|
||||
width,
|
||||
height,
|
||||
showCaption,
|
||||
caption,
|
||||
position,
|
||||
}: {
|
||||
altText: string;
|
||||
caption: LexicalEditor;
|
||||
height: 'inherit' | number;
|
||||
nodeKey: NodeKey;
|
||||
showCaption: boolean;
|
||||
src: string;
|
||||
width: 'inherit' | number;
|
||||
position: Position;
|
||||
}): JSX.Element {
|
||||
const [modal, showModal] = useModal();
|
||||
const imageRef = useRef<null | HTMLImageElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [isSelected, setSelected, clearSelection] =
|
||||
useLexicalNodeSelection(nodeKey);
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [selection, setSelection] = useState<BaseSelection | null>(null);
|
||||
const activeEditorRef = useRef<LexicalEditor | null>(null);
|
||||
|
||||
const $onDelete = useCallback(
|
||||
(payload: KeyboardEvent) => {
|
||||
const deleteSelection = $getSelection();
|
||||
if (isSelected && $isNodeSelection(deleteSelection)) {
|
||||
const event: KeyboardEvent = payload;
|
||||
event.preventDefault();
|
||||
if (isSelected && $isNodeSelection(deleteSelection)) {
|
||||
editor.update(() => {
|
||||
deleteSelection.getNodes().forEach((node) => {
|
||||
if ($isInlineImageNode(node)) {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[editor, isSelected],
|
||||
);
|
||||
|
||||
const $onEnter = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const latestSelection = $getSelection();
|
||||
const buttonElem = buttonRef.current;
|
||||
if (
|
||||
isSelected &&
|
||||
$isNodeSelection(latestSelection) &&
|
||||
latestSelection.getNodes().length === 1
|
||||
) {
|
||||
if (showCaption) {
|
||||
// Move focus into nested editor
|
||||
$setSelection(null);
|
||||
event.preventDefault();
|
||||
caption.focus();
|
||||
return true;
|
||||
} else if (
|
||||
buttonElem !== null &&
|
||||
buttonElem !== document.activeElement
|
||||
) {
|
||||
event.preventDefault();
|
||||
buttonElem.focus();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[caption, isSelected, showCaption],
|
||||
);
|
||||
|
||||
const $onEscape = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (
|
||||
activeEditorRef.current === caption ||
|
||||
buttonRef.current === event.target
|
||||
) {
|
||||
$setSelection(null);
|
||||
editor.update(() => {
|
||||
setSelected(true);
|
||||
const parentRootElement = editor.getRootElement();
|
||||
if (parentRootElement !== null) {
|
||||
parentRootElement.focus();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[caption, editor, setSelected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const unregister = mergeRegister(
|
||||
editor.registerUpdateListener(({editorState}) => {
|
||||
if (isMounted) {
|
||||
setSelection(editorState.read(() => $getSelection()));
|
||||
}
|
||||
}),
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_, activeEditor) => {
|
||||
activeEditorRef.current = activeEditor;
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand<MouseEvent>(
|
||||
CLICK_COMMAND,
|
||||
(payload) => {
|
||||
const event = payload;
|
||||
if (event.target === imageRef.current) {
|
||||
if (event.shiftKey) {
|
||||
setSelected(!isSelected);
|
||||
} else {
|
||||
clearSelection();
|
||||
setSelected(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
DRAGSTART_COMMAND,
|
||||
(event) => {
|
||||
if (event.target === imageRef.current) {
|
||||
// TODO This is just a temporary workaround for FF to behave like other browsers.
|
||||
// Ideally, this handles drag & drop too (and all browsers).
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_DELETE_COMMAND,
|
||||
$onDelete,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
$onDelete,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(KEY_ENTER_COMMAND, $onEnter, COMMAND_PRIORITY_LOW),
|
||||
editor.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
$onEscape,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
unregister();
|
||||
};
|
||||
}, [
|
||||
clearSelection,
|
||||
editor,
|
||||
isSelected,
|
||||
nodeKey,
|
||||
$onDelete,
|
||||
$onEnter,
|
||||
$onEscape,
|
||||
setSelected,
|
||||
]);
|
||||
|
||||
const draggable = isSelected && $isNodeSelection(selection);
|
||||
const isFocused = isSelected;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<>
|
||||
<span draggable={draggable}>
|
||||
{/* <button
|
||||
className="image-edit-button"
|
||||
ref={buttonRef}
|
||||
onClick={() => {
|
||||
showModal('Update Inline Image', (onClose) => (
|
||||
<UpdateInlineImageDialog
|
||||
activeEditor={editor}
|
||||
nodeKey={nodeKey}
|
||||
onClose={onClose}
|
||||
/>
|
||||
));
|
||||
}}>
|
||||
Edit
|
||||
</button> */}
|
||||
<LazyImage
|
||||
className={
|
||||
isFocused
|
||||
? `focused ${$isNodeSelection(selection) ? 'draggable' : ''}`
|
||||
: null
|
||||
}
|
||||
src={src}
|
||||
altText={altText}
|
||||
imageRef={imageRef}
|
||||
width={width}
|
||||
height={height}
|
||||
position={position}
|
||||
/>
|
||||
</span>
|
||||
{showCaption && (
|
||||
<span className="image-caption-container">
|
||||
<LexicalNestedComposer initialEditor={caption}>
|
||||
<AutoFocusPlugin />
|
||||
<LinkPlugin />
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable
|
||||
placeholder="Enter a caption..."
|
||||
placeholderClassName="InlineImageNode__placeholder"
|
||||
className="InlineImageNode__contentEditable"
|
||||
/>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
</LexicalNestedComposer>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
{modal}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
.InlineImageNode__contentEditable {
|
||||
min-height: 20px;
|
||||
border: 0px;
|
||||
resize: none;
|
||||
cursor: text;
|
||||
caret-color: rgb(5, 5, 5);
|
||||
display: block;
|
||||
position: relative;
|
||||
tab-size: 1;
|
||||
outline: 0px;
|
||||
padding: 10px;
|
||||
user-select: text;
|
||||
font-size: 14px;
|
||||
line-height: 1.4em;
|
||||
width: calc(100% - 20px);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.InlineImageNode__placeholder {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
text-overflow: ellipsis;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.InlineImageNode_Checkbox:checked,
|
||||
.InlineImageNode_Checkbox:not(:checked) {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
}
|
||||
|
||||
.InlineImageNode_Checkbox:checked + label,
|
||||
.InlineImageNode_Checkbox:not(:checked) + label {
|
||||
position: absolute;
|
||||
padding-right: 55px;
|
||||
cursor: pointer;
|
||||
line-height: 20px;
|
||||
display: inline-block;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.InlineImageNode_Checkbox:checked + label:before,
|
||||
.InlineImageNode_Checkbox:not(:checked) + label:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid #666;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.InlineImageNode_Checkbox:checked + label:after,
|
||||
.InlineImageNode_Checkbox:not(:checked) + label:after {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #222222;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
-webkit-transition: all 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.InlineImageNode_Checkbox:not(:checked) + label:after {
|
||||
opacity: 0;
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
.InlineImageNode_Checkbox:checked + label:after {
|
||||
opacity: 1;
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedEditor,
|
||||
SerializedLexicalNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
|
||||
import {$applyNodeReplacement, createEditor, DecoratorNode} from 'lexical';
|
||||
import * as React from 'react';
|
||||
import {Suspense} from 'react';
|
||||
|
||||
const InlineImageComponent = React.lazy(() => import('./InlineImageComponent'));
|
||||
|
||||
export type Position = 'left' | 'right' | 'full' | undefined;
|
||||
|
||||
export interface InlineImagePayload {
|
||||
altText: string;
|
||||
caption?: LexicalEditor;
|
||||
height?: number;
|
||||
key?: NodeKey;
|
||||
showCaption?: boolean;
|
||||
src: string;
|
||||
width?: number;
|
||||
position?: Position;
|
||||
}
|
||||
|
||||
export interface UpdateInlineImagePayload {
|
||||
altText?: string;
|
||||
showCaption?: boolean;
|
||||
position?: Position;
|
||||
}
|
||||
|
||||
function $convertInlineImageElement(domNode: Node): null | DOMConversionOutput {
|
||||
if (domNode instanceof HTMLImageElement) {
|
||||
const {alt: altText, src, width, height} = domNode;
|
||||
const node = $createInlineImageNode({altText, height, src, width});
|
||||
return {node};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type SerializedInlineImageNode = Spread<
|
||||
{
|
||||
altText: string;
|
||||
caption: SerializedEditor;
|
||||
height?: number;
|
||||
showCaption: boolean;
|
||||
src: string;
|
||||
width?: number;
|
||||
position?: Position;
|
||||
},
|
||||
SerializedLexicalNode
|
||||
>;
|
||||
|
||||
export class InlineImageNode extends DecoratorNode<JSX.Element> {
|
||||
__src: string;
|
||||
__altText: string;
|
||||
__width: 'inherit' | number;
|
||||
__height: 'inherit' | number;
|
||||
__showCaption: boolean;
|
||||
__caption: LexicalEditor;
|
||||
__position: Position;
|
||||
|
||||
static getType(): string {
|
||||
return 'inline-image';
|
||||
}
|
||||
|
||||
static clone(node: InlineImageNode): InlineImageNode {
|
||||
return new InlineImageNode(
|
||||
node.__src,
|
||||
node.__altText,
|
||||
node.__position,
|
||||
node.__width,
|
||||
node.__height,
|
||||
node.__showCaption,
|
||||
node.__caption,
|
||||
node.__key,
|
||||
);
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
serializedNode: SerializedInlineImageNode,
|
||||
): InlineImageNode {
|
||||
const {altText, height, width, caption, src, showCaption, position} =
|
||||
serializedNode;
|
||||
const node = $createInlineImageNode({
|
||||
altText,
|
||||
height,
|
||||
position,
|
||||
showCaption,
|
||||
src,
|
||||
width,
|
||||
});
|
||||
const nestedEditor = node.__caption;
|
||||
const editorState = nestedEditor.parseEditorState(caption.editorState);
|
||||
if (!editorState.isEmpty()) {
|
||||
nestedEditor.setEditorState(editorState);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
img: (node: Node) => ({
|
||||
conversion: $convertInlineImageElement,
|
||||
priority: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
constructor(
|
||||
src: string,
|
||||
altText: string,
|
||||
position: Position,
|
||||
width?: 'inherit' | number,
|
||||
height?: 'inherit' | number,
|
||||
showCaption?: boolean,
|
||||
caption?: LexicalEditor,
|
||||
key?: NodeKey,
|
||||
) {
|
||||
super(key);
|
||||
this.__src = src;
|
||||
this.__altText = altText;
|
||||
this.__width = width || 'inherit';
|
||||
this.__height = height || 'inherit';
|
||||
this.__showCaption = showCaption || false;
|
||||
this.__caption = caption || createEditor();
|
||||
this.__position = position;
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('img');
|
||||
element.setAttribute('src', this.__src);
|
||||
element.setAttribute('alt', this.__altText);
|
||||
element.setAttribute('width', this.__width.toString());
|
||||
element.setAttribute('height', this.__height.toString());
|
||||
return {element};
|
||||
}
|
||||
|
||||
exportJSON(): SerializedInlineImageNode {
|
||||
return {
|
||||
altText: this.getAltText(),
|
||||
caption: this.__caption.toJSON(),
|
||||
height: this.__height === 'inherit' ? 0 : this.__height,
|
||||
position: this.__position,
|
||||
showCaption: this.__showCaption,
|
||||
src: this.getSrc(),
|
||||
type: 'inline-image',
|
||||
version: 1,
|
||||
width: this.__width === 'inherit' ? 0 : this.__width,
|
||||
};
|
||||
}
|
||||
|
||||
getSrc(): string {
|
||||
return this.__src;
|
||||
}
|
||||
|
||||
getAltText(): string {
|
||||
return this.__altText;
|
||||
}
|
||||
|
||||
setAltText(altText: string): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__altText = altText;
|
||||
}
|
||||
|
||||
setWidthAndHeight(
|
||||
width: 'inherit' | number,
|
||||
height: 'inherit' | number,
|
||||
): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__width = width;
|
||||
writable.__height = height;
|
||||
}
|
||||
|
||||
getShowCaption(): boolean {
|
||||
return this.__showCaption;
|
||||
}
|
||||
|
||||
setShowCaption(showCaption: boolean): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__showCaption = showCaption;
|
||||
}
|
||||
|
||||
getPosition(): Position {
|
||||
return this.__position;
|
||||
}
|
||||
|
||||
setPosition(position: Position): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__position = position;
|
||||
}
|
||||
|
||||
update(payload: UpdateInlineImagePayload): void {
|
||||
const writable = this.getWritable();
|
||||
const {altText, showCaption, position} = payload;
|
||||
if (altText !== undefined) {
|
||||
writable.__altText = altText;
|
||||
}
|
||||
if (showCaption !== undefined) {
|
||||
writable.__showCaption = showCaption;
|
||||
}
|
||||
if (position !== undefined) {
|
||||
writable.__position = position;
|
||||
}
|
||||
}
|
||||
|
||||
// View
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
const className = `${config.theme.inlineImage} position-${this.__position}`;
|
||||
if (className !== undefined) {
|
||||
span.className = className;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
updateDOM(
|
||||
prevNode: InlineImageNode,
|
||||
dom: HTMLElement,
|
||||
config: EditorConfig,
|
||||
): false {
|
||||
const position = this.__position;
|
||||
if (position !== prevNode.__position) {
|
||||
const className = `${config.theme.inlineImage} position-${position}`;
|
||||
if (className !== undefined) {
|
||||
dom.className = className;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
decorate(): JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<InlineImageComponent
|
||||
src={this.__src}
|
||||
altText={this.__altText}
|
||||
width={this.__width}
|
||||
height={this.__height}
|
||||
nodeKey={this.getKey()}
|
||||
showCaption={this.__showCaption}
|
||||
caption={this.__caption}
|
||||
position={this.__position}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function $createInlineImageNode({
|
||||
altText,
|
||||
position,
|
||||
height,
|
||||
src,
|
||||
width,
|
||||
showCaption,
|
||||
caption,
|
||||
key,
|
||||
}: InlineImagePayload): InlineImageNode {
|
||||
return $applyNodeReplacement(
|
||||
new InlineImageNode(
|
||||
src,
|
||||
altText,
|
||||
position,
|
||||
width,
|
||||
height,
|
||||
showCaption,
|
||||
caption,
|
||||
key,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function $isInlineImageNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is InlineImageNode {
|
||||
return node instanceof InlineImageNode;
|
||||
}
|
@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import type {Position} from '../../nodes/InlineImageNode/InlineImageNode';
|
||||
|
||||
import '../../nodes/InlineImageNode/InlineImageNode.css';
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {$wrapNodeInElement, mergeRegister} from '@lexical/utils';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createRangeSelection,
|
||||
$getSelection,
|
||||
$insertNodes,
|
||||
$isNodeSelection,
|
||||
$isRootOrShadowRoot,
|
||||
$setSelection,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
createCommand,
|
||||
DRAGOVER_COMMAND,
|
||||
DRAGSTART_COMMAND,
|
||||
DROP_COMMAND,
|
||||
LexicalCommand,
|
||||
LexicalEditor,
|
||||
} from 'lexical';
|
||||
import * as React from 'react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
// import {CAN_USE_DOM} from 'shared/canUseDOM';
|
||||
|
||||
import {
|
||||
$createInlineImageNode,
|
||||
$isInlineImageNode,
|
||||
InlineImageNode,
|
||||
InlineImagePayload,
|
||||
} from '../../nodes/InlineImageNode/InlineImageNode';
|
||||
import Button from '../../ui/Button';
|
||||
import {DialogActions} from '../../ui/Dialog';
|
||||
import FileInput from '../../ui/FileInput';
|
||||
// import Select from '../../ui/Select';
|
||||
import TextInput from '../../ui/TextInput';
|
||||
|
||||
export type InsertInlineImagePayload = Readonly<InlineImagePayload>;
|
||||
|
||||
const getDOMSelection = (targetWindow: Window | null): Selection | null =>
|
||||
// CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
|
||||
(targetWindow || window).getSelection() ;
|
||||
|
||||
export const INSERT_INLINE_IMAGE_COMMAND: LexicalCommand<InlineImagePayload> =
|
||||
createCommand('INSERT_INLINE_IMAGE_COMMAND');
|
||||
|
||||
export function InsertInlineImageDialog({
|
||||
activeEditor,
|
||||
onClose,
|
||||
}: {
|
||||
activeEditor: LexicalEditor;
|
||||
onClose: () => void;
|
||||
}): JSX.Element {
|
||||
const hasModifier = useRef(false);
|
||||
|
||||
const [src, setSrc] = useState('');
|
||||
const [altText, setAltText] = useState('');
|
||||
const [showCaption, setShowCaption] = useState(false);
|
||||
const [position, setPosition] = useState<Position>('left');
|
||||
|
||||
const isDisabled = src === '';
|
||||
|
||||
const handleShowCaptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setShowCaption(e.target.checked);
|
||||
};
|
||||
|
||||
const handlePositionChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setPosition(e.target.value as Position);
|
||||
};
|
||||
|
||||
const loadImage = (files: FileList | null) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
if (typeof reader.result === 'string') {
|
||||
setSrc(reader.result);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
if (files !== null) {
|
||||
reader.readAsDataURL(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
hasModifier.current = false;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
hasModifier.current = e.altKey;
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handler);
|
||||
};
|
||||
}, [activeEditor]);
|
||||
|
||||
const handleOnClick = () => {
|
||||
const payload = {altText, position, showCaption, src};
|
||||
console.log('payload', payload, activeEditor);
|
||||
|
||||
activeEditor.dispatchCommand(INSERT_INLINE_IMAGE_COMMAND, payload);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{marginBottom: '1em'}}>
|
||||
<FileInput
|
||||
label="Image Upload"
|
||||
onChange={loadImage}
|
||||
accept="image/*"
|
||||
data-test-id="image-modal-file-upload"
|
||||
/>
|
||||
</div>
|
||||
{/* <div style={{marginBottom: '1em'}}>
|
||||
<TextInput
|
||||
label="Alt Text"
|
||||
placeholder="Descriptive alternative text"
|
||||
onChange={setAltText}
|
||||
value={altText}
|
||||
data-test-id="image-modal-alt-text-input"
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
{/* <Select
|
||||
style={{marginBottom: '1em', width: '290px'}}
|
||||
label="Position"
|
||||
name="position"
|
||||
id="position-select"
|
||||
onChange={handlePositionChange}>
|
||||
<option value="left">Left</option>
|
||||
<option value="right">Right</option>
|
||||
<option value="full">Full Width</option>
|
||||
</Select> */}
|
||||
|
||||
{/* <div className="Input__wrapper">
|
||||
<input
|
||||
id="caption"
|
||||
className="InlineImageNode_Checkbox"
|
||||
type="checkbox"
|
||||
checked={showCaption}
|
||||
onChange={handleShowCaptionChange}
|
||||
/>
|
||||
<label htmlFor="caption">Show Caption</label>
|
||||
</div> */}
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
data-test-id="image-modal-file-upload-btn"
|
||||
disabled={isDisabled}
|
||||
onClick={() => handleOnClick()}>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InlineImagePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([InlineImageNode])) {
|
||||
throw new Error('ImagesPlugin: ImageNode not registered on editor');
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand<InsertInlineImagePayload>(
|
||||
INSERT_INLINE_IMAGE_COMMAND,
|
||||
(payload) => {
|
||||
const imageNode = $createInlineImageNode(payload);
|
||||
$insertNodes([imageNode]);
|
||||
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
|
||||
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand<DragEvent>(
|
||||
DRAGSTART_COMMAND,
|
||||
(event) => {
|
||||
return $onDragStart(event);
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
editor.registerCommand<DragEvent>(
|
||||
DRAGOVER_COMMAND,
|
||||
(event) => {
|
||||
return $onDragover(event);
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand<DragEvent>(
|
||||
DROP_COMMAND,
|
||||
(event) => {
|
||||
return $onDrop(event, editor);
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const TRANSPARENT_IMAGE =
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
const img = document.createElement('img');
|
||||
img.src = TRANSPARENT_IMAGE;
|
||||
|
||||
function $onDragStart(event: DragEvent): boolean {
|
||||
const node = $getImageNodeInSelection();
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const dataTransfer = event.dataTransfer;
|
||||
if (!dataTransfer) {
|
||||
return false;
|
||||
}
|
||||
dataTransfer.setData('text/plain', '_');
|
||||
dataTransfer.setDragImage(img, 0, 0);
|
||||
dataTransfer.setData(
|
||||
'application/x-lexical-drag',
|
||||
JSON.stringify({
|
||||
data: {
|
||||
altText: node.__altText,
|
||||
caption: node.__caption,
|
||||
height: node.__height,
|
||||
key: node.getKey(),
|
||||
showCaption: node.__showCaption,
|
||||
src: node.__src,
|
||||
width: node.__width,
|
||||
},
|
||||
type: 'image',
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function $onDragover(event: DragEvent): boolean {
|
||||
const node = $getImageNodeInSelection();
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
if (!canDropImage(event)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function $onDrop(event: DragEvent, editor: LexicalEditor): boolean {
|
||||
const node = $getImageNodeInSelection();
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const data = getDragImageData(event);
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (canDropImage(event)) {
|
||||
const range = getDragSelection(event);
|
||||
node.remove();
|
||||
const rangeSelection = $createRangeSelection();
|
||||
if (range !== null && range !== undefined) {
|
||||
rangeSelection.applyDOMRange(range);
|
||||
}
|
||||
$setSelection(rangeSelection);
|
||||
editor.dispatchCommand(INSERT_INLINE_IMAGE_COMMAND, data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function $getImageNodeInSelection(): InlineImageNode | null {
|
||||
const selection = $getSelection();
|
||||
if (!$isNodeSelection(selection)) {
|
||||
return null;
|
||||
}
|
||||
const nodes = selection.getNodes();
|
||||
const node = nodes[0];
|
||||
return $isInlineImageNode(node) ? node : null;
|
||||
}
|
||||
|
||||
function getDragImageData(event: DragEvent): null | InsertInlineImagePayload {
|
||||
const dragData = event.dataTransfer?.getData('application/x-lexical-drag');
|
||||
if (!dragData) {
|
||||
return null;
|
||||
}
|
||||
const {type, data} = JSON.parse(dragData);
|
||||
if (type !== 'image') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface DragEvent {
|
||||
rangeOffset?: number;
|
||||
rangeParent?: Node;
|
||||
}
|
||||
}
|
||||
|
||||
function canDropImage(event: DragEvent): boolean {
|
||||
const target = event.target;
|
||||
return !!(
|
||||
target &&
|
||||
target instanceof HTMLElement &&
|
||||
!target.closest('code, span.editor-image') &&
|
||||
target.parentElement &&
|
||||
target.parentElement.closest('div.ContentEditable__root')
|
||||
);
|
||||
}
|
||||
|
||||
function getDragSelection(event: DragEvent): Range | null | undefined {
|
||||
let range;
|
||||
const target = event.target as null | Element | Document;
|
||||
const targetWindow =
|
||||
target == null
|
||||
? null
|
||||
: target.nodeType === 9
|
||||
? (target as Document).defaultView
|
||||
: (target as Element).ownerDocument.defaultView;
|
||||
const domSelection = getDOMSelection(targetWindow);
|
||||
if (document.caretRangeFromPoint) {
|
||||
range = document.caretRangeFromPoint(event.clientX, event.clientY);
|
||||
} else if (event.rangeParent && domSelection !== null) {
|
||||
domSelection.collapse(event.rangeParent, event.rangeOffset || 0);
|
||||
range = domSelection.getRangeAt(0);
|
||||
} else {
|
||||
throw Error('Cannot get the selection when dragging');
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
.Modal__overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
flex-direction: column;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
background-color: rgba(40, 40, 40, 0.6);
|
||||
flex-grow: 0px;
|
||||
flex-shrink: 1px;
|
||||
z-index: 100;
|
||||
}
|
||||
.Modal__modal {
|
||||
padding: 20px;
|
||||
min-height: 100px;
|
||||
min-width: 300px;
|
||||
display: flex;
|
||||
flex-grow: 0px;
|
||||
background-color: #fff;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
box-shadow: 0 0 20px 0 #444;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.Modal__title {
|
||||
color: #444;
|
||||
margin: 0px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
.Modal__closeButton {
|
||||
border: 0px;
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
border-radius: 20px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background-color: #eee;
|
||||
}
|
||||
.Modal__closeButton:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
.Modal__content {
|
||||
padding-top: 20px;
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import './Modal.css';
|
||||
|
||||
import * as React from 'react';
|
||||
import {ReactNode, useEffect, useRef} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
function PortalImpl({
|
||||
onClose,
|
||||
children,
|
||||
title,
|
||||
closeOnClickOutside,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
closeOnClickOutside: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
}) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (modalRef.current !== null) {
|
||||
modalRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let modalOverlayElement: HTMLElement | null = null;
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const clickOutsideHandler = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
modalRef.current !== null &&
|
||||
!modalRef.current.contains(target as Node) &&
|
||||
closeOnClickOutside
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const modelElement = modalRef.current;
|
||||
if (modelElement !== null) {
|
||||
modalOverlayElement = modelElement.parentElement;
|
||||
if (modalOverlayElement !== null) {
|
||||
modalOverlayElement.addEventListener('click', clickOutsideHandler);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler);
|
||||
if (modalOverlayElement !== null) {
|
||||
modalOverlayElement?.removeEventListener('click', clickOutsideHandler);
|
||||
}
|
||||
};
|
||||
}, [closeOnClickOutside, onClose]);
|
||||
|
||||
return (
|
||||
<div className="Modal__overlay" role="dialog">
|
||||
<div className="Modal__modal" tabIndex={-1} ref={modalRef}>
|
||||
<h2 className="Modal__title">{title}</h2>
|
||||
<button
|
||||
className="Modal__closeButton"
|
||||
aria-label="Close modal"
|
||||
type="button"
|
||||
onClick={onClose}>
|
||||
X
|
||||
</button>
|
||||
<div className="Modal__content">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
onClose,
|
||||
children,
|
||||
title,
|
||||
closeOnClickOutside = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
closeOnClickOutside?: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
}): JSX.Element {
|
||||
return createPortal(
|
||||
<PortalImpl
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
closeOnClickOutside={closeOnClickOutside}>
|
||||
{children}
|
||||
</PortalImpl>,
|
||||
document.body,
|
||||
);
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0 1em 0 0;
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: inherit;
|
||||
line-height: inherit;
|
||||
|
||||
z-index: 1;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--select-border: #393939;
|
||||
--select-focus: #101484;
|
||||
--select-arrow: var(--select-border);
|
||||
}
|
||||
|
||||
.select {
|
||||
min-width: 160px;
|
||||
max-width: 290px;
|
||||
border: 1px solid var(--select-border);
|
||||
border-radius: 0.25em;
|
||||
padding: 0.25em 0.5em;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
background: linear-gradient(to bottom, #ffffff 0%, #e5e5e5 100%);
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import './Select.css';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
type SelectIntrinsicProps = JSX.IntrinsicElements['select'];
|
||||
interface SelectProps extends SelectIntrinsicProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
...other
|
||||
}: SelectProps): JSX.Element {
|
||||
return (
|
||||
<div className="Input__wrapper">
|
||||
<label style={{marginTop: '-1em'}} className="Input__label">
|
||||
{label}
|
||||
</label>
|
||||
<select {...other} className={className || 'select'}>
|
||||
{children}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue