facebook / lexical

Lexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.
https://lexical.dev
MIT License
19.89k stars 1.69k forks source link

Bug: $getSelectionStyleValueForProperty(selection, 'font-size', '16px') doesn't work as expected #4011

Open kelvinkaicheung opened 1 year ago

kelvinkaicheung commented 1 year ago

Lexical version: 0.8.0

Steps To Reproduce

  1. Copy Markdown plugin coding
  2. Integrate a font-size change from the playground to the Toolbarplugin
  3. Change the font-size, $getSelectionStyleValueForProperty(selection, 'font-size', '16px') do not return the correct value of the actual font-size, but the font-size is changed correctly, so I just can not update the toolbar ui correctly

Link to code example:

import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { useCallback, useEffect, useRef, useState } from "react"; import { SELECTION_CHANGE_COMMAND, FORMAT_TEXT_COMMAND, $getSelection, $isRangeSelection, $createParagraphNode, COMMAND_PRIORITY_CRITICAL, } from "lexical"; import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"; import { $wrapNodes, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText } from "@lexical/selection"; import { $getNearestNodeOfType, mergeRegister, $findMatchingParent } 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 DropDown, { DropDownItem } from "@/Components/DropDown"; import ColorPicker from "@/Components/ColorPicker";

const LowPriority = 1;

const supportedBlockTypes = new Set([ "paragraph", "quote", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol" ]);

const blockTypeToBlockName = { h1: "40px Heading", h2: "30px Heading", h3: "23.4px Heading", h4: "20px Heading", h5: "16.6px Heading", h6: "13.4px Heading", ol: "Numbered List", paragraph: "Normal", quote: "Quote", ul: "Bulleted List" };

const FONT_FAMILY_OPTIONS = [ ['Arial', 'Arial'], ['Courier New', 'Courier New'], ['Georgia', 'Georgia'], ['Times New Roman', 'Times New Roman'], ['Trebuchet MS', 'Trebuchet MS'], ['Verdana', 'Verdana'], ];

const FONT_SIZE_OPTIONS = [ ['10px', '10px'], ['12px', '12px'], ['14px', '14px'], ['16px', '16px'], ['18px', '18px'], ['20px', '20px'], ['22px', '22px'], ['24px', '24px'], ['26px', '26px'], ['28px', '28px'], ['30px', '30px'], ];

function Divider() { return

; }

function positionEditorElement(editor, rect) { if (rect === null) { editor.style.opacity = "0"; editor.style.top = "-1000px"; editor.style.left = "-1000px"; } else { 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; } }

function FloatingLinkEditor({ editor }) { const editorRef = useRef(null); const inputRef = useRef(null); const mouseDownRef = useRef(false); const [linkUrl, setLinkUrl] = useState(""); const [isEditMode, setEditMode] = useState(false); const [lastSelection, setLastSelection] = useState(null);

const updateLinkEditor = useCallback(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { const node = getSelectedNode(selection); const parent = node.getParent(); if ($isLinkNode(parent)) { setLinkUrl(parent.getURL()); } else if ($isLinkNode(node)) { setLinkUrl(node.getURL()); } else { setLinkUrl(""); } } const editorElem = editorRef.current; const nativeSelection = window.getSelection(); const activeElement = document.activeElement;

if (editorElem === null) {
  return;
}

const rootElement = editor.getRootElement();
if (
  selection !== null &&
  !nativeSelection.isCollapsed &&
  rootElement !== null &&
  rootElement.contains(nativeSelection.anchorNode)
) {
  const domRange = nativeSelection.getRangeAt(0);
  let rect;
  if (nativeSelection.anchorNode === rootElement) {
    let inner = rootElement;
    while (inner.firstElementChild != null) {
      inner = inner.firstElementChild;
    }
    rect = inner.getBoundingClientRect();
  } else {
    rect = domRange.getBoundingClientRect();
  }

  if (!mouseDownRef.current) {
    positionEditorElement(editorElem, rect);
  }
  setLastSelection(selection);
} else if (!activeElement || activeElement.className !== "link-input") {
  positionEditorElement(editorElem, null);
  setLastSelection(null);
  setEditMode(false);
  setLinkUrl("");
}

return true;

}, [editor]);

useEffect(() => { return mergeRegister( editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { updateLinkEditor(); }); }),

  editor.registerCommand(
    SELECTION_CHANGE_COMMAND,
    () => {
      updateLinkEditor();
      return true;
    },
    LowPriority
  )
);

}, [editor, updateLinkEditor]);

useEffect(() => { editor.getEditorState().read(() => { updateLinkEditor(); }); }, [editor, updateLinkEditor]);

useEffect(() => { if (isEditMode && inputRef.current) { inputRef.current.focus(); } }, [isEditMode]);

return (

{isEditMode ? ( { setLinkUrl(event.target.value); }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); if (lastSelection !== null) { if (linkUrl !== "") { editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl); } setEditMode(false); } } else if (event.key === "Escape") { event.preventDefault(); setEditMode(false); } }} /> ) : ( <>
{linkUrl}
event.preventDefault()} onClick={() => { setEditMode(true); }} />
)}

); }

function dropDownActiveClass(active) { if (active) return 'active dropdown-item-active'; else return ''; }

function getSelectedNode(selection) { const anchor = selection.anchor; const focus = selection.focus; const anchorNode = selection.anchor.getNode(); const focusNode = selection.focus.getNode(); if (anchorNode === focusNode) { return anchorNode; } const isBackward = selection.isBackward(); if (isBackward) { return $isAtNodeEnd(focus) ? anchorNode : focusNode; } else { return $isAtNodeEnd(anchor) ? focusNode : anchorNode; } }

function BlockOptionsDropdownList({ editor, blockType, toolbarRef, setShowBlockOptionsDropDown }) { const dropDownRef = useRef(null);

useEffect(() => { const toolbar = toolbarRef.current; const dropDown = dropDownRef.current;

if (toolbar !== null && dropDown !== null) {
  const { top, left } = toolbar.getBoundingClientRect();
  dropDown.style.top = `${top + 40}px`;
  dropDown.style.left = `${left}px`;
}

}, [dropDownRef, toolbarRef]);

useEffect(() => { const dropDown = dropDownRef.current; const toolbar = toolbarRef.current;

if (dropDown !== null && toolbar !== null) {
  const handle = (event) => {
    const target = event.target;

    if (!dropDown.contains(target) && !toolbar.contains(target)) {
      setShowBlockOptionsDropDown(false);
    }
  };
  document.addEventListener("click", handle);

  return () => {
    document.removeEventListener("click", handle);
  };
}

}, [dropDownRef, setShowBlockOptionsDropDown, toolbarRef]);

const formatParagraph = () => { if (blockType !== "paragraph") { editor.update(() => { const selection = $getSelection();

    if ($isRangeSelection(selection)) {
      $wrapNodes(selection, () => $createParagraphNode());
    }
  });
}
setShowBlockOptionsDropDown(false);

};

const formatHeading = (size) => { if (blockType !== size) { editor.update(() => { const selection = $getSelection();

    if ($isRangeSelection(selection)) {
      $wrapNodes(selection, () => $createHeadingNode(size));
    }
  });
}
setShowBlockOptionsDropDown(false);

};

/*
const formatSmallHeading = () => { if (blockType !== "h2") { editor.update(() => { const selection = $getSelection();

      if ($isRangeSelection(selection)) {
        $wrapNodes(selection, () => $createHeadingNode("h2"));
      }
    });
  }
  setShowBlockOptionsDropDown(false);
};

*/

const formatBulletList = () => { if (blockType !== "ul") { editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND); } else { editor.dispatchCommand(REMOVE_LIST_COMMAND); } setShowBlockOptionsDropDown(false); };

const formatNumberedList = () => { if (blockType !== "ol") { editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND); } else { editor.dispatchCommand(REMOVE_LIST_COMMAND); } setShowBlockOptionsDropDown(false); };

const formatQuote = () => { if (blockType !== "quote") { editor.update(() => { const selection = $getSelection();

    if ($isRangeSelection(selection)) {
      $wrapNodes(selection, () => $createQuoteNode());
    }
  });
}
setShowBlockOptionsDropDown(false);

};

return (

); }

function FontDropDown({ editor, value, style, disabled = false, }) { const handleClick = useCallback( (option) => { editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $patchStyleText(selection, {

      });
    }
  });
},
[editor, style],

);

const buttonAriaLabel = style === 'font-family' ? 'Formatting options for font family' : 'Formatting options for font size';

return ( <DropDown disabled={disabled} buttonClassName={'toolbar-item ' + style} buttonLabel={value} buttonAriaLabel={buttonAriaLabel}> {(style === 'font-family' ? FONT_FAMILY_OPTIONS : FONT_SIZE_OPTIONS).map( ([option, text]) => ( <DropDownItem className={item ${dropDownActiveClass(value === option)} ${style === 'font-size' ? 'fontsize-item' : '' }} onClick={() => handleClick(option)} key={option}> {text} ), )} ); }

export default function ToolbarPlugin() { const [editor] = useLexicalComposerContext(); const [activeEditor, setActiveEditor] = useState(editor); const toolbarRef = useRef(null); const [blockType, setBlockType] = useState("paragraph"); const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] = useState( false ); const [codeLanguage, setCodeLanguage] = useState(""); const [isLink, setIsLink] = useState(false); const [isBold, setIsBold] = useState(false); const [isItalic, setIsItalic] = useState(false); const [isUnderline, setIsUnderline] = useState(false); const [isStrikethrough, setIsStrikethrough] = useState(false); const [isCode, setIsCode] = useState(false); const [isEditable, setIsEditable] = useState(() => editor.isEditable()); const [fontSize, setFontSize] = useState('16px'); const [fontFamily, setFontFamily] = useState('Arial'); const [fontColor, setFontColor] = useState('#000'); const [bgColor, setBgColor] = useState('#fff');

const updateToolbar = useCallback(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { const anchorNode = selection.anchor.getNode(); const element = anchorNode.getKey() === "root" ? anchorNode : anchorNode.getTopLevelElementOrThrow(); const elementKey = element.getKey(); const elementDOM = activeEditor.getElementByKey(elementKey); if (elementDOM !== null) { if ($isListNode(element)) { const parentList = $getNearestNodeOfType(anchorNode, ListNode); const type = parentList ? parentList.getTag() : element.getTag(); setBlockType(type); } else { const type = $isHeadingNode(element) ? element.getTag() : element.getType(); setBlockType(type); if ($isCodeNode(element)) { setCodeLanguage(element.getLanguage() || getDefaultCodeLanguage()); } } } // Update text format setIsBold(selection.hasFormat("bold")); setIsItalic(selection.hasFormat("italic")); setIsUnderline(selection.hasFormat("underline")); setIsStrikethrough(selection.hasFormat("strikethrough")); setIsCode(selection.hasFormat("code"));

  // Update links
  const node = getSelectedNode(selection);
  const parent = node.getParent();
  if ($isLinkNode(parent) || $isLinkNode(node)) {
    setIsLink(true);
  } else {
    setIsLink(false);
  }
  setFontSize(
    $getSelectionStyleValueForProperty(selection, 'font-size', '16px'),
  );
  setFontColor(
    $getSelectionStyleValueForProperty(selection, 'color', '#000'),
  );
  setBgColor(
    $getSelectionStyleValueForProperty(
      selection,
      'background-color',
      '#fff',
    ),
  );
  setFontFamily(
    $getSelectionStyleValueForProperty(selection, 'font-family', 'Arial'),
  );
}

}, [activeEditor]);

/* const updateToolbar = useCallback(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { 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'));
  setIsStrikethrough(selection.hasFormat('strikethrough'));
  setIsCode(selection.hasFormat('code'));

  // Update links
  const node = getSelectedNode(selection);
  const parent = node.getParent();
  if ($isLinkNode(parent) || $isLinkNode(node)) {
    setIsLink(true);
  } else {
    setIsLink(false);
  }

  if (elementDOM !== null) {
    if ($isListNode(element)) {
      const parentList = $getNearestNodeOfType(
        anchorNode,
        ListNode,
      );
      const type = parentList
        ? parentList.getListType()
        : element.getListType();
      setBlockType(type);
    } else {
      const type = $isHeadingNode(element)
        ? element.getTag()
        : element.getType();
      if (type in blockTypeToBlockName) {
        setBlockType(type);
      }

      if ($isCodeNode(element)) {
        const language =
          element.getLanguage();
        setCodeLanguage(
          language ? CODE_LANGUAGE_MAP[language] || language : '',
        );
        return;
      }

    }
  }
  // Handle buttons
  setFontSize(
    $getSelectionStyleValueForProperty(selection, 'font-size', '16px'),
  );
  setFontColor(
    $getSelectionStyleValueForProperty(selection, 'color', '#000'),
  );
  setBgColor(
    $getSelectionStyleValueForProperty(
      selection,
      'background-color',
      '#fff',
    ),
  );
  setFontFamily(
    $getSelectionStyleValueForProperty(selection, 'font-family', 'Arial'),
  );
}

}, [activeEditor]); */

useEffect(() => { return editor.registerCommand( SELECTION_CHANGE_COMMAND, (_payload, newEditor) => { updateToolbar(); setActiveEditor(newEditor); return false; }, COMMAND_PRIORITY_CRITICAL, ); }, [editor, updateToolbar]);

useEffect(() => { return mergeRegister( editor.registerEditableListener((editable) => { setIsEditable(editable); }), activeEditor.registerUpdateListener(({ editorState }) => { editorState.read(() => { updateToolbar(); }); }), ); }, [activeEditor, editor, updateToolbar]);

/ useEffect(() => { return mergeRegister( editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { updateToolbar(); }); }), editor.registerCommand( SELECTION_CHANGE_COMMAND, (_payload, newEditor) => { updateToolbar(); return false; }, LowPriority ) ); }, [editor, updateToolbar]); /

const applyStyleText = useCallback( (styles) => { activeEditor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $patchStyleText(selection, styles); } }); }, [activeEditor], );

const onFontColorSelect = useCallback( (value) => { applyStyleText({ color: value }); }, [applyStyleText], );

const onBgColorSelect = useCallback( (value) => { applyStyleText({ 'background-color': value }); }, [applyStyleText], );

const insertLink = useCallback(() => { if (!isLink) { editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://"); } else { editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); } }, [editor, isLink]);

return (

{supportedBlockTypes.has(blockType) && ( <> {showBlockOptionsDropDown && createPortal( , document.body )} )}
{isLink && createPortal(, document.body)}

); }

The current behavior

The font-size ui do not show the correct font-size of the current node

The expected behavior

The font-size ui should display correct font-size of the current node

thegreatercurve commented 1 year ago

@changneng Can you provide a CodeSandbox for your issue and provide some more context around what you're trying to achieve? It's a little unclear.

kelvinkaicheung commented 1 year ago

https://codesandbox.io/s/cocky-grass-5pzg0b

Try to remove every words inside and start typing in different font size. Then, click on the words. The font size UI do not change according to actual font size of the words. This problem happens in 0.8.0 version of lexical but not in 0.6.0 version

kelvinkaicheung commented 1 year ago

This is because $getSelectionStyleValueForProperty(selection, 'font-size', '16px') do not return the correct font-size of the selection.