feat: Image Node; Resizer

2.0/email-builder
Lei OT 11 months ago
parent 30b9814181
commit 2aedb1b6c0

@ -27,8 +27,11 @@ import ListMaxIndentLevelPlugin from "./plugins/ListMaxIndentLevelPlugin";
import CodeHighlightPlugin from "./plugins/CodeHighlightPlugin";
import AutoLinkPlugin from "./plugins/AutoLinkPlugin";
import TabFocusPlugin from './plugins/TabFocusPlugin';
// import ImagesPlugin from './plugins/ImagesPlugin';
import ImagesPlugin from './plugins/ImagesPlugin';
import InlineImagePlugin from './plugins/InlineImagePlugin';
import { ImageNode } from './nodes/ImageNode';
import {InlineImageNode} from './nodes/InlineImageNode/InlineImageNode';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
// import { useLexicalEditable } from '@lexical/react/useLexicalEditable';
@ -68,7 +71,7 @@ const editorConfig = {
AutoLinkNode,
LinkNode,
HorizontalRuleNode,
ImageNode,
ImageNode,InlineImageNode,
]
};
@ -131,7 +134,6 @@ function MyOnChangePlugin({ onChange }) {
return null;
}
export default function Editor({ isRichText, onChange, initialValue, ...props }) {
// const isEditable = useLexicalEditable();
return (
<LexicalComposer initialConfig={editorConfig}>
<div className='editor-container'>
@ -150,14 +152,14 @@ export default function Editor({ isRichText, onChange, initialValue, ...props })
<CodeHighlightPlugin />
<ListPlugin />
<ListMaxIndentLevelPlugin maxDepth={7} />
<LinkPlugin />
<AutoLinkPlugin />
<LinkPlugin />
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
<TabFocusPlugin />
<TabIndentationPlugin />
<HorizontalRulePlugin />
{/* <ImagesPlugin /> */}
{/* <ClickableLinkPlugin disabled={isEditable} /> */}
<ImagesPlugin />
<InlineImagePlugin />
<MyOnChangePlugin onChange={onChange}/>
</div>
</div>

@ -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;
}

@ -71,13 +71,13 @@ export function InsertImageUriDialogBody({
value={src}
data-test-id="image-modal-url-input"
/>
<TextInput
{/* <TextInput
label="Alt Text"
placeholder="Random unsplash image"
onChange={setAltText}
value={altText}
data-test-id="image-modal-alt-text-input"
/>
/> */}
<DialogActions>
<Button
data-test-id="image-modal-confirm-btn"
@ -121,13 +121,13 @@ export function InsertImageUploadedDialogBody({
accept="image/*"
data-test-id="image-modal-file-upload"
/>
<TextInput
{/* <TextInput
label="Alt Text"
placeholder="Descriptive alternative text"
onChange={setAltText}
value={altText}
data-test-id="image-modal-alt-text-input"
/>
/> */}
<DialogActions>
<Button
data-test-id="image-modal-file-upload-btn"
@ -162,6 +162,8 @@ export function InsertImageDialog({
}, [activeEditor]);
const onClick = (payload: InsertImagePayload) => {
console.log('payload', payload);
activeEditor.dispatchCommand(INSERT_IMAGE_COMMAND, payload);
onClose();
};

@ -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;
}

@ -33,6 +33,14 @@ import { $createCodeNode, $isCodeNode, getDefaultCodeLanguage, getCodeLanguages
import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode';
import DropDown, { DropDownItem } from './../ui/DropDown';
import DropdownColorPicker from '../ui/DropdownColorPicker';
import {
// INSERT_IMAGE_COMMAND,
InsertImageDialog,
// InsertImagePayload,
} from './ImagesPlugin';
import {InsertInlineImageDialog} from './InlineImagePlugin';
import useModal from './../hooks/useModal';
const LowPriority = 1;
@ -590,6 +598,7 @@ function ElementFormatDropdown({ editor, value, isRTL, disabled = false }) {
export default function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const [activeEditor, setActiveEditor] = useState(editor);
const toolbarRef = useRef(null);
const [isEditable, setIsEditable] = useState(() => editor.isEditable());
const [canUndo, setCanUndo] = useState(false);
@ -613,6 +622,8 @@ export default function ToolbarPlugin() {
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null);
const [isSmallWidthViewport, setIsSmallWidthViewport] = useState(false);
const [modal, showModal] = useModal();
const applyStyleText = useCallback(
(styles, skipHistoryStack = null) => {
editor.update(
@ -711,10 +722,12 @@ export default function ToolbarPlugin() {
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
setActiveEditor(newEditor);
updateToolbar();
return false;
},
LowPriority
// COMMAND_PRIORITY_CRITICAL,
),
editor.registerCommand(
CAN_UNDO_COMMAND,
@ -886,44 +899,44 @@ export default function ToolbarPlugin() {
/>
<Divider />
<ElementFormatDropdown disabled={!isEditable} value={elementFormat} editor={editor} isRTL={isRTL} />
{/* <button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "left");
}}
className="toolbar-item spaced"
aria-label="Left Align"
>
<i className="format left-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "center");
}}
className="toolbar-item spaced"
aria-label="Center Align"
>
<i className="format center-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "right");
}}
className="toolbar-item spaced"
aria-label="Right Align"
>
<i className="format right-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "justify");
}}
className="toolbar-item"
aria-label="Justify Align"
>
<i className="format justify-align" />
</button> */}
<Divider />
<DropDown
disabled={!isEditable}
buttonClassName="toolbar-item spaced"
buttonLabel="Insert"
buttonAriaLabel="Insert specialized editor node"
buttonIconClassName="icon plus">
<DropDownItem
onClick={() => {
showModal('Insert Image', (onClose) => (
<InsertImageDialog
activeEditor={activeEditor}
onClose={onClose}
/>
));
}}
className="item">
<i className="icon image" />
<span className="text">Image</span>
</DropDownItem>
<DropDownItem
onClick={() => {
showModal('Insert Inline Image', (onClose) => (
<InsertInlineImageDialog
activeEditor={activeEditor}
onClose={onClose}
/>
));
}}
className="item">
<i className="icon image" />
<span className="text">Inline Image</span>
</DropDownItem>
</DropDown>
</>
)}
{modal}
</div>
);
}

@ -796,7 +796,6 @@ i.redo {
.icon.code {
background-image: url(/images/icons/code.svg);
}
.icon.font-family {
background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-fonts'%3e%3cpath%20d='M12.258%203h-8.51l-.083%202.46h.479c.26-1.544.758-1.783%202.693-1.845l.424-.013v7.827c0%20.663-.144.82-1.3.923v.52h4.082v-.52c-1.162-.103-1.306-.26-1.306-.923V3.602l.431.013c1.934.062%202.434.301%202.693%201.846h.479L12.258%203z'/%3e%3c/svg%3e")
}
@ -824,7 +823,12 @@ i.redo {
.icon.justify-align,i.justify-align {
background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-justify'%3e%3cpath%20fill-rule='evenodd'%20d='M2%2012.5a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm0-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm0-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm0-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5z'/%3e%3c/svg%3e")
}
.icon.plus {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z'%3E%3C/path%3E%3C/svg%3E");
}
.icon.image{
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 5H4V19L13.2923 9.70649C13.6828 9.31595 14.3159 9.31591 14.7065 9.70641L20 15.0104V5ZM2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z'%3E%3C/path%3E%3C/svg%3E");
}
i.indent {
background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-text-indent-left'%3e%3cpath%20d='M2%203.5a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm.646%202.146a.5.5%200%200%201%20.708%200l2%202a.5.5%200%200%201%200%20.708l-2%202a.5.5%200%200%201-.708-.708L4.293%208%202.646%206.354a.5.5%200%200%201%200-.708zM7%206.5a.5.5%200%200%201%20.5-.5h6a.5.5%200%200%201%200%201h-6a.5.5%200%200%201-.5-.5zm0%203a.5.5%200%200%201%20.5-.5h6a.5.5%200%200%201%200%201h-6a.5.5%200%200%201-.5-.5zm-5%203a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5z'/%3e%3c/svg%3e")
}
@ -874,3 +878,221 @@ i.right-align {
i.justify-align {
background-image: url(/images/icons/justify.svg);
}
.editor-container span.editor-image {
cursor: default;
display: inline-block;
position: relative;
user-select: none;
}
.editor-container .editor-image img {
max-width: 100%;
cursor: default;
}
.editor-container .editor-image img.focused {
outline: 2px solid rgb(60, 132, 244);
user-select: none;
}
.editor-container .editor-image img.focused.draggable {
cursor: grab;
}
.editor-container .editor-image img.focused.draggable:active {
cursor: grabbing;
}
.editor-container .editor-image .image-caption-container .tree-view-output {
margin: 0;
border-radius: 0;
}
.editor-container .editor-image .image-caption-container {
display: block;
position: absolute;
bottom: 4px;
left: 0;
right: 0;
padding: 0;
margin: 0;
border-top: 1px solid #fff;
background-color: rgba(255, 255, 255, 0.9);
min-width: 100px;
color: #000;
overflow: hidden;
}
.editor-container .editor-image .image-caption-button {
display: block;
position: absolute;
bottom: 20px;
left: 0;
right: 0;
width: 30%;
padding: 10px;
margin: 0 auto;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.5);
min-width: 100px;
color: #fff;
cursor: pointer;
user-select: none;
}
.editor-container .editor-image .image-caption-button:hover {
background-color: rgba(60, 132, 244, 0.5);
}
.editor-container .editor-image .image-edit-button {
border: 1px solid rgba(0, 0, 0, 0.3);
border-radius: 5px;
background-image: url(/src/images/icons/pencil-fill.svg);
background-size: 16px;
background-position: center;
background-repeat: no-repeat;
width: 35px;
height: 35px;
vertical-align: -0.25em;
position: absolute;
right: 4px;
top: 4px;
cursor: pointer;
user-select: none;
}
.editor-container .editor-image .image-edit-button:hover {
background-color: rgba(60, 132, 244, 0.1);
}
.editor-container .editor-image .image-resizer {
display: block;
width: 7px;
height: 7px;
position: absolute;
background-color: rgb(60, 132, 244);
border: 1px solid #fff;
}
.editor-container .editor-image .image-resizer.image-resizer-n {
top: -6px;
left: 48%;
cursor: n-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-ne {
top: -6px;
right: -6px;
cursor: ne-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-e {
bottom: 48%;
right: -6px;
cursor: e-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-se {
bottom: -2px;
right: -6px;
cursor: nwse-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-s {
bottom: -2px;
left: 48%;
cursor: s-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-sw {
bottom: -2px;
left: -6px;
cursor: sw-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-w {
bottom: 48%;
left: -6px;
cursor: w-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-nw {
top: -6px;
left: -6px;
cursor: nw-resize;
}
.editor-container span.inline-editor-image {
cursor: default;
display: inline-block;
position: relative;
z-index: 1;
}
.editor-container .inline-editor-image img {
max-width: 100%;
cursor: default;
}
.editor-container .inline-editor-image img.focused {
outline: 2px solid rgb(60, 132, 244);
}
.editor-container .inline-editor-image img.focused.draggable {
cursor: grab;
}
.editor-container .inline-editor-image img.focused.draggable:active {
cursor: grabbing;
}
.editor-container .inline-editor-image .image-caption-container .tree-view-output {
margin: 0;
border-radius: 0;
}
.editor-container .inline-editor-image.position-full {
margin: 1em 0 1em 0;
}
.editor-container .inline-editor-image.position-left {
float: left;
width: 50%;
margin: 1em 1em 0 0;
}
.editor-container .inline-editor-image.position-right {
float: right;
width: 50%;
margin: 1em 0 0 1em;
}
.editor-container .inline-editor-image .image-edit-button {
display: block;
position: absolute;
top: 12px;
right: 12px;
padding: 6px 8px;
margin: 0 auto;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.5);
min-width: 60px;
color: #fff;
cursor: pointer;
user-select: none;
}
.editor-container .inline-editor-image .image-edit-button:hover {
background-color: rgba(60, 132, 244, 0.5);
}
.editor-container .inline-editor-image .image-caption-container {
display: block;
background-color: #f4f4f4;
min-width: 100%;
color: #000;
overflow: hidden;
}

@ -253,7 +253,7 @@ export default function ImageResizer({
};
return (
<div ref={controlWrapperRef}>
{!showCaption && captionsEnabled && (
{/* {!showCaption && captionsEnabled && (
<button
className="image-caption-button"
ref={buttonRef}
@ -262,7 +262,7 @@ export default function ImageResizer({
}}>
Add Caption
</button>
)}
)} */}
<div
className="image-resizer image-resizer-n"
onPointerDown={(event) => {

@ -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…
Cancel
Save