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 ( + {altText} + ) +} + +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 ( +
{ + editor.dispatchCommand(INSERT_IMAGE_COMMAND, { src: ensureProtocol(url), altText: alt }) + onClose() + }} + > + + alt text optional} + name='alt' + /> +
+ ok +
+
+ ) +} + +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 +
+ +
{ + editor.dispatchCommand(INSERT_LINK_COMMAND, { url: ensureProtocol(url), text }) + await setLink(null) + setTimeout(() => editor.focus(), 100) + }} + > + + +
+ ok +
+
+
+
+ ) +} 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 ( +
+ + + <> + + + + + + + + + {/* */} + {/* */} + {/* */} + {/* */} + + + + {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)) + })} + /> + +
+ + {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