lexical beta test url

This commit is contained in:
keyan 2023-01-05 13:24:09 -06:00
parent a92172ab9b
commit 2080cb896d
40 changed files with 2449 additions and 14 deletions

View File

@ -43,8 +43,6 @@ export default {
GROUP BY time
ORDER BY time ASC`, Number(me.id))
console.log(totalSats)
return {
totalSats,
totalReferrals,

View File

@ -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'
}

View File

@ -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)
})

View File

@ -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
}

View File

@ -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 {

461
lexical/nodes/image.js Normal file
View File

@ -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 (
<img
className={className || undefined}
src={src}
alt={altText}
ref={imageRef}
style={{
height,
maxHeight: '25vh',
// maxWidth,
// width,
display: 'block',
marginBottom: '.5rem',
marginTop: '.5rem',
borderRadius: '.4rem',
width: 'auto',
maxWidth: '100%'
}}
/>
)
}
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 (
<Suspense fallback={null}>
<ImageComponent
src={this.__src}
altText={this.__altText}
width={this.__width}
height={this.__height}
maxWidth={this.__maxWidth}
nodeKey={this.getKey()}
showCaption={this.__showCaption}
caption={this.__caption}
captionsEnabled={this.__captionsEnabled}
resizable
/>
</Suspense>
)
}
}
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 (
<Suspense fallback={null}>
<>
<div draggable>
<LazyImage
// className={
// isFocused
// ? `focused ${$isNodeSelection(selection) ? 'draggable' : ''}`
// : null
// }
src={src}
altText={altText}
imageRef={imageRef}
width={width}
height={height}
maxWidth={maxWidth}
/>
</div>
</>
</Suspense>
)
}

View File

@ -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 <AutoLinkPlugin matchers={MATCHERS} />
}

View File

@ -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 (
<Form
initial={{
url: '',
alt: ''
}}
schema={LinkSchema}
onSubmit={async ({ alt, url }) => {
editor.dispatchCommand(INSERT_IMAGE_COMMAND, { src: ensureProtocol(url), altText: alt })
onClose()
}}
>
<Input
label='url'
name='url'
innerRef={inputRef}
required
autoFocus
/>
<Input
label={<>alt text <small className='text-muted ml-2'>optional</small></>}
name='alt'
/>
<div className='d-flex'>
<SubmitButton variant='success' className='ml-auto mt-1 px-4'>ok</SubmitButton>
</div>
</Form>
)
}
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
}

View File

@ -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 (
<LinkInsertContext.Provider value={contextValue}>
<LinkInsertModal />
{children}
</LinkInsertContext.Provider>
)
}
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 (
<Modal
show={!!link}
onHide={() => {
setLink(null)
setTimeout(() => editor.focus(), 100)
}}
>
<div
className='modal-close' onClick={() => {
setLink(null)
// I think bootstrap messes with the focus on close so we have to do this ourselves
setTimeout(() => editor.focus(), 100)
}}
>X
</div>
<Modal.Body>
<Form
initial={{
text: link?.text,
url: link?.url
}}
schema={LinkSchema}
onSubmit={async ({ text, url }) => {
editor.dispatchCommand(INSERT_LINK_COMMAND, { url: ensureProtocol(url), text })
await setLink(null)
setTimeout(() => editor.focus(), 100)
}}
>
<Input
label='text'
name='text'
innerRef={inputRef}
required
/>
<Input
label='url'
name='url'
required
/>
<div className='d-flex'>
<SubmitButton variant='success' className='ml-auto mt-1 px-4'>ok</SubmitButton>
</div>
</Form>
</Modal.Body>
</Modal>
)
}

View File

@ -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 &&
<div ref={editorRef} className={styles.linkTooltip}>
<div className='tooltip-inner d-flex'>
<a href={linkUrl} target='_blank' rel='noreferrer' className={`${styles.tooltipUrl} text-reset`}>{linkUrl.replace('https://', '').replace('http://', '')}</a>
<span className='px-1'> \ </span>
<span
className='pointer'
onClick={() => {
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
</span>
<span className='px-1'> \ </span>
<span
className='pointer'
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}}
>remove
</span>
</div>
</div>
)
}
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
? <FloatingLinkEditor
editor={activeEditor}
isLink={isLink}
anchorElem={anchorElem}
setIsLink={setIsLink}
/>
: null
}
export default function LinkTooltipPlugin ({
anchorElem = document.body
}) {
const [editor] = useLexicalComposerContext()
return useFloatingLinkEditorToolbar({ editor, anchorElem })
}

View File

@ -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
}

383
lexical/plugins/toolbar.js Normal file
View File

@ -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 <div className={styles.divider} />
}
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 (
<Dropdown className='pointer' as='span'>
<Dropdown.Toggle
id='dropdown-basic'
as='button' className={styles.toolbarItem} aria-label='Font size'
>
<FontSizeIcon />
<ArrowDownIcon />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item as='button' className={`${styles.paragraph} my-0`} onClick={formatParagraph}>
<CheckIcon className={`mr-1 ${blockType === 'paragraph' ? 'fill-grey' : 'invisible'}`} />
<span className={styles.text}>normal</span>
</Dropdown.Item>
<Dropdown.Item as='button' className={`${styles.heading2} my-0`} onClick={formatSmallHeading}>
<CheckIcon className={`mr-1 ${['h2', 'h3', 'h4', 'h5', 'h6'].includes(blockType) ? 'fill-grey' : 'invisible'}`} />
<span className={styles.text}>subheading</span>
</Dropdown.Item>
<Dropdown.Item as='button' className={`${styles.heading1} my-0`} onClick={formatLargeHeading}>
<CheckIcon className={`mr-1 ${blockType === 'h1' ? 'fill-grey' : 'invisible'}`} />
<span className={styles.text}>heading</span>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
)
}
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 (
<div className={styles.toolbar} ref={toolbarRef}>
<FontSizeDropdown editor={editor} blockType={blockType} />
<Divider />
<>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
}}
className={`${styles.toolbarItem} ${styles.spaced} ${isBold ? styles.active : ''}`}
aria-label='Format Bold'
>
<BoldIcon />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
}}
className={`${styles.toolbarItem} ${styles.spaced} ${isItalic ? styles.active : ''}`}
aria-label='Format Italics'
>
<ItalicIcon />
</button>
<Divider />
<button
onClick={formatBulletList}
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'ul' ? styles.active : ''}`}
>
<ListUnorderedIcon />
</button>
<button
onClick={formatNumberedList}
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'ol' ? styles.active : ''}`}
aria-label='Insert numbered list'
>
<ListOrderedIcon />
</button>
<button
onClick={() => {
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
}}
className={`${styles.toolbarItem} ${styles.spaced}`}
aria-label='Indent'
>
<IndentIcon />
</button>
<button
onClick={() => {
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
}}
className={`${styles.toolbarItem} ${styles.spaced}`}
aria-label='Outdent'
>
<OutdentIcon />
</button>
<button
onClick={formatQuote}
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'quote' ? styles.active : ''}`}
aria-label='Insert Quote'
>
<QuoteIcon />
</button>
{/* <Divider /> */}
{/* <button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
}}
className={
`${styles.toolbarItem} ${styles.spaced} ${isStrikethrough ? styles.active : ''}`
}
aria-label='Format Strikethrough'
>
<StrikethroughIcon />
</button> */}
{/* <button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
}}
className={`${styles.toolbarItem} ${styles.spaced} ${isCode ? styles.active : ''}`}
aria-label='Insert Code'
>
<CodeIcon />
</button> */}
{/* <button
onClick={formatCode}
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'code' ? styles.active : ''}`}
aria-label='Insert Code'
>
<CodeBoxIcon />
</button> */}
<Divider />
<button
onClick={insertLink}
className={`${styles.toolbarItem} ${styles.spaced} ${isLink ? styles.active : ''}`}
aria-label='Insert Link'
>
<LinkIcon />
</button>
<button
onClick={() => {
showModal((onClose) => (
<ImageInsertModal
editor={editor}
onClose={onClose}
/>
))
}}
className={`${styles.toolbarItem} ${styles.spaced}`}
aria-label='Insert Image'
>
<ImageIcon />
</button>
{modal}
</>
</div>
)
}

256
lexical/styles.module.css Normal file
View File

@ -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;
}

36
lexical/theme.js Normal file
View File

@ -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

View File

@ -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
]

View File

@ -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 }
}

34
lexical/utils/modal.js Normal file
View File

@ -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 (
<Modal onHide={onClose} show={!!modalContent}>
<div className='modal-close' onClick={onClose}>X</div>
<Modal.Body>
{modalContent}
</Modal.Body>
</Modal>
)
}, [modalContent, onClose])
const showModal = useCallback(
(getContent) => {
setModalContent(getContent(onClose))
},
[onClose]
)
return [modal, showModal]
}

View File

@ -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
}
}

View File

@ -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)`
}

24
lexical/utils/url.js Normal file
View File

@ -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)
}

View File

@ -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

179
package-lock.json generated
View File

@ -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",

View File

@ -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",

146
pages/lexical.js Normal file
View File

@ -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 (
<LexicalComposer initialConfig={initialConfig}>
<div className={styles.editorContainer}>
<div className={styles.editorInner}>
<LinkInsertProvider>
<ToolbarPlugin />
<LinkTooltipPlugin anchorElem={floatingAnchorElem} />
<LinkInsertPlugin />
</LinkInsertProvider>
<RichTextPlugin
contentEditable={
<div className={styles.editor} ref={onRef}>
<ContentEditable className={styles.editorInput} />
</div>
}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
/>
<ImageInsertPlugin />
<AutoFocusPlugin />
<ListPlugin />
<LinkPlugin />
<AutoLinkPlugin />
<HistoryPlugin />
<ListMaxIndentLevelPlugin maxDepth={4} />
<MarkdownShortcutPlugin transformers={SN_TRANSFORMERS} />
</div>
</div>
{!markdown && <Markdown />}
</LexicalComposer>
)
}
function Markdown () {
const [editor] = useLexicalComposerContext()
const [markdown, setMarkdown] = useState(null)
const [preview, togglePreview] = useState(true)
return (
<>
<div className='text-left w-100'>
<OnChangePlugin onChange={() => editor.update(() => {
setMarkdown($convertToMarkdownString(SN_TRANSFORMERS))
})}
/>
<Button size='sm' className='mb-2' onClick={() => togglePreview(!preview)}>{preview ? 'show markdown' : 'show preview'}</Button>
<div style={{ border: '1px solid var(--theme-color)', padding: '.5rem', borderRadius: '.4rem' }}>
{preview
? (
<Text>
{markdown}
</Text>
)
: (
<pre className='text-reset p-0 m-0'>
{markdown}
</pre>
)}
</div>
</div>
</>
)
}
export default function Lexical () {
return (
<LayoutCenter footerLinks>
<Editor />
</LayoutCenter>
)
}

View File

@ -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'
}

View File

@ -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);
}

1
svgs/bold.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M8 11h4.5a2.5 2.5 0 1 0 0-5H8v5zm10 4.5a4.5 4.5 0 0 1-4.5 4.5H6V4h6.5a4.5 4.5 0 0 1 3.256 7.606A4.498 4.498 0 0 1 18 15.5zM8 13v5h5.5a2.5 2.5 0 1 0 0-5H8z"/></svg>

After

Width:  |  Height:  |  Size: 292 B

1
svgs/check-line.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414z"/></svg>

After

Width:  |  Height:  |  Size: 204 B

1
svgs/code-box-line.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 3h18a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2v14h16V5H4zm16 7l-3.536 3.536-1.414-1.415L17.172 12 15.05 9.879l1.414-1.415L20 12zM6.828 12l2.122 2.121-1.414 1.415L4 12l3.536-3.536L8.95 9.88 6.828 12zm4.416 5H9.116l3.64-10h2.128l-3.64 10z"/></svg>

After

Width:  |  Height:  |  Size: 403 B

1
svgs/code-line.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M23 12l-7.071 7.071-1.414-1.414L20.172 12l-5.657-5.657 1.414-1.414L23 12zM3.828 12l5.657 5.657-1.414 1.414L1 12l7.071-7.071 1.414 1.414L3.828 12z"/></svg>

After

Width:  |  Height:  |  Size: 283 B

1
svgs/double-quotes-r.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M19.417 6.679C20.447 7.773 21 9 21 10.989c0 3.5-2.457 6.637-6.03 8.188l-.893-1.378c3.335-1.804 3.987-4.145 4.247-5.621-.537.278-1.24.375-1.929.311-1.804-.167-3.226-1.648-3.226-3.489a3.5 3.5 0 0 1 3.5-3.5c1.073 0 2.099.49 2.748 1.179zm-10 0C10.447 7.773 11 9 11 10.989c0 3.5-2.457 6.637-6.03 8.188l-.893-1.378c3.335-1.804 3.987-4.145 4.247-5.621-.537.278-1.24.375-1.929.311C4.591 12.322 3.17 10.841 3.17 9a3.5 3.5 0 0 1 3.5-3.5c1.073 0 2.099.49 2.748 1.179z"/></svg>

After

Width:  |  Height:  |  Size: 594 B

1
svgs/font-size-2.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M10 6v15H8V6H2V4h14v2h-6zm8 8v7h-2v-7h-3v-2h8v2h-3z"/></svg>

After

Width:  |  Height:  |  Size: 189 B

1
svgs/image-add-line.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M21 15v3h3v2h-3v3h-2v-3h-3v-2h3v-3h2zm.008-12c.548 0 .992.445.992.993V13h-2V5H4v13.999L14 9l3 3v2.829l-3-3L6.827 19H14v2H2.992A.993.993 0 0 1 2 20.007V3.993A1 1 0 0 1 2.992 3h18.016zM8 7a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/></svg>

After

Width:  |  Height:  |  Size: 353 B

1
svgs/image-line.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M4.828 21l-.02.02-.021-.02H2.992A.993.993 0 0 1 2 20.007V3.993A1 1 0 0 1 2.992 3h18.016c.548 0 .992.445.992.993v16.014a1 1 0 0 1-.992.993H4.828zM20 15V5H4v14L14 9l6 6zm0 2.828l-6-6L6.828 19H20v-1.172zM8 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></svg>

After

Width:  |  Height:  |  Size: 372 B

1
svgs/indent-decrease.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 4h18v2H3V4zm0 15h18v2H3v-2zm8-5h10v2H11v-2zm0-5h10v2H11V9zm-8 3.5L7 9v7l-4-3.5z"/></svg>

After

Width:  |  Height:  |  Size: 220 B

1
svgs/indent-increase.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 4h18v2H3V4zm0 15h18v2H3v-2zm8-5h10v2H11v-2zm0-5h10v2H11V9zm-4 3.5L3 16V9l4 3.5z"/></svg>

After

Width:  |  Height:  |  Size: 220 B

1
svgs/italic.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M15 20H7v-2h2.927l2.116-12H9V4h8v2h-2.927l-2.116 12H15z"/></svg>

After

Width:  |  Height:  |  Size: 193 B

1
svgs/list-ordered.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M8 4h13v2H8V4zM5 3v3h1v1H3V6h1V4H3V3h2zM3 14v-2.5h2V11H3v-1h3v2.5H4v.5h2v1H3zm2 5.5H3v-1h2V18H3v-1h3v4H3v-1h2v-.5zM8 11h13v2H8v-2zm0 7h13v2H8v-2z"/></svg>

After

Width:  |  Height:  |  Size: 283 B

1
svgs/strikethrough.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M17.154 14c.23.516.346 1.09.346 1.72 0 1.342-.524 2.392-1.571 3.147C14.88 19.622 13.433 20 11.586 20c-1.64 0-3.263-.381-4.87-1.144V16.6c1.52.877 3.075 1.316 4.666 1.316 2.551 0 3.83-.732 3.839-2.197a2.21 2.21 0 0 0-.648-1.603l-.12-.117H3v-2h18v2h-3.846zm-4.078-3H7.629a4.086 4.086 0 0 1-.481-.522C6.716 9.92 6.5 9.246 6.5 8.452c0-1.236.466-2.287 1.397-3.153C8.83 4.433 10.271 4 12.222 4c1.471 0 2.879.328 4.222.984v2.152c-1.2-.687-2.515-1.03-3.946-1.03-2.48 0-3.719.782-3.719 2.346 0 .42.218.786.654 1.099.436.313.974.562 1.613.75.62.18 1.297.414 2.03.699z"/></svg>

After

Width:  |  Height:  |  Size: 694 B

1
svgs/underline.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M8 3v9a4 4 0 1 0 8 0V3h2v9a6 6 0 1 1-12 0V3h2zM4 20h16v2H4v-2z"/></svg>

After

Width:  |  Height:  |  Size: 200 B