phx-react
Version:
PHX REACT
226 lines • 12.2 kB
JavaScript
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
import { $isListItemNode, $isListNode, ListNode } from '@lexical/list';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $isHeadingNode } from '@lexical/rich-text';
import { $getSelectionStyleValueForProperty, getStyleObjectFromCSS } from '@lexical/selection';
import { $findMatchingParent, $getNearestNodeOfType, mergeRegister } from '@lexical/utils';
import { $getSelection, $isElementNode, $isNodeSelection, $isRangeSelection, $isRootOrShadowRoot, COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_NORMAL, KEY_MODIFIER_COMMAND, SELECTION_CHANGE_COMMAND, } from 'lexical';
import { useCallback, useEffect, useState } from 'react';
import * as React from 'react';
import { getSelectedNode } from '../../utils/getSelectedNode';
import { sanitizeUrl } from '../../utils/url';
import HeaderDropdown, { DEFAULT_HEADING_FONT_SIZES, getListItemBlockType } from './components/heading';
import FontSize from './components/font-size';
import BoldText from './components/bold-text';
import ItalicText from './components/italic-text';
import UnderlineText from './components/underline-text';
import TextColor from './components/text-color';
import TextAlign from './components/text-align';
import Bullet from './components/bullet';
import NumberBullet from './components/number-bullet';
import UploadPdf from './components/upload-pdf';
import UploadImage from './components/upload-image';
import Link from './components/link';
import { $isImageNode } from '../../nodes/ImageNode';
import { $isInlineImageNode } from '../../nodes/InlineImageNode';
const blockTypeToBlockName = {
bullet: 'Bulleted List',
check: 'Check List',
code: 'Code Block',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
number: 'Numbered List',
paragraph: 'Normal',
quote: 'Quote',
};
/**
* Checks whether a block type is a heading type.
* @param value Block type to test.
* @returns True when the value is a heading block type.
*/
function isHeadingBlockType(value) {
return value in DEFAULT_HEADING_FONT_SIZES;
}
/**
* Gets the fallback block type represented by a list item font size.
* @param fontSize Font size from the selected list item.
* @returns Matching heading type, or paragraph when there is no match.
*/
function getBlockTypeFromFontSize(fontSize) {
var _a;
const headingType = (_a = Object.entries(DEFAULT_HEADING_FONT_SIZES).find(([, size]) => size === fontSize)) === null || _a === void 0 ? void 0 : _a[0];
return headingType !== undefined ? headingType : 'paragraph';
}
/**
* Reads font size from a serialized style string.
* @param style Serialized CSS style string.
* @returns Font size value when present.
*/
function getFontSizeFromStyle(style) {
return getStyleObjectFromCSS(style)['font-size'];
}
function DisabledToolbarGroup({ children, disabled, }) {
return (React.createElement("div", { className: disabled ? 'relative cursor-not-allowed opacity-40' : undefined },
children,
disabled && React.createElement("div", { className: 'absolute inset-0 z-10 cursor-not-allowed' })));
}
export default function ToolbarPlugin({ apiCdnUpload, disabled, }) {
const [editor] = useLexicalComposerContext();
const [activeEditor, setActiveEditor] = useState(editor);
const [blockType, setBlockType] = useState('paragraph');
const [listBlockType, setListBlockType] = useState(null);
const [fontSize, setFontSize] = useState('14px');
const [fontColor, setFontColor] = useState('#000');
const [elementFormat, setElementFormat] = useState('left');
const [isImageSelected, setIsImageSelected] = useState(false);
const [isLink, setIsLink] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const $updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isNodeSelection(selection)) {
const node = selection.getNodes()[0];
if ($isImageNode(node) || $isInlineImageNode(node)) {
setIsImageSelected(true);
setElementFormat((node.getPosition() || 'center'));
}
else {
setIsImageSelected(false);
}
return;
}
if ($isRangeSelection(selection)) {
setIsImageSelected(false);
const anchorNode = selection.anchor.getNode();
let element = anchorNode.getKey() === 'root'
? anchorNode
: $findMatchingParent(anchorNode, (e) => {
const parent = e.getParent();
return parent !== null && $isRootOrShadowRoot(parent);
});
if (element === null) {
element = anchorNode.getTopLevelElementOrThrow();
}
const elementKey = element.getKey();
const elementDOM = activeEditor.getElementByKey(elementKey);
// Update text format
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
// Update links
const node = getSelectedNode(selection);
const parent = node.getParent();
if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsLink(true);
}
else {
setIsLink(false);
}
const defaultFontSize = $isHeadingNode(element) ? DEFAULT_HEADING_FONT_SIZES[element.getTag()] : '14px';
let selectionFontSize = $getSelectionStyleValueForProperty(selection, 'font-size', defaultFontSize);
if (elementDOM !== null) {
const parentList = $getNearestNodeOfType(anchorNode, ListNode);
if (parentList !== null || $isListNode(element)) {
const listItemNode = $isListItemNode(anchorNode)
? anchorNode
: $findMatchingParent(anchorNode, (node) => $isListItemNode(node));
const listItemFontSize = $isListItemNode(listItemNode)
? getFontSizeFromStyle(listItemNode.getTextStyle())
: undefined;
const listItemBlockType = $isListItemNode(listItemNode) ? getListItemBlockType(listItemNode) : null;
// @ts-ignore
const type = parentList ? parentList.getListType() : element.getListType();
if (listItemFontSize !== undefined && !selection.isCollapsed()) {
selectionFontSize = listItemFontSize;
}
setListBlockType(type);
setBlockType(listItemBlockType !== null && listItemBlockType !== void 0 ? listItemBlockType : getBlockTypeFromFontSize(selectionFontSize));
}
else {
setListBlockType(null);
const type = $isHeadingNode(element) ? element.getTag() : element.getType();
if (type in blockTypeToBlockName) {
setBlockType(type);
}
}
}
// Handle buttons
setFontSize(selectionFontSize);
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 === null || parent === void 0 ? void 0 : parent.getFormatType()) || 'left');
}
}, [activeEditor]);
useEffect(() => activeEditor.registerCommand(SELECTION_CHANGE_COMMAND, (_payload, newEditor) => {
$updateToolbar();
setActiveEditor(newEditor);
return false;
}, COMMAND_PRIORITY_CRITICAL), [activeEditor, $updateToolbar]);
useEffect(() => mergeRegister(activeEditor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
$updateToolbar();
});
})), [$updateToolbar, activeEditor]);
useEffect(() => activeEditor.registerCommand(KEY_MODIFIER_COMMAND, (payload) => {
const event = payload;
const { code, ctrlKey, metaKey } = event;
if (code === 'KeyK' && (ctrlKey || metaKey)) {
event.preventDefault();
return activeEditor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl('https://'));
}
return false;
}, COMMAND_PRIORITY_NORMAL), [activeEditor, isLink]);
useEffect(() => {
if (!window.pdfjsLib) {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
document.body.appendChild(script);
}
}, []);
useEffect(() => {
if (activeEditor) {
activeEditor.setEditable(!disabled);
}
}, [activeEditor, disabled]);
const parsedFontSize = parseInt(fontSize, 10);
const activeListHeadingTag = listBlockType !== null && isHeadingBlockType(blockType) ? blockType : null;
return (React.createElement("div", { className: 'flex relative flex-wrap gap-1 p-2 bg-gray-100 border-b border-gray-300' },
disabled && React.createElement("div", { className: 'cursor-not-allowed z-20 absolute inset-0 bg-gray-200 opacity-20' }),
React.createElement(DisabledToolbarGroup, { disabled: isImageSelected }, blockType in blockTypeToBlockName && activeEditor === editor && (React.createElement(HeaderDropdown, { value: blockType, editor: activeEditor }))),
React.createElement(DisabledToolbarGroup, { disabled: isImageSelected },
React.createElement(FontSize, { editor: activeEditor, fontSize: Number.isNaN(parsedFontSize) ? null : parsedFontSize, listHeadingTag: activeListHeadingTag })),
React.createElement(DisabledToolbarGroup, { disabled: isImageSelected },
React.createElement(BoldText, { isActive: isBold, editor: activeEditor })),
React.createElement(DisabledToolbarGroup, { disabled: isImageSelected },
React.createElement(ItalicText, { isActive: isItalic, editor: activeEditor })),
React.createElement(DisabledToolbarGroup, { disabled: isImageSelected },
React.createElement(UnderlineText, { isActive: isUnderline, editor: activeEditor })),
React.createElement(DisabledToolbarGroup, { disabled: isImageSelected },
React.createElement(TextColor, { editor: activeEditor, color: fontColor })),
React.createElement(TextAlign, { value: elementFormat, editor: activeEditor, isImageSelected: isImageSelected }),
React.createElement(DisabledToolbarGroup, { disabled: isImageSelected },
React.createElement(Bullet, { isActive: listBlockType === 'bullet', editor: activeEditor })),
React.createElement(DisabledToolbarGroup, { disabled: isImageSelected },
React.createElement(NumberBullet, { isActive: listBlockType === 'number', editor: activeEditor })),
React.createElement(DisabledToolbarGroup, { disabled: isImageSelected },
React.createElement(UploadPdf, { editor: activeEditor, apiCdnUpload: apiCdnUpload })),
React.createElement(DisabledToolbarGroup, { disabled: isImageSelected },
React.createElement(UploadImage, { editor: activeEditor, apiCdnUpload: apiCdnUpload })),
React.createElement(DisabledToolbarGroup, { disabled: isImageSelected },
React.createElement(Link, { editor: activeEditor }))));
}
//# sourceMappingURL=index.js.map