forked from github/plane
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
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>
|
|
);
|
|
};
|