diff --git a/articles/lexical-link-preview/index.tsx b/articles/lexical-link-preview/index.tsx new file mode 100644 index 0000000..97baa05 --- /dev/null +++ b/articles/lexical-link-preview/index.tsx @@ -0,0 +1,185 @@ +import React, { useRef, useState } from "react"; +import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; + +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { TRANSFORMERS } from "@lexical/markdown"; +import { CodeHighlightNode, CodeNode } from "@lexical/code"; +import { AutoLinkNode, LinkNode } from "@lexical/link"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; +import { HashtagNode } from "@lexical/hashtag"; +import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; +import { + BannerNode, + BannerPlugin, +} from "../../features/article/components/lexicalComponents/Banner"; +import { JsonButtonContainer } from "../../utils/lexical"; +import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; +import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin"; +import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; +import { EditorState, LineBreakNode, ParagraphNode } from "lexical"; +import { HashtagPlugin } from "@lexical/react/LexicalHashtagPlugin"; +import ToolbarPlugin from "../../features/article/components/lexicalComponents/ToolbarPlugin"; +import { + LinkPreviewNode, + ResOfWebsite, +} from "../../features/article/components/lexicalComponents/LinkPreviewNode"; +import { LinkPreviewPlugin } from "../../features/article/components/lexicalComponents/LinkPreviewPlugin"; +import TreeViewPlugin from "../../features/article/components/lexicalComponents/TreeViewPlugin"; +import { + AutoLinkPlugin, + createLinkMatcherWithRegExp, +} from "@lexical/react/LexicalAutoLinkPlugin"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { + LoadFromJsonOnToolbar, + SaveToJsonOnToolbar, + CodeHighlightPlugin, +} from "../../features/article/components/lexicalComponents/OwnLexicalToolbar"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import DraggableBlockPlugin from "../../features/article/components/lexicalComponents/DraggableBlockPlugin"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; + +function onError(error: Error): void { + console.error(error); +} + +const myTheme = { + text: { + bold: "textBold", + code: "textCode", + italic: "textItalic", + strikethrough: "textStrikethrough", + subscript: "textSubscript", + superscript: "textSuperscript", + underline: "textUnderline", + underlineStrikethrough: "textUnderlineStrikethrough", + }, + list: { + listitem: "listItem", + listitemChecked: "listItemChecked", + listitemUnchecked: "listItemUnchecked", + }, + quote: "quote", + link: "mylink", + hashtag: "hashtag", + linkPreviewContainer: "linkPreviewContainer", +}; + +const initialConfig = { + namespace: "MyEditor", + theme: myTheme, + onError, + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + LinkNode, + BannerNode, + HorizontalRuleNode, + CodeNode, + QuoteNode, + HashtagNode, + CodeHighlightNode, + ParagraphNode, + AutoLinkNode, + LinkPreviewNode, + LineBreakNode, + ], +}; + +async function thisFetchingFunction(link: string): Promise { + const data = await fetch("/api/link-preview", { + method: "POST", + body: JSON.stringify({ + link, + }), + }); + const { + data: { url, title, description, images }, + } = await data.json(); + return { url, title, description, images }; +} + +const Editor = (): JSX.Element => { + const editorStateRef = useRef(); + const [editorJson, setEditorJson] = useState(""); + const [floatingAnchorElem, setFloatingAnchorElem] = + useState(null); + + const onRef = (_floatingAnchorElem: HTMLDivElement) => { + if (_floatingAnchorElem !== null) { + setFloatingAnchorElem(_floatingAnchorElem); + } + }; + + const URL_REGEX = + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; + + const MATCHERS = [ + createLinkMatcherWithRegExp(URL_REGEX, (text) => { + return text.startsWith("http") ? text : `https://${text}`; + }), + ]; + return ( + +
+ + + + + {floatingAnchorElem && ( + + )} + + + + + + + + + + + + + +
+ +
+
+ } + placeholder={
🖇 Paste your link!
} + ErrorBoundary={LexicalErrorBoundary} + /> + + { + if (editorStateRef) editorStateRef.current = editorState; + }} + /> + + { + if (editorStateRef.current) + setEditorJson(JSON.stringify(editorStateRef.current)); + }} + /> + + + + + +
+ ); +}; + +export default Editor; diff --git a/features/article/components/lexicalComponents/Banner.tsx b/features/article/components/lexicalComponents/Banner.tsx new file mode 100644 index 0000000..f15e90b --- /dev/null +++ b/features/article/components/lexicalComponents/Banner.tsx @@ -0,0 +1,93 @@ +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_LOW, + createCommand, + EditorConfig, + ElementNode, + LexicalNode, + RangeSelection, +} from "lexical"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { $setBlocksType } from "@lexical/selection"; +//////////////////////////////////////////////////////////////// +// 1.) +export class BannerNode extends ElementNode { + // constructor(key?: NodeKey) { + // super(key); + // } + + static getType(): string { + return "banner"; + } + + static clone(node: BannerNode): BannerNode { + return new BannerNode(node.__key); + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement("div"); + element.style.backgroundColor = "lightblue"; + return element; + } + + updateDOM(): false { + return false; + } + + insertNewAfter( + selection: RangeSelection, + restoreSelection?: boolean | undefined, + ): LexicalNode | null { + const newBlock = $createParagraphNode(); + const direction = this.getDirection(); + newBlock.setDirection(direction); + this.inserAfter(newBlock, restoreSelection); + return newBlock; + } + + collapseAtStart(): boolean { + const p = $createParagraphNode(); + const children = this.getChildren(); + children.forEach((child) => p.append(child)); + this.replace(p); + + return true; + } +} + +export function $createBannerNode(): BannerNode { + return new BannerNode(); +} + +// function $isBannerNode(node: LexicalNode): node is BannerNode { +// return node instanceof BannerNode; +// } + +//////////////////////////////////////////////////////////////// +// 2.) +export const INSERT_BANNER_COMMAND = createCommand("insertCommand"); + +//////////////////////////////////////////////////////////////// +// 3.) +export const BannerPlugin = (): null => { + const [editor] = useLexicalComposerContext(); + if (!editor.hasNodes([BannerNode])) + throw new Error("BannerNode is not registered in the editor"); + + editor.registerCommand( + INSERT_BANNER_COMMAND, + () => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, $createBannerNode); + } + return true; + }, + COMMAND_PRIORITY_LOW, + ); + return null; +}; + +//////////////////////////////////////////////////////////////// diff --git a/features/article/components/lexicalComponents/DraggableBlockPlugin.tsx b/features/article/components/lexicalComponents/DraggableBlockPlugin.tsx new file mode 100644 index 0000000..a396d87 --- /dev/null +++ b/features/article/components/lexicalComponents/DraggableBlockPlugin.tsx @@ -0,0 +1,440 @@ +/** + * 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 { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { eventFiles } from "@lexical/rich-text"; +import { mergeRegister } from "@lexical/utils"; +import { + $getNearestNodeFromDOMNode, + $getNodeByKey, + $getRoot, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + DRAGOVER_COMMAND, + DROP_COMMAND, + LexicalEditor, +} from "lexical"; +import * as React from "react"; +import { + DragEvent as ReactDragEvent, + useEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +import { Point } from "./point"; +import { Rect } from "./rect"; + +const SPACE = 4; +const TARGET_LINE_HALF_HEIGHT = 2; +const DRAGGABLE_BLOCK_MENU_CLASSNAME = "draggable-block-menu"; +const DRAG_DATA_FORMAT = "application/x-lexical-drag-block"; +const TEXT_BOX_HORIZONTAL_PADDING = 28; + +const Downward = 1; +const Upward = -1; +const Indeterminate = 0; + +let prevIndex = Infinity; + +export function isHTMLElement(x: unknown): x is HTMLElement { + return x instanceof HTMLElement; +} + +function getCurrentIndex(keysLength: number): number { + if (keysLength === 0) { + return Infinity; + } + if (prevIndex >= 0 && prevIndex < keysLength) { + return prevIndex; + } + + return Math.floor(keysLength / 2); +} + +function getTopLevelNodeKeys(editor: LexicalEditor): string[] { + return editor.getEditorState().read(() => $getRoot().getChildrenKeys()); +} + +function getCollapsedMargins(elem: HTMLElement): { + marginTop: number; + marginBottom: number; +} { + const getMargin = ( + element: Element | null, + margin: "marginTop" | "marginBottom", + ): number => + element ? parseFloat(window.getComputedStyle(element)[margin]) : 0; + + const { marginTop, marginBottom } = window.getComputedStyle(elem); + const prevElemSiblingMarginBottom = getMargin( + elem.previousElementSibling, + "marginBottom", + ); + const nextElemSiblingMarginTop = getMargin( + elem.nextElementSibling, + "marginTop", + ); + const collapsedTopMargin = Math.max( + parseFloat(marginTop), + prevElemSiblingMarginBottom, + ); + const collapsedBottomMargin = Math.max( + parseFloat(marginBottom), + nextElemSiblingMarginTop, + ); + + return { marginBottom: collapsedBottomMargin, marginTop: collapsedTopMargin }; +} + +function getBlockElement( + anchorElem: HTMLElement, + editor: LexicalEditor, + event: MouseEvent, + useEdgeAsDefault = false, +): HTMLElement | null { + const anchorElementRect = anchorElem.getBoundingClientRect(); + const topLevelNodeKeys = getTopLevelNodeKeys(editor); + + let blockElem: HTMLElement | null = null; + + editor.getEditorState().read(() => { + if (useEdgeAsDefault) { + const [firstNode, lastNode] = [ + editor.getElementByKey(topLevelNodeKeys[0]), + editor.getElementByKey(topLevelNodeKeys[topLevelNodeKeys.length - 1]), + ]; + + const [firstNodeRect, lastNodeRect] = [ + firstNode?.getBoundingClientRect(), + lastNode?.getBoundingClientRect(), + ]; + + if (firstNodeRect && lastNodeRect) { + if (event.y < firstNodeRect.top) { + blockElem = firstNode; + } else if (event.y > lastNodeRect.bottom) { + blockElem = lastNode; + } + + if (blockElem) { + return; + } + } + } + + let index = getCurrentIndex(topLevelNodeKeys.length); + let direction = Indeterminate; + + while (index >= 0 && index < topLevelNodeKeys.length) { + const key = topLevelNodeKeys[index]; + const elem = editor.getElementByKey(key); + if (elem === null) { + break; + } + const point = new Point(event.x, event.y); + const domRect = Rect.fromDOM(elem); + const { marginTop, marginBottom } = getCollapsedMargins(elem); + + const rect = domRect.generateNewRect({ + bottom: domRect.bottom + marginBottom, + left: anchorElementRect.left, + right: anchorElementRect.right, + top: domRect.top - marginTop, + }); + + const { + result, + reason: { isOnTopSide, isOnBottomSide }, + } = rect.contains(point); + + if (result) { + blockElem = elem; + prevIndex = index; + break; + } + + if (direction === Indeterminate) { + if (isOnTopSide) { + direction = Upward; + } else if (isOnBottomSide) { + direction = Downward; + } else { + // stop search block element + direction = Infinity; + } + } + + index += direction; + } + }); + + return blockElem; +} + +function isOnMenu(element: HTMLElement): boolean { + return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`); +} + +function setMenuPosition( + targetElem: HTMLElement | null, + floatingElem: HTMLElement, + anchorElem: HTMLElement, +) { + if (!targetElem) { + floatingElem.style.opacity = "0"; + floatingElem.style.transform = "translate(-100000000px, 10000000px)"; + + return; + } + + const targetRect = targetElem.getBoundingClientRect(); + const targetStyle = window.getComputedStyle(targetElem); + const floatingElemRect = floatingElem.getBoundingClientRect(); + const anchorElementRect = anchorElem.getBoundingClientRect(); + + const left = SPACE; + + const top = + targetRect.top + + (parseInt(targetStyle.height, 10) - floatingElemRect.height) / 2 - + anchorElementRect.top; + + floatingElem.style.opacity = "1"; + floatingElem.style.transform = `translate(${left}px, ${top}px)`; +} + +function setDragImage( + dataTransfer: DataTransfer, + draggableBlockElem: HTMLElement, +) { + const { transform } = draggableBlockElem.style; + + // Remove dragImage borders + draggableBlockElem.style.transform = "translateZ(0)"; + dataTransfer.setDragImage(draggableBlockElem, 0, 0); + + setTimeout(() => { + draggableBlockElem.style.transform = transform; + }); +} + +function setTargetLine( + targetLineElem: HTMLElement, + targetBlockElem: HTMLElement, + mouseY: number, + anchorElem: HTMLElement, +) { + const { top: targetBlockElemTop, height: targetBlockElemHeight } = + targetBlockElem.getBoundingClientRect(); + const { top: anchorTop, width: anchorWidth } = + anchorElem.getBoundingClientRect(); + + const { marginTop, marginBottom } = getCollapsedMargins(targetBlockElem); + let lineTop = targetBlockElemTop; + if (mouseY >= targetBlockElemTop) { + lineTop += targetBlockElemHeight + marginBottom / 2; + } else { + lineTop -= marginTop / 2; + } + + const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT; + const left = TEXT_BOX_HORIZONTAL_PADDING - SPACE; + + targetLineElem.style.transform = `translate(${left}px, ${top}px)`; + targetLineElem.style.width = `${ + anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - SPACE) * 2 + }px`; + targetLineElem.style.opacity = ".4"; +} + +function hideTargetLine(targetLineElem: HTMLElement | null) { + if (targetLineElem) { + targetLineElem.style.opacity = "0"; + targetLineElem.style.transform = "translate(-10000px, -10000px)"; + } +} + +function useDraggableBlockMenu( + editor: LexicalEditor, + anchorElem: HTMLElement, + isEditable: boolean, +): JSX.Element { + const scrollerElem = anchorElem.parentElement; + + const menuRef = useRef(null); + const targetLineRef = useRef(null); + const isDraggingBlockRef = useRef(false); + const [draggableBlockElem, setDraggableBlockElem] = + useState(null); + + useEffect(() => { + function onMouseMove(event: MouseEvent) { + const target = event.target; + + if (!isHTMLElement(target)) { + setDraggableBlockElem(null); + return; + } + + if (isOnMenu(target)) { + return; + } + + const _draggableBlockElem = getBlockElement(anchorElem, editor, event); + + setDraggableBlockElem(_draggableBlockElem); + } + + function onMouseLeave() { + setDraggableBlockElem(null); + } + + scrollerElem?.addEventListener("mousemove", onMouseMove); + scrollerElem?.addEventListener("mouseleave", onMouseLeave); + + return () => { + scrollerElem?.removeEventListener("mousemove", onMouseMove); + scrollerElem?.removeEventListener("mouseleave", onMouseLeave); + }; + }, [scrollerElem, anchorElem, editor]); + + useEffect(() => { + if (menuRef.current) { + setMenuPosition(draggableBlockElem, menuRef.current, anchorElem); + } + }, [anchorElem, draggableBlockElem]); + + useEffect(() => { + function onDragover(event: DragEvent): boolean { + if (!isDraggingBlockRef.current) { + return false; + } + const [isFileTransfer] = eventFiles(event); + if (isFileTransfer) { + return false; + } + const { pageY, target } = event; + if (!isHTMLElement(target)) { + return false; + } + const targetBlockElem = getBlockElement(anchorElem, editor, event, true); + const targetLineElem = targetLineRef.current; + if (targetBlockElem === null || targetLineElem === null) { + return false; + } + setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem); + // Prevent default event to be able to trigger onDrop events + event.preventDefault(); + return true; + } + + function onDrop(event: DragEvent): boolean { + if (!isDraggingBlockRef.current) { + return false; + } + const [isFileTransfer] = eventFiles(event); + if (isFileTransfer) { + return false; + } + const { target, dataTransfer, pageY } = event; + const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || ""; + const draggedNode = $getNodeByKey(dragData); + if (!draggedNode) { + return false; + } + if (!isHTMLElement(target)) { + return false; + } + const targetBlockElem = getBlockElement(anchorElem, editor, event, true); + if (!targetBlockElem) { + return false; + } + const targetNode = $getNearestNodeFromDOMNode(targetBlockElem); + if (!targetNode) { + return false; + } + if (targetNode === draggedNode) { + return true; + } + const targetBlockElemTop = targetBlockElem.getBoundingClientRect().top; + if (pageY >= targetBlockElemTop) { + targetNode.insertAfter(draggedNode); + } else { + targetNode.insertBefore(draggedNode); + } + setDraggableBlockElem(null); + + return true; + } + + return mergeRegister( + editor.registerCommand( + DRAGOVER_COMMAND, + (event) => { + return onDragover(event); + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DROP_COMMAND, + (event) => { + return onDrop(event); + }, + COMMAND_PRIORITY_HIGH, + ), + ); + }, [anchorElem, editor]); + + function onDragStart(event: ReactDragEvent): void { + const dataTransfer = event.dataTransfer; + if (!dataTransfer || !draggableBlockElem) { + return; + } + setDragImage(dataTransfer, draggableBlockElem); + let nodeKey = ""; + editor.update(() => { + const node = $getNearestNodeFromDOMNode(draggableBlockElem); + if (node) { + nodeKey = node.getKey(); + } + }); + isDraggingBlockRef.current = true; + dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey); + } + + function onDragEnd(): void { + isDraggingBlockRef.current = false; + hideTargetLine(targetLineRef.current); + } + + return createPortal( + <> +
+
+
+
+ , + anchorElem, + ); +} + +export default function DraggableBlockPlugin({ + anchorElem = document.body, +}: { + anchorElem?: HTMLElement; +}): JSX.Element { + const [editor] = useLexicalComposerContext(); + return useDraggableBlockMenu(editor, anchorElem, editor._editable); +} diff --git a/features/article/components/lexicalComponents/LinkPreviewBox.tsx b/features/article/components/lexicalComponents/LinkPreviewBox.tsx new file mode 100644 index 0000000..3a8bde8 --- /dev/null +++ b/features/article/components/lexicalComponents/LinkPreviewBox.tsx @@ -0,0 +1,26 @@ +import React, { FC } from "react"; +import { BlockWithAlignableContents } from "@lexical/react/LexicalBlockWithAlignableContents"; +import { LinkPreviewT } from "./LinkPreviewNode"; + +const LinkPreviewBox: FC = ({ + className, + nodeKey, + url, + res, +}): JSX.Element => { + return ( + + +
+ {""} +
+
{res.title}
+
{res.description}
+
+
+
+
+ ); +}; + +export default LinkPreviewBox; diff --git a/features/article/components/lexicalComponents/LinkPreviewNode.tsx b/features/article/components/lexicalComponents/LinkPreviewNode.tsx new file mode 100644 index 0000000..00d7bad --- /dev/null +++ b/features/article/components/lexicalComponents/LinkPreviewNode.tsx @@ -0,0 +1,127 @@ +import { + EditorConfig, + ElementFormatType, + LexicalEditor, + NodeKey, + SerializedLexicalNode, + Spread, +} from "lexical"; +import React from "react"; +import { DecoratorBlockNode } from "@lexical/react/LexicalDecoratorBlockNode"; +import LinkPreviewBox from "./LinkPreviewBox"; + +export type LinkPreviewT = { + className: Readonly<{ + base: string; + focus: string; + }>; + nodeKey: NodeKey; + url: string; + res: ResOfWebsite; + onError?: (error: string) => void; + loadingComponent?: JSX.Element | string; + onLoad?: () => void; +}; + +export type SerializedLinkPreviewNode = Spread< + { + url: string; + res: ResOfWebsite; + format: ElementFormatType; + }, + SerializedLexicalNode +>; + +export type ResOfWebsite = { + url: string; + title: string; + description: string; + images: Array; +}; + +export class LinkPreviewNode extends DecoratorBlockNode { + __url: string; + __res: ResOfWebsite; + constructor( + url: string, + res: ResOfWebsite, + format?: ElementFormatType, + key?: NodeKey, + ) { + super(format, key); + this.__url = url; + this.__res = res; + if (format) this.__format = format; + } + + static getType() { + return "LinkPreviewNode"; + } + static clone(node: LinkPreviewNode): LinkPreviewNode { + return new LinkPreviewNode( + node.__url, + node.__res, + node.__format, + node.__key, + ); + } + + decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element { + const linkPreviewStyle = config.theme.linkPreviewContainer; + const className = { + base: linkPreviewStyle.base, + focus: linkPreviewStyle.focus, + }; + + return ( + + ); + } + updateDOM(): false { + return false; + } + + getURL(): string { + return this.getLatest().__url; + } + + getRes(): ResOfWebsite { + return this.getLatest().__res; + } + + setFormat(format: ElementFormatType): void { + const self = this.getWritable(); + self.__format = format; + } + + exportJSON(): SerializedLinkPreviewNode { + return { + ...super.exportJSON(), + format: this.__format || "", + url: this.getURL(), + res: this.getRes(), + type: "link-preview", + version: 1, + }; + } + + static importJSON( + serializedNode: SerializedLinkPreviewNode, + ): LinkPreviewNode { + const node = $createLinkPreviewNode(serializedNode.url, serializedNode.res); + node.setFormat(serializedNode.format); + return node; + } +} + +export function $createLinkPreviewNode( + url: string, + res: ResOfWebsite, +): LinkPreviewNode { + return new LinkPreviewNode(url, res); +} diff --git a/features/article/components/lexicalComponents/LinkPreviewPlugin.tsx b/features/article/components/lexicalComponents/LinkPreviewPlugin.tsx new file mode 100644 index 0000000..ab99a8a --- /dev/null +++ b/features/article/components/lexicalComponents/LinkPreviewPlugin.tsx @@ -0,0 +1,87 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useEffect } from "react"; +import { + $getRoot, + $insertNodes, + COMMAND_PRIORITY_CRITICAL, + PASTE_COMMAND, +} from "lexical"; +import { + $createLinkPreviewNode, + LinkPreviewNode, + ResOfWebsite, +} from "./LinkPreviewNode"; + +export const LinkPreviewPlugin = ({ + showLink, + fetchingFunction, +}: { + showLink: boolean; + fetchingFunction: (link: string) => Promise; +}): null => { + const [editor] = useLexicalComposerContext(); + const result = [{ url: "", title: "", description: "", images: ["", ""] }]; + let alreadyHaveIt: ResOfWebsite | undefined; + + if (!editor.hasNodes([LinkPreviewNode])) + throw new Error("LinkPreviewNode" + " is not registered in the editor"); + + const urlRegexp = + /((http(s)?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; + + useEffect(() => { + const removeListener = editor.registerCommand( + PASTE_COMMAND, + (event: ClipboardEvent) => { + const clipboardData = event.clipboardData; + const pastedItem = clipboardData?.getData("text"); + + if (typeof pastedItem === "string") { + if (urlRegexp.test(pastedItem)) { + const createPreview = async () => { + try { + await fetchingFunction(pastedItem).then((res) => { + alreadyHaveIt = result.find( + (website) => res.url === website.url, + ); + if (!alreadyHaveIt) { + result.unshift(res); + } + }); + + if (!alreadyHaveIt) { + editor.update(() => { + if (showLink) { + $getRoot() + .getLastChild() + ?.append($createLinkPreviewNode(pastedItem, result[0])); + } else { + $insertNodes([ + $createLinkPreviewNode(pastedItem, result[0]), + ]); + } + return true; + }); + } + } catch (err) { + alert( + `Cannot show the preview of this link (${pastedItem}) as it is either not correct or is redirecting to another URL. \n\nTips: Try to leave the 'www.' part and use 'https://' instead of 'http://'`, + ); + } + }; + createPreview(); + return !showLink; + } + return false; + } + + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ); + return () => { + removeListener(); + }; + }, [editor, fetchingFunction]); + return null; +}; diff --git a/features/article/components/lexicalComponents/LinkPreviewbackup.tsx b/features/article/components/lexicalComponents/LinkPreviewbackup.tsx new file mode 100644 index 0000000..d6d0161 --- /dev/null +++ b/features/article/components/lexicalComponents/LinkPreviewbackup.tsx @@ -0,0 +1,40 @@ +import { DOMExportOutput, EditorConfig, LexicalEditor } from "lexical"; +import { AutoLinkNode } from "@lexical/link"; + +export class LinkPreviewNode extends AutoLinkNode { + static getType() { + return "linkPreview"; + } + static clone(node: LinkPreviewNode): LinkPreviewNode { + return new LinkPreviewNode(node.__key); + } + createDOM(config: EditorConfig): HTMLAnchorElement { + let previewImage = document.createElement("img"); + previewImage.className = config.theme.previewImage; + previewImage.setAttribute("src", "/linkprev.jpg"); + + const previewDescription = document.createElement("div"); + previewDescription.className = config.theme.previewDescription; + previewDescription.innerText = "Lorem Ipsum"; + + const previewBox = document.createElement("div"); + previewBox.className = config.theme.previewBox; + previewBox.appendChild(previewImage); + previewBox.appendChild(previewDescription); + + const linkPreviewContainer = super.createDOM(config); + linkPreviewContainer.className = config.theme.linkPreviewContainer; + linkPreviewContainer.target = "_blank"; + linkPreviewContainer.appendChild(previewBox); + + return linkPreviewContainer; + } + + updateDOM(prevNode: AutoLinkNode, dom: HTMLAnchorElement): boolean { + return false; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + return super.exportDOM(editor); + } +} diff --git a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx new file mode 100644 index 0000000..ceff4d1 --- /dev/null +++ b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx @@ -0,0 +1,424 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import React, { useCallback, useEffect, useState } from "react"; +import { ToolbarItem } from "../../../../utils/lexical"; +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + FORMAT_TEXT_COMMAND, + REDO_COMMAND, + UNDO_COMMAND, +} from "lexical"; +import { $patchStyleText, $setBlocksType } from "@lexical/selection"; +import { + INSERT_CHECK_LIST_COMMAND, + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, +} from "@lexical/list"; +import { INSERT_HORIZONTAL_RULE_COMMAND } from "@lexical/react/LexicalHorizontalRuleNode"; +import { INSERT_BANNER_COMMAND } from "./Banner"; +import { $createHeadingNode, $createQuoteNode } from "@lexical/rich-text"; +import { $createLinkNode } from "@lexical/link"; +import { registerCodeHighlighting, $createCodeNode } from "@lexical/code"; + +export const DoOnToolbar = () => { + const [editor] = useLexicalComposerContext(); + + const undoRedo = (e: React.MouseEvent) => { + const target = e.currentTarget; + const id = target.id; + + if (id === "undo") { + editor.dispatchCommand(UNDO_COMMAND, undefined); + } else { + editor.dispatchCommand(REDO_COMMAND, undefined); + } + }; + + return ( + <> + undoRedo(e)}> + {"<<"} + + undoRedo(e)}> + {">>"} + + + ); +}; + +export const NormalPOnToolbar = () => { + const [editor] = useLexicalComposerContext(); + + const normalPOnClick = () => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createParagraphNode()); + } + }); + }; + + return Normal P; +}; +export const BannerOnToolbar = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + + const bannerOnClick = (): void => { + editor.dispatchCommand(INSERT_BANNER_COMMAND, undefined); + }; + return Banner; +}; + +type HeadingTags = "h1" | "h2" | "h3"; +type ListTags = "ul" | "ol" | "checklist"; + +export const HeadingOnToolbar = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + const headingTags: HeadingTags[] = ["h1", "h2", "h3"]; + + const headingOnClick = (tag: HeadingTags): void => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createHeadingNode(tag)); + } + }); + }; + + return ( + <> + {headingTags.map((tag, i) => ( + headingOnClick(tag)}> + {tag} + + ))} + + ); +}; + +export const ListingOnToolbar = () => { + const [editor] = useLexicalComposerContext(); + const listingTags: ListTags[] = ["ul", "ol", "checklist"]; + + const listingOnClick = (tag: ListTags): void => { + if (tag === "ol") { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + return; + } else if (tag === "checklist") { + editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined); + return; + } + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + }; + return ( + <> + {listingTags.map((tag, i) => ( + listingOnClick(tag)}> + {tag} + + ))} + + ); +}; + +export const BlockquoteOnToolbar = () => { + const [editor] = useLexicalComposerContext(); + + const blockquoteOnClick = () => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createQuoteNode()); + } + }); + }; + + return quote; +}; + +export const MonocodeOnToolbar = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + + const codeOnClick = (): void => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code"); + }; + return mono code; +}; + +export const LinkOnToolbar = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + const [isLink, setIsLink] = useState(false); + const [link, setLink] = useState(""); + const [isSeen, setIsSeen] = useState(false); + const linkRegex = + /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; + + const linkOnClick = useCallback(() => { + setIsLink(!isLink); + setIsSeen(true); + }, [editor, isLink]); + + const handleLinkEnter = useCallback( + (link: string) => { + if (link === "") { + alert("link is empty"); + setIsSeen(false); + return; + } + if (!linkRegex.test(link)) { + alert("invalid link"); + setIsSeen(false); + return; + } + setIsSeen(false); + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createLinkNode(link)); + } + }); + }, + [editor, isLink], + ); + + return ( + <> + Link + + {isSeen && ( +
+ ) => { + setLink(e.target.value); + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleLinkEnter(link); + } + }} + /> +
+ )} + + ); +}; + +export const FormatThings = () => { + const [editor] = useLexicalComposerContext(); + const formattingTextOptions = [ + "bold", + "italic", + "underline", + "strikethrough", + "highlight", + "subscript", + "superscript", + ]; + + const formattingTextOnClick = (tag: string): void => { + switch (tag) { + case "bold": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold"); + break; + case "italic": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic"); + break; + case "underline": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline"); + break; + case "strikethrough": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough"); + break; + case "highlight": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "highlight"); + break; + case "subscript": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "subscript"); + break; + case "superscript": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "superscript"); + break; + default: + console.log("no bro"); + break; + } + }; + + return ( + <> + {formattingTextOptions.map((tag, i) => ( + formattingTextOnClick(tag)}> + {tag} + + ))} + + ); +}; + +export const FontSizeOnToolbar = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + const fontSizeOptions = [ + "10px", + "12px", + "14px", + "16px", + "18px", + "20px", + "24px", + "28px", + "32px", + ]; + + const fontSizeOnClick = useCallback( + (size: string) => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $patchStyleText(selection, { + ["font-size"]: size, + }); + } + }); + }, + [editor], + ); + + return ( + <> + {fontSizeOptions.map((size, i) => ( + fontSizeOnClick(size)}> + {size} + + ))} + + ); +}; + +export const ColoringOnToolbar = () => { + const [editor] = useLexicalComposerContext(); + const [fontColor, setFontColor] = useState("#000000"); + const [backgrColor, setBackgrColor] = useState("#000000"); + + const applyStyleText = useCallback( + (styles: Record) => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $patchStyleText(selection, styles); + } + }); + }, + [editor], + ); + + const onFontColorSelect = useCallback( + (value: string) => { + setFontColor(value); + applyStyleText({ color: value }); + }, + [applyStyleText], + ); + + const onBackgrColorSelect = useCallback( + (value: string) => { + setBackgrColor(value); + applyStyleText({ "background-color": value }); + }, + [applyStyleText], + ); + + return ( + <> + + Text: + onFontColorSelect(e.target.value)} + /> + + + Backgr.: + onBackgrColorSelect(e.target.value)} + /> + + + ); +}; + +export const HROnToolbar = () => { + const [editor] = useLexicalComposerContext(); + + const hrOnClick = (): void => { + editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined); + }; + return --- hr ---; +}; + +export const SaveToJsonOnToolbar = ({ onClick }: { onClick: () => void }) => { + return ( + + to JSON + + ); +}; + +export const LoadFromJsonOnToolbar = ({ data }: { data: string }) => { + const [editor] = useLexicalComposerContext(); + + const loadOnClick = () => { + const editorState = editor.parseEditorState(data); + editor.setEditorState(editorState); + return null; + }; + return ( + + to Editor{" "} + + ); +}; + +export function CodeHighlightPlugin(): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return registerCodeHighlighting(editor); + }, [editor]); + + return null; +} + +export const CodeBlockOnToolbar = () => { + // const codeLanguages = ["JavaScript", "HTML", "CSS", "Python", "TypeScript"]; + + const [editor] = useLexicalComposerContext(); + + const codeBlockOnClick = () => { + editor.update(() => { + let selection = $getSelection(); + + if ($isRangeSelection(selection)) { + if (selection.isCollapsed()) { + $setBlocksType(selection, () => $createCodeNode()); + } else { + const textContent = selection.getTextContent(); + const codeNode = $createCodeNode(); + selection.insertNodes([codeNode]); + selection = $getSelection(); + if ($isRangeSelection(selection)) + selection.insertRawText(textContent); + } + } + }); + }; + + return CodeBlock; +}; diff --git a/features/article/components/lexicalComponents/README-lexical-github.md b/features/article/components/lexicalComponents/README-lexical-github.md new file mode 100644 index 0000000..e7c48ab --- /dev/null +++ b/features/article/components/lexicalComponents/README-lexical-github.md @@ -0,0 +1,146 @@ +# lexical-link-preview-react + +![made by Emergence Engineering](https://emergence-engineering.com/ee-logo.svg) + + +[**Made by Emergence-Engineering**](https://emergence-engineering.com/) + +## Features + +![feature-gif](https://emergence-engineering.com/lexical-link-preview.gif) + + +This easy-to-use plugin for lexical editors enhances the user experience by allowing them to see the page behind the link. + +The plugin takes two properties that allow you to: +- set whether to display the inserted link as well, next to the preview, and +- use **your async callback function** to retrieve metadata from the site + +The plugin applies the data to the preview box and shows you the name, the description and, if available, the image of the website. +You can easily customize the preview - use the popular 'card' design, stick to the 'bookmark' style, or create your own - whichever fits better to your UI. + +Try it out at http://emergence-engineering.com/blog/lexical-link-preview + +## How to use? + +1. **Installation**: Install the plugin from your preferred package manager. For example, using npm, run the following command: `npm i -S lexical-link-preview-react` +2. **Import**: Import the node and the plugin into your project. +```typescript +import { LinkPreviewNode, LinkPreviewPlugin } from "lexical-link-preview-react"; +``` + +3. Don't forget to include the classname in your theme config: +```html + linkPreviewContainer: "linkPreviewContainer", +``` +???????????????????????? where do I put this component to be editable? +You can use your custom css to style the preview, here is an example(which is the actual css used by default) + + + - basic card structure + + ```html + + +
+ {""} +
+
{res.title}
+
{res.description}
+
+
+
+
+ ``` + +4. Add the node to the nodes array in your config: LinkPreviewNode + + ```typescript + nodes: [LinkPreviewNode,] + ``` + +5. Initialize the editor with the plugin + + ```typescript + const Editor = (): JSX.Element => { + return ( + + + + + } + placeholder={placeholder} + ErrorBoundary={LexicalErrorBoundary} + /> + + ) + } + ``` + +6. `LinkPreviewPlugin` requires 2 parameters: + +- `showLink`: takes a boolean value + - if true, inserts the link inline and puts the preview to the bottom of the editor + - if false, inserts the preview as an inline-block element and nothing else + + + - `fetchingFunction`: `(link: string) => Promise<{url: string, title: string, description: string, images: string[]}>` a function that takes a link and returns a `Promise` that resolves to the link preview data, you can easily do this using next.js API routes + ??????????????? or just using `link-preview-js` library on your custom backend + + ```typescript + import type { NextApiRequest, NextApiResponse } from "next"; + import Cors from "cors"; + import { getLinkPreview } from "link-preview-js"; + // Initializing the cors middleware + // You can read more about the available options here: https://github.com/expressjs/cors#configuration-options + const cors = Cors({ + methods: ["POST", "GET", "HEAD"], + }); + // Helper method to wait for a middleware to execute before continuing + // And to throw an error when an error happens in a middleware + function runMiddleware( + req: NextApiRequest, + res: NextApiResponse, + fn: Function + ) { + return new Promise((resolve, reject) => { + fn(req, res, (result: any) => { + if (result instanceof Error) { + return reject(result); + } + return resolve(result); + }); + }); + } + export default async function handler( + req: NextApiRequest, + res: NextApiResponse + ) { + // Run the middleware + await runMiddleware(req, res, cors); + + const { link } = JSON.parse(req.body); + console.log({ link }); + + const data = await getLinkPreview(link); + + // Rest of the API logic + res.json({ data }); + } + ``` + + + + +## Fetching preview data + +**this does not happen automatically**, you need to handle it yourself by providing the `fetchingFunction` callback function + +- this usually requires a backend using a 3rd party library like `link-preview-js` +- you can use `linkpreview.net` API endpoint to fetch your preview data from the frontend +- in case you are using nextjs, you can easily use our example above +- or any other tool you see fit \ No newline at end of file diff --git a/features/article/components/lexicalComponents/ToolbarPlugin.tsx b/features/article/components/lexicalComponents/ToolbarPlugin.tsx new file mode 100644 index 0000000..3ced5ff --- /dev/null +++ b/features/article/components/lexicalComponents/ToolbarPlugin.tsx @@ -0,0 +1,161 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Dropdown, Toolbar, ToolbarItem } from "../../../../utils/lexical"; +import { + BannerOnToolbar, + BlockquoteOnToolbar, + CodeBlockOnToolbar, + ColoringOnToolbar, + FontSizeOnToolbar, + FormatThings, + HeadingOnToolbar, + ListingOnToolbar, + MonocodeOnToolbar, + NormalPOnToolbar, +} from "./OwnLexicalToolbar"; + +const ToolbarPlugin = (): JSX.Element => { + const [isStylingPOpen, setIsStylingPOpen] = useState(false); + const [isFormattingTextOpen, setIsFormattingTextOpen] = useState(false); + const [isColoringOpen, setIsColoringOpen] = useState(false); + const [isFontSizeOpen, setIsFontSizeOpen] = useState(false); + + const styleRef = useRef(null); + const formatRef = useRef(null); + const colorRef = useRef(null); + const fontSizeRef = useRef(null); + + useEffect(() => { + const handler = (event: MouseEvent) => { + const target = event.target; + + if ( + isStylingPOpen && + !styleRef.current?.contains(target as HTMLDivElement) + ) { + setIsStylingPOpen(false); + } else if ( + isFormattingTextOpen && + !formatRef.current?.contains(target as HTMLDivElement) + ) { + setIsFormattingTextOpen(false); + } else if ( + isColoringOpen && + !colorRef.current?.contains(target as HTMLDivElement) + ) { + setIsColoringOpen(false); + } else if ( + isFontSizeOpen && + !fontSizeRef.current?.contains(target as HTMLDivElement) + ) { + setIsFontSizeOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => { + document.removeEventListener("mousedown", handler); + }; + }, [isStylingPOpen, isColoringOpen, isFormattingTextOpen, isFontSizeOpen]); + + // TODO: its not working + + // const handleClick = useCallback( + // ({ + // event, + // ref, + // }: { + // event: React.MouseEvent; + // ref: React.Ref; + // }) => { + // const target = event?.target; + // console.log("00" + isStylingPOpen); + // + // isStylingPOpen && !styleRef.current?.contains(target as HTMLDivElement) + // ? setIsStylingPOpen(false) + // : console.log("b"); + // + // isFormattingTextOpen && + // !formatRef.current?.contains(target as HTMLDivElement) + // ? setIsFormattingTextOpen(false) + // : setIsFormattingTextOpen(true); + // + // isColoringOpen && !colorRef.current?.contains(target as HTMLDivElement) + // ? setIsColoringOpen(false) + // : setIsColoringOpen(true); + // + // isFontSizeOpen && !fontSizeRef.current?.contains(target as HTMLDivElement) + // ? setIsFontSizeOpen(false) + // : setIsFontSizeOpen(true); + + // if ( + // isStylingPOpen || + // isFormattingTextOpen || + // isColoringOpen || + // isFontSizeOpen + // ) { + // ref === styleRef && + // !styleRef.current?.contains(event.target as HTMLDivElement) + // ? console.log("Im in") + // : setIsStylingPOpen(false); + // ref === formatRef && + // formatRef.current?.contains(event.target as HTMLDivElement) + // ? setIsFormattingTextOpen(true) + // : setIsFormattingTextOpen(false); + // ref === colorRef && + // colorRef.current?.contains(event.target as HTMLDivElement) + // ? setIsColoringOpen(true) + // : setIsColoringOpen(false); + // ref === fontSizeRef && + // fontSizeRef.current?.contains(event.target as HTMLDivElement) + // ? setIsFontSizeOpen(true) + // : setIsFontSizeOpen(false); + // } + // }, + // [], + // ); + + return ( + + + +
setIsStylingPOpen(!isStylingPOpen)}> + Style P ⭣ + + + + + + + +
+ +
setIsFormattingTextOpen(!isFormattingTextOpen)} + > + Format text ⭣ + + + + +
+ +
setIsColoringOpen(!isColoringOpen)}> + + Coloring ⭣ + + + + +
+ +
setIsFontSizeOpen(!isFontSizeOpen)}> + Font Size ⭣ + + + +
+
+ ); +}; + +export default ToolbarPlugin; diff --git a/features/article/components/lexicalComponents/ToolbarPluginOnTheLeft.tsx b/features/article/components/lexicalComponents/ToolbarPluginOnTheLeft.tsx new file mode 100644 index 0000000..3b5915b --- /dev/null +++ b/features/article/components/lexicalComponents/ToolbarPluginOnTheLeft.tsx @@ -0,0 +1,74 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + BtnForLeftToolbar, + Dropdown, + LeftToolbar, + ToolbarItem, +} from "../../../../utils/lexical"; +import { DoOnToolbar, HROnToolbar } from "./OwnLexicalToolbar"; + +const OpenToolbarOnTheLeft = ({ + showLeftMenu, +}: { + showLeftMenu: boolean; +}): JSX.Element => { + const [isInsertingThingsOpen, setIsInsertingThingsOpen] = useState(false); + const insertRef = useRef(null); + const [leftMenuOpen, setLeftMenuOpen] = useState(false); + const leftMenuRef = useRef(null); + + useEffect(() => { + const handler = (event: MouseEvent) => { + const target = event.target; + if ( + isInsertingThingsOpen && + insertRef.current && + !insertRef.current.contains(target as HTMLDivElement) + ) { + setIsInsertingThingsOpen(false); + } else if ( + leftMenuOpen && + leftMenuRef.current && + !leftMenuRef.current.contains(target as HTMLDivElement) + ) { + setLeftMenuOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => { + document.removeEventListener("mousedown", handler); + }; + }, [isInsertingThingsOpen]); + + return ( + <> + {!leftMenuOpen && ( + { + setLeftMenuOpen(!leftMenuOpen); + }} + > + {"< "}Open menu + + )} + {leftMenuOpen && ( + + + +
setIsInsertingThingsOpen(!isInsertingThingsOpen)} + > + Insert things ⬇️ + + + +
+
+ )} + + ); +}; + +export default OpenToolbarOnTheLeft; diff --git a/features/article/components/lexicalComponents/TreeViewPlugin.tsx b/features/article/components/lexicalComponents/TreeViewPlugin.tsx new file mode 100644 index 0000000..d83cfd0 --- /dev/null +++ b/features/article/components/lexicalComponents/TreeViewPlugin.tsx @@ -0,0 +1,18 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import * as React from "react"; +import { StyledTreeView } from "../../../../utils/lexical"; + +export default function TreeViewPlugin(): JSX.Element { + const [editor] = useLexicalComposerContext(); + return ( + + ); +} diff --git a/features/article/components/lexicalComponents/point.ts b/features/article/components/lexicalComponents/point.ts new file mode 100644 index 0000000..e84bf3e --- /dev/null +++ b/features/article/components/lexicalComponents/point.ts @@ -0,0 +1,55 @@ +/** + * 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. + * + */ +export class Point { + private readonly _x: number; + private readonly _y: number; + + constructor(x: number, y: number) { + this._x = x; + this._y = y; + } + + get x(): number { + return this._x; + } + + get y(): number { + return this._y; + } + + public equals({ x, y }: Point): boolean { + return this.x === x && this.y === y; + } + + public calcDeltaXTo({ x }: Point): number { + return this.x - x; + } + + public calcDeltaYTo({ y }: Point): number { + return this.y - y; + } + + public calcHorizontalDistanceTo(point: Point): number { + return Math.abs(this.calcDeltaXTo(point)); + } + + public calcVerticalDistance(point: Point): number { + return Math.abs(this.calcDeltaYTo(point)); + } + + public calcDistanceTo(point: Point): number { + return Math.sqrt( + Math.pow(this.calcDeltaXTo(point), 2) + + Math.pow(this.calcDeltaYTo(point), 2), + ); + } +} + +export function isPoint(x: unknown): x is Point { + return x instanceof Point; +} diff --git a/features/article/components/lexicalComponents/rect.ts b/features/article/components/lexicalComponents/rect.ts new file mode 100644 index 0000000..bccc230 --- /dev/null +++ b/features/article/components/lexicalComponents/rect.ts @@ -0,0 +1,158 @@ +/** + * 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 { isPoint, Point } from "./point"; + +type ContainsPointReturn = { + result: boolean; + reason: { + isOnTopSide: boolean; + isOnBottomSide: boolean; + isOnLeftSide: boolean; + isOnRightSide: boolean; + }; +}; + +export class Rect { + private readonly _left: number; + private readonly _top: number; + private readonly _right: number; + private readonly _bottom: number; + + constructor(left: number, top: number, right: number, bottom: number) { + const [physicTop, physicBottom] = + top <= bottom ? [top, bottom] : [bottom, top]; + + const [physicLeft, physicRight] = + left <= right ? [left, right] : [right, left]; + + this._top = physicTop; + this._right = physicRight; + this._left = physicLeft; + this._bottom = physicBottom; + } + + get top(): number { + return this._top; + } + + get right(): number { + return this._right; + } + + get bottom(): number { + return this._bottom; + } + + get left(): number { + return this._left; + } + + get width(): number { + return Math.abs(this._left - this._right); + } + + get height(): number { + return Math.abs(this._bottom - this._top); + } + + public equals({ top, left, bottom, right }: Rect): boolean { + return ( + top === this._top && + bottom === this._bottom && + left === this._left && + right === this._right + ); + } + + public contains({ x, y }: Point): ContainsPointReturn; + public contains({ top, left, bottom, right }: Rect): boolean; + public contains(target: Point | Rect): boolean | ContainsPointReturn { + if (isPoint(target)) { + const { x, y } = target; + + const isOnTopSide = y < this._top; + const isOnBottomSide = y > this._bottom; + const isOnLeftSide = x < this._left; + const isOnRightSide = x > this._right; + + const result = + !isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide; + + return { + reason: { + isOnBottomSide, + isOnLeftSide, + isOnRightSide, + isOnTopSide, + }, + result, + }; + } else { + const { top, left, bottom, right } = target; + + return ( + top >= this._top && + top <= this._bottom && + bottom >= this._top && + bottom <= this._bottom && + left >= this._left && + left <= this._right && + right >= this._left && + right <= this._right + ); + } + } + + public intersectsWith(rect: Rect): boolean { + const { left: x1, top: y1, width: w1, height: h1 } = rect; + const { left: x2, top: y2, width: w2, height: h2 } = this; + const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2; + const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2; + const minX = x1 <= x2 ? x1 : x2; + const minY = y1 <= y2 ? y1 : y2; + return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2; + } + + public generateNewRect({ + left = this.left, + top = this.top, + right = this.right, + bottom = this.bottom, + }): Rect { + return new Rect(left, top, right, bottom); + } + + static fromLTRB( + left: number, + top: number, + right: number, + bottom: number, + ): Rect { + return new Rect(left, top, right, bottom); + } + + static fromLWTH( + left: number, + width: number, + top: number, + height: number, + ): Rect { + return new Rect(left, top, left + width, top + height); + } + + static fromPoints(startPoint: Point, endPoint: Point): Rect { + const { y: top, x: left } = startPoint; + const { y: bottom, x: right } = endPoint; + return Rect.fromLTRB(left, top, right, bottom); + } + + static fromDOM(dom: HTMLElement): Rect { + const { top, width, left, height } = dom.getBoundingClientRect(); + return Rect.fromLWTH(left, width, top, height); + } +} diff --git a/package-lock.json b/package-lock.json index 90a73f8..c8cf1d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "@codemirror/lang-javascript": "^6.1.2", "@codemirror/state": "^6.1.4", "@codemirror/view": "^6.7.1", + "@lexical/list": "^0.11.3", + "@lexical/react": "^0.11.3", + "@lexical/selection": "^0.11.3", "@popperjs/core": "^2.11.8", "@textea/json-viewer": "^2.12.0", "axios": "^1.2.1", @@ -20,6 +23,7 @@ "cors": "^2.8.5", "css-loader": "^6.7.3", "form-data": "^4.0.0", + "lexical": "^0.11.3", "link-preview-js": "^3.0.4", "next": "^13.0.7", "orderedmap": "^2.1.0", @@ -1169,6 +1173,242 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@lexical/clipboard": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.11.3.tgz", + "integrity": "sha512-6xggT8b0hd4OQy25mBH+yiJsr3Bm8APHjDOd3yINCGeiiHXIC+2qKQn3MG70euxQQuyzq++tYHcSsFq42g8Jyw==", + "dependencies": { + "@lexical/html": "0.11.3", + "@lexical/list": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/code": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.11.3.tgz", + "integrity": "sha512-BIMPd2op65iP4N9SkKIUVodZoWeSsnk6skNJ8UHBO/Rg0ZxyAqxLpnBhEgHq2QOoTBbEW6OEFtkc7/+f9LINZg==", + "dependencies": { + "@lexical/utils": "0.11.3", + "prismjs": "^1.27.0" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/dragon": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.11.3.tgz", + "integrity": "sha512-S18uwqOOpV2yIAFVWqSvBdhZ5BGadPQO4ejZF15wP8LUuqkxCs+0I/MjLovQ7tx0Cx34KdDaOXtM6XeG74ixYw==", + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.11.3.tgz", + "integrity": "sha512-7auoaWp2QhsX9/Bq0SxLXatUaSwqoT9HlWNTH2vKsw8tdeUBYacTHLuBNncTGrznXLG0/B5+FWoLuM6Pzqq4Ig==", + "dependencies": { + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/history": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.11.3.tgz", + "integrity": "sha512-QLJQRH2rbadRwXd4c/U4TqjLWDQna6Q43nCocIZF+SdVG9TlASp7m6dS7hiHfPtV1pkxJUxPhZY6EsB/Ok5WGA==", + "dependencies": { + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/html": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.11.3.tgz", + "integrity": "sha512-+8AYnxxml9PneZLkGfdTenqDjE2yD1ZfCmQLrD/L1TEn22OjZh4uvKVHb13wEhgUZTuLKF0PNdnuecko9ON/aQ==", + "dependencies": { + "@lexical/selection": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/link": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.11.3.tgz", + "integrity": "sha512-stAjIrDrF18dPKK25ExPwMCcMe0KKD0FWVzo3F7ejh9DvrQcLFeBPcs8ze71chS3D5fQDB/CzdwvMjEViKmq2A==", + "dependencies": { + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/list": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.11.3.tgz", + "integrity": "sha512-Cs9071wDfqi4j1VgodceiR1jTHj13eCoEJDhr3e/FW0x5we7vfbTMtWlOWbveIoryAh+rQNgiD5e8SrAm6Zs3g==", + "dependencies": { + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/mark": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.11.3.tgz", + "integrity": "sha512-0wAtufmaA0rMVFXoiJ0sY/tiJsQbHuDpgywb1Qa8qnZZcg7ZTrQMz9Go0fEWYcbSp8OH2o0cjbDTz3ACS1qCUA==", + "dependencies": { + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/markdown": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.11.3.tgz", + "integrity": "sha512-sF8ow32BDme3UvxaKpf+j+vMc4T/XvDEzteZHmvvP7NX/iUtK3yUkTyT7rKuGwiKLYfMBwQaKMGjU3/nlIOzUg==", + "dependencies": { + "@lexical/code": "0.11.3", + "@lexical/link": "0.11.3", + "@lexical/list": "0.11.3", + "@lexical/rich-text": "0.11.3", + "@lexical/text": "0.11.3", + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/offset": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.11.3.tgz", + "integrity": "sha512-3H9X8iqDSk0LrMOHZuqYuqX4EYGb78TIhtjrFbLJi/OgKmHaSeLx59xcMZdgd5kBdRitzQYMmvbRDvbLfMgWrA==", + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/overflow": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.11.3.tgz", + "integrity": "sha512-ShjCG8lICShOBKwrpP+9PjRFKEBCSUUMjbIGZfLnoL//3hyRtGv5aRgRyfJlRgDhCve0ROt5znLJV88EXzGRyA==", + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.11.3.tgz", + "integrity": "sha512-cQ5Us+GNzShyjjgRqWTnYv0rC+jHJ96LvBA1aSieM77H8/Im5BeoLl6TgBK2NqPkp8fGpj8JnDEdT8h9Qh1jtA==", + "peerDependencies": { + "@lexical/clipboard": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/utils": "0.11.3", + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/react": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.11.3.tgz", + "integrity": "sha512-Rn0Agnrz3uLIWbNyS9PRlkxOxcIDl2kxaVfgBacqQtYKR0ZVB2Hnoi89Cq6VmWPovauPyryx4Q3FC8Y11X7Otg==", + "dependencies": { + "@lexical/clipboard": "0.11.3", + "@lexical/code": "0.11.3", + "@lexical/dragon": "0.11.3", + "@lexical/hashtag": "0.11.3", + "@lexical/history": "0.11.3", + "@lexical/link": "0.11.3", + "@lexical/list": "0.11.3", + "@lexical/mark": "0.11.3", + "@lexical/markdown": "0.11.3", + "@lexical/overflow": "0.11.3", + "@lexical/plain-text": "0.11.3", + "@lexical/rich-text": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/table": "0.11.3", + "@lexical/text": "0.11.3", + "@lexical/utils": "0.11.3", + "@lexical/yjs": "0.11.3", + "react-error-boundary": "^3.1.4" + }, + "peerDependencies": { + "lexical": "0.11.3", + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.11.3.tgz", + "integrity": "sha512-fBFs6wMS7GFLbk+mzIWtwpP+EmnTZZ5bHpveuQ5wXONBuUuLcsYF5KO7UhLxXNLmiViV6lxatZPavEzgZdW7oQ==", + "peerDependencies": { + "@lexical/clipboard": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/utils": "0.11.3", + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/selection": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.11.3.tgz", + "integrity": "sha512-15lQpcKT/vd7XZ5pnF1nb+kpKb72e9Yi1dVqieSxTeXkzt1cAZFKP3NB4RlhOKCv1N+glSBnjSxRwgsFfbD+NQ==", + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/table": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.11.3.tgz", + "integrity": "sha512-EyRnN39CSPsMceADBR7Kf+sBHNpNQlPEkn/52epeDSnakR6s80woyrA3kIzKo6mLB4afvoqdYc7RfR96M9JLIA==", + "dependencies": { + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/text": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.11.3.tgz", + "integrity": "sha512-gCEN8lJyR6b+yaOwKWGj79pbOfCQPWU/PHWyoNFUkEJXn3KydCzr2EYb6ta2cvQWRQU4G2BClKCR56jL4NS+qg==", + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-vC4saCrlcmyIJnvrYKw1uYxZojlD1DCIBsFlgmO8kXyRYXjj+o/8PBdn2dsgSQ3rADrC2mUloOm/maekDcYe9Q==", + "dependencies": { + "@lexical/list": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/table": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/yjs": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.11.3.tgz", + "integrity": "sha512-TLDQG2FSEw/aOfppEBb0wRlIuzJ57W//8ImfzyZvckSC12tvU0YKQQX8nQz/rybXdyfRy5eN+8gX5K2EyZx+pQ==", + "dependencies": { + "@lexical/offset": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3", + "yjs": ">=13.5.22" + } + }, "node_modules/@lezer/common": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.2.tgz", @@ -7346,6 +7586,11 @@ "node": ">= 0.8.0" } }, + "node_modules/lexical": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.11.3.tgz", + "integrity": "sha512-xsMKgx/Fa+QHg/nweemU04lCy7TnEr8LyeDtsKUC7fIDN9wH3GqbnQ0+e3Hbg4FmxlhDCiPPt0GcZAROq3R8uw==" + }, "node_modules/lib0": { "version": "0.2.74", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.74.tgz", @@ -13981,6 +14226,21 @@ "react": "^18.2.0" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -17200,6 +17460,180 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "@lexical/clipboard": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.11.3.tgz", + "integrity": "sha512-6xggT8b0hd4OQy25mBH+yiJsr3Bm8APHjDOd3yINCGeiiHXIC+2qKQn3MG70euxQQuyzq++tYHcSsFq42g8Jyw==", + "requires": { + "@lexical/html": "0.11.3", + "@lexical/list": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/utils": "0.11.3" + } + }, + "@lexical/code": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.11.3.tgz", + "integrity": "sha512-BIMPd2op65iP4N9SkKIUVodZoWeSsnk6skNJ8UHBO/Rg0ZxyAqxLpnBhEgHq2QOoTBbEW6OEFtkc7/+f9LINZg==", + "requires": { + "@lexical/utils": "0.11.3", + "prismjs": "^1.27.0" + } + }, + "@lexical/dragon": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.11.3.tgz", + "integrity": "sha512-S18uwqOOpV2yIAFVWqSvBdhZ5BGadPQO4ejZF15wP8LUuqkxCs+0I/MjLovQ7tx0Cx34KdDaOXtM6XeG74ixYw==", + "requires": {} + }, + "@lexical/hashtag": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.11.3.tgz", + "integrity": "sha512-7auoaWp2QhsX9/Bq0SxLXatUaSwqoT9HlWNTH2vKsw8tdeUBYacTHLuBNncTGrznXLG0/B5+FWoLuM6Pzqq4Ig==", + "requires": { + "@lexical/utils": "0.11.3" + } + }, + "@lexical/history": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.11.3.tgz", + "integrity": "sha512-QLJQRH2rbadRwXd4c/U4TqjLWDQna6Q43nCocIZF+SdVG9TlASp7m6dS7hiHfPtV1pkxJUxPhZY6EsB/Ok5WGA==", + "requires": { + "@lexical/utils": "0.11.3" + } + }, + "@lexical/html": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.11.3.tgz", + "integrity": "sha512-+8AYnxxml9PneZLkGfdTenqDjE2yD1ZfCmQLrD/L1TEn22OjZh4uvKVHb13wEhgUZTuLKF0PNdnuecko9ON/aQ==", + "requires": { + "@lexical/selection": "0.11.3" + } + }, + "@lexical/link": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.11.3.tgz", + "integrity": "sha512-stAjIrDrF18dPKK25ExPwMCcMe0KKD0FWVzo3F7ejh9DvrQcLFeBPcs8ze71chS3D5fQDB/CzdwvMjEViKmq2A==", + "requires": { + "@lexical/utils": "0.11.3" + } + }, + "@lexical/list": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.11.3.tgz", + "integrity": "sha512-Cs9071wDfqi4j1VgodceiR1jTHj13eCoEJDhr3e/FW0x5we7vfbTMtWlOWbveIoryAh+rQNgiD5e8SrAm6Zs3g==", + "requires": { + "@lexical/utils": "0.11.3" + } + }, + "@lexical/mark": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.11.3.tgz", + "integrity": "sha512-0wAtufmaA0rMVFXoiJ0sY/tiJsQbHuDpgywb1Qa8qnZZcg7ZTrQMz9Go0fEWYcbSp8OH2o0cjbDTz3ACS1qCUA==", + "requires": { + "@lexical/utils": "0.11.3" + } + }, + "@lexical/markdown": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.11.3.tgz", + "integrity": "sha512-sF8ow32BDme3UvxaKpf+j+vMc4T/XvDEzteZHmvvP7NX/iUtK3yUkTyT7rKuGwiKLYfMBwQaKMGjU3/nlIOzUg==", + "requires": { + "@lexical/code": "0.11.3", + "@lexical/link": "0.11.3", + "@lexical/list": "0.11.3", + "@lexical/rich-text": "0.11.3", + "@lexical/text": "0.11.3", + "@lexical/utils": "0.11.3" + } + }, + "@lexical/offset": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.11.3.tgz", + "integrity": "sha512-3H9X8iqDSk0LrMOHZuqYuqX4EYGb78TIhtjrFbLJi/OgKmHaSeLx59xcMZdgd5kBdRitzQYMmvbRDvbLfMgWrA==", + "requires": {} + }, + "@lexical/overflow": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.11.3.tgz", + "integrity": "sha512-ShjCG8lICShOBKwrpP+9PjRFKEBCSUUMjbIGZfLnoL//3hyRtGv5aRgRyfJlRgDhCve0ROt5znLJV88EXzGRyA==", + "requires": {} + }, + "@lexical/plain-text": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.11.3.tgz", + "integrity": "sha512-cQ5Us+GNzShyjjgRqWTnYv0rC+jHJ96LvBA1aSieM77H8/Im5BeoLl6TgBK2NqPkp8fGpj8JnDEdT8h9Qh1jtA==", + "requires": {} + }, + "@lexical/react": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.11.3.tgz", + "integrity": "sha512-Rn0Agnrz3uLIWbNyS9PRlkxOxcIDl2kxaVfgBacqQtYKR0ZVB2Hnoi89Cq6VmWPovauPyryx4Q3FC8Y11X7Otg==", + "requires": { + "@lexical/clipboard": "0.11.3", + "@lexical/code": "0.11.3", + "@lexical/dragon": "0.11.3", + "@lexical/hashtag": "0.11.3", + "@lexical/history": "0.11.3", + "@lexical/link": "0.11.3", + "@lexical/list": "0.11.3", + "@lexical/mark": "0.11.3", + "@lexical/markdown": "0.11.3", + "@lexical/overflow": "0.11.3", + "@lexical/plain-text": "0.11.3", + "@lexical/rich-text": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/table": "0.11.3", + "@lexical/text": "0.11.3", + "@lexical/utils": "0.11.3", + "@lexical/yjs": "0.11.3", + "react-error-boundary": "^3.1.4" + } + }, + "@lexical/rich-text": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.11.3.tgz", + "integrity": "sha512-fBFs6wMS7GFLbk+mzIWtwpP+EmnTZZ5bHpveuQ5wXONBuUuLcsYF5KO7UhLxXNLmiViV6lxatZPavEzgZdW7oQ==", + "requires": {} + }, + "@lexical/selection": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.11.3.tgz", + "integrity": "sha512-15lQpcKT/vd7XZ5pnF1nb+kpKb72e9Yi1dVqieSxTeXkzt1cAZFKP3NB4RlhOKCv1N+glSBnjSxRwgsFfbD+NQ==", + "requires": {} + }, + "@lexical/table": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.11.3.tgz", + "integrity": "sha512-EyRnN39CSPsMceADBR7Kf+sBHNpNQlPEkn/52epeDSnakR6s80woyrA3kIzKo6mLB4afvoqdYc7RfR96M9JLIA==", + "requires": { + "@lexical/utils": "0.11.3" + } + }, + "@lexical/text": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.11.3.tgz", + "integrity": "sha512-gCEN8lJyR6b+yaOwKWGj79pbOfCQPWU/PHWyoNFUkEJXn3KydCzr2EYb6ta2cvQWRQU4G2BClKCR56jL4NS+qg==", + "requires": {} + }, + "@lexical/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-vC4saCrlcmyIJnvrYKw1uYxZojlD1DCIBsFlgmO8kXyRYXjj+o/8PBdn2dsgSQ3rADrC2mUloOm/maekDcYe9Q==", + "requires": { + "@lexical/list": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/table": "0.11.3" + } + }, + "@lexical/yjs": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.11.3.tgz", + "integrity": "sha512-TLDQG2FSEw/aOfppEBb0wRlIuzJ57W//8ImfzyZvckSC12tvU0YKQQX8nQz/rybXdyfRy5eN+8gX5K2EyZx+pQ==", + "requires": { + "@lexical/offset": "0.11.3" + } + }, "@lezer/common": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.2.tgz", @@ -21741,6 +22175,11 @@ "type-check": "~0.4.0" } }, + "lexical": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.11.3.tgz", + "integrity": "sha512-xsMKgx/Fa+QHg/nweemU04lCy7TnEr8LyeDtsKUC7fIDN9wH3GqbnQ0+e3Hbg4FmxlhDCiPPt0GcZAROq3R8uw==" + }, "lib0": { "version": "0.2.74", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.74.tgz", @@ -26566,6 +27005,14 @@ "scheduler": "^0.23.0" } }, + "react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "requires": { + "@babel/runtime": "^7.12.5" + } + }, "react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", diff --git a/package.json b/package.json index d73c822..c2c6c64 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,9 @@ "@codemirror/lang-javascript": "^6.1.2", "@codemirror/state": "^6.1.4", "@codemirror/view": "^6.7.1", + "@lexical/list": "^0.11.3", + "@lexical/react": "^0.11.3", + "@lexical/selection": "^0.11.3", "@popperjs/core": "^2.11.8", "@textea/json-viewer": "^2.12.0", "axios": "^1.2.1", @@ -63,6 +66,7 @@ "cors": "^2.8.5", "css-loader": "^6.7.3", "form-data": "^4.0.0", + "lexical": "^0.11.3", "link-preview-js": "^3.0.4", "next": "^13.0.7", "orderedmap": "^2.1.0", @@ -99,5 +103,10 @@ "y-prosemirror": "^1.2.1", "y-protocols": "^1.0.5", "yjs": "^13.6.4" + }, + "browser": { + "fs": false, + "os": false, + "path": false } } diff --git a/pages/_document.tsx b/pages/_document.tsx index 579fb95..01e3781 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -2,9 +2,9 @@ import React from "react"; import Document, { DocumentContext, Head, + Html, Main, NextScript, - Html, } from "next/document"; import { ServerStyleSheet } from "styled-components"; @@ -46,6 +46,8 @@ export default class MyDocument extends Document<{ + + + ); } diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx new file mode 100644 index 0000000..20bcb50 --- /dev/null +++ b/pages/blog/implementing-lexical.tsx @@ -0,0 +1,198 @@ +import React, { useRef, useState } from "react"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; +import ArticleWrapper from "../../features/article/components/ArticleWrapper"; + +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { TRANSFORMERS } from "@lexical/markdown"; +import { CodeHighlightNode, CodeNode } from "@lexical/code"; +import { AutoLinkNode, LinkNode } from "@lexical/link"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; +import { HashtagNode } from "@lexical/hashtag"; +import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; +import { + BannerNode, + BannerPlugin, +} from "../../features/article/components/lexicalComponents/Banner"; +import { + JsonButtonContainer, + Placeholder, + StyledContentEditable, +} from "../../utils/lexical"; +import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; +import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin"; +import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; +import { EditorState, LineBreakNode, ParagraphNode } from "lexical"; +import { HashtagPlugin } from "@lexical/react/LexicalHashtagPlugin"; +import ToolbarPlugin from "../../features/article/components/lexicalComponents/ToolbarPlugin"; +import OpenToolbarOnTheLeft from "../../features/article/components/lexicalComponents/ToolbarPluginOnTheLeft"; +import { + LinkPreviewNode, + ResOfWebsite, +} from "../../features/article/components/lexicalComponents/LinkPreviewNode"; +import { LinkPreviewPlugin } from "../../features/article/components/lexicalComponents/LinkPreviewPlugin"; +import TreeViewPlugin from "../../features/article/components/lexicalComponents/TreeViewPlugin"; +import { + AutoLinkPlugin, + createLinkMatcherWithRegExp, +} from "@lexical/react/LexicalAutoLinkPlugin"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { + LoadFromJsonOnToolbar, + SaveToJsonOnToolbar, + CodeHighlightPlugin, +} from "../../features/article/components/lexicalComponents/OwnLexicalToolbar"; +import DraggableBlockPlugin from "../../features/article/components/lexicalComponents/DraggableBlockPlugin"; + +function onError(error: Error): void { + console.error(error); +} + +const myTheme = { + text: { + bold: "textBold", + code: "textCode", + italic: "textItalic", + strikethrough: "textStrikethrough", + subscript: "textSubscript", + superscript: "textSuperscript", + underline: "textUnderline", + underlineStrikethrough: "textUnderlineStrikethrough", + }, + list: { + listitem: "listItem", + listitemChecked: "listItemChecked", + listitemUnchecked: "listItemUnchecked", + }, + quote: "quote", + link: "mylink", + hashtag: "hashtag", + linkPreviewContainer: "linkPreviewContainer", + // previewBox: "previewBox", + // previewImage: "previewImage", + // previewDescription: "previewDescription", + code: "codeBlock", +}; + +interface Props {} + +const thissInitialConfig = { + namespace: "MyEditor", + theme: myTheme, + onError, + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + LinkNode, + BannerNode, + HorizontalRuleNode, + CodeNode, + QuoteNode, + HashtagNode, + CodeHighlightNode, + ParagraphNode, + AutoLinkNode, + LinkPreviewNode, + LineBreakNode, + ], +}; + +async function thisFetchingFunction(link: string): Promise { + const data = await fetch("/api/link-preview", { + method: "POST", + body: JSON.stringify({ + link, + }), + }); + const { + data: { url, title, description, images }, + } = await data.json(); + return { url, title, description, images }; +} + +const Editor = ({}: Props): JSX.Element => { + const [showBtnForLeftMenu, setShowBtnForLeftMenu] = useState(false); + const editorStateRef = useRef(); + const [editorJson, setEditorJson] = useState(""); + const [floatingAnchorElem, setFloatingAnchorElem] = + useState(null); + + const onRef = (_floatingAnchorElem: HTMLDivElement) => { + if (_floatingAnchorElem !== null) { + setFloatingAnchorElem(_floatingAnchorElem); + } + }; + + const URL_REGEX = + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; + + const MATCHERS = [ + createLinkMatcherWithRegExp(URL_REGEX, (text) => { + return text.startsWith("http") ? text : `https://${text}`; + }), + ]; + + return ( + + +
+ + + + {floatingAnchorElem && ( + + )} + + + + + + + + + + + + +
+ setShowBtnForLeftMenu(!showBtnForLeftMenu)} + /> +
+
+ } + placeholder={ 🖇 Paste your link!} + ErrorBoundary={LexicalErrorBoundary} + /> + { + if (editorStateRef) editorStateRef.current = editorState; + }} + /> + + { + if (editorStateRef.current) + setEditorJson(JSON.stringify(editorStateRef.current)); + }} + /> + + + +
+ + + ); +}; + +export default Editor; diff --git a/pages/blog/lexical-link-preview.tsx b/pages/blog/lexical-link-preview.tsx new file mode 100644 index 0000000..97223aa --- /dev/null +++ b/pages/blog/lexical-link-preview.tsx @@ -0,0 +1,128 @@ +import React from "react"; +import styled from "styled-components"; +import dynamic from "next/dynamic"; + +import ArticleWrapper from "../../features/article/components/ArticleWrapper"; +import { ArticleIntro } from "../../features/article/types"; +import Markdown from "../../features/article/components/Markdown"; +import ArticleShareOgTags from "../../features/article/components/ArticleShareOgTags"; +import SalesBox from "../../features/article/components/SalesBox"; +import ArticleHeader from "../../features/article/components/ArticleHeader"; + +const DynamicEditor = dynamic( + () => import("../../articles/lexical-link-preview"), + { ssr: false }, +); + +const EditorStyling = styled.div` + flex: 1; +`; + +export const article17Metadata: ArticleIntro = { + title: "lexical-link-preview-react: Link preview for Lexical", + author: "Kata", + authorLink: null, + introText: /* language=md */ `An open source link preview plugin for lexical made by Emergence-Engineering.`, + postId: "lexical-link-preview", + timestamp: 1691532000000, + imgSrc: "https://lexical.dev/img/logo.svg", + url: "https://emergence-engineering.com/blog/lexical-link-preview", +}; + +const MD0 = /* language=md */ ` + +# Introduction +This easy-to-use plugin for lexical editors enhances the user experience by allowing them to see the page behind the link. It applies the fetched metadata from the website to the preview box and shows you the name, the description and, if available, the image of the page. + + +# Features +- Listens to paste-event, therefore it automatically fetches the metadata from the page +- Uses your callback function to fetch +- Pastes inline URL and inline-block preview +- Can handle displaying both the link and preview, or just the preview +- Uses modern 'bookmark' design +- Accepts custom styling with CSS classnames + +## Go on and try it here: + +`; + +const MD1 = /* language=md */ ` + +# Caveat +Because of CORS, you can't get the link preview directly from the client. You need to have a custom backend that fetches the necessary metadata for the preview. You can either use the paid OpenGraph fetcher API or you can use the \`link-preview-js\` library on your backend. In this article, we will use \`link-preview-js\` with Next.js API to do this. + +# How to use + +**1.** Install the plugin: \`npm i -S lexical-link-preview-react\` \n +**2.** Import the node and the plugin: \`import { LinkPreviewNode, LinkPreviewPlugin } from "lexical-link-preview-react"\` \n +**3.** Add the style to your **theme** in the editor-config: \`linkPreviewContainer: "linkPreviewContainer"\` \n +**4.** Add the node to your **node array** in the editor-config: \`node: [LinkPreviewNode,]\` \n +**5.** Add the plugin to your Editor and set its two properties: **showLink** and **fetchingFunction** \n + +*This is what you will end up with:* + +\`\`\`typescript + import { LinkPreviewNode, LinkPreviewPlugin } from "lexical-link-preview-react" + + const initialconfig = { + namespace: "yourEditor", + theme: {linkPreviewContainer: "linkPreviewContainer"}, + onError, + nodes: [LinkPreviewNode] + } + + const Editor = (): JSX.Element => { + return ( + + + + + } + placeholder={placeholder} + ErrorBoundary={LexicalErrorBoundary} + /> + + ) + } +\`\`\` + +Here we are; now you can paste any valid link into your editor and capture the details of the page: \n +`; +const MD2 = /* language=md */ ` +If the look doesn't work for you, you can easily customize the CSS of the box as it uses classnames. Toggle the 'showLink' property on and off to find the look that best suits your design. +And if you have any questions or want to leave feedback, please feel free to contact us! + +You can find some more info and check out the docs at +`; + +const Article = () => ( + + + + + + + + +
+ {""} +
+ + +
+); +export default Article; diff --git a/public/draggable-block-icon.svg b/public/draggable-block-icon.svg new file mode 100644 index 0000000..7086d29 --- /dev/null +++ b/public/draggable-block-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/global.css b/public/global.css index 1c99af0..ee6d01c 100644 --- a/public/global.css +++ b/public/global.css @@ -10,6 +10,7 @@ h5, h6 { font-family: "Oswald", sans-serif; font-weight: bold; + line-height: 1.2; } .articleWrapper h1 { font-size: 2em; diff --git a/public/lexical-link-preview.gif b/public/lexical-link-preview.gif new file mode 100644 index 0000000..0af62b3 Binary files /dev/null and b/public/lexical-link-preview.gif differ diff --git a/public/lexical.css b/public/lexical.css new file mode 100644 index 0000000..8cd8a0f --- /dev/null +++ b/public/lexical.css @@ -0,0 +1,1615 @@ + +p, span { + line-height: 1.5; +} + + +.linkPreviewContainer { + display: flex; + cursor: pointer; +} + +.linkPreviewContainer:hover { + opacity: 0.8; +} + +.linkPreviewContainer:focus{ + outline: 2px solid rgb(60, 132, 244); + padding: 8px; +} + +.previewBox { + width: 600px; + /*width: 60%;*/ + height: 80px; + display: flex; + flex-direction: row-reverse; + gap: 10px; + justify-content: space-between; + align-items: center; + padding: 5px 0 5px 10px; + background-color: white; + border: 2px solid lightgray; + border-radius: 10px; + margin-top: 10px; + cursor: pointer; +} + +.previewImage { + min-width: 60px; + height: 100%; + border-radius: 8px; + object-fit: contain; + padding-right: 10px; +} + +.previewTextWrapper{ + display: flex; + flex-direction: column; + justify-content: center; + align-items: start; + gap: 5px; + width: 100%; + height: 100%; +} + +.previewTitle { + color: black; + font-size: 12px; + font-weight: 700; +} + +.previewDescription { + color: black; + font-size: 12px; + font-weight: 200; + text-align: start; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.draggable-block-menu { + border-radius: 4px; + padding: 2px 1px; + cursor: grab; + opacity: 0; + position: absolute; + left: 0; + top: 0; + will-change: transform; +} + +.draggable-block-menu .icon { + width: 16px; + height: 16px; + opacity: 0.3; + background-image: url("./draggable-block-icon.svg"); + justify-self: center; + align-self: center; +} + +.draggable-block-menu:active { + cursor: grabbing; +} + +.draggable-block-menu:hover { + background-color: #efefef; +} + +.draggable-block-target-line { + pointer-events: none; + background: deepskyblue; + height: 4px; + position: absolute; + left: 0; + top: 0; + opacity: 0; + will-change: transform; +} + +.loading { + position: absolute; + top: 200px; + left: 0; + width: 100px; + height: 50px; + padding: 10px; + background-color: red; + border: thin solid gray; +} + +/* original below */ +/** + * 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 'https://fonts.googleapis.com/css?family=Reenie+Beanie';*/ +/* Placeholder.css */ +.Placeholder__root { + font-size: 15px; + color: #999; + overflow: hidden; + position: absolute; + text-overflow: ellipsis; + top: 8px; + left: 28px; + right: 28px; + user-select: none; + white-space: nowrap; + display: inline-block; + pointer-events: none; +} +@media (max-width: 1025px) { + .Placeholder__root { + left: 8px; + } +} + +body { + margin: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, '.SFNSText-Regular', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: #eee; + padding: 0 20px; +} + +header { + max-width: 580px; + margin: auto; + position: relative; + display: flex; + justify-content: center; +} + +header a { + max-width: 220px; + margin: 20px 0 0 0; + display: block; +} + +header img { + display: block; + height: 100%; + width: 100%; +} + +header h1 { + text-align: left; + color: #333; + display: inline-block; + margin: 20px 0 0 0; +} + +.editor-shell { + margin: 20px auto; + border-radius: 2px; + max-width: 1100px; + color: #000; + position: relative; + line-height: 1.7; + font-weight: 400; +} + +.editor-shell .editor-container { + background: #fff; + position: relative; + display: block; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; +} + +.editor-shell, +.editor-container.tree-view +{ + border-radius: 20px; +} + +.editor-shell .editor-container.plain-text { + border-top-left-radius: 10px; + border-top-right-radius: 10px; +} + +.editor-scroller { + min-height: 150px; + border: 0; + display: flex; + position: relative; + outline: 0; + z-index: 0; + overflow: auto; + resize: vertical; +} + +.editor { + flex: auto; + position: relative; + resize: vertical; + z-index: -1; +} + +.ContentEditable__root { + font-size: 15px; + display: block; + position: relative; + outline: 0; + + height: max-content; + min-height: 300px; + width: 100%; + padding: 8px 8px 24px 24px; + border: 1px solid #c7c7c7; + border-radius: 0 0 10px 10px; + margin-bottom: 10px; +} + +.ContentEditable__root:focus { + outline: 0; +} + +@media (max-width: 1025px) { + .ContentEditable__root { + padding-left: 8px; + padding-right: 8px; + } +} + +.placeholder { + position: absolute; + top: 56px; + left: 28px; + color: #bbbbbb; +} + +.test-recorder-output { + margin: 20px auto 20px auto; + width: 100%; +} + +div > pre { + line-height: 1.1; + background: #f1f1f1; + color: #000000; + margin: 0; + padding: 10px; + font-size: 12px; + overflow: auto; + max-height: 400px; + border-radius: 10px; +} + +.tree-view-output { + display: block; + background: #f1f1f1; + color: #fff; + padding: 10px; + font-size: 12px; + margin: 1px auto 10px auto; + position: relative; + overflow: hidden; + border-radius: 10px; +} + +pre::-webkit-scrollbar { + background: transparent; + width: 10px; +} + +pre::-webkit-scrollbar-thumb { + background: #999; +} + +.editor-dev-button { + position: relative; + display: block; + width: 40px; + height: 40px; + font-size: 12px; + border-radius: 20px; + border: none; + cursor: pointer; + outline: none; + box-shadow: 0 1px 10px rgba(0, 0, 0, 0.3); + background-color: #444; +} + +.editor-dev-button::after { + content: ''; + position: absolute; + top: 10px; + right: 10px; + bottom: 10px; + left: 10px; + display: block; + background-size: contain; + filter: invert(1); +} + +.editor-dev-button:hover { + background-color: #555; +} + +.editor-dev-button.active { + background-color: rgb(233, 35, 35); +} + +.test-recorder-toolbar { + display: flex; +} + +.test-recorder-button { + position: relative; + display: block; + width: 32px; + height: 32px; + font-size: 10px; + padding: 6px 6px; + border-radius: 4px; + border: none; + cursor: pointer; + outline: none; + box-shadow: 1px 2px 2px rgba(0, 0, 0, 0.4); + background-color: #222; + transition: box-shadow 50ms ease-out; +} + +.test-recorder-button:active { + box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.4); +} + +.test-recorder-button + .test-recorder-button { + margin-left: 4px; +} + +.test-recorder-button::after { + content: ''; + position: absolute; + top: 8px; + right: 8px; + bottom: 8px; + left: 8px; + display: block; + background-size: contain; + filter: invert(1); +} + +#options-button { + position: fixed; + left: 20px; + bottom: 20px; +} + +#test-recorder-button { + position: fixed; + left: 70px; + bottom: 20px; +} + +#paste-log-button { + position: fixed; + left: 120px; + bottom: 20px; +} + +#docs-button { + position: fixed; + left: 170px; + bottom: 20px; +} + + +#test-recorder-button-snapshot { + margin-right: auto; +} + + +.typeahead-popover { + background: #fff; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3); + border-radius: 8px; + margin-top: 25px; +} + +.typeahead-popover ul { + padding: 0; + list-style: none; + margin: 0; + border-radius: 8px; + max-height: 200px; + overflow-y: scroll; +} + +.typeahead-popover ul::-webkit-scrollbar { + display: none; +} + +.typeahead-popover ul { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.typeahead-popover ul li { + margin: 0; + min-width: 180px; + font-size: 14px; + outline: none; + cursor: pointer; + border-radius: 8px; +} + +.typeahead-popover ul li.selected { + background: #eee; +} + +.typeahead-popover li { + margin: 0 8px 0 8px; + padding: 8px; + color: #050505; + cursor: pointer; + line-height: 16px; + font-size: 15px; + display: flex; + align-content: center; + flex-direction: row; + flex-shrink: 0; + background-color: #fff; + border-radius: 8px; + border: 0; +} + +.typeahead-popover li.active { + display: flex; + width: 20px; + height: 20px; + background-size: contain; +} + +.typeahead-popover li:first-child { + border-radius: 8px 8px 0 0; +} + +.typeahead-popover li:last-child { + border-radius: 0 0 8px 8px; +} + +.typeahead-popover li:hover { + background-color: #eee; +} + +.typeahead-popover li .text { + display: flex; + line-height: 20px; + flex-grow: 1; + min-width: 150px; +} + +.typeahead-popover li .icon { + display: flex; + width: 20px; + height: 20px; + user-select: none; + margin-right: 8px; + line-height: 16px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +.component-picker-menu { + width: 200px; +} + +.mentions-menu { + width: 250px; +} + +.auto-embed-menu { + width: 150px; +} + +.emoji-menu { + width: 200px; +} + + +.icon.table { + background-color: #6c757d; + + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-size: contain; +} + + + +.link-editor .button.active, +.toolbar .button.active { + background-color: rgb(223, 232, 250); +} + +.link-editor .link-input { + display: block; + width: calc(100% - 75px); + box-sizing: border-box; + margin: 12px 12px; + padding: 8px 12px; + border-radius: 15px; + background-color: #eee; + font-size: 15px; + color: rgb(5, 5, 5); + border: 0; + outline: 0; + position: relative; + font-family: inherit; +} + +.link-editor .link-view { + display: block; + width: calc(100% - 24px); + margin: 8px 12px; + padding: 8px 12px; + border-radius: 15px; + font-size: 15px; + color: rgb(5, 5, 5); + border: 0; + outline: 0; + position: relative; + font-family: inherit; +} + +.link-editor .link-view a { + display: block; + word-break: break-word; + width: calc(100% - 33px); +} + +.link-editor div.link-edit { + background-size: 16px; + background-position: center; + background-repeat: no-repeat; + width: 35px; + vertical-align: -0.25em; + position: absolute; + right: 30px; + top: 0; + bottom: 0; + cursor: pointer; +} + +.link-editor div.link-trash { + background-size: 16px; + background-position: center; + background-repeat: no-repeat; + width: 35px; + vertical-align: -0.25em; + position: absolute; + right: 0; + top: 0; + bottom: 0; + cursor: pointer; +} + +.link-editor div.link-cancel { + background-size: 16px; + background-position: center; + background-repeat: no-repeat; + width: 35px; + vertical-align: -0.25em; + margin-right: 28px; + position: absolute; + right: 0; + top: 0; + bottom: 0; + cursor: pointer; +} + +.link-editor div.link-confirm { + background-size: 16px; + background-position: center; + background-repeat: no-repeat; + width: 35px; + vertical-align: -0.25em; + margin-right: 2px; + position: absolute; + right: 0; + top: 0; + bottom: 0; + cursor: pointer; +} + +.link-editor .link-input a { + color: rgb(33, 111, 219); + text-decoration: underline; + white-space: nowrap; + overflow: hidden; + margin-right: 30px; + text-overflow: ellipsis; +} + +.link-editor .link-input a:hover { + text-decoration: underline; +} + +.link-editor .font-size-wrapper, +.link-editor .font-family-wrapper { + display: flex; + margin: 0 4px; +} + +.link-editor select { + padding: 6px; + border: none; + background-color: rgba(0, 0, 0, 0.075); + border-radius: 4px; +} + +.mention:focus { + box-shadow: rgb(180 213 255) 0 0 0 2px; + outline: none; +} + +.characters-limit { + color: #888; + font-size: 12px; + text-align: right; + display: block; + position: absolute; + left: 12px; + bottom: 5px; +} + +.characters-limit.characters-limit-exceeded { + color: red; +} + +.dropdown { + z-index: 10; + display: block; + position: fixed; + box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1), + inset 0 0 0 1px rgba(255, 255, 255, 0.5); + border-radius: 8px; + min-height: 40px; + background-color: #fff; +} + +.dropdown .item { + margin: 0 8px 0 8px; + padding: 8px; + color: #050505; + cursor: pointer; + line-height: 16px; + font-size: 15px; + display: flex; + align-content: center; + flex-direction: row; + flex-shrink: 0; + justify-content: space-between; + background-color: #fff; + border-radius: 8px; + border: 0; + max-width: 250px; + min-width: 100px; +} + +.dropdown .item.fontsize-item, +.dropdown .item.fontsize-item .text { + min-width: unset; +} + +.dropdown .item .active { + display: flex; + width: 20px; + height: 20px; + background-size: contain; +} + +.dropdown .item:first-child { + margin-top: 8px; +} + +.dropdown .item:last-child { + margin-bottom: 8px; +} + +.dropdown .item:hover { + background-color: #eee; +} + +.dropdown .item .text { + display: flex; + line-height: 20px; + flex-grow: 1; + min-width: 150px; +} + +.dropdown .item .icon { + display: flex; + width: 20px; + height: 20px; + user-select: none; + margin-right: 12px; + line-height: 16px; + background-size: contain; + background-position: center; + background-repeat: no-repeat; +} + +.dropdown .divider { + width: auto; + background-color: #eee; + margin: 4px 8px; + height: 1px; +} + +@media screen and (max-width: 1100px) { + .dropdown-button-text { + display: none !important; + } + + .font-size .dropdown-button-text { + display: flex !important; + } + + .code-language .dropdown-button-text { + display: flex !important; + } +} + + +.switches { + z-index: 6; + position: fixed; + left: 10px; + bottom: 70px; + animation: slide-in 0.4s ease; +} + +@keyframes slide-in { + 0% { + opacity: 0; + transform: translateX(-200px); + } + + 100% { + opacity: 1; + transform: translateX(0); + } +} + +.switch { + display: block; + color: #444; + margin: 5px 0; + background-color: rgba(238, 238, 238, 0.7); + padding: 5px 10px; + border-radius: 10px; +} + +#rich-text-switch { + right: 0; +} + +#character-count-switch { + right: 130px; +} + +.switch label { + margin-right: 5px; + line-height: 24px; + width: 100px; + font-size: 14px; + display: inline-block; + vertical-align: middle; +} + +.switch button { + background-color: rgb(206, 208, 212); + height: 24px; + box-sizing: border-box; + border-radius: 12px; + width: 44px; + display: inline-block; + vertical-align: middle; + position: relative; + outline: none; + cursor: pointer; + transition: background-color 0.1s; + border: 2px solid transparent; +} + +.switch button:focus-visible { + border-color: blue; +} + +.switch button span { + top: 0; + left: 0; + display: block; + position: absolute; + width: 20px; + height: 20px; + border-radius: 12px; + background-color: white; + transition: transform 0.2s; +} + +.switch button[aria-checked='true'] { + background-color: rgb(24, 119, 242); +} + +.switch button[aria-checked='true'] span { + transform: translateX(20px); +} + +.editor-shell span.editor-image { + cursor: default; + display: inline-block; + position: relative; + user-select: none; +} + +.editor-shell .editor-image img { + max-width: 100%; + cursor: default; +} + +.editor-shell .editor-image img.focused { + outline: 2px solid rgb(60, 132, 244); + user-select: none; +} + +.editor-shell .editor-image img.focused.draggable { + cursor: grab; +} + +.editor-shell .editor-image img.focused.draggable:active { + cursor: grabbing; +} + +.editor-shell .editor-image .image-caption-container .tree-view-output +{ + margin: 0; + border-radius: 0; +} + +.editor-shell .editor-image .image-caption-container { + display: block; + position: absolute; + bottom: 4px; + left: 0; + right: 0; + padding: 0; + margin: 0; + border-top: 1px solid #fff; + background-color: rgba(255, 255, 255, 0.9); + min-width: 100px; + color: #000; + overflow: hidden; +} + +.editor-shell .editor-image .image-caption-button { + display: block; + position: absolute; + bottom: 20px; + left: 0; + right: 0; + width: 30%; + padding: 10px; + margin: 0 auto; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 5px; + background-color: rgba(0, 0, 0, 0.5); + min-width: 100px; + color: #fff; + cursor: pointer; + user-select: none; +} + +.editor-shell .editor-image .image-caption-button:hover { + background-color: rgba(60, 132, 244, 0.5); +} + +.editor-shell .editor-image .image-edit-button { + border: 1px solid rgba(0, 0, 0, 0.3); + border-radius: 5px; + background-size: 16px; + background-position: center; + background-repeat: no-repeat; + width: 35px; + height: 35px; + vertical-align: -0.25em; + position: absolute; + right: 4px; + top: 4px; + cursor: pointer; + user-select: none; +} + +.editor-shell .editor-image .image-edit-button:hover { + background-color: rgba(60, 132, 244, 0.1); +} + +.editor-shell .editor-image .image-resizer { + display: block; + width: 7px; + height: 7px; + position: absolute; + background-color: rgb(60, 132, 244); + border: 1px solid #fff; +} + +.editor-shell .editor-image .image-resizer.image-resizer-n { + top: -6px; + left: 48%; + cursor: n-resize; +} + +.editor-shell .editor-image .image-resizer.image-resizer-ne { + top: -6px; + right: -6px; + cursor: ne-resize; +} + +.editor-shell .editor-image .image-resizer.image-resizer-e { + bottom: 48%; + right: -6px; + cursor: e-resize; +} + +.editor-shell .editor-image .image-resizer.image-resizer-se { + bottom: -2px; + right: -6px; + cursor: nwse-resize; +} + +.editor-shell .editor-image .image-resizer.image-resizer-s { + bottom: -2px; + left: 48%; + cursor: s-resize; +} + +.editor-shell .editor-image .image-resizer.image-resizer-sw { + bottom: -2px; + left: -6px; + cursor: sw-resize; +} + +.editor-shell .editor-image .image-resizer.image-resizer-w { + bottom: 48%; + left: -6px; + cursor: w-resize; +} + +.editor-shell .editor-image .image-resizer.image-resizer-nw { + top: -6px; + left: -6px; + cursor: nw-resize; +} + +.editor-shell span.inline-editor-image { + cursor: default; + display: inline-block; + position: relative; + z-index: 1; +} + +.editor-shell .inline-editor-image img { + max-width: 100%; + cursor: default; +} + +.editor-shell .inline-editor-image img.focused { + outline: 2px solid rgb(60, 132, 244); +} + +.editor-shell .inline-editor-image img.focused.draggable { + cursor: grab; +} + +.editor-shell .inline-editor-image img.focused.draggable:active { + cursor: grabbing; +} + +.editor-shell .inline-editor-image .image-caption-container .tree-view-output +{ + margin: 0; + border-radius: 0; +} + +.editor-shell .inline-editor-image.position-full { + margin: 1em 0 1em 0; +} + +.editor-shell .inline-editor-image.position-left { + float: left; + width: 50%; + margin: 1em 1em 0 0; +} + +.editor-shell .inline-editor-image.position-right { + float: right; + width: 50%; + margin: 1em 0 0 1em; +} + +.editor-shell .inline-editor-image .image-edit-button { + display: block; + position: absolute; + top: 12px; + right: 12px; + padding: 6px 8px; + margin: 0 auto; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 5px; + background-color: rgba(0, 0, 0, 0.5); + min-width: 60px; + color: #fff; + cursor: pointer; + user-select: none; +} + +.editor-shell .inline-editor-image .image-edit-button:hover { + background-color: rgba(60, 132, 244, 0.5); +} + +.editor-shell .inline-editor-image .image-caption-container { + display: block; + background-color: #f4f4f4; + min-width: 100%; + color: #000; + overflow: hidden; +} + +.emoji { + color: transparent; + caret-color: rgb(5, 5, 5); + background-size: 16px 16px; + background-position: center; + background-repeat: no-repeat; + vertical-align: middle; + margin: 0 -1px; +} + +.emoji-inner { + padding: 0 0.15em; +} + +.emoji-inner::selection { + color: transparent; + background-color: rgba(150, 150, 150, 0.4); +} + + + +.keyword { + color: rgb(241, 118, 94); + font-weight: bold; +} + +.actions { + position: absolute; + text-align: right; + margin: 10px; + bottom: 0; + right: 0; +} + +.actions.tree-view { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.actions i { + background-size: contain; + display: inline-block; + height: 15px; + width: 15px; + vertical-align: -0.25em; +} + + + +.table-cell-action-button-container { + position: absolute; + top: 0; + left: 0; + will-change: transform; +} + +.table-cell-action-button { + /*background-color: none;*/ + display: flex; + justify-content: center; + align-items: center; + border: 0; + position: relative; + border-radius: 15px; + color: #222; + display: inline-block; + cursor: pointer; +} + +i.chevron-down { + background-color: transparent; + background-size: contain; + display: inline-block; + height: 8px; + width: 8px; +} + +.action-button { + background-color: #eee; + border: 0; + padding: 8px 12px; + position: relative; + margin-left: 5px; + border-radius: 15px; + color: #222; + display: inline-block; + cursor: pointer; +} + +.action-button:hover { + background-color: #ddd; + color: #000; +} + +.action-button-mic.active { + animation: mic-pulsate-color 3s infinite; +} + +button.action-button:disabled { + opacity: 0.6; + background: #eee; + cursor: not-allowed; +} + +@keyframes mic-pulsate-color { + 0% { + background-color: #ffdcdc; + } + + 50% { + background-color: #ff8585; + } + + 100% { + background-color: #ffdcdc; + } +} + +.debug-timetravel-panel { + overflow: hidden; + padding: 0 0 10px 0; + margin: auto; + display: flex; +} + +.debug-timetravel-panel-slider { + padding: 0; + flex: 8; +} + +.debug-timetravel-panel-button { + padding: 0; + border: 0; + background: none; + flex: 1; + color: #fff; + font-size: 12px; +} + +.debug-timetravel-panel-button:hover { + text-decoration: underline; +} + +.debug-timetravel-button { + border: 0; + padding: 0; + font-size: 12px; + top: 2px; + right: 250px; + position: absolute; + background: none; + color: #000000; +} + +.debug-timetravel-button:hover { + text-decoration: underline; +} + +.debug-treetype-button { + border: 0; + padding: 0; + font-size: 12px; + top: 2px; + right: 150px; + position: absolute; + background: none; + color: #000000; +} + +.debug-treetype-button:hover { + text-decoration: underline; +} + +.connecting { + font-size: 15px; + color: #999; + overflow: hidden; + position: absolute; + text-overflow: ellipsis; + top: 10px; + left: 10px; + user-select: none; + white-space: nowrap; + display: inline-block; + pointer-events: none; +} + +.ltr { + text-align: left; +} + +.rtl { + text-align: right; +} + +.toolbar { + display: flex; + margin-bottom: 1px; + background: #fff; + padding: 4px; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + vertical-align: middle; + overflow: auto; + height: 36px; + position: sticky; + top: 0; + z-index: 2; +} + +.toolbar button.toolbar-item { + border: 0; + display: flex; + background: none; + border-radius: 10px; + padding: 8px; + cursor: pointer; + vertical-align: middle; + flex-shrink: 0; + align-items: center; + justify-content: space-between; +} + +.toolbar button.toolbar-item:disabled { + cursor: not-allowed; +} + +.toolbar button.toolbar-item.spaced { + margin-right: 2px; +} + +.toolbar button.toolbar-item i.format { + background-size: contain; + display: inline-block; + height: 18px; + width: 18px; + vertical-align: -0.25em; + display: flex; + opacity: 0.6; +} + +.toolbar button.toolbar-item:disabled .icon, +.toolbar button.toolbar-item:disabled .text, +.toolbar button.toolbar-item:disabled i.format, +.toolbar button.toolbar-item:disabled .chevron-down { + opacity: 0.2; +} + +.toolbar button.toolbar-item.active { + background-color: rgba(223, 232, 250, 0.3); +} + +.toolbar button.toolbar-item.active i { + opacity: 1; +} + +.toolbar .toolbar-item:hover:not([disabled]) { + background-color: #eee; +} + +.toolbar .toolbar-item.font-family .text { + display: block; + max-width: 40px; +} + +.toolbar .code-language { + width: 150px; +} + +.toolbar .toolbar-item .text { + display: flex; + line-height: 20px; + vertical-align: middle; + font-size: 14px; + color: #777; + text-overflow: ellipsis; + overflow: hidden; + height: 20px; + text-align: left; + padding-right: 10px; +} + +.toolbar .toolbar-item .icon { + display: flex; + width: 20px; + height: 20px; + user-select: none; + margin-right: 8px; + line-height: 16px; + background-size: contain; +} + +.toolbar i.chevron-down { + margin-top: 3px; + width: 16px; + height: 16px; + display: flex; + user-select: none; +} + +.toolbar i.chevron-down.inside { + width: 16px; + height: 16px; + display: flex; + margin-left: -25px; + margin-top: 11px; + margin-right: 10px; + pointer-events: none; +} + +.toolbar .divider { + width: 1px; + background-color: #eee; + margin: 0 4px; +} + +.sticky-note-container { + position: absolute; + z-index: 9; + width: 120px; + display: inline-block; +} + +.sticky-note { + line-height: 1; + text-align: left; + width: 120px; + margin: 25px; + padding: 20px 10px; + position: relative; + border: 1px solid #e8e8e8; + /*font-family: 'Reenie Beanie';*/ + font-size: 24px; + border-bottom-right-radius: 60px 5px; + display: block; + cursor: move; +} + +.sticky-note:after { + content: ''; + position: absolute; + z-index: -1; + right: -0px; + bottom: 20px; + width: 120px; + height: 25px; + background: rgba(0, 0, 0, 0.2); + box-shadow: 2px 15px 5px rgba(0, 0, 0, 0.4); + transform: matrix(-1, -0.1, 0, 1, 0, 0); +} + +.sticky-note.yellow { + border-top: 1px solid #fdfd86; + background: linear-gradient( + 135deg, + #ffff88 81%, + #ffff88 82%, + #ffff88 82%, + #ffffc6 100% + ); +} + +.sticky-note.pink { + border-top: 1px solid #e7d1e4; + background: linear-gradient( + 135deg, + #f7cbe8 81%, + #f7cbe8 82%, + #f7cbe8 82%, + #e7bfe1 100% + ); +} + +.sticky-note-container.dragging { + transition: none !important; +} + +.sticky-note div { + cursor: text; +} + +.sticky-note .delete { + border: 0; + background: none; + position: absolute; + top: 8px; + right: 10px; + font-size: 10px; + cursor: pointer; + opacity: 0.5; +} + +.sticky-note .delete:hover { + font-weight: bold; + opacity: 1; +} + +.sticky-note .color { + border: 0; + background: none; + position: absolute; + top: 8px; + right: 25px; + cursor: pointer; + opacity: 0.5; +} + +.sticky-note .color:hover { + opacity: 1; +} + +.sticky-note .color i { + display: block; + width: 12px; + height: 12px; + background-size: contain; +} + +.excalidraw-button { + border: 0; + padding: 0; + margin: 0; + background-color: transparent; +} + +.excalidraw-button.selected { + outline: 2px solid rgb(60, 132, 244); + user-select: none; +} + +.github-corner:hover .octo-arm { + animation: octocat-wave 560ms ease-in-out; +} + +@keyframes octocat-wave { + 0%, + 100% { + transform: rotate(0); + } + + 20%, + 60% { + transform: rotate(-25deg); + } + + 40%, + 80% { + transform: rotate(10deg); + } +} + +@media (max-width: 500px) { + .github-corner:hover .octo-arm { + animation: none; + } + + .github-corner .octo-arm { + animation: octocat-wave 560ms ease-in-out; + } +} + +.spacer { + letter-spacing: -2px; +} + +.editor-equation { + cursor: default; + user-select: none; +} + +.editor-equation.focused { + outline: 2px solid rgb(60, 132, 244); +} + +button.item i { + opacity: 0.6; +} + +button.item.dropdown-item-active { + background-color: rgba(223, 232, 250, 0.3); +} + +button.item.dropdown-item-active i { + opacity: 1; +} + +hr { + padding: 2px 2px; + border: none; + margin: 1em 0; + cursor: pointer; +} + +hr:after { + content: ''; + display: block; + height: 2px; + background-color: #ccc; + line-height: 2px; +} + +hr.selected { + outline: 2px solid rgb(60, 132, 244); + user-select: none; +} + +.TableNode__contentEditable { + min-height: 20px; + border: 0; + resize: none; + cursor: text; + display: block; + position: relative; + outline: 0; + padding: 0; + user-select: text; + font-size: 15px; + white-space: pre-wrap; + word-break: break-word; + z-index: 3; +} + +.PlaygroundEditorTheme__blockCursor { + display: block; + pointer-events: none; + position: absolute; +} + +.PlaygroundEditorTheme__blockCursor:after { + content: ''; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid black; + animation: CursorBlink 1.1s steps(2, start) infinite; +} + +@keyframes CursorBlink { + to { + visibility: hidden; + } +} + diff --git a/public/linkprev.jpg b/public/linkprev.jpg new file mode 100644 index 0000000..fb6feb6 Binary files /dev/null and b/public/linkprev.jpg differ diff --git a/utils/lexical.ts b/utils/lexical.ts new file mode 100644 index 0000000..41bea03 --- /dev/null +++ b/utils/lexical.ts @@ -0,0 +1,132 @@ +import styled from "styled-components"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { TreeView } from "@lexical/react/LexicalTreeView"; + +export const StyledContentEditable = styled(ContentEditable)` + height: max-content; + min-height: 300px; + width: 100%; + padding: 8px 8px 24px 24px; + border: 1px solid #c7c7c7; + + border-radius: 0 0 10px 10px; + margin-bottom: 10px; + position: relative; + + :focus { + outline: none; + } +`; + +export const Placeholder = styled.div` + position: absolute; + top: 56px; + left: 28px; + color: #bbbbbb; +`; + +export const StyledTreeView = styled(TreeView)` + line-height: 1.1; + background: #f1f1f1; + color: #000000; + margin: 0; + padding: 10px; + font-size: 12px; + overflow: auto; + max-height: 400px; + border-radius: 10px; +`; + +export const Toolbar = styled.div` + display: flex; + gap: 5px; + align-items: center; + padding: 3px 6px; + border: 1px solid #c7c7c7; + + border-radius: 10px 10px 0 0; + margin-bottom: 10px; +`; + +export const ToolbarItem = styled.button` + padding: 3px 6px; + border: none; + background: none; + cursor: pointer; + color: black; + align-items: baseline; + + min-width: 50px; + font-size: 14px; + + :hover { + background: #d1e3ff; + border-radius: 5px; + } +`; + +export const BtnForLeftToolbar = styled(ToolbarItem)<{ showLeftMenu: boolean }>` + display: ${({ showLeftMenu }) => (showLeftMenu ? "flex" : "none")}; + position: absolute; + top: 55px; + left: -110px; +`; + +export const LeftToolbar = styled(Toolbar)<{ show: boolean }>` + flex-direction: column; + border: 1px solid #c7c7c7; + border-radius: 10px 0 0 10px; + padding: 8px; + display: ${({ show }) => (show ? "flex" : "none")}; + + position: fixed; + top: 180px; + left: 20px; +`; + +export const Dropdown = styled.div<{ isOpen: boolean; id: string }>` + display: ${({ isOpen }) => (isOpen ? "flex" : "none")}; + flex-direction: column; + align-items: center; + gap: 5px; + padding: 3px 6px; + border-radius: 10px; + border: 2px solid #c7c7c7; + background-color: #f8f8f8; + + z-index: 10; + width: 100px; + justify-content: center; + text-align: center; + + position: absolute; + top: ${({ id }) => (id === "i" ? "110px" : "35px")}; + left: ${({ id }) => { + switch (id) { + case "s": + return "80px"; + case "f": + return "160px"; + case "c": + return "270px"; + case "i": + return "10px"; + case "fs": + return "360px"; + case "cb": + return "455px"; + default: + return 0; + } + }}; +`; + +export const JsonButtonContainer = styled.div` + display: flex; + justify-content: flex-end; + align-items: center; + gap: 5px; + right: 5px; + position: absolute; + z-index: 1000; +`;