邮件编辑: 字体颜色; 背景颜色

dev/email
Lei OT 12 months ago
parent d36c9d5d43
commit 7314895549

@ -1,5 +1,5 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
@ -8,80 +8,71 @@ import {
SELECTION_CHANGE_COMMAND,
FORMAT_TEXT_COMMAND,
FORMAT_ELEMENT_COMMAND,
OUTDENT_CONTENT_COMMAND,
INDENT_CONTENT_COMMAND,
$getSelection,
$isElementNode,
$isRangeSelection,
$createParagraphNode,
$getNodeByKey
} from "lexical";
import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
$getNodeByKey,
} from 'lexical';
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
import {
$getSelectionStyleValueForProperty,
$isParentElementRTL,
$wrapNodes,
$isAtNodeEnd
} from "@lexical/selection";
import { $getNearestNodeOfType, mergeRegister, } from "@lexical/utils";
import {
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
REMOVE_LIST_COMMAND,
$isListNode,
ListNode
} from "@lexical/list";
import { createPortal } from "react-dom";
import {
$createHeadingNode,
$createQuoteNode,
$isHeadingNode
} from "@lexical/rich-text";
import {
$createCodeNode,
$isCodeNode,
getDefaultCodeLanguage,
getCodeLanguages
} from "@lexical/code";
import { INSERT_HORIZONTAL_RULE_COMMAND, } from '@lexical/react/LexicalHorizontalRuleNode';
$patchStyleText,
$setBlocksType,
// $wrapNodes,
$isAtNodeEnd,
} from '@lexical/selection';
import { $findMatchingParent, $getNearestNodeOfType, mergeRegister } from '@lexical/utils';
import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, $isListNode, ListNode } from '@lexical/list';
import { createPortal } from 'react-dom';
import { $createHeadingNode, $createQuoteNode, $isHeadingNode } from '@lexical/rich-text';
import { $createCodeNode, $isCodeNode, getDefaultCodeLanguage, getCodeLanguages } from '@lexical/code';
import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode';
import DropDown, { DropDownItem } from './../ui/DropDown';
import DropdownColorPicker from '../ui/DropdownColorPicker';
const LowPriority = 1;
const supportedBlockTypes = new Set([
"paragraph",
"quote",
"code",
"h1",
"h2",
"h3",
"ul",
"ol"
]);
const supportedBlockTypes = new Set(['paragraph', 'quote', 'code', 'h1', 'h2', 'h3', 'ul', 'ol']);
const blockTypeToBlockName = {
code: "Code Block",
h1: "Large Heading",
h2: "Small Heading",
h3: "Heading",
h4: "Heading",
h5: "Heading",
ol: "Numbered List",
paragraph: "Normal",
quote: "Quote",
ul: "Bulleted List"
code: 'Code Block',
h1: 'Large Heading',
h2: 'Small Heading',
h3: 'Heading',
h4: 'Heading',
h5: 'Heading',
ol: 'Numbered List',
paragraph: 'Normal',
quote: 'Quote',
ul: 'Bulleted List',
};
const ELEMENT_FORMAT_OPTIONS = {
center: { icon: 'center-align', iconRTL: 'center-align', name: 'Center Align' },
end: { icon: 'right-align', iconRTL: 'left-align', name: 'End Align' },
justify: { icon: 'justify-align', iconRTL: 'justify-align', name: 'Justify Align' },
left: { icon: 'left-align', iconRTL: 'left-align', name: 'Left Align' },
right: { icon: 'right-align', iconRTL: 'right-align', name: 'Right Align' },
start: { icon: 'left-align', iconRTL: 'right-align', name: 'Start Align' },
};
function Divider() {
return <div className="divider" />;
return <div className='divider' />;
}
function positionEditorElement(editor, rect) {
if (rect === null) {
editor.style.opacity = "0";
editor.style.top = "-1000px";
editor.style.left = "-1000px";
editor.style.opacity = '0';
editor.style.top = '-1000px';
editor.style.left = '-1000px';
} else {
editor.style.opacity = "1";
editor.style.opacity = '1';
editor.style.top = `${rect.top + rect.height + window.pageYOffset + 10}px`;
editor.style.left = `${
rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2
}px`;
editor.style.left = `${rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2}px`;
}
}
@ -89,7 +80,7 @@ function FloatingLinkEditor({ editor }) {
const editorRef = useRef(null);
const inputRef = useRef(null);
const mouseDownRef = useRef(false);
const [linkUrl, setLinkUrl] = useState("");
const [linkUrl, setLinkUrl] = useState('');
const [isEditMode, setEditMode] = useState(false);
const [lastSelection, setLastSelection] = useState(null);
@ -103,7 +94,7 @@ function FloatingLinkEditor({ editor }) {
} else if ($isLinkNode(node)) {
setLinkUrl(node.getURL());
} else {
setLinkUrl("");
setLinkUrl('');
}
}
const editorElem = editorRef.current;
@ -115,12 +106,7 @@ function FloatingLinkEditor({ editor }) {
}
const rootElement = editor.getRootElement();
if (
selection !== null &&
!nativeSelection.isCollapsed &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
if (selection !== null && !nativeSelection.isCollapsed && rootElement !== null && rootElement.contains(nativeSelection.anchorNode)) {
const domRange = nativeSelection.getRangeAt(0);
let rect;
if (nativeSelection.anchorNode === rootElement) {
@ -137,11 +123,11 @@ function FloatingLinkEditor({ editor }) {
positionEditorElement(editorElem, rect);
}
setLastSelection(selection);
} else if (!activeElement || activeElement.className !== "link-input") {
} else if (!activeElement || activeElement.className !== 'link-input') {
positionEditorElement(editorElem, null);
setLastSelection(null);
setEditMode(false);
setLinkUrl("");
setLinkUrl('');
}
return true;
@ -179,25 +165,25 @@ function FloatingLinkEditor({ editor }) {
}, [isEditMode]);
return (
<div ref={editorRef} className="link-editor">
<div ref={editorRef} className='link-editor'>
{isEditMode ? (
<input
ref={inputRef}
className="link-input"
className='link-input'
value={linkUrl}
onChange={(event) => {
setLinkUrl(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
if (event.key === 'Enter') {
event.preventDefault();
if (lastSelection !== null) {
if (linkUrl !== "") {
if (linkUrl !== '') {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl);
}
setEditMode(false);
}
} else if (event.key === "Escape") {
} else if (event.key === 'Escape') {
event.preventDefault();
setEditMode(false);
}
@ -205,13 +191,13 @@ function FloatingLinkEditor({ editor }) {
/>
) : (
<>
<div className="link-input">
<a href={linkUrl} target="_blank" rel="noopener noreferrer">
<div className='link-input'>
<a href={linkUrl} target='_blank' rel='noopener noreferrer'>
{linkUrl}
</a>
<div
className="link-edit"
role="button"
className='link-edit'
role='button'
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
@ -228,7 +214,7 @@ function FloatingLinkEditor({ editor }) {
function Select({ onChange, className, options, value }) {
return (
<select className={className} onChange={onChange} value={value}>
<option hidden={true} value="" />
<option hidden={true} value='' />
{options.map((option) => (
<option key={option} value={option}>
{option}
@ -271,12 +257,7 @@ function getDomRangeRect(nativeSelection, rootElement) {
return rect;
}
function BlockOptionsDropdownList({
editor,
blockType,
toolbarRef,
setShowBlockOptionsDropDown
}) {
function BlockOptionsDropdownList({ editor, blockType, toolbarRef, setShowBlockOptionsDropDown }) {
const dropDownRef = useRef(null);
useEffect(() => {
@ -302,21 +283,21 @@ function BlockOptionsDropdownList({
setShowBlockOptionsDropDown(false);
}
};
document.addEventListener("click", handle);
document.addEventListener('click', handle);
return () => {
document.removeEventListener("click", handle);
document.removeEventListener('click', handle);
};
}
}, [dropDownRef, setShowBlockOptionsDropDown, toolbarRef]);
const formatParagraph = () => {
if (blockType !== "paragraph") {
if (blockType !== 'paragraph') {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createParagraphNode());
$setBlocksType(selection, () => $createParagraphNode());
}
});
}
@ -324,12 +305,12 @@ function BlockOptionsDropdownList({
};
const formatLargeHeading = () => {
if (blockType !== "h1") {
if (blockType !== 'h1') {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createHeadingNode("h1"));
$setBlocksType(selection, () => $createHeadingNode('h1'));
}
});
}
@ -337,24 +318,24 @@ function BlockOptionsDropdownList({
};
const formatSmallHeading = () => {
if (blockType !== "h2") {
if (blockType !== 'h2') {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createHeadingNode("h2"));
$setBlocksType(selection, () => $createHeadingNode('h2'));
}
});
}
setShowBlockOptionsDropDown(false);
};
const formatSmallHeading3 = () => {
if (blockType !== "h3") {
if (blockType !== 'h3') {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createHeadingNode("h3"));
$setBlocksType(selection, () => $createHeadingNode('h3'));
}
});
}
@ -362,7 +343,7 @@ function BlockOptionsDropdownList({
};
const formatBulletList = () => {
if (blockType !== "ul") {
if (blockType !== 'ul') {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND);
@ -371,7 +352,7 @@ function BlockOptionsDropdownList({
};
const formatNumberedList = () => {
if (blockType !== "ol") {
if (blockType !== 'ol') {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND);
@ -380,12 +361,12 @@ function BlockOptionsDropdownList({
};
const formatQuote = () => {
if (blockType !== "quote") {
if (blockType !== 'quote') {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createQuoteNode());
$setBlocksType(selection, () => $createQuoteNode());
}
});
}
@ -393,12 +374,12 @@ function BlockOptionsDropdownList({
};
const formatCode = () => {
if (blockType !== "code") {
if (blockType !== 'code') {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createCodeNode());
$setBlocksType(selection, () => $createCodeNode());
}
});
}
@ -406,41 +387,41 @@ function BlockOptionsDropdownList({
};
return (
<div className="dropdown" ref={dropDownRef}>
<button className="item" onClick={formatParagraph}>
<span className="icon paragraph" />
<span className="text">Normal</span>
{blockType === "paragraph" && <span className="active" />}
<div className='dropdown' ref={dropDownRef}>
<button className='item' onClick={formatParagraph}>
<span className='icon paragraph' />
<span className='text'>Normal</span>
{blockType === 'paragraph' && <span className='active' />}
</button>
<button className="item" onClick={formatLargeHeading}>
<span className="icon large-heading" />
<span className="text">Heading 1</span>
{blockType === "h1" && <span className="active" />}
<button className='item' onClick={formatLargeHeading}>
<span className='icon large-heading' />
<span className='text'>Heading 1</span>
{blockType === 'h1' && <span className='active' />}
</button>
<button className="item" onClick={formatSmallHeading}>
<span className="icon small-heading" />
<span className="text">Heading 2</span>
{blockType === "h2" && <span className="active" />}
<button className='item' onClick={formatSmallHeading}>
<span className='icon small-heading' />
<span className='text'>Heading 2</span>
{blockType === 'h2' && <span className='active' />}
</button>
<button className="item" onClick={formatSmallHeading3}>
<span className="icon h3" />
<span className="text">Heading 3</span>
{blockType === "h3" && <span className="active" />}
<button className='item' onClick={formatSmallHeading3}>
<span className='icon h3' />
<span className='text'>Heading 3</span>
{blockType === 'h3' && <span className='active' />}
</button>
<button className="item" onClick={formatBulletList}>
<span className="icon bullet-list" />
<span className="text">Bullet List</span>
{blockType === "ul" && <span className="active" />}
<button className='item' onClick={formatBulletList}>
<span className='icon bullet-list' />
<span className='text'>Bullet List</span>
{blockType === 'ul' && <span className='active' />}
</button>
<button className="item" onClick={formatNumberedList}>
<span className="icon numbered-list" />
<span className="text">Numbered List</span>
{blockType === "ol" && <span className="active" />}
<button className='item' onClick={formatNumberedList}>
<span className='icon numbered-list' />
<span className='text'>Numbered List</span>
{blockType === 'ol' && <span className='active' />}
</button>
<button className="item" onClick={formatQuote}>
<span className="icon quote" />
<span className="text">Quote</span>
{blockType === "quote" && <span className="active" />}
<button className='item' onClick={formatQuote}>
<span className='icon quote' />
<span className='text'>Quote</span>
{blockType === 'quote' && <span className='active' />}
</button>
{/* <button className="item" onClick={formatCode}>
<span className="icon code" />
@ -451,17 +432,95 @@ function BlockOptionsDropdownList({
);
}
function ElementFormatDropdown({ editor, value, isRTL, disabled = false }) {
const formatOption = ELEMENT_FORMAT_OPTIONS[value || 'left'];
return (
<DropDown
disabled={disabled}
// buttonLabel={formatOption.name}
buttonIconClassName={`icon ${isRTL ? formatOption.iconRTL : formatOption.icon}`}
buttonClassName='toolbar-item spaced alignment'
buttonAriaLabel='Formatting options for text alignment'>
<DropDownItem
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left');
}}
className='item'>
<i className='icon left-align' />
<span className='text'>Left Align</span>
</DropDownItem>
<DropDownItem
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center');
}}
className='item'>
<i className='icon center-align' />
<span className='text'>Center Align</span>
</DropDownItem>
<DropDownItem
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right');
}}
className='item'>
<i className='icon right-align' />
<span className='text'>Right Align</span>
</DropDownItem>
<DropDownItem
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify');
}}
className='item'>
<i className='icon justify-align' />
<span className='text'>Justify Align</span>
</DropDownItem>
<DropDownItem
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'start');
}}
className='item'>
<i className={`icon ${isRTL ? ELEMENT_FORMAT_OPTIONS.start.iconRTL : ELEMENT_FORMAT_OPTIONS.start.icon}`} />
<span className='text'>Start Align</span>
</DropDownItem>
<DropDownItem
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'end');
}}
className='item'>
<i className={`icon ${isRTL ? ELEMENT_FORMAT_OPTIONS.end.iconRTL : ELEMENT_FORMAT_OPTIONS.end.icon}`} />
<span className='text'>End Align</span>
</DropDownItem>
<Divider />
<DropDownItem
onClick={() => {
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
}}
className='item'>
<i className={'icon ' + (isRTL ? 'indent' : 'outdent')} />
<span className='text'>Outdent</span>
</DropDownItem>
<DropDownItem
onClick={() => {
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined);
}}
className='item'>
<i className={'icon ' + (isRTL ? 'outdent' : 'indent')} />
<span className='text'>Indent</span>
</DropDownItem>
</DropDown>
);
}
export default function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const toolbarRef = useRef(null);
const [isEditable, setIsEditable] = useState(() => editor.isEditable());
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [blockType, setBlockType] = useState("paragraph");
const [blockType, setBlockType] = useState('paragraph');
const [selectedElementKey, setSelectedElementKey] = useState(null);
const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] = useState(
false
);
const [codeLanguage, setCodeLanguage] = useState("");
const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] = useState(false);
const [codeLanguage, setCodeLanguage] = useState('');
const [isRTL, setIsRTL] = useState(false);
const [isLink, setIsLink] = useState(false);
const [isBold, setIsBold] = useState(false);
@ -469,15 +528,43 @@ export default function ToolbarPlugin() {
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isCode, setIsCode] = useState(false);
const [fontColor, setFontColor] = useState('#000');
const [bgColor, setBgColor] = useState('#fff');
const [elementFormat, setElementFormat] = useState('left');
const applyStyleText = useCallback(
(styles, skipHistoryStack = null) => {
editor.update(
() => {
const selection = $getSelection();
if (selection !== null) {
$patchStyleText(selection, styles);
}
},
skipHistoryStack ? { tag: 'historic' } : {}
);
},
[editor]
);
const onFontColorSelect = useCallback(
(value, skipHistoryStack) => {
applyStyleText({ color: value }, skipHistoryStack);
},
[applyStyleText]
);
const onBgColorSelect = useCallback(
(value, skipHistoryStack) => {
applyStyleText({ 'background-color': value }, skipHistoryStack);
},
[applyStyleText]
);
const updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const anchorNode = selection.anchor.getNode();
const element =
anchorNode.getKey() === "root"
? anchorNode
: anchorNode.getTopLevelElementOrThrow();
const element = anchorNode.getKey() === 'root' ? anchorNode : anchorNode.getTopLevelElementOrThrow();
const elementKey = element.getKey();
const elementDOM = editor.getElementByKey(elementKey);
if (elementDOM !== null) {
@ -487,9 +574,7 @@ export default function ToolbarPlugin() {
const type = parentList ? parentList.getTag() : element.getTag();
setBlockType(type);
} else {
const type = $isHeadingNode(element)
? element.getTag()
: element.getType();
const type = $isHeadingNode(element) ? element.getTag() : element.getType();
setBlockType(type);
if ($isCodeNode(element)) {
setCodeLanguage(element.getLanguage() || getDefaultCodeLanguage());
@ -497,11 +582,11 @@ export default function ToolbarPlugin() {
}
}
// Update text format
setIsBold(selection.hasFormat("bold"));
setIsItalic(selection.hasFormat("italic"));
setIsUnderline(selection.hasFormat("underline"));
setIsStrikethrough(selection.hasFormat("strikethrough"));
setIsCode(selection.hasFormat("code"));
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
setIsCode(selection.hasFormat('code'));
setIsRTL($isParentElementRTL(selection));
// Update links
@ -512,6 +597,17 @@ export default function ToolbarPlugin() {
} else {
setIsLink(false);
}
// Handle buttons
setFontColor($getSelectionStyleValueForProperty(selection, 'color', '#000'));
let matchingParent;
if ($isLinkNode(parent)) {
// If node is a link, we need to fetch the parent paragraph node to set format
matchingParent = $findMatchingParent(node, (parentNode) => $isElementNode(parentNode) && !parentNode.isInline());
}
// If matchingParent is a valid node, pass it's format type
setElementFormat($isElementNode(matchingParent) ? matchingParent.getFormatType() : $isElementNode(node) ? node.getFormatType() : parent?.getFormatType() || 'left');
}
}, [editor]);
@ -566,117 +662,90 @@ export default function ToolbarPlugin() {
const insertLink = useCallback(() => {
if (!isLink) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://');
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}
}, [editor, isLink]);
const insertHorizontalRule = useCallback(() => {
editor.dispatchCommand(
INSERT_HORIZONTAL_RULE_COMMAND,
undefined,
);
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined);
}, [editor]);
return (
<div className="toolbar" ref={toolbarRef}>
<div className='toolbar' ref={toolbarRef}>
<button
disabled={!canUndo}
onClick={() => {
editor.dispatchCommand(UNDO_COMMAND);
}}
className="toolbar-item spaced"
aria-label="Undo"
>
<i className="format undo" />
className='toolbar-item spaced'
aria-label='Undo'>
<i className='format undo' />
</button>
<button
disabled={!canRedo}
onClick={() => {
editor.dispatchCommand(REDO_COMMAND);
}}
className="toolbar-item"
aria-label="Redo"
>
<i className="format redo" />
className='toolbar-item'
aria-label='Redo'>
<i className='format redo' />
</button>
<Divider />
{supportedBlockTypes.has(blockType) && (
<>
<button
className="toolbar-item block-controls"
onClick={() =>
setShowBlockOptionsDropDown(!showBlockOptionsDropDown)
}
aria-label="Formatting Options"
>
<span className={"icon block-type " + blockType} />
<span className="text">{blockTypeToBlockName[blockType]}</span>
<i className="chevron-down" />
<button className='toolbar-item block-controls' onClick={() => setShowBlockOptionsDropDown(!showBlockOptionsDropDown)} aria-label='Formatting Options'>
<span className={'icon block-type ' + blockType} />
<span className='text'>{blockTypeToBlockName[blockType]}</span>
<i className='chevron-down' />
</button>
{showBlockOptionsDropDown &&
createPortal(
<BlockOptionsDropdownList
editor={editor}
blockType={blockType}
toolbarRef={toolbarRef}
setShowBlockOptionsDropDown={setShowBlockOptionsDropDown}
/>,
<BlockOptionsDropdownList editor={editor} blockType={blockType} toolbarRef={toolbarRef} setShowBlockOptionsDropDown={setShowBlockOptionsDropDown} />,
document.body
)}
<Divider />
</>
)}
{blockType === "code" ? (
{blockType === 'code' ? (
<>
<Select
className="toolbar-item code-language"
onChange={onCodeLanguageSelect}
options={codeLanguges}
value={codeLanguage}
/>
<i className="chevron-down inside" />
<Select className='toolbar-item code-language' onChange={onCodeLanguageSelect} options={codeLanguges} value={codeLanguage} />
<i className='chevron-down inside' />
</>
) : (
<>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
}}
className={"toolbar-item spaced " + (isBold ? "active" : "")}
aria-label="Format Bold"
>
<i className="format bold" />
className={'toolbar-item spaced ' + (isBold ? 'active' : '')}
aria-label='Format Bold'>
<i className='format bold' />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
className={"toolbar-item spaced " + (isItalic ? "active" : "")}
aria-label="Format Italics"
>
<i className="format italic" />
className={'toolbar-item spaced ' + (isItalic ? 'active' : '')}
aria-label='Format Italics'>
<i className='format italic' />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
}}
className={"toolbar-item spaced " + (isUnderline ? "active" : "")}
aria-label="Format Underline"
>
<i className="format underline" />
className={'toolbar-item spaced ' + (isUnderline ? 'active' : '')}
aria-label='Format Underline'>
<i className='format underline' />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
className={
"toolbar-item spaced " + (isStrikethrough ? "active" : "")
}
aria-label="Format Strikethrough"
>
<i className="format strikethrough" />
className={'toolbar-item spaced ' + (isStrikethrough ? 'active' : '')}
aria-label='Format Strikethrough'>
<i className='format strikethrough' />
</button>
{/* <button
onClick={() => {
@ -687,27 +756,40 @@ export default function ToolbarPlugin() {
>
<i className="format code" />
</button> */}
<button
onClick={insertLink}
className={"toolbar-item spaced " + (isLink ? "active" : "")}
aria-label="Insert Link"
>
<i className="format link" />
<button onClick={insertLink} className={'toolbar-item spaced ' + (isLink ? 'active' : '')} aria-label='Insert Link'>
<i className='format link' />
</button>
{isLink &&
createPortal(<FloatingLinkEditor editor={editor} />, document.body)}
{isLink && createPortal(<FloatingLinkEditor editor={editor} />, document.body)}
<button
onClick={() => {
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined);
}}
className={"toolbar-item spaced "}
aria-label="Insert Horizontal Rule"
>
<i className="format icon horizontal-rule" />
className={'toolbar-item spaced '}
aria-label='Insert Horizontal Rule'>
<i className='format icon horizontal-rule' />
{/* <span className="text">Horizontal Rule</span> */}
</button>
<DropdownColorPicker
disabled={!isEditable}
buttonClassName='toolbar-item color-picker'
buttonAriaLabel='Formatting text color'
buttonIconClassName='icon font-color'
color={fontColor}
onChange={onFontColorSelect}
title='text color'
/>
<DropdownColorPicker
disabled={!isEditable}
buttonClassName='toolbar-item color-picker'
buttonAriaLabel='Formatting background color'
buttonIconClassName='icon bg-color'
color={bgColor}
onChange={onBgColorSelect}
title='bg color'
/>
<Divider />
<button
<ElementFormatDropdown disabled={!isEditable} value={elementFormat} editor={editor} isRTL={isRTL} />
{/* <button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "left");
}}
@ -742,7 +824,7 @@ export default function ToolbarPlugin() {
aria-label="Justify Align"
>
<i className="format justify-align" />
</button>{" "}
</button> */}
</>
)}
</div>

@ -440,7 +440,12 @@ pre::-webkit-scrollbar-thumb {
background-color: #eee;
margin: 0 4px;
}
.dropdown .divider {
width: auto;
background-color: #eee;
margin: 4px 8px;
height: 1px;
}
.toolbar select.toolbar-item {
border: 0;
display: flex;
@ -749,6 +754,37 @@ i.redo {
.icon.code {
background-image: url(/images/icons/code.svg);
}
/* .icon.font-color {
background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='14'%20height='14'%20viewBox='0%200%20512%20512'%3e%3cpath%20fill='%23777'%20d='M221.631%20109%20109.92%20392h58.055l24.079-61h127.892l24.079%2061h58.055L290.369%20109Zm-8.261%20168L256%20169l42.63%20108Z'/%3e%3c/svg%3e");
} */
.icon.font-color {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23777'%3E%3Cpath d='M15.2459 14H8.75407L7.15407 18H5L11 3H13L19 18H16.8459L15.2459 14ZM14.4459 12L12 5.88516L9.55407 12H14.4459ZM3 20H21V22H3V20Z'%3E%3C/path%3E%3C/svg%3E");
}
.icon.bg-color {
background-image: url("data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2048%2048'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20fill='%23fff'%20fill-opacity='.01'%20d='M0%200h48v48H0z'/%3e%3cpath%20fill-rule='evenodd'%20clip-rule='evenodd'%20d='M37%2037a4%204%200%200%200%204-4c0-1.473-1.333-3.473-4-6-2.667%202.527-4%204.527-4%206a4%204%200%200%200%204%204Z'%20fill='%23777'/%3e%3cpath%20d='m20.854%205.504%203.535%203.536'%20stroke='%23777'%20stroke-width='4'%20stroke-linecap='round'/%3e%3cpath%20d='M23.682%208.333%208.125%2023.889%2019.44%2035.203l15.556-15.557L23.682%208.333Z'%20stroke='%23777'%20stroke-width='4'%20stroke-linejoin='round'/%3e%3cpath%20d='m12%2020.073%2016.961%205.577M4%2043h40'%20stroke='%23777'%20stroke-width='4'%20stroke-linecap='round'/%3e%3c/svg%3e");
}
.icon.left-align, i.left-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-text-left'%3e%3cpath%20fill-rule='evenodd'%20d='M2%2012.5a.5.5%200%200%201%20.5-.5h7a.5.5%200%200%201%200%201h-7a.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-.5h7a.5.5%200%200%201%200%201h-7a.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.center-align,i.center-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-text-center'%3e%3cpath%20fill-rule='evenodd'%20d='M4%2012.5a.5.5%200%200%201%20.5-.5h7a.5.5%200%200%201%200%201h-7a.5.5%200%200%201-.5-.5zm-2-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm2-3a.5.5%200%200%201%20.5-.5h7a.5.5%200%200%201%200%201h-7a.5.5%200%200%201-.5-.5zm-2-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.right-align,i.right-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-text-right'%3e%3cpath%20fill-rule='evenodd'%20d='M6%2012.5a.5.5%200%200%201%20.5-.5h7a.5.5%200%200%201%200%201h-7a.5.5%200%200%201-.5-.5zm-4-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm4-3a.5.5%200%200%201%20.5-.5h7a.5.5%200%200%201%200%201h-7a.5.5%200%200%201-.5-.5zm-4-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.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")
}
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")
}
i.outdent {
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-right'%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-.5zm10.646%202.146a.5.5%200%200%201%20.708.708L11.707%208l1.647%201.646a.5.5%200%200%201-.708.708l-2-2a.5.5%200%200%201%200-.708l2-2zM2%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-.5zm0%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")
}
i.bold {
background-image: url(/images/icons/type-bold.svg);

@ -0,0 +1,88 @@
/**
* 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.
*
*/
.color-picker-wrapper {
padding: 20px;
}
.color-picker-basic-color {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 0;
padding: 0;
}
.color-picker-basic-color button {
border: 1px solid #ccc;
border-radius: 4px;
height: 16px;
width: 16px;
cursor: pointer;
list-style-type: none;
}
.color-picker-basic-color button.active {
box-shadow: 0px 0px 2px 2px rgba(0, 0, 0, 0.3);
}
.color-picker-saturation {
width: 100%;
position: relative;
margin-top: 15px;
height: 150px;
background-image: linear-gradient(transparent, black),
linear-gradient(to right, white, transparent);
user-select: none;
}
.color-picker-saturation_cursor {
position: absolute;
width: 20px;
height: 20px;
border: 2px solid #ffffff;
border-radius: 50%;
box-shadow: 0 0 15px #00000026;
box-sizing: border-box;
transform: translate(-10px, -10px);
}
.color-picker-hue {
width: 100%;
position: relative;
margin-top: 15px;
height: 12px;
background-image: linear-gradient(
to right,
rgb(255, 0, 0),
rgb(255, 255, 0),
rgb(0, 255, 0),
rgb(0, 255, 255),
rgb(0, 0, 255),
rgb(255, 0, 255),
rgb(255, 0, 0)
);
user-select: none;
border-radius: 12px;
}
.color-picker-hue_cursor {
position: absolute;
width: 20px;
height: 20px;
border: 2px solid #ffffff;
border-radius: 50%;
box-shadow: #0003 0 0 0 0.5px;
box-sizing: border-box;
transform: translate(-10px, -4px);
}
.color-picker-color {
border: 1px solid #ccc;
margin-top: 15px;
width: 100%;
height: 20px;
}

@ -0,0 +1,364 @@
/**
* 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 './ColorPicker.css';
import {calculateZoomLevel} from '@lexical/utils';
import {useEffect, useMemo, useRef, useState} from 'react';
import * as React from 'react';
import TextInput from './TextInput';
let skipAddingToHistoryStack = false;
interface ColorPickerProps {
color: string;
onChange?: (value: string, skipHistoryStack: boolean) => void;
}
const basicColors = [
'#d0021b',
'#f5a623',
'#f8e71c',
'#8b572a',
'#7ed321',
'#417505',
'#bd10e0',
'#9013fe',
'#4a90e2',
'#50e3c2',
'#b8e986',
'#000000',
'#4a4a4a',
'#9b9b9b',
'#ffffff',
];
const WIDTH = 214;
const HEIGHT = 150;
export default function ColorPicker({
color,
onChange,
}: Readonly<ColorPickerProps>): JSX.Element {
const [selfColor, setSelfColor] = useState(transformColor('hex', color));
const [inputColor, setInputColor] = useState(color);
const innerDivRef = useRef(null);
const saturationPosition = useMemo(
() => ({
x: (selfColor.hsv.s / 100) * WIDTH,
y: ((100 - selfColor.hsv.v) / 100) * HEIGHT,
}),
[selfColor.hsv.s, selfColor.hsv.v],
);
const huePosition = useMemo(
() => ({
x: (selfColor.hsv.h / 360) * WIDTH,
}),
[selfColor.hsv],
);
const onSetHex = (hex: string) => {
setInputColor(hex);
if (/^#[0-9A-Fa-f]{6}$/i.test(hex)) {
const newColor = transformColor('hex', hex);
setSelfColor(newColor);
}
};
const onMoveSaturation = ({x, y}: Position) => {
const newHsv = {
...selfColor.hsv,
s: (x / WIDTH) * 100,
v: 100 - (y / HEIGHT) * 100,
};
const newColor = transformColor('hsv', newHsv);
setSelfColor(newColor);
setInputColor(newColor.hex);
};
const onMoveHue = ({x}: Position) => {
const newHsv = {...selfColor.hsv, h: (x / WIDTH) * 360};
const newColor = transformColor('hsv', newHsv);
setSelfColor(newColor);
setInputColor(newColor.hex);
};
useEffect(() => {
// Check if the dropdown is actually active
if (innerDivRef.current !== null && onChange) {
onChange(selfColor.hex, skipAddingToHistoryStack);
setInputColor(selfColor.hex);
}
}, [selfColor, onChange]);
useEffect(() => {
if (color === undefined) {
return;
}
const newColor = transformColor('hex', color);
setSelfColor(newColor);
setInputColor(newColor.hex);
}, [color]);
return (
<div
className="color-picker-wrapper"
style={{width: WIDTH}}
ref={innerDivRef}>
<TextInput label="Hex" onChange={onSetHex} value={inputColor} />
<div className="color-picker-basic-color">
{basicColors.map((basicColor) => (
<button
className={basicColor === selfColor.hex ? ' active' : ''}
key={basicColor}
style={{backgroundColor: basicColor}}
onClick={() => {
setInputColor(basicColor);
setSelfColor(transformColor('hex', basicColor));
}}
/>
))}
</div>
<MoveWrapper
className="color-picker-saturation"
style={{backgroundColor: `hsl(${selfColor.hsv.h}, 100%, 50%)`}}
onChange={onMoveSaturation}>
<div
className="color-picker-saturation_cursor"
style={{
backgroundColor: selfColor.hex,
left: saturationPosition.x,
top: saturationPosition.y,
}}
/>
</MoveWrapper>
<MoveWrapper className="color-picker-hue" onChange={onMoveHue}>
<div
className="color-picker-hue_cursor"
style={{
backgroundColor: `hsl(${selfColor.hsv.h}, 100%, 50%)`,
left: huePosition.x,
}}
/>
</MoveWrapper>
<div
className="color-picker-color"
style={{backgroundColor: selfColor.hex}}
/>
</div>
);
}
export interface Position {
x: number;
y: number;
}
interface MoveWrapperProps {
className?: string;
style?: React.CSSProperties;
onChange: (position: Position) => void;
children: JSX.Element;
}
function MoveWrapper({className, style, onChange, children}: MoveWrapperProps) {
const divRef = useRef<HTMLDivElement>(null);
const draggedRef = useRef(false);
const move = (e: React.MouseEvent | MouseEvent): void => {
if (divRef.current) {
const {current: div} = divRef;
const {width, height, left, top} = div.getBoundingClientRect();
const zoom = calculateZoomLevel(div);
const x = clamp(e.clientX / zoom - left, width, 0);
const y = clamp(e.clientY / zoom - top, height, 0);
onChange({x, y});
}
};
const onMouseDown = (e: React.MouseEvent): void => {
if (e.button !== 0) {
return;
}
move(e);
const onMouseMove = (_e: MouseEvent): void => {
draggedRef.current = true;
skipAddingToHistoryStack = true;
move(_e);
};
const onMouseUp = (_e: MouseEvent): void => {
if (draggedRef.current) {
skipAddingToHistoryStack = false;
}
document.removeEventListener('mousemove', onMouseMove, false);
document.removeEventListener('mouseup', onMouseUp, false);
move(_e);
draggedRef.current = false;
};
document.addEventListener('mousemove', onMouseMove, false);
document.addEventListener('mouseup', onMouseUp, false);
};
return (
<div
ref={divRef}
className={className}
style={style}
onMouseDown={onMouseDown}>
{children}
</div>
);
}
function clamp(value: number, max: number, min: number) {
return value > max ? max : value < min ? min : value;
}
interface RGB {
b: number;
g: number;
r: number;
}
interface HSV {
h: number;
s: number;
v: number;
}
interface Color {
hex: string;
hsv: HSV;
rgb: RGB;
}
export function toHex(value: string): string {
if (!value.startsWith('#')) {
const ctx = document.createElement('canvas').getContext('2d');
if (!ctx) {
throw new Error('2d context not supported or canvas already initialized');
}
ctx.fillStyle = value;
return ctx.fillStyle;
} else if (value.length === 4 || value.length === 5) {
value = value
.split('')
.map((v, i) => (i ? v + v : '#'))
.join('');
return value;
} else if (value.length === 7 || value.length === 9) {
return value;
}
return '#000000';
}
function hex2rgb(hex: string): RGB {
const rbgArr = (
hex
.replace(
/^#?([a-f\d])([a-f\d])([a-f\d])$/i,
(m, r, g, b) => '#' + r + r + g + g + b + b,
)
.substring(1)
.match(/.{2}/g) || []
).map((x) => parseInt(x, 16));
return {
b: rbgArr[2],
g: rbgArr[1],
r: rbgArr[0],
};
}
function rgb2hsv({r, g, b}: RGB): HSV {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const d = max - Math.min(r, g, b);
const h = d
? (max === r
? (g - b) / d + (g < b ? 6 : 0)
: max === g
? 2 + (b - r) / d
: 4 + (r - g) / d) * 60
: 0;
const s = max ? (d / max) * 100 : 0;
const v = max * 100;
return {h, s, v};
}
function hsv2rgb({h, s, v}: HSV): RGB {
s /= 100;
v /= 100;
const i = ~~(h / 60);
const f = h / 60 - i;
const p = v * (1 - s);
const q = v * (1 - s * f);
const t = v * (1 - s * (1 - f));
const index = i % 6;
const r = Math.round([v, q, p, p, t, v][index] * 255);
const g = Math.round([t, v, v, q, p, p][index] * 255);
const b = Math.round([p, p, t, v, v, q][index] * 255);
return {b, g, r};
}
function rgb2hex({b, g, r}: RGB): string {
return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
}
function transformColor<M extends keyof Color, C extends Color[M]>(
format: M,
color: C,
): Color {
let hex: Color['hex'] = toHex('#121212');
let rgb: Color['rgb'] = hex2rgb(hex);
let hsv: Color['hsv'] = rgb2hsv(rgb);
if (format === 'hex') {
const value = color as Color['hex'];
hex = toHex(value);
rgb = hex2rgb(hex);
hsv = rgb2hsv(rgb);
} else if (format === 'rgb') {
const value = color as Color['rgb'];
rgb = value;
hex = rgb2hex(rgb);
hsv = rgb2hsv(rgb);
} else if (format === 'hsv') {
const value = color as Color['hsv'];
hsv = value;
rgb = hsv2rgb(hsv);
hex = rgb2hex(rgb);
}
return {hex, hsv, rgb};
}

@ -0,0 +1,259 @@
/**
* 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 * as React from 'react';
import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {createPortal} from 'react-dom';
type DropDownContextType = {
registerItem: (ref: React.RefObject<HTMLButtonElement>) => void;
};
const DropDownContext = React.createContext<DropDownContextType | null>(null);
const dropDownPadding = 4;
export function DropDownItem({
children,
className,
onClick,
title,
}: {
children: React.ReactNode;
className: string;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
title?: string;
}) {
const ref = useRef<HTMLButtonElement>(null);
const dropDownContext = React.useContext(DropDownContext);
if (dropDownContext === null) {
throw new Error('DropDownItem must be used within a DropDown');
}
const {registerItem} = dropDownContext;
useEffect(() => {
if (ref && ref.current) {
registerItem(ref);
}
}, [ref, registerItem]);
return (
<button
className={className}
onClick={onClick}
ref={ref}
title={title}
type="button">
{children}
</button>
);
}
function DropDownItems({
children,
dropDownRef,
onClose,
}: {
children: React.ReactNode;
dropDownRef: React.Ref<HTMLDivElement>;
onClose: () => void;
}) {
const [items, setItems] = useState<React.RefObject<HTMLButtonElement>[]>();
const [highlightedItem, setHighlightedItem] =
useState<React.RefObject<HTMLButtonElement>>();
const registerItem = useCallback(
(itemRef: React.RefObject<HTMLButtonElement>) => {
setItems((prev) => (prev ? [...prev, itemRef] : [itemRef]));
},
[setItems],
);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!items) {
return;
}
const key = event.key;
if (['Escape', 'ArrowUp', 'ArrowDown', 'Tab'].includes(key)) {
event.preventDefault();
}
if (key === 'Escape' || key === 'Tab') {
onClose();
} else if (key === 'ArrowUp') {
setHighlightedItem((prev) => {
if (!prev) {
return items[0];
}
const index = items.indexOf(prev) - 1;
return items[index === -1 ? items.length - 1 : index];
});
} else if (key === 'ArrowDown') {
setHighlightedItem((prev) => {
if (!prev) {
return items[0];
}
return items[items.indexOf(prev) + 1];
});
}
};
const contextValue = useMemo(
() => ({
registerItem,
}),
[registerItem],
);
useEffect(() => {
if (items && !highlightedItem) {
setHighlightedItem(items[0]);
}
if (highlightedItem && highlightedItem.current) {
highlightedItem.current.focus();
}
}, [items, highlightedItem]);
return (
<DropDownContext.Provider value={contextValue}>
<div className="dropdown" ref={dropDownRef} onKeyDown={handleKeyDown}>
{children}
</div>
</DropDownContext.Provider>
);
}
export default function DropDown({
disabled = false,
buttonLabel,
buttonAriaLabel,
buttonClassName,
buttonIconClassName,
children,
stopCloseOnClickSelf,
}: {
disabled?: boolean;
buttonAriaLabel?: string;
buttonClassName: string;
buttonIconClassName?: string;
buttonLabel?: string;
children: ReactNode;
stopCloseOnClickSelf?: boolean;
}): JSX.Element {
const dropDownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [showDropDown, setShowDropDown] = useState(false);
const handleClose = () => {
setShowDropDown(false);
if (buttonRef && buttonRef.current) {
buttonRef.current.focus();
}
};
useEffect(() => {
const button = buttonRef.current;
const dropDown = dropDownRef.current;
if (showDropDown && button !== null && dropDown !== null) {
const {top, left} = button.getBoundingClientRect();
dropDown.style.top = `${top + button.offsetHeight + dropDownPadding}px`;
dropDown.style.left = `${Math.min(
left,
window.innerWidth - dropDown.offsetWidth - 20,
)}px`;
}
}, [dropDownRef, buttonRef, showDropDown]);
useEffect(() => {
const button = buttonRef.current;
if (button !== null && showDropDown) {
const handle = (event: MouseEvent) => {
const target = event.target;
if (stopCloseOnClickSelf) {
if (
dropDownRef.current &&
dropDownRef.current.contains(target as Node)
) {
return;
}
}
if (!button.contains(target as Node)) {
setShowDropDown(false);
}
};
document.addEventListener('click', handle);
return () => {
document.removeEventListener('click', handle);
};
}
}, [dropDownRef, buttonRef, showDropDown, stopCloseOnClickSelf]);
useEffect(() => {
const handleButtonPositionUpdate = () => {
if (showDropDown) {
const button = buttonRef.current;
const dropDown = dropDownRef.current;
if (button !== null && dropDown !== null) {
const {top} = button.getBoundingClientRect();
const newPosition = top + button.offsetHeight + dropDownPadding;
if (newPosition !== dropDown.getBoundingClientRect().top) {
dropDown.style.top = `${newPosition}px`;
}
}
}
};
document.addEventListener('scroll', handleButtonPositionUpdate);
return () => {
document.removeEventListener('scroll', handleButtonPositionUpdate);
};
}, [buttonRef, dropDownRef, showDropDown]);
return (
<>
<button
type="button"
disabled={disabled}
aria-label={buttonAriaLabel || buttonLabel}
className={buttonClassName}
onClick={() => setShowDropDown(!showDropDown)}
ref={buttonRef}>
{buttonIconClassName && <span className={buttonIconClassName} />}
{buttonLabel && (
<span className="text dropdown-button-text">{buttonLabel}</span>
)}
<i className="chevron-down" />
</button>
{showDropDown &&
createPortal(
<DropDownItems dropDownRef={dropDownRef} onClose={handleClose}>
{children}
</DropDownItems>,
document.body,
)}
</>
);
}

@ -0,0 +1,41 @@
/**
* 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 * as React from 'react';
import ColorPicker from './ColorPicker';
import DropDown from './DropDown';
type Props = {
disabled?: boolean;
buttonAriaLabel?: string;
buttonClassName: string;
buttonIconClassName?: string;
buttonLabel?: string;
title?: string;
stopCloseOnClickSelf?: boolean;
color: string;
onChange?: (color: string, skipHistoryStack: boolean) => void;
};
export default function DropdownColorPicker({
disabled = false,
stopCloseOnClickSelf = true,
color,
onChange,
...rest
}: Props) {
return (
<DropDown
{...rest}
disabled={disabled}
stopCloseOnClickSelf={stopCloseOnClickSelf}>
<ColorPicker color={color} onChange={onChange} />
</DropDown>
);
}

@ -0,0 +1,32 @@
/**
* 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.
*
*
*/
.Input__wrapper {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 10px;
}
.Input__label {
display: flex;
flex: 1;
color: #666;
}
.Input__input {
display: flex;
flex: 2;
border: 1px solid #999;
padding-top: 7px;
padding-bottom: 7px;
padding-left: 10px;
padding-right: 10px;
font-size: 16px;
border-radius: 5px;
min-width: 0;
}

@ -0,0 +1,46 @@
/**
* 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 './Input.css';
import * as React from 'react';
import {HTMLInputTypeAttribute} from 'react';
type Props = Readonly<{
'data-test-id'?: string;
label: string;
onChange: (val: string) => void;
placeholder?: string;
value: string;
type?: HTMLInputTypeAttribute;
}>;
export default function TextInput({
label,
value,
onChange,
placeholder = '',
'data-test-id': dataTestId,
type = 'text',
}: Props): JSX.Element {
return (
<div className="Input__wrapper">
<label className="Input__label">{label}</label>
<input
type={type}
className="Input__input"
placeholder={placeholder}
value={value}
onChange={(e) => {
onChange(e.target.value);
}}
data-test-id={dataTestId}
/>
</div>
);
}
Loading…
Cancel
Save