diff --git a/api/resolvers/referrals.js b/api/resolvers/referrals.js
index 55ec2ad1..ab5b8d66 100644
--- a/api/resolvers/referrals.js
+++ b/api/resolvers/referrals.js
@@ -43,8 +43,6 @@ export default {
GROUP BY time
ORDER BY time ASC`, Number(me.id))
- console.log(totalSats)
-
return {
totalSats,
totalReferrals,
diff --git a/components/footer.js b/components/footer.js
index f3cb2377..d69f01fe 100644
--- a/components/footer.js
+++ b/components/footer.js
@@ -35,6 +35,11 @@ const COLORS = {
brandColor: 'rgba(0, 0, 0, 0.9)',
grey: '#707070',
link: '#007cbe',
+ toolbarActive: 'rgba(0, 0, 0, 0.10)',
+ toolbarHover: 'rgba(0, 0, 0, 0.20)',
+ toolbar: '#ffffff',
+ quoteBar: 'rgb(206, 208, 212)',
+ quoteColor: 'rgb(101, 103, 107)',
linkHover: '#004a72',
linkVisited: '#537587'
},
@@ -54,6 +59,11 @@ const COLORS = {
brandColor: 'var(--primary)',
grey: '#969696',
link: '#2e99d1',
+ toolbarActive: 'rgba(255, 255, 255, 0.10)',
+ toolbarHover: 'rgba(255, 255, 255, 0.20)',
+ toolbar: '#3e3f3f',
+ quoteBar: 'rgb(158, 159, 163)',
+ quoteColor: 'rgb(141, 144, 150)',
linkHover: '#007cbe',
linkVisited: '#56798E'
}
diff --git a/components/link-form.js b/components/link-form.js
index 4c1db4ff..e60b6a67 100644
--- a/components/link-form.js
+++ b/components/link-form.js
@@ -8,11 +8,9 @@ import { ITEM_FIELDS } from '../fragments/items'
import Item from './item'
import AccordianItem from './accordian-item'
import { MAX_TITLE_LENGTH } from '../lib/constants'
+import { URL_REGEXP } from '../lib/url'
import FeeButton, { EditFeeButton } from './fee-button'
-// eslint-disable-next-line
-const URL = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
-
export function LinkForm ({ item, editThreshold }) {
const router = useRouter()
const client = useApolloClient()
@@ -46,7 +44,7 @@ export function LinkForm ({ item, editThreshold }) {
title: Yup.string().required('required').trim()
.max(MAX_TITLE_LENGTH,
({ max, value }) => `${Math.abs(max - value.length)} too many`),
- url: Yup.string().matches(URL, 'invalid url').required('required'),
+ url: Yup.string().matches(URL_REGEXP, 'invalid url').required('required'),
...AdvPostSchema(client)
})
diff --git a/components/text.js b/components/text.js
index 77fb035c..4cb6ed28 100644
--- a/components/text.js
+++ b/components/text.js
@@ -133,7 +133,7 @@ export default function Text ({ topLevel, noFragments, nofollow, children }) {
)
}
-function ZoomableImage ({ src, topLevel, ...props }) {
+export function ZoomableImage ({ src, topLevel, ...props }) {
if (!src) {
return null
}
diff --git a/components/text.module.css b/components/text.module.css
index bf5cdd5d..6d2e0947 100644
--- a/components/text.module.css
+++ b/components/text.module.css
@@ -59,13 +59,18 @@
margin-bottom: 0 !important;
}
-.text blockquote>*:last-child {
+.text blockquote>* {
margin-bottom: 0 !important;
}
+.text blockquote:has(+ :not(blockquote)) {
+ margin-bottom: .5rem;
+}
+
.text img {
display: block;
margin-top: .5rem;
+ margin-bottom: .5rem;
border-radius: .4rem;
width: auto;
max-width: 100%;
@@ -81,9 +86,19 @@
}
.text blockquote {
- border-left: 2px solid var(--theme-grey);
+ border-left: 4px solid var(--theme-quoteBar);
padding-left: 1rem;
- margin: 0 0 0.5rem 0.5rem !important;
+ margin-left: 1.25rem;
+ margin-bottom: 0;
+}
+
+.text ul {
+ margin-bottom: 0;
+}
+
+.text li {
+ margin-top: .5rem;
+ margin-bottom: .5rem;
}
.text h1 {
diff --git a/lexical/nodes/image.js b/lexical/nodes/image.js
new file mode 100644
index 00000000..03fbf67e
--- /dev/null
+++ b/lexical/nodes/image.js
@@ -0,0 +1,461 @@
+import {
+ $applyNodeReplacement,
+ $getNodeByKey,
+ $getSelection,
+ $isNodeSelection,
+ $setSelection,
+ CLICK_COMMAND, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, createEditor, DecoratorNode,
+ DRAGSTART_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND,
+ KEY_ESCAPE_COMMAND, SELECTION_CHANGE_COMMAND
+} from 'lexical'
+import { useRef, Suspense, useEffect, useCallback } from 'react'
+import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { mergeRegister } from '@lexical/utils'
+
+const imageCache = new Set()
+
+function useSuspenseImage (src) {
+ if (!imageCache.has(src)) {
+ throw new Promise((resolve) => {
+ const img = new Image()
+ img.src = src
+ img.onload = () => {
+ imageCache.add(src)
+ resolve(null)
+ }
+ })
+ }
+}
+
+function LazyImage ({
+ altText,
+ className,
+ imageRef,
+ src,
+ width,
+ height,
+ maxWidth
+}) {
+ useSuspenseImage(src)
+ return (
+
+ )
+}
+
+function convertImageElement (domNode) {
+ if (domNode instanceof HTMLImageElement) {
+ const { alt: altText, src } = domNode
+ const node = $createImageNode({ altText, src })
+ return { node }
+ }
+ return null
+}
+
+export class ImageNode extends DecoratorNode {
+ __src;
+ __altText;
+ __width;
+ __height;
+ __maxWidth;
+ __showCaption;
+ __caption;
+ // Captions cannot yet be used within editor cells
+ __captionsEnabled;
+
+ static getType () {
+ return 'image'
+ }
+
+ static clone (node) {
+ return new ImageNode(
+ node.__src,
+ node.__altText,
+ node.__maxWidth,
+ node.__width,
+ node.__height,
+ node.__showCaption,
+ node.__caption,
+ node.__captionsEnabled,
+ node.__key
+ )
+ }
+
+ static importJSON (serializedNode) {
+ const { altText, height, width, maxWidth, caption, src, showCaption } =
+ serializedNode
+ const node = $createImageNode({
+ altText,
+ height,
+ maxWidth,
+ showCaption,
+ src,
+ width
+ })
+ const nestedEditor = node.__caption
+ const editorState = nestedEditor.parseEditorState(caption.editorState)
+ if (!editorState.isEmpty()) {
+ nestedEditor.setEditorState(editorState)
+ }
+ return node
+ }
+
+ exportDOM () {
+ const element = document.createElement('img')
+ element.setAttribute('src', this.__src)
+ element.setAttribute('alt', this.__altText)
+ return { element }
+ }
+
+ static importDOM () {
+ return {
+ img: (node) => ({
+ conversion: convertImageElement,
+ priority: 0
+ })
+ }
+ }
+
+ constructor (
+ src,
+ altText,
+ maxWidth,
+ width,
+ height,
+ showCaption,
+ caption,
+ captionsEnabled,
+ key
+ ) {
+ super(key)
+ this.__src = src
+ this.__altText = altText
+ this.__maxWidth = maxWidth
+ this.__width = width || 'inherit'
+ this.__height = height || 'inherit'
+ this.__showCaption = showCaption || false
+ this.__caption = caption || createEditor()
+ this.__captionsEnabled = captionsEnabled || captionsEnabled === undefined
+ }
+
+ exportJSON () {
+ return {
+ altText: this.getAltText(),
+ caption: this.__caption.toJSON(),
+ height: this.__height === 'inherit' ? 0 : this.__height,
+ maxWidth: this.__maxWidth,
+ showCaption: this.__showCaption,
+ src: this.getSrc(),
+ type: 'image',
+ version: 1,
+ width: this.__width === 'inherit' ? 0 : this.__width
+ }
+ }
+
+ setWidthAndHeight (
+ width,
+ height
+ ) {
+ const writable = this.getWritable()
+ writable.__width = width
+ writable.__height = height
+ }
+
+ setShowCaption (showCaption) {
+ const writable = this.getWritable()
+ writable.__showCaption = showCaption
+ }
+
+ // View
+
+ createDOM (config) {
+ const span = document.createElement('span')
+ const theme = config.theme
+ const className = theme.image
+ if (className !== undefined) {
+ span.className = className
+ }
+ return span
+ }
+
+ updateDOM () {
+ return false
+ }
+
+ getSrc () {
+ return this.__src
+ }
+
+ getAltText () {
+ return this.__altText
+ }
+
+ decorate () {
+ return (
+
+
+
+ )
+ }
+}
+
+export function $createImageNode ({
+ altText,
+ height,
+ maxWidth = 500,
+ captionsEnabled,
+ src,
+ width,
+ showCaption,
+ caption,
+ key
+}) {
+ return $applyNodeReplacement(
+ new ImageNode(
+ src,
+ altText,
+ maxWidth,
+ width,
+ height,
+ showCaption,
+ caption,
+ captionsEnabled,
+ key
+ )
+ )
+}
+
+export function $isImageNode (
+ node
+) {
+ return node instanceof ImageNode
+}
+
+export default function ImageComponent ({
+ src,
+ altText,
+ nodeKey,
+ width,
+ height,
+ maxWidth,
+ resizable,
+ showCaption,
+ caption,
+ captionsEnabled
+}) {
+ const imageRef = useRef(null)
+ const buttonRef = useRef(null)
+ const [isSelected, setSelected, clearSelection] =
+ useLexicalNodeSelection(nodeKey)
+ const [editor] = useLexicalComposerContext()
+ // const [selection, setSelection] = useState(null)
+ const activeEditorRef = useRef(null)
+
+ const onDelete = useCallback(
+ (payload) => {
+ if (isSelected && $isNodeSelection($getSelection())) {
+ const event = payload
+ event.preventDefault()
+ const node = $getNodeByKey(nodeKey)
+ if ($isImageNode(node)) {
+ node.remove()
+ }
+ setSelected(false)
+ }
+ return false
+ },
+ [isSelected, nodeKey, setSelected]
+ )
+
+ const onEnter = useCallback(
+ (event) => {
+ const latestSelection = $getSelection()
+ const buttonElem = buttonRef.current
+ if (
+ isSelected &&
+ $isNodeSelection(latestSelection) &&
+ latestSelection.getNodes().length === 1
+ ) {
+ if (showCaption) {
+ // Move focus into nested editor
+ $setSelection(null)
+ event.preventDefault()
+ caption.focus()
+ return true
+ } else if (
+ buttonElem !== null &&
+ buttonElem !== document.activeElement
+ ) {
+ event.preventDefault()
+ buttonElem.focus()
+ return true
+ }
+ }
+ return false
+ },
+ [caption, isSelected, showCaption]
+ )
+
+ const onEscape = useCallback(
+ (event) => {
+ if (
+ activeEditorRef.current === caption ||
+ buttonRef.current === event.target
+ ) {
+ $setSelection(null)
+ editor.update(() => {
+ setSelected(true)
+ const parentRootElement = editor.getRootElement()
+ if (parentRootElement !== null) {
+ parentRootElement.focus()
+ }
+ })
+ return true
+ }
+ return false
+ },
+ [caption, editor, setSelected]
+ )
+
+ useEffect(() => {
+ return mergeRegister(
+ // editor.registerUpdateListener(({ editorState }) => {
+ // setSelection(editorState.read(() => $getSelection()))
+ // }),
+ editor.registerCommand(
+ SELECTION_CHANGE_COMMAND,
+ (_, activeEditor) => {
+ activeEditorRef.current = activeEditor
+ return false
+ },
+ COMMAND_PRIORITY_LOW
+ ),
+ editor.registerCommand(
+ CLICK_COMMAND,
+ (payload) => {
+ const event = payload
+ if (event.target === imageRef.current) {
+ if (event.shiftKey) {
+ setSelected(!isSelected)
+ } else {
+ clearSelection()
+ setSelected(true)
+ }
+ return true
+ }
+
+ return false
+ },
+ COMMAND_PRIORITY_LOW
+ ),
+ editor.registerCommand(
+ DRAGSTART_COMMAND,
+ (payload) => {
+ const event = payload
+ if (event.target === imageRef.current) {
+ if (event.shiftKey) {
+ setSelected(!isSelected)
+ } else {
+ clearSelection()
+ setSelected(true)
+ }
+ return true
+ }
+
+ return false
+ },
+ COMMAND_PRIORITY_HIGH
+ ),
+ editor.registerCommand(
+ DRAGSTART_COMMAND,
+ (event) => {
+ if (event.target === imageRef.current) {
+ // TODO This is just a temporary workaround for FF to behave like other browsers.
+ // Ideally, this handles drag & drop too (and all browsers).
+ event.preventDefault()
+ return true
+ }
+ return false
+ },
+ COMMAND_PRIORITY_LOW
+ ),
+ editor.registerCommand(
+ KEY_DELETE_COMMAND,
+ onDelete,
+ COMMAND_PRIORITY_LOW
+ ),
+ editor.registerCommand(
+ KEY_BACKSPACE_COMMAND,
+ onDelete,
+ COMMAND_PRIORITY_LOW
+ ),
+ editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_LOW),
+ editor.registerCommand(
+ KEY_ESCAPE_COMMAND,
+ onEscape,
+ COMMAND_PRIORITY_LOW
+ )
+ )
+ }, [
+ clearSelection,
+ editor,
+ isSelected,
+ nodeKey,
+ onDelete,
+ onEnter,
+ onEscape,
+ setSelected
+ ])
+
+ // const draggable = isSelected && $isNodeSelection(selection)
+ // const isFocused = isSelected
+ return (
+
+ <>
+
+
+
+ >
+
+ )
+}
diff --git a/lexical/plugins/autolink.js b/lexical/plugins/autolink.js
new file mode 100644
index 00000000..382aae53
--- /dev/null
+++ b/lexical/plugins/autolink.js
@@ -0,0 +1,34 @@
+import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'
+
+const URL_MATCHER = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
+
+const EMAIL_MATCHER = /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/
+
+const MATCHERS = [
+ (text) => {
+ const match = URL_MATCHER.exec(text)
+ return (
+ match && {
+ index: match.index,
+ length: match[0].length,
+ text: match[0],
+ url: match[0]
+ }
+ )
+ },
+ (text) => {
+ const match = EMAIL_MATCHER.exec(text)
+ return (
+ match && {
+ index: match.index,
+ length: match[0].length,
+ text: match[0],
+ url: `mailto:${match[0]}`
+ }
+ )
+ }
+]
+
+export default function PlaygroundAutoLinkPlugin () {
+ return
+}
diff --git a/lexical/plugins/image-insert.js b/lexical/plugins/image-insert.js
new file mode 100644
index 00000000..c8a33ad0
--- /dev/null
+++ b/lexical/plugins/image-insert.js
@@ -0,0 +1,252 @@
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'
+import {
+ $createParagraphNode,
+ $createRangeSelection,
+ $getSelection,
+ $insertNodes,
+ $isRootOrShadowRoot,
+ $setSelection,
+ COMMAND_PRIORITY_EDITOR,
+ COMMAND_PRIORITY_HIGH,
+ COMMAND_PRIORITY_LOW,
+ createCommand,
+ DRAGOVER_COMMAND,
+ DRAGSTART_COMMAND,
+ DROP_COMMAND
+} from 'lexical'
+import { useEffect, useRef } from 'react'
+import * as Yup from 'yup'
+import { ensureProtocol, URL_REGEXP } from '../../lib/url'
+
+import {
+ $createImageNode,
+ $isImageNode,
+ ImageNode
+} from '../nodes/image'
+import { Form, Input, SubmitButton } from '../../components/form'
+import styles from '../styles.module.css'
+
+const getDOMSelection = (targetWindow) =>
+ typeof window !== 'undefined' ? (targetWindow || window).getSelection() : null
+
+export const INSERT_IMAGE_COMMAND = createCommand('INSERT_IMAGE_COMMAND')
+
+const LinkSchema = Yup.object({
+ url: Yup.string().matches(URL_REGEXP, 'invalid url').required('required')
+})
+
+export function ImageInsertModal ({ onClose, editor }) {
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ inputRef.current?.focus()
+ }, [])
+
+ return (
+
+ )
+}
+
+export default function ImageInsertPlugin ({
+ captionsEnabled
+}) {
+ const [editor] = useLexicalComposerContext()
+
+ useEffect(() => {
+ if (!editor.hasNodes([ImageNode])) {
+ throw new Error('ImagesPlugin: ImageNode not registered on editor')
+ }
+
+ return mergeRegister(
+ editor.registerCommand(
+ INSERT_IMAGE_COMMAND,
+ (payload) => {
+ const imageNode = $createImageNode(payload)
+ $insertNodes([imageNode])
+ if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
+ $wrapNodeInElement(imageNode, $createParagraphNode).selectEnd()
+ }
+
+ return true
+ },
+ COMMAND_PRIORITY_EDITOR
+ ),
+ editor.registerCommand(
+ DRAGSTART_COMMAND,
+ (event) => {
+ return onDragStart(event)
+ },
+ COMMAND_PRIORITY_HIGH
+ ),
+ editor.registerCommand(
+ DRAGOVER_COMMAND,
+ (event) => {
+ return onDragover(event)
+ },
+ COMMAND_PRIORITY_LOW
+ ),
+ editor.registerCommand(
+ DROP_COMMAND,
+ (event) => {
+ return onDrop(event, editor)
+ },
+ COMMAND_PRIORITY_HIGH
+ )
+ )
+ }, [captionsEnabled, editor])
+
+ return null
+}
+
+const TRANSPARENT_IMAGE =
+ 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
+const img = typeof window !== 'undefined' ? document.createElement('img') : undefined
+if (img) {
+ img.src = TRANSPARENT_IMAGE
+}
+
+function onDragStart (event) {
+ const node = getImageNodeInSelection()
+ if (!node) {
+ return false
+ }
+ const dataTransfer = event.dataTransfer
+ if (!dataTransfer) {
+ return false
+ }
+ dataTransfer.setData('text/plain', '_')
+ img.src = node.getSrc()
+ dataTransfer.setDragImage(img, 0, 0)
+ dataTransfer.setData(
+ 'application/x-lexical-drag',
+ JSON.stringify({
+ data: {
+ altText: node.__altText,
+ caption: node.__caption,
+ height: node.__height,
+ maxHeight: '25vh',
+ key: node.getKey(),
+ maxWidth: node.__maxWidth,
+ showCaption: node.__showCaption,
+ src: node.__src,
+ width: node.__width
+ },
+ type: 'image'
+ })
+ )
+
+ return true
+}
+
+function onDragover (event) {
+ const node = getImageNodeInSelection()
+ if (!node) {
+ return false
+ }
+ if (!canDropImage(event)) {
+ event.preventDefault()
+ }
+ return true
+}
+
+function onDrop (event, editor) {
+ const node = getImageNodeInSelection()
+ if (!node) {
+ return false
+ }
+ const data = getDragImageData(event)
+ if (!data) {
+ return false
+ }
+ event.preventDefault()
+ if (canDropImage(event)) {
+ const range = getDragSelection(event)
+ node.remove()
+ const rangeSelection = $createRangeSelection()
+ if (range !== null && range !== undefined) {
+ rangeSelection.applyDOMRange(range)
+ }
+ $setSelection(rangeSelection)
+ editor.dispatchCommand(INSERT_IMAGE_COMMAND, data)
+ }
+ return true
+}
+
+function getImageNodeInSelection () {
+ const selection = $getSelection()
+ const nodes = selection.getNodes()
+ const node = nodes[0]
+ return $isImageNode(node) ? node : null
+}
+
+function getDragImageData (event) {
+ const dragData = event.dataTransfer?.getData('application/x-lexical-drag')
+ if (!dragData) {
+ return null
+ }
+ const { type, data } = JSON.parse(dragData)
+ if (type !== 'image') {
+ return null
+ }
+
+ return data
+}
+
+function canDropImage (event) {
+ const target = event.target
+ return !!(
+ target &&
+ target instanceof HTMLElement &&
+ !target.closest('code, span.editor-image') &&
+ target.parentElement &&
+ target.parentElement.closest(`div.${styles.editorInput}`)
+ )
+}
+
+function getDragSelection (event) {
+ let range
+ const target = event.target
+ const targetWindow =
+ target == null
+ ? null
+ : target.nodeType === 9
+ ? target.defaultView
+ : target.ownerDocument.defaultView
+ const domSelection = getDOMSelection(targetWindow)
+ if (document.caretRangeFromPoint) {
+ range = document.caretRangeFromPoint(event.clientX, event.clientY)
+ } else if (event.rangeParent && domSelection !== null) {
+ domSelection.collapse(event.rangeParent, event.rangeOffset || 0)
+ range = domSelection.getRangeAt(0)
+ } else {
+ throw Error('Cannot get the selection when dragging')
+ }
+
+ return range
+}
diff --git a/lexical/plugins/link-insert.js b/lexical/plugins/link-insert.js
new file mode 100644
index 00000000..7b33f111
--- /dev/null
+++ b/lexical/plugins/link-insert.js
@@ -0,0 +1,134 @@
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { $createTextNode, $getSelection, $insertNodes, $setSelection, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical'
+import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'
+import { $createLinkNode, $isLinkNode } from '@lexical/link'
+import { Modal } from 'react-bootstrap'
+import React, { useState, useCallback, useContext, useRef, useEffect } from 'react'
+import * as Yup from 'yup'
+import { Form, Input, SubmitButton } from '../../components/form'
+import { ensureProtocol, URL_REGEXP } from '../../lib/url'
+import { getSelectedNode } from '../utils/selected-node'
+
+export const INSERT_LINK_COMMAND = createCommand('INSERT_LINK_COMMAND')
+
+export default function LinkInsertPlugin () {
+ const [editor] = useLexicalComposerContext()
+
+ useEffect(() => {
+ return mergeRegister(
+ editor.registerCommand(
+ INSERT_LINK_COMMAND,
+ (payload) => {
+ const selection = $getSelection()
+ const node = getSelectedNode(selection)
+ const parent = node.getParent()
+ if ($isLinkNode(parent)) {
+ parent.remove()
+ } else if ($isLinkNode(node)) {
+ node.remove()
+ }
+ const textNode = $createTextNode(payload.text)
+ $insertNodes([textNode])
+ const linkNode = $createLinkNode(payload.url)
+ $wrapNodeInElement(textNode, () => linkNode)
+ $setSelection(textNode.select())
+ return true
+ },
+ COMMAND_PRIORITY_EDITOR
+ )
+ )
+ }, [editor])
+
+ return null
+}
+
+export const LinkInsertContext = React.createContext({
+ link: null,
+ setLink: () => {}
+})
+
+export function LinkInsertProvider ({ children }) {
+ const [link, setLink] = useState(null)
+
+ const contextValue = {
+ link,
+ setLink: useCallback(link => setLink(link), [])
+ }
+
+ return (
+
+
+ {children}
+
+ )
+}
+
+export function useLinkInsert () {
+ const { link, setLink } = useContext(LinkInsertContext)
+ return { link, setLink }
+}
+
+const LinkSchema = Yup.object({
+ text: Yup.string().required('required'),
+ url: Yup.string().matches(URL_REGEXP, 'invalid url').required('required')
+})
+
+export function LinkInsertModal () {
+ const [editor] = useLexicalComposerContext()
+ const { link, setLink } = useLinkInsert()
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ if (link) {
+ inputRef.current?.focus()
+ }
+ }, [link])
+
+ return (
+ {
+ setLink(null)
+ setTimeout(() => editor.focus(), 100)
+ }}
+ >
+ {
+ setLink(null)
+ // I think bootstrap messes with the focus on close so we have to do this ourselves
+ setTimeout(() => editor.focus(), 100)
+ }}
+ >X
+
+
+
+
+
+ )
+}
diff --git a/lexical/plugins/link-tooltip.js b/lexical/plugins/link-tooltip.js
new file mode 100644
index 00000000..2ddbf2c8
--- /dev/null
+++ b/lexical/plugins/link-tooltip.js
@@ -0,0 +1,232 @@
+import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { $findMatchingParent, mergeRegister } from '@lexical/utils'
+import styles from '../styles.module.css'
+import {
+ $getSelection,
+ $isRangeSelection,
+ COMMAND_PRIORITY_CRITICAL,
+ COMMAND_PRIORITY_HIGH,
+ COMMAND_PRIORITY_LOW,
+ KEY_ESCAPE_COMMAND,
+ SELECTION_CHANGE_COMMAND
+} from 'lexical'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import * as React from 'react'
+
+import { getSelectedNode } from '../utils/selected-node'
+import { setTooltipPosition } from '../utils/tooltip-position'
+import { useLinkInsert } from './link-insert'
+import { getLinkFromSelection } from '../utils/link-from-selection'
+
+function FloatingLinkEditor ({
+ editor,
+ isLink,
+ setIsLink,
+ anchorElem
+}) {
+ const { setLink } = useLinkInsert()
+ const editorRef = useRef(null)
+ const inputRef = useRef(null)
+ const [linkUrl, setLinkUrl] = useState('')
+ const [isEditMode, setEditMode] = useState(false)
+
+ const updateLinkEditor = useCallback(() => {
+ const selection = $getSelection()
+ if ($isRangeSelection(selection)) {
+ const node = getSelectedNode(selection)
+ const parent = node.getParent()
+ if ($isLinkNode(parent)) {
+ setLinkUrl(parent.getURL())
+ } else if ($isLinkNode(node)) {
+ setLinkUrl(node.getURL())
+ } else {
+ setLinkUrl('')
+ }
+ }
+ const editorElem = editorRef.current
+ const nativeSelection = window.getSelection()
+ const activeElement = document.activeElement
+
+ if (editorElem === null) {
+ return
+ }
+
+ const rootElement = editor.getRootElement()
+
+ if (
+ selection !== null &&
+ nativeSelection !== null &&
+ rootElement !== null &&
+ rootElement.contains(nativeSelection.anchorNode) &&
+ editor.isEditable()
+ ) {
+ const domRange = nativeSelection.getRangeAt(0)
+ let rect
+ if (nativeSelection.anchorNode === rootElement) {
+ let inner = rootElement
+ while (inner.firstElementChild != null) {
+ inner = inner.firstElementChild
+ }
+ rect = inner.getBoundingClientRect()
+ } else {
+ rect = domRange.getBoundingClientRect()
+ }
+
+ setTooltipPosition(rect, editorElem, anchorElem)
+ } else if (!activeElement) {
+ if (rootElement !== null) {
+ setTooltipPosition(null, editorElem, anchorElem)
+ }
+ setEditMode(false)
+ setLinkUrl('')
+ }
+
+ return true
+ }, [anchorElem, editor])
+
+ useEffect(() => {
+ const scrollerElem = anchorElem.parentElement
+
+ const update = () => {
+ editor.getEditorState().read(() => {
+ updateLinkEditor()
+ })
+ }
+
+ window.addEventListener('resize', update)
+
+ if (scrollerElem) {
+ scrollerElem.addEventListener('scroll', update)
+ }
+
+ return () => {
+ window.removeEventListener('resize', update)
+
+ if (scrollerElem) {
+ scrollerElem.removeEventListener('scroll', update)
+ }
+ }
+ }, [anchorElem.parentElement, editor, updateLinkEditor])
+
+ useEffect(() => {
+ return mergeRegister(
+ editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ updateLinkEditor()
+ })
+ }),
+
+ editor.registerCommand(
+ SELECTION_CHANGE_COMMAND,
+ () => {
+ updateLinkEditor()
+ return true
+ },
+ COMMAND_PRIORITY_LOW
+ ),
+ editor.registerCommand(
+ KEY_ESCAPE_COMMAND,
+ () => {
+ if (isLink) {
+ setIsLink(false)
+ return true
+ }
+ return false
+ },
+ COMMAND_PRIORITY_HIGH
+ )
+ )
+ }, [editor, updateLinkEditor, setIsLink, isLink])
+
+ useEffect(() => {
+ editor.getEditorState().read(() => {
+ updateLinkEditor()
+ })
+ }, [editor, updateLinkEditor])
+
+ useEffect(() => {
+ if (isEditMode && inputRef.current) {
+ inputRef.current.focus()
+ }
+ }, [isEditMode])
+
+ return (
+ linkUrl &&
+
+
+
{linkUrl.replace('https://', '').replace('http://', '')}
+
\
+
{
+ editor.update(() => {
+ // we need to replace the link
+ // their playground simple 'TOGGLE's it with a new url
+ // but we need to potentiallyr replace the text
+ setLink(getLinkFromSelection())
+ })
+ }}
+ >edit
+
+
\
+
{
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
+ }}
+ >remove
+
+
+
+ )
+}
+
+function useFloatingLinkEditorToolbar ({ editor, anchorElem }) {
+ const [activeEditor, setActiveEditor] = useState(editor)
+ const [isLink, setIsLink] = useState(false)
+
+ const updateToolbar = useCallback(() => {
+ const selection = $getSelection()
+ if ($isRangeSelection(selection)) {
+ const node = getSelectedNode(selection)
+ const linkParent = $findMatchingParent(node, $isLinkNode)
+ const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode)
+
+ // We don't want this menu to open for auto links.
+ if (linkParent != null && autoLinkParent == null) {
+ setIsLink(true)
+ } else {
+ setIsLink(false)
+ }
+ }
+ }, [])
+
+ useEffect(() => {
+ return editor.registerCommand(
+ SELECTION_CHANGE_COMMAND,
+ (_payload, newEditor) => {
+ updateToolbar()
+ setActiveEditor(newEditor)
+ return false
+ },
+ COMMAND_PRIORITY_CRITICAL
+ )
+ }, [editor, updateToolbar])
+
+ return isLink
+ ?
+ : null
+}
+
+export default function LinkTooltipPlugin ({
+ anchorElem = document.body
+}) {
+ const [editor] = useLexicalComposerContext()
+ return useFloatingLinkEditorToolbar({ editor, anchorElem })
+}
diff --git a/lexical/plugins/list-max-indent.js b/lexical/plugins/list-max-indent.js
new file mode 100644
index 00000000..3a09a068
--- /dev/null
+++ b/lexical/plugins/list-max-indent.js
@@ -0,0 +1,68 @@
+import { $getListDepth, $isListItemNode, $isListNode } from '@lexical/list'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import {
+ $getSelection,
+ $isElementNode,
+ $isRangeSelection,
+ INDENT_CONTENT_COMMAND,
+ COMMAND_PRIORITY_HIGH
+} from 'lexical'
+import { useEffect } from 'react'
+
+function getElementNodesInSelection (selection) {
+ const nodesInSelection = selection.getNodes()
+
+ if (nodesInSelection.length === 0) {
+ return new Set([
+ selection.anchor.getNode().getParentOrThrow(),
+ selection.focus.getNode().getParentOrThrow()
+ ])
+ }
+
+ return new Set(
+ nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow()))
+ )
+}
+
+function isIndentPermitted (maxDepth) {
+ const selection = $getSelection()
+
+ if (!$isRangeSelection(selection)) {
+ return false
+ }
+
+ const elementNodesInSelection = getElementNodesInSelection(selection)
+
+ let totalDepth = 0
+
+ for (const elementNode of elementNodesInSelection) {
+ if ($isListNode(elementNode)) {
+ totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth)
+ } else if ($isListItemNode(elementNode)) {
+ const parent = elementNode.getParent()
+ if (!$isListNode(parent)) {
+ throw new Error(
+ 'ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.'
+ )
+ }
+
+ totalDepth = Math.max($getListDepth(parent) + 1, totalDepth)
+ }
+ }
+
+ return totalDepth <= maxDepth
+}
+
+export default function ListMaxIndentLevelPlugin ({ maxDepth }) {
+ const [editor] = useLexicalComposerContext()
+
+ useEffect(() => {
+ return editor.registerCommand(
+ INDENT_CONTENT_COMMAND,
+ () => !isIndentPermitted(maxDepth ?? 7),
+ COMMAND_PRIORITY_HIGH
+ )
+ }, [editor, maxDepth])
+
+ return null
+}
diff --git a/lexical/plugins/toolbar.js b/lexical/plugins/toolbar.js
new file mode 100644
index 00000000..90b60538
--- /dev/null
+++ b/lexical/plugins/toolbar.js
@@ -0,0 +1,383 @@
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import {
+ SELECTION_CHANGE_COMMAND,
+ FORMAT_TEXT_COMMAND,
+ INDENT_CONTENT_COMMAND,
+ OUTDENT_CONTENT_COMMAND,
+ $getSelection,
+ $isRangeSelection,
+ $createParagraphNode
+} from 'lexical'
+import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
+import {
+ $wrapNodes
+} from '@lexical/selection'
+import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils'
+import {
+ INSERT_ORDERED_LIST_COMMAND,
+ INSERT_UNORDERED_LIST_COMMAND,
+ REMOVE_LIST_COMMAND,
+ $isListNode,
+ ListNode
+} from '@lexical/list'
+import {
+ $createHeadingNode,
+ $createQuoteNode,
+ $isHeadingNode
+} from '@lexical/rich-text'
+// import {
+// $createCodeNode
+// } from '@lexical/code'
+import BoldIcon from '../../svgs/bold.svg'
+import ItalicIcon from '../../svgs/italic.svg'
+// import StrikethroughIcon from '../../svgs/strikethrough.svg'
+import LinkIcon from '../../svgs/link.svg'
+import ListOrderedIcon from '../../svgs/list-ordered.svg'
+import ListUnorderedIcon from '../../svgs/list-unordered.svg'
+import IndentIcon from '../../svgs/indent-increase.svg'
+import OutdentIcon from '../../svgs/indent-decrease.svg'
+import ImageIcon from '../../svgs/image-line.svg'
+import FontSizeIcon from '../../svgs/font-size-2.svg'
+import QuoteIcon from '../../svgs/double-quotes-r.svg'
+// import CodeIcon from '../../svgs/code-line.svg'
+// import CodeBoxIcon from '../../svgs/code-box-line.svg'
+import ArrowDownIcon from '../../svgs/arrow-down-s-fill.svg'
+import CheckIcon from '../../svgs/check-line.svg'
+
+import styles from '../styles.module.css'
+import { Dropdown } from 'react-bootstrap'
+import { useLinkInsert } from './link-insert'
+import { getSelectedNode } from '../utils/selected-node'
+import { getLinkFromSelection } from '../utils/link-from-selection'
+import { ImageInsertModal } from './image-insert'
+import useModal from '../utils/modal'
+
+const LowPriority = 1
+
+function Divider () {
+ return
+}
+
+function FontSizeDropdown ({
+ editor,
+ blockType
+}) {
+ const formatParagraph = () => {
+ if (blockType !== 'paragraph') {
+ editor.update(() => {
+ const selection = $getSelection()
+
+ if ($isRangeSelection(selection)) {
+ $wrapNodes(selection, () => $createParagraphNode())
+ }
+ setTimeout(() => editor.focus(), 100)
+ })
+ }
+ }
+
+ const formatLargeHeading = () => {
+ if (blockType !== 'h1') {
+ editor.update(() => {
+ const selection = $getSelection()
+
+ if ($isRangeSelection(selection)) {
+ $wrapNodes(selection, () => $createHeadingNode('h1'))
+ }
+
+ setTimeout(() => editor.focus(), 100)
+ })
+ }
+ }
+
+ const formatSmallHeading = () => {
+ if (blockType !== 'h2') {
+ editor.update(() => {
+ const selection = $getSelection()
+
+ if ($isRangeSelection(selection)) {
+ $wrapNodes(selection, () => $createHeadingNode('h2'))
+ }
+
+ setTimeout(() => editor.focus(), 100)
+ })
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ normal
+
+
+
+ subheading
+
+
+
+ heading
+
+
+
+ )
+}
+
+export default function ToolbarPlugin () {
+ const [editor] = useLexicalComposerContext()
+ const { setLink } = useLinkInsert()
+ const toolbarRef = useRef(null)
+ const [blockType, setBlockType] = useState('paragraph')
+ const [isLink, setIsLink] = useState(false)
+ const [isBold, setIsBold] = useState(false)
+ const [isItalic, setIsItalic] = useState(false)
+ // const [isStrikethrough, setIsStrikethrough] = useState(false)
+ // const [isCode, setIsCode] = useState(false)
+ const [modal, showModal] = useModal()
+
+ const updateToolbar = useCallback(() => {
+ const selection = $getSelection()
+ if ($isRangeSelection(selection)) {
+ const anchorNode = selection.anchor.getNode()
+ const element =
+ anchorNode.getKey() === 'root'
+ ? anchorNode
+ : anchorNode.getTopLevelElementOrThrow()
+ const elementKey = element.getKey()
+ const elementDOM = editor.getElementByKey(elementKey)
+ if (elementDOM !== null) {
+ if ($isListNode(element)) {
+ const parentList = $getNearestNodeOfType(anchorNode, ListNode)
+ const type = parentList ? parentList.getTag() : element.getTag()
+ setBlockType(type)
+ } else {
+ const type = $isHeadingNode(element)
+ ? element.getTag()
+ : element.getType()
+ setBlockType(type)
+ }
+ }
+ // Update text format
+ setIsBold(selection.hasFormat('bold'))
+ setIsItalic(selection.hasFormat('italic'))
+ // setIsStrikethrough(selection.hasFormat('strikethrough'))
+ // setIsCode(selection.hasFormat('code'))
+
+ // Update links
+ const node = getSelectedNode(selection)
+ const parent = node.getParent()
+ if ($isLinkNode(parent) || $isLinkNode(node)) {
+ setIsLink(true)
+ } else {
+ setIsLink(false)
+ }
+ }
+ }, [editor])
+
+ useEffect(() => {
+ return mergeRegister(
+ editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ updateToolbar()
+ })
+ }),
+ editor.registerCommand(
+ SELECTION_CHANGE_COMMAND,
+ (_payload, newEditor) => {
+ updateToolbar()
+ return false
+ },
+ LowPriority
+ )
+ )
+ }, [editor, updateToolbar])
+
+ const insertLink = useCallback(() => {
+ if (isLink) {
+ // unlink it
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
+ } else {
+ editor.update(() => {
+ setLink(getLinkFromSelection())
+ })
+ }
+ }, [editor, isLink])
+
+ const formatBulletList = () => {
+ if (blockType !== 'ul') {
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND)
+ } else {
+ editor.dispatchCommand(REMOVE_LIST_COMMAND)
+ }
+ }
+
+ const formatNumberedList = () => {
+ if (blockType !== 'ol') {
+ editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND)
+ } else {
+ editor.dispatchCommand(REMOVE_LIST_COMMAND)
+ }
+ }
+
+ const formatQuote = () => {
+ if (blockType !== 'quote') {
+ editor.update(() => {
+ const selection = $getSelection()
+
+ if ($isRangeSelection(selection)) {
+ $wrapNodes(selection, () => $createQuoteNode())
+ }
+ })
+ } else {
+ editor.update(() => {
+ const selection = $getSelection()
+
+ if ($isRangeSelection(selection)) {
+ $wrapNodes(selection, () => $createParagraphNode())
+ }
+ })
+ }
+ }
+
+ // const formatCode = () => {
+ // if (blockType !== 'code') {
+ // editor.update(() => {
+ // const selection = $getSelection()
+
+ // if ($isRangeSelection(selection)) {
+ // $wrapNodes(selection, () => {
+ // const node = $createCodeNode()
+ // node.setLanguage('plain')
+ // return node
+ // })
+ // }
+ // })
+ // }
+ // }
+
+ return (
+
+
+
+ <>
+
{
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
+ }}
+ className={`${styles.toolbarItem} ${styles.spaced} ${isBold ? styles.active : ''}`}
+ aria-label='Format Bold'
+ >
+
+
+
{
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
+ }}
+ className={`${styles.toolbarItem} ${styles.spaced} ${isItalic ? styles.active : ''}`}
+ aria-label='Format Italics'
+ >
+
+
+
+
+
+
+
+
+
+
{
+ editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
+ }}
+ className={`${styles.toolbarItem} ${styles.spaced}`}
+ aria-label='Indent'
+ >
+
+
+
{
+ editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
+ }}
+ className={`${styles.toolbarItem} ${styles.spaced}`}
+ aria-label='Outdent'
+ >
+
+
+
+
+
+ {/*
*/}
+ {/*
{
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
+ }}
+ className={
+ `${styles.toolbarItem} ${styles.spaced} ${isStrikethrough ? styles.active : ''}`
+ }
+ aria-label='Format Strikethrough'
+ >
+
+ */}
+ {/*
{
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
+ }}
+ className={`${styles.toolbarItem} ${styles.spaced} ${isCode ? styles.active : ''}`}
+ aria-label='Insert Code'
+ >
+
+ */}
+ {/*
+
+ */}
+
+
+
+
+
{
+ showModal((onClose) => (
+
+ ))
+ }}
+ className={`${styles.toolbarItem} ${styles.spaced}`}
+ aria-label='Insert Image'
+ >
+
+
+ {modal}
+ >
+
+ )
+}
diff --git a/lexical/styles.module.css b/lexical/styles.module.css
new file mode 100644
index 00000000..6e8ead3d
--- /dev/null
+++ b/lexical/styles.module.css
@@ -0,0 +1,256 @@
+/* editor */
+
+.editor {
+ height: 100%;
+ position: relative;
+}
+
+.editorContainer {
+ margin: 20px auto 20px auto;
+ width: 100%;
+ color: var(--theme-color);
+ position: relative;
+ line-height: 20px;
+ font-weight: 400;
+ text-align: left;
+ border-top-left-radius: .4rem;
+ border-top-right-radius: .4rem;
+}
+
+.editorInner {
+ position: relative;
+}
+
+.editorInput>hr {
+ border-top: 1px solid var(--theme-clickToContextColor);
+}
+
+.editorInput {
+ min-height: 150px;
+ resize: auto;
+ font-size: 15px;
+ caret-color: var(--theme-color);
+ background-color: var(--theme-body);
+ position: relative;
+ tab-size: 1;
+ outline: 0;
+ padding: 15px 10px;
+ border: 1px solid;
+ border-bottom-left-radius: .4rem;
+ border-bottom-right-radius: .4rem;
+ /* border-top: 0px; */
+}
+
+.editorPlaceholder {
+ color: var(--theme-grey);
+ overflow: hidden;
+ position: absolute;
+ text-overflow: ellipsis;
+ top: 15px;
+ left: 10px;
+ font-size: 15px;
+ user-select: none;
+ display: inline-block;
+ pointer-events: none;
+}
+
+/* blocks */
+
+.image {
+ display: inline-block;
+}
+
+.paragraph {
+ margin: 0;
+ margin-bottom: 8px;
+ position: relative;
+}
+
+.paragraph:last-child {
+ margin-bottom: 0;
+}
+
+.quote {
+ margin: 0;
+ margin-left: 20px;
+ font-size: 15px;
+ color: var(--theme-quoteColor);
+ border-left-color: var(--theme-quoteBar);
+ border-left-width: 4px;
+ border-left-style: solid;
+ padding-left: 16px;
+}
+
+.heading1 {
+ font-size: 24px;
+ color: var(--theme-color);
+ font-weight: 400;
+ margin: 0;
+ margin-bottom: 12px;
+}
+
+.heading2 {
+ font-size: 15px;
+ color: var(--theme-navLink);
+ font-weight: 700;
+ margin: 0;
+ margin-top: 10px;
+ text-transform: uppercase;
+}
+
+
+.code {
+ background-color: rgb(240, 242, 245);
+ font-family: Menlo, Consolas, Monaco, monospace;
+ display: block;
+ padding: 8px 8px 8px 52px;
+ line-height: 1.53;
+ font-size: 13px;
+ margin: 0;
+ margin-top: 8px;
+ margin-bottom: 8px;
+ tab-size: 2;
+ /* white-space: pre; */
+ overflow-x: auto;
+ position: relative;
+}
+
+/* inline blocks */
+
+.link {
+ color: var(--theme-link);
+ text-decoration: none;
+}
+
+/* lists */
+
+.listOl {
+ padding: 0;
+ margin: 0;
+ margin-left: 16px;
+}
+
+.listUl {
+ padding: 0;
+ margin: 0;
+ margin-left: 16px;
+}
+
+.listItem {
+ margin: 8px 32px 8px 32px;
+}
+
+.nestedListItem {
+ list-style-type: none;
+}
+
+/* text */
+
+.textBold {
+ font-weight: bold;
+}
+
+.textItalic {
+ font-style: italic;
+}
+
+.textUnderline {
+ text-decoration: underline;
+}
+
+.textStrikethrough {
+ text-decoration: line-through;
+}
+
+.textUnderlineStrikethrough {
+ text-decoration: underline line-through;
+}
+
+.textCode {
+ background-color: rgb(240, 242, 245);
+ padding: 1px 0.25rem;
+ font-family: Menlo, Consolas, Monaco, monospace;
+ font-size: 94%;
+}
+
+/* toolbar */
+.toolbar {
+ display: flex;
+ background: var(--theme-toolbar);
+ padding: 4px;
+ border-top-left-radius: .4rem;
+ border-top-right-radius: .4rem;
+ vertical-align: middle;
+ flex-wrap: wrap;
+}
+
+.toolbar button.toolbarItem {
+ border: 0;
+ display: flex;
+ background: none;
+ border-radius: .4rem;
+ padding: 8px;
+ cursor: pointer;
+ vertical-align: middle;
+}
+
+.toolbar button.toolbarItem:disabled {
+ cursor: not-allowed;
+}
+
+.toolbar button.toolbarItem.spaced {
+ margin-right: 2px;
+}
+
+.toolbar button.toolbarItem svg {
+ background-size: contain;
+ display: inline-block;
+ height: 18px;
+ width: 18px;
+ margin-top: 2px;
+ vertical-align: -0.25em;
+ display: flex;
+ opacity: 0.6;
+}
+
+.toolbar button.toolbarItem:disabled svg {
+ opacity: 0.2;
+}
+
+.toolbar button.toolbarItem.active {
+ background-color: var(--theme-toolbarActive);
+}
+
+.toolbar button.toolbarItem.active svg {
+ opacity: 1;
+}
+
+.toolbar .toolbarItem:hover:not([disabled]) {
+ background-color: var(--theme-toolbarHover);
+}
+
+.toolbar .divider {
+ width: 1px;
+ background-color: var(--theme-borderColor);
+ margin: 0 4px;
+}
+
+.toolbar .toolbarItem svg {
+ fill: var(--theme-color) !important;
+}
+
+.linkTooltip {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 10;
+ font-size: 0.7875rem;
+ opacity: 0;
+ will-change: transform;
+}
+
+.tooltipUrl {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
\ No newline at end of file
diff --git a/lexical/theme.js b/lexical/theme.js
new file mode 100644
index 00000000..0501a52a
--- /dev/null
+++ b/lexical/theme.js
@@ -0,0 +1,36 @@
+import styles from './styles.module.css'
+
+const theme = {
+ paragraph: styles.paragraph,
+ quote: styles.quote,
+ heading: {
+ h1: styles.heading1,
+ h2: styles.heading2,
+ h3: styles.heading2,
+ h4: styles.heading2,
+ h5: styles.heading2
+ },
+ image: styles.image,
+ link: styles.link,
+ code: styles.code,
+ list: {
+ nested: {
+ listitem: styles.nestedListItem
+ },
+ ol: styles.listOl,
+ ul: styles.listUl,
+ listitem: styles.listItem
+ },
+ text: {
+ bold: styles.textBold,
+ italic: styles.textItalic,
+ // overflowed: 'editor-text-overflowed',
+ // hashtag: 'editor-text-hashtag',
+ underline: styles.textUnderline,
+ strikethrough: styles.textStrikethrough,
+ underlineStrikethrough: styles.underlineStrikethrough,
+ code: styles.textCode
+ }
+}
+
+export default theme
diff --git a/lexical/utils/image-markdown-transformer.js b/lexical/utils/image-markdown-transformer.js
new file mode 100644
index 00000000..637e437b
--- /dev/null
+++ b/lexical/utils/image-markdown-transformer.js
@@ -0,0 +1,55 @@
+import {
+ $createImageNode,
+ $isImageNode,
+ ImageNode
+} from '../nodes/image'
+import {
+ $createHorizontalRuleNode,
+ $isHorizontalRuleNode,
+ HorizontalRuleNode
+} from '@lexical/react/LexicalHorizontalRuleNode'
+import { TRANSFORMERS } from '@lexical/markdown'
+
+export const IMAGE = {
+ dependencies: [ImageNode],
+ export: (node, exportChildren, exportFormat) => {
+ if (!$isImageNode(node)) {
+ return null
+ }
+ return `![${node.getAltText()}](${node.getSrc()})`
+ },
+ importRegExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))/,
+ regExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))$/,
+ replace: (textNode, match) => {
+ const [, altText, src] = match
+ const imageNode = $createImageNode({ altText, src })
+ textNode.replace(imageNode)
+ },
+ trigger: ')',
+ type: 'text-match'
+}
+
+export const HR = {
+ dependencies: [HorizontalRuleNode],
+ export: (node) => {
+ return $isHorizontalRuleNode(node) ? '***' : null
+ },
+ regExp: /^(-{3,}|\*{3,}|_{3,})\s?$/,
+ replace: (parentNode, _1, _2, isImport) => {
+ const line = $createHorizontalRuleNode()
+
+ // TODO: Get rid of isImport flag
+ if (isImport || parentNode.getNextSibling() != null) {
+ parentNode.replace(line)
+ } else {
+ parentNode.insertBefore(line)
+ }
+
+ line.selectNext()
+ },
+ type: 'element'
+}
+
+export const SN_TRANSFORMERS = [
+ HR, IMAGE, ...TRANSFORMERS
+]
diff --git a/lexical/utils/link-from-selection.js b/lexical/utils/link-from-selection.js
new file mode 100644
index 00000000..012bda90
--- /dev/null
+++ b/lexical/utils/link-from-selection.js
@@ -0,0 +1,24 @@
+import { $getSelection, $getTextContent, $isRangeSelection } from 'lexical'
+import { getSelectedNode } from './selected-node'
+import { $isLinkNode } from '@lexical/link'
+
+export function getLinkFromSelection () {
+ const selection = $getSelection()
+ let url = ''
+ let text = ''
+ if ($isRangeSelection(selection)) {
+ const node = getSelectedNode(selection)
+ const parent = node.getParent()
+ if ($isLinkNode(parent)) {
+ url = parent.getURL()
+ text = parent.getTextContent()
+ } else if ($isLinkNode(node)) {
+ url = node.getURL()
+ text = node.getTextContent()
+ } else {
+ url = ''
+ text = $getTextContent(selection)
+ }
+ }
+ return { url, text }
+}
diff --git a/lexical/utils/modal.js b/lexical/utils/modal.js
new file mode 100644
index 00000000..6c515834
--- /dev/null
+++ b/lexical/utils/modal.js
@@ -0,0 +1,34 @@
+import { useCallback, useMemo, useState } from 'react'
+import * as React from 'react'
+import { Modal } from 'react-bootstrap'
+
+export default function useModal () {
+ const [modalContent, setModalContent] = useState(null)
+
+ const onClose = useCallback(() => {
+ setModalContent(null)
+ }, [])
+
+ const modal = useMemo(() => {
+ if (modalContent === null) {
+ return null
+ }
+ return (
+
+ X
+
+ {modalContent}
+
+
+ )
+ }, [modalContent, onClose])
+
+ const showModal = useCallback(
+ (getContent) => {
+ setModalContent(getContent(onClose))
+ },
+ [onClose]
+ )
+
+ return [modal, showModal]
+}
diff --git a/lexical/utils/selected-node.js b/lexical/utils/selected-node.js
new file mode 100644
index 00000000..276e716f
--- /dev/null
+++ b/lexical/utils/selected-node.js
@@ -0,0 +1,17 @@
+import { $isAtNodeEnd } from '@lexical/selection'
+
+export function getSelectedNode (selection) {
+ const anchor = selection.anchor
+ const focus = selection.focus
+ const anchorNode = selection.anchor.getNode()
+ const focusNode = selection.focus.getNode()
+ if (anchorNode === focusNode) {
+ return anchorNode
+ }
+ const isBackward = selection.isBackward()
+ if (isBackward) {
+ return $isAtNodeEnd(focus) ? anchorNode : focusNode
+ } else {
+ return $isAtNodeEnd(anchor) ? anchorNode : focusNode
+ }
+}
diff --git a/lexical/utils/tooltip-position.js b/lexical/utils/tooltip-position.js
new file mode 100644
index 00000000..713818d9
--- /dev/null
+++ b/lexical/utils/tooltip-position.js
@@ -0,0 +1,41 @@
+const VERTICAL_GAP = 5
+const HORIZONTAL_OFFSET = 5
+
+export function setTooltipPosition (
+ targetRect,
+ floatingElem,
+ anchorElem,
+ verticalGap = VERTICAL_GAP,
+ horizontalOffset = HORIZONTAL_OFFSET
+) {
+ const scrollerElem = anchorElem.parentElement
+
+ if (targetRect === null || !scrollerElem) {
+ floatingElem.style.opacity = '0'
+ floatingElem.style.transform = 'translate(-10000px, -10000px)'
+ return
+ }
+
+ const floatingElemRect = floatingElem.getBoundingClientRect()
+ const anchorElementRect = anchorElem.getBoundingClientRect()
+ const editorScrollerRect = scrollerElem.getBoundingClientRect()
+
+ let top = targetRect.top - floatingElemRect.height - verticalGap
+ let left = targetRect.left - horizontalOffset
+
+ top += floatingElemRect.height + targetRect.height + verticalGap * 2
+
+ if (left + floatingElemRect.width > editorScrollerRect.right) {
+ left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset
+ }
+
+ top -= anchorElementRect.top
+ left -= anchorElementRect.left
+
+ if (top > 0 && left > 0) {
+ floatingElem.style.opacity = '1'
+ } else {
+ floatingElem.style.opacity = '0'
+ }
+ floatingElem.style.transform = `translate(${left}px, ${top}px)`
+}
diff --git a/lexical/utils/url.js b/lexical/utils/url.js
new file mode 100644
index 00000000..f662a8aa
--- /dev/null
+++ b/lexical/utils/url.js
@@ -0,0 +1,24 @@
+export function sanitizeUrl (url) {
+ /** A pattern that matches safe URLs. */
+ const SAFE_URL_PATTERN =
+ /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi
+
+ /** A pattern that matches safe data URLs. */
+ const DATA_URL_PATTERN =
+ /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i
+
+ url = String(url).trim()
+
+ if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url
+
+ return 'https://'
+}
+
+// Source: https://stackoverflow.com/a/8234912/2013580
+const urlRegExp =
+ /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/
+export function validateUrl (url) {
+ // TODO Fix UI for link insertion; it should never default to an invalid URL such as https://.
+ // Maybe show a dialog where they user can type the URL before inserting it.
+ return url === 'https://' || urlRegExp.test(url)
+}
diff --git a/lib/url.js b/lib/url.js
index 434ac50d..8075cef4 100644
--- a/lib/url.js
+++ b/lib/url.js
@@ -4,3 +4,6 @@ export function ensureProtocol (value) {
}
return value
}
+
+// eslint-disable-next-line
+const URL_REGEXP = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 690f3f21..b9ef7ff4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1143,6 +1143,172 @@
"@jridgewell/sourcemap-codec": "1.4.14"
}
},
+ "@lexical/clipboard": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.7.5.tgz",
+ "integrity": "sha512-H5KA7CfrCYJs3fcDG2LkG/s5dfT1KcuOxEZwinCE0Quzu4aPxHLWKRXImwNsN/zVU5zTnvjd29Zv2NYtfYfBiA==",
+ "requires": {
+ "@lexical/html": "0.7.5",
+ "@lexical/list": "0.7.5",
+ "@lexical/selection": "0.7.5",
+ "@lexical/utils": "0.7.5"
+ }
+ },
+ "@lexical/code": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.7.5.tgz",
+ "integrity": "sha512-WjLkKdP/fpCfokhXmUHIWm9SMJHnD2/u3X6DplQBAQ/BKHfSWHbIpkzOpxB+SPHezzhwX49/j2I3jiJSr4AMbw==",
+ "requires": {
+ "@lexical/utils": "0.7.5",
+ "prismjs": "^1.27.0"
+ }
+ },
+ "@lexical/dragon": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.7.5.tgz",
+ "integrity": "sha512-/EWJJNhlSW/4ixvDyhaS7sBkbKX5fW5wEvpBldmfPvVuALJxj0p/2vN2FVFmLxWlQGxCoFvDU5IA7RJ+Y/U+SQ=="
+ },
+ "@lexical/hashtag": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.7.5.tgz",
+ "integrity": "sha512-5867EAZJvAThHta1JG85n/eLTRirkHaqb/7SI+WAIZSkGfYVJUhk0LjxF5B3GfzBbdGlqJ7NyKZ0DE9QqhL0oA==",
+ "requires": {
+ "@lexical/utils": "0.7.5"
+ }
+ },
+ "@lexical/history": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.7.5.tgz",
+ "integrity": "sha512-vvUr7wQ9KiGXSm8dvozAMs/oOqi6ePcI7QyD1hwD2QF8z8x1f+0D6R9MMdN5y35XsnH6N2ujHI14LX5CuNXeqw==",
+ "requires": {
+ "@lexical/utils": "0.7.5"
+ }
+ },
+ "@lexical/html": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.7.5.tgz",
+ "integrity": "sha512-aHG0pxvVmYvyXstCVmzXpVYR7n3rMUQ6aSCd7fnreJGSxOr/LafT5+8CZQkUm7iR2IiiK8umX8NHzz4cMNgR3Q==",
+ "requires": {
+ "@lexical/selection": "0.7.5"
+ }
+ },
+ "@lexical/link": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.7.5.tgz",
+ "integrity": "sha512-95V183O7uotiF8JRS1CN2272ckgNe3EJI1ezT5dGjdw8JJYDN6mAMhUVQWKBR/kKpNXuznprMH8QjkLLESBakw==",
+ "requires": {
+ "@lexical/utils": "0.7.5"
+ }
+ },
+ "@lexical/list": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.7.5.tgz",
+ "integrity": "sha512-YCDsP0hxIMwjtN3wDKLsFdjrDenX+OdOswm+4Zt/mtLMCOSwibcDDyzWnA7UbR2s4Fwy1Rvhl56xdR2egGCQhw==",
+ "requires": {
+ "@lexical/utils": "0.7.5"
+ }
+ },
+ "@lexical/mark": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.7.5.tgz",
+ "integrity": "sha512-fKEyAUKTJt79YTN24Qi5Uc3HHYZCJs/HPq/yn3Y09Z74K258cW9uyj7LPpjRBeuiD47SVF1Mq7wbbqQ0ak4GPA==",
+ "requires": {
+ "@lexical/utils": "0.7.5"
+ }
+ },
+ "@lexical/markdown": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.7.5.tgz",
+ "integrity": "sha512-kSg1qAgDGGrXo+LBEnOaAOkR0KPYorKjldBnlttH8x0qMIKrblDd6v56oOK7Yl/LgGT3uq7RpAJFK5gKDnVn6w==",
+ "requires": {
+ "@lexical/code": "0.7.5",
+ "@lexical/link": "0.7.5",
+ "@lexical/list": "0.7.5",
+ "@lexical/rich-text": "0.7.5",
+ "@lexical/text": "0.7.5",
+ "@lexical/utils": "0.7.5"
+ }
+ },
+ "@lexical/offset": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.7.5.tgz",
+ "integrity": "sha512-RvBCkXnpgESx0UUXg4G6xJMcT3PBe+kuSQElkFB2KqRqTF0S3eMskLoakFdpSA0yc89Ubrh9+zKMzxoIwGtrmg=="
+ },
+ "@lexical/overflow": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.7.5.tgz",
+ "integrity": "sha512-JoY2jKfoDBVDy6XDCVMakWV7jJO8ks++wx+1uvPrK1s5Qx7UMvaUtID6f1+hph0437JuLKA0IpfDQFKQfp6RIg=="
+ },
+ "@lexical/plain-text": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.7.5.tgz",
+ "integrity": "sha512-sZta4HgT8ShIHCDXF3sBrj7APtBKaLL6Lm9nwyVmv8NWLb1TItOKGy0+i3FrAAVSFNW+RHWJFltMiRUoPRBQFw=="
+ },
+ "@lexical/react": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.7.5.tgz",
+ "integrity": "sha512-Lk51Y514kOfUMF3+92dKKVuPlFrL1r19hOTGhvdi71DF1ewUzTVbbS9oyMFzNAA+rsuKqLKn0zILT43CHRk22w==",
+ "requires": {
+ "@lexical/clipboard": "0.7.5",
+ "@lexical/code": "0.7.5",
+ "@lexical/dragon": "0.7.5",
+ "@lexical/hashtag": "0.7.5",
+ "@lexical/history": "0.7.5",
+ "@lexical/link": "0.7.5",
+ "@lexical/list": "0.7.5",
+ "@lexical/mark": "0.7.5",
+ "@lexical/markdown": "0.7.5",
+ "@lexical/overflow": "0.7.5",
+ "@lexical/plain-text": "0.7.5",
+ "@lexical/rich-text": "0.7.5",
+ "@lexical/selection": "0.7.5",
+ "@lexical/table": "0.7.5",
+ "@lexical/text": "0.7.5",
+ "@lexical/utils": "0.7.5",
+ "@lexical/yjs": "0.7.5",
+ "react-error-boundary": "^3.1.4"
+ }
+ },
+ "@lexical/rich-text": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.7.5.tgz",
+ "integrity": "sha512-z4sscX2Xq7hRUXcLIqgQVcBsifTHreTcC4McXwyD5TC5U+2rk+guREeb6Rq/+Mhct1VttdWIdblw8Odw0oHSqA=="
+ },
+ "@lexical/selection": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.7.5.tgz",
+ "integrity": "sha512-8AUwsDz/i1fshmRlXUQuDFKcilgzi5GIUc38Lpp7HN5ErvyH7EjbFWA+b8qKWidTnWQ9yKHQLUsIFlYMy91V/Q=="
+ },
+ "@lexical/table": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.7.5.tgz",
+ "integrity": "sha512-6hPtEZq+0qOki8vU/sEBL5CHYJ7obt5kbCknEHW3xm38gW7NWMqZ15tvnw1owJnPJNhu4wfCSHPR1JeUHfcJLg==",
+ "requires": {
+ "@lexical/utils": "0.7.5"
+ }
+ },
+ "@lexical/text": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.7.5.tgz",
+ "integrity": "sha512-JGx89XATNdqi3BD60kWdTv15kgV7HoFTgdgrzBc4ZJ2AYj6p16pMl1UaF+x5O++sNWONbQ0mGPyYjfRWhOxqvQ=="
+ },
+ "@lexical/utils": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.7.5.tgz",
+ "integrity": "sha512-WGf96y1h1qDsJK9wztXPzcEupyToa4TmoSzSKhOvebFqS/yg3WlRVUBne6oBj3hptjGoeU9MhGdcGK61EQ/TOw==",
+ "requires": {
+ "@lexical/list": "0.7.5",
+ "@lexical/table": "0.7.5"
+ }
+ },
+ "@lexical/yjs": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.7.5.tgz",
+ "integrity": "sha512-pNyw175VfWhJQj4pnvXgddfjyZhF/lotn7/4MTKbtGxyAI61LrjV7Z3M7v76003v4SEIyQ3WqWOU4TCjNt+alQ==",
+ "requires": {
+ "@lexical/offset": "0.7.5"
+ }
+ },
"@mdn/browser-compat-data": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz",
@@ -5280,6 +5446,11 @@
"type-check": "~0.4.0"
}
},
+ "lexical": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.7.5.tgz",
+ "integrity": "sha512-NrSWqggN1/9EzKLwYKURc+AGTQcPh4kXrE8qemp5yYCJG0XGu25CVNXKFXQkCHJJbdAsfn6qmprH5aVV7sDp5Q=="
+ },
"lightning": {
"version": "6.2.7",
"resolved": "https://registry.npmjs.org/lightning/-/lightning-6.2.7.tgz",
@@ -7545,6 +7716,14 @@
"scheduler": "^0.20.2"
}
},
+ "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": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
diff --git a/package.json b/package.json
index 093a342a..411ea8cf 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
},
"dependencies": {
"@apollo/client": "^3.7.1",
+ "@lexical/react": "^0.7.5",
"@opensearch-project/opensearch": "^1.1.0",
"@prisma/client": "^2.30.3",
"apollo-server-micro": "^3.11.1",
@@ -29,6 +30,7 @@
"graphql-tools": "^8.3.10",
"graphql-type-json": "^0.3.2",
"jquery": "^3.6.1",
+ "lexical": "^0.7.5",
"ln-service": "^54.2.6",
"mdast-util-find-and-replace": "^1.1.1",
"mdast-util-from-markdown": "^1.2.0",
diff --git a/pages/lexical.js b/pages/lexical.js
new file mode 100644
index 00000000..85a59b2f
--- /dev/null
+++ b/pages/lexical.js
@@ -0,0 +1,146 @@
+import LayoutCenter from '../components/layout-center'
+import styles from '../lexical/styles.module.css'
+
+import Theme from '../lexical/theme'
+import ListMaxIndentLevelPlugin from '../lexical/plugins/list-max-indent'
+import AutoLinkPlugin from '../lexical/plugins/autolink'
+import ToolbarPlugin from '../lexical/plugins/toolbar'
+import LinkTooltipPlugin from '../lexical/plugins/link-tooltip'
+
+import { LexicalComposer } from '@lexical/react/LexicalComposer'
+import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
+import { ContentEditable } from '@lexical/react/LexicalContentEditable'
+import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'
+import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
+import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'
+import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'
+import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
+import { HeadingNode, QuoteNode } from '@lexical/rich-text'
+import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
+import { ListItemNode, ListNode } from '@lexical/list'
+import { CodeHighlightNode, CodeNode } from '@lexical/code'
+import { AutoLinkNode, LinkNode } from '@lexical/link'
+import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
+import { ListPlugin } from '@lexical/react/LexicalListPlugin'
+import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'
+import { useState } from 'react'
+import LinkInsertPlugin, { LinkInsertProvider } from '../lexical/plugins/link-insert'
+import { ImageNode } from '../lexical/nodes/image'
+import ImageInsertPlugin from '../lexical/plugins/image-insert'
+import { SN_TRANSFORMERS } from '../lexical/utils/image-markdown-transformer'
+import { $convertToMarkdownString, $convertFromMarkdownString } from '@lexical/markdown'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import Text from '../components/text'
+import { Button } from 'react-bootstrap'
+
+const editorConfig = {
+ // The editor theme
+ theme: Theme,
+ // Handling of errors during update
+ onError (error) {
+ throw error
+ },
+ // Any custom nodes go here
+ nodes: [
+ HeadingNode,
+ ListNode,
+ ListItemNode,
+ QuoteNode,
+ CodeNode,
+ CodeHighlightNode,
+ TableNode,
+ TableCellNode,
+ TableRowNode,
+ AutoLinkNode,
+ LinkNode,
+ HorizontalRuleNode,
+ ImageNode
+ ]
+}
+
+function Editor ({ markdown }) {
+ const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
+
+ const onRef = (_floatingAnchorElem) => {
+ if (_floatingAnchorElem !== null) {
+ setFloatingAnchorElem(_floatingAnchorElem)
+ }
+ }
+
+ let initialConfig = editorConfig
+ if (markdown) {
+ initialConfig = { ...initialConfig, editorState: () => $convertFromMarkdownString(markdown, SN_TRANSFORMERS) }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ }
+ placeholder={null}
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+
+
+
+
+
+
+
+
+
+
+ {!markdown && }
+
+ )
+}
+
+function Markdown () {
+ const [editor] = useLexicalComposerContext()
+ const [markdown, setMarkdown] = useState(null)
+ const [preview, togglePreview] = useState(true)
+
+ return (
+ <>
+
+
editor.update(() => {
+ setMarkdown($convertToMarkdownString(SN_TRANSFORMERS))
+ })}
+ />
+ togglePreview(!preview)}>{preview ? 'show markdown' : 'show preview'}
+
+
+ {preview
+ ? (
+
+ {markdown}
+
+ )
+ : (
+
+ {markdown}
+
+ )}
+
+
+ >
+ )
+}
+
+export default function Lexical () {
+ return (
+
+
+
+ )
+}
diff --git a/public/darkmode.js b/public/darkmode.js
index 6ccb7495..4bb7756a 100644
--- a/public/darkmode.js
+++ b/public/darkmode.js
@@ -18,6 +18,11 @@ const COLORS = {
brandColor: 'rgba(0, 0, 0, 0.9)',
grey: '#707070',
link: '#007cbe',
+ toolbarActive: 'rgba(0, 0, 0, 0.10)',
+ toolbarHover: 'rgba(0, 0, 0, 0.20)',
+ toolbar: '#ffffff',
+ quoteBar: 'rgb(206, 208, 212)',
+ quoteColor: 'rgb(101, 103, 107)',
linkHover: '#004a72',
linkVisited: '#537587'
},
@@ -37,6 +42,11 @@ const COLORS = {
brandColor: 'var(--primary)',
grey: '#969696',
link: '#2e99d1',
+ toolbarActive: 'rgba(255, 255, 255, 0.10)',
+ toolbarHover: 'rgba(255, 255, 255, 0.20)',
+ toolbar: '#3e3f3f',
+ quoteBar: 'rgb(158, 159, 163)',
+ quoteColor: 'rgb(141, 144, 150)',
linkHover: '#007cbe',
linkVisited: '#56798E'
}
diff --git a/styles/globals.scss b/styles/globals.scss
index d43cd89f..d5e3b7eb 100644
--- a/styles/globals.scss
+++ b/styles/globals.scss
@@ -155,17 +155,22 @@ a:hover {
}
select.custom-select,
+div[contenteditable],
.form-control {
background-color: var(--theme-inputBg);
color: var(--theme-color);
border-color: var(--theme-borderColor);
}
+div[contenteditable]:focus,
.form-control:focus {
background-color: var(--theme-inputBg);
color: var(--theme-color);
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgb(250 218 94 / 25%);
}
+div[contenteditable]:disabled,
.form-control:disabled,
.form-control[readonly] {
background-color: var(--theme-inputDisabledBg);
@@ -222,15 +227,15 @@ select.custom-select,
}
.dropdown-item {
- color: var(--theme-dropdownItemColor);
+ color: var(--theme-dropdownItemColor) !important;
}
.dropdown-item:hover {
- color: var(--theme-dropdownItemColorHover);
+ color: var(--theme-dropdownItemColorHover) !important;
}
.dropdown-item.active {
- color: var(--theme-brandColor);
+ color: var(--theme-brandColor) !important;
text-shadow: 0 0 10px var(--primary);
}
@@ -286,12 +291,15 @@ footer {
textarea,
.form-control,
.form-control:focus,
+ div[contenteditable],
+ div[contenteditable]:focus,
.input-group-text {
font-size: 1rem !important;
}
}
-textarea.form-control {
+textarea.form-control,
+div[contenteditable] {
line-height: 1rem;
}
@@ -364,6 +372,7 @@ textarea.form-control {
text-shadow: 0 0 10px var(--primary);
}
+div[contenteditable]:focus,
.form-control:focus {
border-color: var(--primary);
}
diff --git a/svgs/bold.svg b/svgs/bold.svg
new file mode 100644
index 00000000..01231712
--- /dev/null
+++ b/svgs/bold.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/check-line.svg b/svgs/check-line.svg
new file mode 100644
index 00000000..4a987a60
--- /dev/null
+++ b/svgs/check-line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/code-box-line.svg b/svgs/code-box-line.svg
new file mode 100644
index 00000000..d94ca92c
--- /dev/null
+++ b/svgs/code-box-line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/code-line.svg b/svgs/code-line.svg
new file mode 100644
index 00000000..e4919bfe
--- /dev/null
+++ b/svgs/code-line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/double-quotes-r.svg b/svgs/double-quotes-r.svg
new file mode 100644
index 00000000..c37f4073
--- /dev/null
+++ b/svgs/double-quotes-r.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/font-size-2.svg b/svgs/font-size-2.svg
new file mode 100644
index 00000000..693916d7
--- /dev/null
+++ b/svgs/font-size-2.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/image-add-line.svg b/svgs/image-add-line.svg
new file mode 100644
index 00000000..d01565e9
--- /dev/null
+++ b/svgs/image-add-line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/image-line.svg b/svgs/image-line.svg
new file mode 100644
index 00000000..0090d377
--- /dev/null
+++ b/svgs/image-line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/indent-decrease.svg b/svgs/indent-decrease.svg
new file mode 100644
index 00000000..54403802
--- /dev/null
+++ b/svgs/indent-decrease.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/indent-increase.svg b/svgs/indent-increase.svg
new file mode 100644
index 00000000..e9f8aef8
--- /dev/null
+++ b/svgs/indent-increase.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/italic.svg b/svgs/italic.svg
new file mode 100644
index 00000000..3268b1d4
--- /dev/null
+++ b/svgs/italic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/list-ordered.svg b/svgs/list-ordered.svg
new file mode 100644
index 00000000..7664d042
--- /dev/null
+++ b/svgs/list-ordered.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/strikethrough.svg b/svgs/strikethrough.svg
new file mode 100644
index 00000000..9b0f45ad
--- /dev/null
+++ b/svgs/strikethrough.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/underline.svg b/svgs/underline.svg
new file mode 100644
index 00000000..b07a2361
--- /dev/null
+++ b/svgs/underline.svg
@@ -0,0 +1 @@
+
\ No newline at end of file