plane/apps/app/components/lexical/toolbar/index.tsx

431 lines
14 KiB
TypeScript
Raw Normal View History

import { useEffect, useState, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import {
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
REDO_COMMAND,
UNDO_COMMAND,
SELECTION_CHANGE_COMMAND,
FORMAT_TEXT_COMMAND,
FORMAT_ELEMENT_COMMAND,
$getSelection,
$isRangeSelection,
$createParagraphNode,
$getNodeByKey,
RangeSelection,
NodeSelection,
GridSelection,
} from "lexical";
import {
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
REMOVE_LIST_COMMAND,
$isListNode,
ListNode,
} from "@lexical/list";
import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
$isParentElementRTL,
$wrapNodes,
$isAtNodeEnd,
} from "@lexical/selection";
import { $getNearestNodeOfType, mergeRegister } from "@lexical/utils";
import {
$createHeadingNode,
$createQuoteNode,
$isHeadingNode,
} from "@lexical/rich-text";
// custom elements
import { FloatingLinkEditor } from "./floating-link-editor";
import { BlockTypeSelect } from "./block-type-select";
const LowPriority = 1;
function getSelectedNode(selection: any) {
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;
}
}
export const LexicalToolbar = () => {
// editor
const [editor] = useLexicalComposerContext();
// ref
const toolbarRef = useRef(null);
// states
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [blockType, setBlockType] = useState("paragraph");
const [selectedElementKey, setSelectedElementKey] = useState<string | null>(
null
);
const [isRTL, setIsRTL] = useState(false);
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 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 = editor.getElementByKey(elementKey);
if (elementDOM !== null) {
setSelectedElementKey(elementKey);
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);
}
}
// Update text format
setIsBold(selection.hasFormat("bold"));
setIsItalic(selection.hasFormat("italic"));
setIsUnderline(selection.hasFormat("underline"));
setIsStrikethrough(selection.hasFormat("strikethrough"));
setIsRTL($isParentElementRTL(selection));
// Update links
const node = getSelectedNode(selection);
const parent = node.getParent();
if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsLink(true);
} else {
setIsLink(false);
}
}
}, [editor]);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateToolbar();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
updateToolbar();
return false;
},
LowPriority
),
editor.registerCommand(
CAN_UNDO_COMMAND,
(payload) => {
setCanUndo(payload);
return false;
},
LowPriority
),
editor.registerCommand(
CAN_REDO_COMMAND,
(payload) => {
setCanRedo(payload);
return false;
},
LowPriority
)
);
}, [editor, updateToolbar]);
const insertLink = useCallback(
(e: any) => {
e.preventDefault();
if (!isLink) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}
},
[editor, isLink]
);
return (
<div
className="flex items-center mb-1 p-1 w-full flex-wrap border-b "
ref={toolbarRef}
>
<button
disabled={!canUndo}
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(UNDO_COMMAND, undefined);
}}
className="p-2 mr-2"
aria-label="Undo"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-arrow-counterclockwise"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M8 3a5 5 0 11-4.546 2.914.5.5 0 00-.908-.417A6 6 0 108 2v1z"
></path>
<path d="M8 4.466V.534a.25.25 0 00-.41-.192L5.23 2.308a.25.25 0 000 .384l2.36 1.966A.25.25 0 008 4.466z"></path>
</svg>
</button>
<button
disabled={!canRedo}
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(REDO_COMMAND, undefined);
}}
className="p-2 mr-2"
aria-label="Redo"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-arrow-clockwise"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M8 3a5 5 0 104.546 2.914.5.5 0 01.908-.417A6 6 0 118 2v1z"
></path>
<path d="M8 4.466V.534a.25.25 0 01.41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 018 4.466z"></path>
</svg>
</button>
<BlockTypeSelect
editor={editor}
toolbarRef={toolbarRef}
blockType={blockType}
/>
<button
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
}}
className={`p-2 mr-2 ${isBold ? "active" : ""}`}
aria-label="Format Bold"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-type-bold"
viewBox="0 0 16 16"
>
<path d="M8.21 13c2.106 0 3.412-1.087 3.412-2.823 0-1.306-.984-2.283-2.324-2.386v-.055a2.176 2.176 0 001.852-2.14c0-1.51-1.162-2.46-3.014-2.46H3.843V13H8.21zM5.908 4.674h1.696c.963 0 1.517.451 1.517 1.244 0 .834-.629 1.32-1.73 1.32H5.908V4.673zm0 6.788V8.598h1.73c1.217 0 1.88.492 1.88 1.415 0 .943-.643 1.449-1.832 1.449H5.907z"></path>
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
}}
className={"p-2 mr-2" + (isItalic ? "active" : "")}
aria-label="Format Italics"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-type-italic"
viewBox="0 0 16 16"
>
<path d="M7.991 11.674L9.53 4.455c.123-.595.246-.71 1.347-.807l.11-.52H7.211l-.11.52c1.06.096 1.128.212 1.005.807L6.57 11.674c-.123.595-.246.71-1.346.806l-.11.52h3.774l.11-.52c-1.06-.095-1.129-.211-1.006-.806z"></path>
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
}}
className={"p-2 mr-2" + (isUnderline ? "active" : "")}
aria-label="Format Underline"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-type-underline"
viewBox="0 0 16 16"
>
<path d="M5.313 3.136h-1.23V9.54c0 2.105 1.47 3.623 3.917 3.623s3.917-1.518 3.917-3.623V3.136h-1.23v6.323c0 1.49-.978 2.57-2.687 2.57-1.709 0-2.687-1.08-2.687-2.57V3.136zM12.5 15h-9v-1h9v1z"></path>
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
}}
className={"p-2 mr-2" + (isStrikethrough ? "active" : "")}
aria-label="Format Strikethrough"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-type-strikethrough"
viewBox="0 0 16 16"
>
<path d="M6.333 5.686c0 .31.083.581.27.814H5.166a2.776 2.776 0 01-.099-.76c0-1.627 1.436-2.768 3.48-2.768 1.969 0 3.39 1.175 3.445 2.85h-1.23c-.11-1.08-.964-1.743-2.25-1.743-1.23 0-2.18.602-2.18 1.607zm2.194 7.478c-2.153 0-3.589-1.107-3.705-2.81h1.23c.144 1.06 1.129 1.703 2.544 1.703 1.34 0 2.31-.705 2.31-1.675 0-.827-.547-1.374-1.914-1.675L8.046 8.5H1v-1h14v1h-3.504c.468.437.675.994.675 1.697 0 1.826-1.436 2.967-3.644 2.967z"></path>
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
}}
className={"p-2 mr-2 " + (isCode ? "active" : "")}
aria-label="Insert Code"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-code"
viewBox="0 0 16 16"
>
<path d="M5.854 4.854a.5.5 0 10-.708-.708l-3.5 3.5a.5.5 0 000 .708l3.5 3.5a.5.5 0 00.708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 01.708-.708l3.5 3.5a.5.5 0 010 .708l-3.5 3.5a.5.5 0 01-.708-.708L13.293 8l-3.147-3.146z"></path>
</svg>
</button>
<button
onClick={insertLink}
className={"p-2 mr-2 " + (isLink ? "active" : "")}
aria-label="Insert Link"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-link"
viewBox="0 0 16 16"
>
<path d="M6.354 5.5H4a3 3 0 000 6h3a3 3 0 002.83-4H9c-.086 0-.17.01-.25.031A2 2 0 017 10.5H4a2 2 0 110-4h1.535c.218-.376.495-.714.82-1z"></path>
<path d="M9 5.5a3 3 0 00-2.83 4h1.098A2 2 0 019 6.5h3a2 2 0 110 4h-1.535a4.02 4.02 0 01-.82 1H12a3 3 0 100-6H9z"></path>
</svg>
</button>
{isLink &&
createPortal(<FloatingLinkEditor editor={editor} />, document.body)}
<button
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "left");
}}
className="p-2 mr-2"
aria-label="Left Align"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-text-left"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M2 12.5a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5z"
></path>
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "center");
}}
className="p-2 mr-2"
aria-label="Center Align"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-text-center"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M4 12.5a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm-2-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm2-3a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm-2-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5z"
></path>
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "right");
}}
className="p-2 mr-2"
aria-label="Right Align"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-text-right"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M6 12.5a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm-4-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm4-3a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7a.5.5 0 01-.5-.5zm-4-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5z"
></path>
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault();
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "justify");
}}
className="p-2 mr-2"
aria-label="Justify Align"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-justify"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M2 12.5a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zm0-3a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5z"
></path>
</svg>
</button>{" "}
</div>
);
};