diff --git a/api/payIn/lib/item.js b/api/payIn/lib/item.js index f4222fab8f..319c7591b6 100644 --- a/api/payIn/lib/item.js +++ b/api/payIn/lib/item.js @@ -1,6 +1,6 @@ import { USER_ID } from '@/lib/constants' import { deleteReminders, getDeleteAt, getRemindAt } from '@/lib/item' -import { parseInternalLinks } from '@/lib/url' +import { parseInternalLinks, SN_ITEM_URL_REGEXP } from '@/lib/url' export async function getSub (models, { subName, parentId }) { if (!subName && !parentId) { @@ -49,7 +49,7 @@ export async function getMentions (tx, { text, userId }) { } export const getItemMentions = async (tx, { text, userId }) => { - const linkPattern = new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/\\d+[a-zA-Z0-9/?=]*`, 'gi') + const linkPattern = new RegExp(SN_ITEM_URL_REGEXP.source, 'gi') const refs = text.match(linkPattern)?.map(m => { try { const { itemId, commentId } = parseInternalLinks(m) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 4bdb81d558..b0a786856d 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -28,6 +28,8 @@ import { GqlAuthenticationError, GqlInputError } from '@/lib/error' import { verifyHmac } from './wallet' import { parse } from 'tldts' import { shuffleArray } from '@/lib/rand' +import { lexicalHTMLGenerator } from '@/lib/lexical/utils/server/html' +import { prepareLexicalState } from '@/lib/lexical/utils/server/interpolator' import pay from '../payIn' function commentsOrderByClause (me, models, sort) { @@ -1150,6 +1152,62 @@ export default { }) return result.lastViewedAt + }, + executeConversion: async (parent, { itemId, fullRefresh }, { models, me }) => { + if (me?.id !== 21861) { + throw new GqlAuthenticationError() + } + + console.log(`[executeConversion] scheduling conversion for item ${itemId}`) + + // check if job is already scheduled or running + const alreadyScheduled = await models.$queryRaw` + SELECT state + FROM pgboss.job + WHERE name = 'migrateLegacyContent' + AND data->>'itemId' = ${itemId}::TEXT + AND state IN ('created', 'active', 'retry') + LIMIT 1 + ` + + if (alreadyScheduled.length > 0) { + console.log(`[executeConversion] item ${itemId} already has active job`) + return { + success: false, + message: `migration already ${alreadyScheduled[0].state} for this item` + } + } + + // schedule the migration job + await models.$executeRaw` + INSERT INTO pgboss.job ( + name, + data, + retrylimit, + retrybackoff, + startafter, + keepuntil, + singletonKey + ) + VALUES ( + 'migrateLegacyContent', + jsonb_build_object( + 'itemId', ${itemId}::INTEGER, + 'fullRefresh', ${fullRefresh}::BOOLEAN, + 'checkMedia', true + ), + 3, -- reduced retry limit for manual conversions + true, + now(), + now() + interval '1 hour', + 'migrateLegacyContent:' || ${itemId}::TEXT + ) + ` + + return { + success: true, + message: 'migration scheduled successfully' + } } }, Item: { @@ -1586,21 +1644,32 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. item.url = removeTracking(item.url) } + // create markdown from a lexical state + const { text, lexicalState } = await prepareLexicalState({ text: item.text, lexicalState: item.lexicalState }) + item.text = text + item.lexicalState = lexicalState + if (old.bio) { // prevent editing a bio like a regular item - item = { id: Number(item.id), text: item.text, title: `@${user.name}'s bio` } + item = { id: Number(item.id), text: item.text, lexicalState: item.lexicalState, title: `@${user.name}'s bio` } } else if (old.parentId) { // prevent editing a comment like a post - item = { id: Number(item.id), text: item.text, boost: item.boost } + item = { id: Number(item.id), text: item.text, lexicalState: item.lexicalState, boost: item.boost } } else { item = { subName, ...item } item.forwardUsers = await getForwardUsers(models, forward) } + // todo: refactor to use uploadIdsFromLexicalState + // it should be way faster and more reliable + // by checking MediaNodes directly. item.uploadIds = uploadIdsFromText(item.text) // never change author of item item.userId = old.userId + // generate sanitized html from lexical state + item.html = lexicalHTMLGenerator(item.lexicalState) + return await pay('ITEM_UPDATE', item, { models, me, lnd }) } @@ -1612,6 +1681,15 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd item.userId = me ? Number(me.id) : USER_ID.anon item.forwardUsers = await getForwardUsers(models, forward) + + // create markdown from a lexical state + const { text, lexicalState } = await prepareLexicalState({ text: item.text, lexicalState: item.lexicalState }) + item.text = text + item.lexicalState = lexicalState + + // todo: refactor to use uploadIdsFromLexicalState + // it should be way faster and more reliable + // by checking MediaNodes directly. item.uploadIds = uploadIdsFromText(item.text) if (item.url && !isJob(item)) { @@ -1629,6 +1707,9 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd // mark item as created with API key item.apiKey = me?.apiKey + // generate sanitized html from lexical state + item.html = lexicalHTMLGenerator(item.lexicalState) + return await pay('ITEM_CREATE', item, { models, me, lnd }) } diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index da76cb62e5..b4baab57b7 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -6,6 +6,7 @@ import pay from '../payIn' import { GqlAuthenticationError, GqlInputError } from '@/lib/error' import { uploadIdsFromText } from './upload' import { Prisma } from '@prisma/client' +import { prepareLexicalState } from '@/lib/lexical/utils/server/interpolator' export async function getSub (parent, { name }, { models, me }) { if (!name) return null @@ -241,6 +242,13 @@ export default { await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } }) + // QUIRK + // if we have a lexicalState, we'll convert it to markdown to fit the schema + if (data.lexicalState) { + const { text } = await prepareLexicalState({ lexicalState: data.lexicalState }) + data.desc = text + delete data.lexicalState + } data.uploadIds = uploadIdsFromText(data.desc) if (data.oldName) { diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 35ca496315..d45e9af64f 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -208,7 +208,7 @@ export default { let users = [] if (q) { users = await models.$queryRaw` - SELECT name + SELECT id, name FROM users WHERE ( id > ${RESERVED_MAX_USER_ID} OR id IN (${USER_ID.anon}, ${USER_ID.delete}) @@ -218,7 +218,7 @@ export default { LIMIT ${limit}` } else { users = await models.$queryRaw` - SELECT name + SELECT id, name FROM "AggPayOut" JOIN users on users.id = "AggPayOut"."userId" WHERE NOT users."hideFromTopUsers" @@ -676,19 +676,19 @@ export default { return Number(photoId) }, - upsertBio: async (parent, { text }, { me, models, lnd }) => { + upsertBio: async (parent, { text, lexicalState }, { me, models, lnd }) => { if (!me) { throw new GqlAuthenticationError() } - await validateSchema(bioSchema, { text }) + await validateSchema(bioSchema, { text, lexicalState }) const user = await models.user.findUnique({ where: { id: me.id } }) if (user.bioId) { - return await updateItem(parent, { id: user.bioId, bio: true, text, title: `@${user.name}'s bio` }, { me, models, lnd }) + return await updateItem(parent, { id: user.bioId, bio: true, text, lexicalState, title: `@${user.name}'s bio` }, { me, models, lnd }) } else { - return await createItem(parent, { bio: true, text, title: `@${user.name}'s bio` }, { me, models, lnd }) + return await createItem(parent, { bio: true, text, lexicalState, title: `@${user.name}'s bio` }, { me, models, lnd }) } }, generateApiKey: async (parent, { id }, { models, me }) => { diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index df9c6edcad..f1fa523db4 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -33,26 +33,32 @@ export default gql` subscribeItem(id: ID): Item deleteItem(id: ID): Item upsertLink( - id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput], + id: ID, sub: String, title: String!, url: String!, text: String, lexicalState: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): PayIn! upsertDiscussion( - id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], + id: ID, sub: String, title: String!, text: String, lexicalState: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): PayIn! upsertBounty( - id: ID, sub: String, title: String!, text: String, bounty: Int, boost: Int, forward: [ItemForwardInput], + id: ID, sub: String, title: String!, text: String, lexicalState: String, bounty: Int, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): PayIn! upsertJob( id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, - text: String!, url: String!, boost: Int, status: String, logo: Int): PayIn! + text: String!, lexicalState: String, url: String!, boost: Int, status: String, logo: Int): PayIn! upsertPoll( - id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date, + id: ID, sub: String, title: String!, text: String, lexicalState: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date, randPollOptions: Boolean, hash: String, hmac: String): PayIn! updateNoteId(id: ID!, noteId: String!): Item! - upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): PayIn! + upsertComment(id: ID, text: String, lexicalState: String, parentId: ID, boost: Int, hash: String, hmac: String): PayIn! act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): PayIn! pollVote(id: ID!): PayIn! toggleOutlaw(id: ID!): Item! updateCommentsViewAt(id: ID!, meCommentsViewedAt: Date!): Date + executeConversion(itemId: ID!, fullRefresh: Boolean): ConversionResult! + } + + type ConversionResult { + success: Boolean! + message: String! } type PollOption { @@ -105,6 +111,8 @@ export default gql` url: String searchText: String text: String + lexicalState: String + html: String parentId: Int parent: Item root: Item diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index 12f6066816..954c2ab616 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -18,7 +18,7 @@ export default gql` } extend type Mutation { - upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!, + upsertSub(oldName: String, name: String!, desc: String, lexicalState: String, baseCost: Int!, replyCost: Int!, postTypes: [String!]!, billingType: String!, billingAutoRenew: Boolean!, @@ -27,7 +27,7 @@ export default gql` toggleMuteSub(name: String!): Boolean! toggleSubSubscription(name: String!): Boolean! transferTerritory(subName: String!, userName: String!): Sub - unarchiveTerritory(name: String!, desc: String, baseCost: Int!, + unarchiveTerritory(name: String!, desc: String, lexicalState: String, baseCost: Int!, replyCost: Int!, postTypes: [String!]!, billingType: String!, billingAutoRenew: Boolean!, moderated: Boolean!, nsfw: Boolean!): PayIn! diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 5d7a2f88a3..dcf99741a2 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -42,7 +42,7 @@ export default gql` setSettings(settings: SettingsInput!): User cropPhoto(photoId: ID!, cropData: CropData): String! setPhoto(photoId: ID!): Int! - upsertBio(text: String!): PayIn! + upsertBio(text: String, lexicalState: String): PayIn! setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean unlinkAuth(authType: String!): AuthMethods! linkUnverifiedEmail(email: String!): Boolean diff --git a/components/action-tooltip.js b/components/action-tooltip.js index ecc011a856..7528f06872 100644 --- a/components/action-tooltip.js +++ b/components/action-tooltip.js @@ -2,7 +2,7 @@ import { useFormikContext } from 'formik' import OverlayTrigger from 'react-bootstrap/OverlayTrigger' import Tooltip from 'react-bootstrap/Tooltip' -export default function ActionTooltip ({ children, notForm, disable, overlayText, placement }) { +export default function ActionTooltip ({ children, notForm, disable, overlayText, placement, noWrapper, showDelay, hideDelay, transition }) { // if we're in a form, we want to hide tooltip on submit let formik if (!notForm) { @@ -21,6 +21,8 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText } trigger={['hover', 'focus']} show={formik?.isSubmitting ? false : undefined} + delay={{ show: showDelay || 0, hide: hideDelay || 0 }} + transition={transition || false} popperConfig={{ modifiers: { preventOverflow: { @@ -29,9 +31,7 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText } }} > - - {children} - + {noWrapper ? children : {children}} ) } diff --git a/components/bounty-form.js b/components/bounty-form.js index 1212cc42af..c6334e28e2 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -1,4 +1,4 @@ -import { Form, Input, MarkdownInput } from '@/components/form' +import { Form, Input, LexicalInput } from '@/components/form' import { useApolloClient } from '@apollo/client' import AdvPostForm, { AdvPostInitial } from './adv-post-form' import InputGroup from 'react-bootstrap/InputGroup' @@ -34,6 +34,7 @@ export function BountyForm ({ initial={{ title: item?.title || '', text: item?.text || '', + lexicalState: item?.lexicalState || '', crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting, bounty: item?.bounty || 1000, ...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }), @@ -60,7 +61,9 @@ export function BountyForm ({ label={bountyLabel} name='bounty' required append={sats} /> - {textLabel} optional} topLevel /> + {/* TODO: implement EditInfo in LexicalInput */} + {/* @@ -70,7 +73,7 @@ export function BountyForm ({ name='text' minRows={6} hint={EditInfo} - /> + /> */} diff --git a/components/comment-edit.js b/components/comment-edit.js index 197150d48d..a345687a76 100644 --- a/components/comment-edit.js +++ b/components/comment-edit.js @@ -1,10 +1,11 @@ -import { Form, MarkdownInput } from '@/components/form' +import { Form, LexicalInput, MarkdownInput } from '@/components/form' import styles from './reply.module.css' import { commentSchema } from '@/lib/validate' import { FeeButtonProvider } from './fee-button' import { ItemButtonBar } from './post' import { UPDATE_COMMENT } from '@/fragments/payIn' import useItemSubmit from './use-item-submit' +import { MAX_COMMENT_TEXT_LENGTH } from '@/lib/constants' export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) { const onSubmit = useItemSubmit(UPDATE_COMMENT, { @@ -18,6 +19,12 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc fields: { text () { return result.text + }, + lexicalState () { + return result.lexicalState + }, + html () { + return result.html } }, optimistic: true @@ -34,17 +41,26 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
- + {comment.lexicalState + ? + : ( + + )}
diff --git a/components/comment.js b/components/comment.js index 99af3b8ebb..cdd801b209 100644 --- a/components/comment.js +++ b/components/comment.js @@ -1,6 +1,6 @@ import itemStyles from './item.module.css' import styles from './comment.module.css' -import Text, { SearchText } from './text' +import Text, { SearchText, LexicalText } from './text' import Link from 'next/link' import Reply from './reply' import { useEffect, useMemo, useRef, useState } from 'react' @@ -28,6 +28,7 @@ import LinkToContext from './link-to-context' import Boost from './boost-button' import { gql, useApolloClient } from '@apollo/client' import classNames from 'classnames' +import { getParsedHTML } from '@/lib/dompurify' function Parent ({ item, rootText }) { const root = useRoot() @@ -61,6 +62,29 @@ const truncateString = (string = '', maxLength = 140) => ? `${string.substring(0, maxLength)} […]` : string +// sanitizes HTML via getParsedHTML +// truncates the resulting HTML and returns it +const truncateHTML = (html = '', text = '', maxLines = 3) => { + try { + const doc = getParsedHTML(html) + const body = doc.body + + // take the first maxLines child nodes + const nodes = Array.from(body.children).slice(0, maxLines) + + // create a new container with the first maxLines child nodes + const container = doc.createElement('div') + nodes.forEach(node => { + container.appendChild(node.cloneNode(true)) + }) + + return container.innerHTML + } catch (error) { + console.error('error truncating HTML: ', error) + return text ? truncateString(text) : '' + } +} + export function CommentFlat ({ item, rank, siblingComments, ...props }) { const router = useRouter() const [href, as] = useMemo(() => { @@ -286,12 +310,20 @@ export default function Comment ({
{item.searchText ? - : ( - - {item.outlawed && !me?.privates?.wildWestMode - ? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*' - : truncate ? truncateString(item.text) : item.text} - )} + : item.lexicalState && router.query.md !== 'true' + ? ( + + {item.outlawed && !me?.privates?.wildWestMode + ? stackers have outlawed this. turn on wild west mode in your settings to see outlawed content. + : truncate ?
: undefined} + + ) + : ( + + {item.outlawed && !me?.privates?.wildWestMode + ? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*' + : truncate ? truncateString(item.text) : item.text} + )}
)}
diff --git a/components/delete.js b/components/delete.js index 1189276517..9b3aa5838f 100644 --- a/components/delete.js +++ b/components/delete.js @@ -15,6 +15,8 @@ export default function Delete ({ itemId, children, onDelete, type = 'post' }) { mutation deleteItem($id: ID!) { deleteItem(id: $id) { text + lexicalState + html title url pollCost @@ -26,6 +28,8 @@ export default function Delete ({ itemId, children, onDelete, type = 'post' }) { id: `Item:${itemId}`, fields: { text: () => deleteItem.text, + lexicalState: () => deleteItem.lexicalState, + html: () => deleteItem.html, title: () => deleteItem.title, url: () => deleteItem.url, pollCost: () => deleteItem.pollCost, diff --git a/components/discussion-form.js b/components/discussion-form.js index 8afd133e68..862112af8f 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -1,4 +1,4 @@ -import { Form, Input, MarkdownInput } from '@/components/form' +import { Form, Input, LexicalInput } from '@/components/form' import { useRouter } from 'next/router' import { gql, useApolloClient, useLazyQuery } from '@apollo/client' import AdvPostForm, { AdvPostInitial } from './adv-post-form' @@ -44,7 +44,7 @@ export function DiscussionForm ({
- {textLabel} optional} topLevel /> + {/* TODO: implement EditInfo in LexicalInput */} + {/* {textLabel} optional} name='text' minRows={6} hint={EditInfo} - /> + /> */} {!item && diff --git a/components/file-upload.js b/components/file-upload.js index f411c25dd5..bfae145791 100644 --- a/components/file-upload.js +++ b/components/file-upload.js @@ -5,7 +5,7 @@ import gql from 'graphql-tag' import { useMutation } from '@apollo/client' import piexif from 'piexifjs' -export const FileUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar, allow }, ref) => { +export const FileUpload = forwardRef(({ children, className, onSelect, onConfirm, onUpload, onSuccess, onError, onProgress, multiple, avatar, allow }, ref) => { const toaster = useToast() ref ??= useRef(null) @@ -23,8 +23,6 @@ export const FileUpload = forwardRef(({ children, className, onSelect, onUpload, ? new window.Image() : document.createElement('video') - file = await removeExifData(file) - return new Promise((resolve, reject) => { async function onload () { onUpload?.(file) @@ -51,25 +49,37 @@ export const FileUpload = forwardRef(({ children, className, onSelect, onUpload, form.append('acl', 'public-read') form.append('file', file) - const res = await fetch(data.getSignedPOST.url, { - method: 'POST', - body: form - }) + const xhr = new window.XMLHttpRequest() + xhr.open('POST', data.getSignedPOST.url) + + xhr.upload.onprogress = (e) => { + if (!e.lengthComputable) return + onProgress?.({ + file, + loaded: e.loaded, + total: e.total + }) + } - if (!res.ok) { - // TODO make sure this is actually a helpful error message and does not expose anything to the user we don't want + xhr.onerror = () => { onError?.({ ...variables, name: file.name, file }) - reject(new Error(res.statusText)) - return + reject(new Error('Upload failed')) } - const url = `${MEDIA_URL}/${data.getSignedPOST.fields.key}` - // key is upload id in database - const id = data.getSignedPOST.fields.key - onSuccess?.({ ...variables, id, name: file.name, url, file }) + xhr.onload = () => { + if (xhr.status < 200 || xhr.status >= 300) { + onError?.({ ...variables, name: file.name, file }) + reject(new Error(xhr.statusText)) + return + } + + const url = `${MEDIA_URL}/${data.getSignedPOST.fields.key}` + const id = data.getSignedPOST.fields.key + onSuccess?.({ ...variables, id, name: file.name, url, file }) + resolve(id) + } - console.log('resolve id', id) - resolve(id) + xhr.send(form) } // img fire 'load' event while videos fire 'loadeddata' @@ -98,22 +108,35 @@ export const FileUpload = forwardRef(({ children, className, onSelect, onUpload, accept={accept.join(', ')} onChange={async (e) => { const fileList = e.target.files - for (const file of Array.from(fileList)) { + // remove files that are not allowed and alert the user + const filteredFiles = Array.from(fileList).filter((file) => { + if (!accept.includes(file.type)) { + toaster.danger(`file must be ${accept.map(t => t.replace(/^(image|video)\//, '')).join(', ')}`) + return false + } + if (file.type === 'video/quicktime') { + toaster.danger(`upload of '${file.name}' failed: codec might not be supported, check video settings`) + return false + } + return true + }) + // remove exif data from the remaining files + const cleanedFiles = await Promise.all(filteredFiles.map(async (file) => { + return await removeExifData(file) + })) + if (onConfirm && cleanedFiles.length > 0) await onConfirm?.(cleanedFiles) + + const uploadPromises = cleanedFiles.map(async (file) => { try { - if (accept.indexOf(file.type) === -1) { - throw new Error(`file must be ${accept.map(t => t.replace(/^(image|video)\//, '')).join(', ')}`) - } if (onSelect) await onSelect?.(file, s3Upload) else await s3Upload(file) } catch (e) { - if (file.type === 'video/quicktime') { - toaster.danger(`upload of '${file.name}' failed: codec might not be supported, check video settings`) - } else { - toaster.danger(`upload of '${file.name}' failed: ` + e.message || e.toString?.()) - } - continue + toaster.danger(`upload of '${file.name}' failed: ` + e.message || e.toString?.()) } - } + }) + // upload files concurrently + await Promise.allSettled(uploadPromises) + // reset file input // see https://bobbyhadz.com/blog/react-reset-file-input#reset-a-file-input-in-react e.target.value = null diff --git a/components/form.js b/components/form.js index 8d81324357..c475d675ec 100644 --- a/components/form.js +++ b/components/form.js @@ -39,6 +39,7 @@ import { useShowModal } from './modal' import dynamic from 'next/dynamic' import { useIsClient } from './use-client' import PageLoading from './page-loading' +import { LexicalEditor } from '@/components/lexical' import { WalletPromptClosed } from '@/wallets/client/hooks' export class SessionRequiredError extends Error { @@ -76,13 +77,13 @@ export function SubmitButton ({ ) } -export function CopyButton ({ value, icon, ...props }) { +export function CopyButton ({ value, icon, bareIcon, ...props }) { const toaster = useToast() const [copied, setCopied] = useState(false) const handleClick = useCallback(async () => { try { - await copy(value) + await copy(typeof value === 'function' ? value() : value) toaster.success('copied') setCopied(true) setTimeout(() => setCopied(false), 1500) @@ -97,6 +98,12 @@ export function CopyButton ({ value, icon, ...props }) { ) + } else if (bareIcon) { + return ( + + {copied ? : } + + ) } return ( @@ -306,6 +313,14 @@ export function DualAutocompleteWrapper ({ ) } +export function LexicalInput ({ label, topLevel, groupClassName, onChange, ...props }) { + return ( + + + + ) +} + export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) { const [tab, setTab] = useState('write') const [, meta, helpers] = useField(props) @@ -1045,7 +1060,7 @@ export function CheckboxGroup ({ label, groupClassName, children, ...props }) { ) } -const StorageKeyPrefixContext = createContext() +export const StorageKeyPrefixContext = createContext() export function Form ({ initial, validate, schema, onSubmit, children, initialError, validateImmediately, diff --git a/components/item-full.js b/components/item-full.js index 8831a6d72e..c8db2c4a50 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -2,7 +2,7 @@ import Item from './item' import ItemJob from './item-job' import Reply from './reply' import Comment from './comment' -import Text, { SearchText } from './text' +import Text, { SearchText, LexicalText } from './text' import MediaOrLink from './media-or-link' import Comments from './comments' import styles from '@/styles/item.module.css' @@ -26,10 +26,11 @@ import classNames from 'classnames' import { CarouselProvider } from './carousel' import Embed from './embed' import useCommentsView from './use-comments-view' +import { useRouter } from 'next/router' function BioItem ({ item, handleClick }) { const { me } = useMe() - if (!item.text) { + if (!item.text && !item.html) { return null } @@ -109,7 +110,7 @@ function TopLevelItem ({ item, noReply, ...props }) { {...props} >
- {item.text && } + {(item.text || item.html) && } {item.url && !item.outlawed && } {item.poll && } {item.bounty && @@ -155,9 +156,14 @@ function TopLevelItem ({ item, noReply, ...props }) { } function ItemText ({ item }) { + // TODO: debug, to be removed + const router = useRouter() + return item.searchText ? - : {item.text} + : item.lexicalState && router.query.md !== 'true' + ? + : {item.text} } export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props }) { diff --git a/components/item-info.js b/components/item-info.js index c45a8f9cfc..44cf4464a2 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -28,6 +28,10 @@ import { useShowModal } from './modal' import classNames from 'classnames' import SubPopover from './sub-popover' import useCanEdit from './use-can-edit' +// TODO: clean up from dev debugging tools +import { useMutation } from '@apollo/client' +import gql from 'graphql-tag' +import BardIcon from '@/svgs/lexical/bard-line.svg' import { useRetryPayIn } from './payIn/hooks/use-retry-pay-in' import { willAutoRetryPayIn } from './payIn/hooks/use-auto-retry-pay-ins' @@ -97,6 +101,10 @@ export default function ItemInfo ({ return (
+ {item.lexicalState && + + + } {!isPinnedPost && !(isPinnedSubReply && !full) && !isAd && <> @@ -230,6 +238,15 @@ export default function ItemInfo ({
} + {/* TODO: remove this once we're done debugging */} + {/* this is a debug tool for lexical state migration */} + {process.env.NODE_ENV === 'development' && + <> +
+ +
+ + } } @@ -384,3 +401,86 @@ function EditInfo ({ item, edit, canEdit, setCanEdit, toggleEdit, editText, edit return null } + +function DevCopyMarkdownDropdownItem ({ item }) { + const toaster = useToast() + return ( + { + try { + toaster.success('markdown copied to clipboard') + navigator.clipboard.writeText(item.text) + } catch (error) { + toaster.danger('failed to copy markdown to clipboard') + } + }} + > + copy markdown + + ) +} + +// TODO: remove this once we're done debugging +// temporary debugging tool for lexical state migration +function DevLexicalConversionDropdownItem ({ item }) { + const toaster = useToast() + const router = useRouter() + const isPost = !item.parentId + const [shiftHeld, setShiftHeld] = useState(false) + + const [executeConversion] = useMutation(gql` + mutation executeConversion($itemId: ID!, $fullRefresh: Boolean!) { + executeConversion(itemId: $itemId, fullRefresh: $fullRefresh) { + success + message + } + } + `, { + onCompleted: (data) => { + if (data.executeConversion.success) { + toaster.success('conversion scheduled, refreshing in 15 seconds...') + setTimeout(() => { + isPost ? router.push(`/items/${item.id}`) : router.push(`/items/${item.parentId}?commentId=${item.id}`) + toaster.success('refreshing now...') + }, 15000) + } else { + toaster.danger(data.executeConversion.message) + } + } + }) + + useEffect(() => { + const handleKeyDown = (e) => { + if (e.shiftKey) { + setShiftHeld(true) + } + } + + const handleKeyUp = (e) => { + if (!e.shiftKey) { + setShiftHeld(false) + } + } + + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + } + }, []) + + // press shift to force a full refresh + const getDropdownText = () => { + if (shiftHeld) { + return 'FULL REFRESH!' + } + return !item.lexicalState ? 'convert to lexical' : 'refresh html' + } + + return ( + { executeConversion({ variables: { itemId: item.id, fullRefresh: shiftHeld } }) }}> + {getDropdownText()} + + ) +} diff --git a/components/job-form.js b/components/job-form.js index af195585b4..5269de0963 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -1,4 +1,4 @@ -import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form' +import { Checkbox, Form, Input, SubmitButton, LexicalInput } from './form' import Row from 'react-bootstrap/Row' import Col from 'react-bootstrap/Col' import Image from 'react-bootstrap/Image' @@ -50,6 +50,7 @@ export default function JobForm ({ item, sub }) { remote: item?.remote || false, boost: item?.boost || '', text: item?.text || '', + lexicalState: item?.lexicalState || '', url: item?.url || '', stop: false, start: false @@ -97,13 +98,14 @@ export default function JobForm ({ item, sub }) { /> - + {/* + /> */} how to apply url or email address} name='url' diff --git a/components/katex-renderer.js b/components/katex-renderer.js new file mode 100644 index 0000000000..5805207884 --- /dev/null +++ b/components/katex-renderer.js @@ -0,0 +1,44 @@ +import { useEffect, useRef } from 'react' +import katex from 'katex' + +export default function KatexRenderer ({ equation, inline, onClick, onDoubleClick }) { + const katexElementRef = useRef(null) + + useEffect(() => { + const katexElement = katexElementRef.current + if (!katexElement) return + + katex.render(equation, katexElement, { + displayMode: !inline, + errorColor: '#cc0000', + output: 'html', + strict: 'warn', + throwOnError: false, + trust: false + }) + }, [equation, inline]) + + return ( + <> + + + + + ) +} diff --git a/components/layout.module.css b/components/layout.module.css index 798a085d04..f1acdbd69f 100644 --- a/components/layout.module.css +++ b/components/layout.module.css @@ -29,4 +29,9 @@ .content form { width: 100%; +} + +/* special case for post creation, 1:1 width editor-resulting post */ +.content:has(form [contenteditable="true"]) { + max-width: 850px !important; } \ No newline at end of file diff --git a/components/lexical/contexts/item.js b/components/lexical/contexts/item.js new file mode 100644 index 0000000000..11fef4b0bd --- /dev/null +++ b/components/lexical/contexts/item.js @@ -0,0 +1,28 @@ +import { createContext, useContext, useMemo } from 'react' +import { UNKNOWN_LINK_REL } from '@/lib/constants' + +const LexicalItemContext = createContext({ + imgproxyUrls: null, + topLevel: false, + outlawed: false, + rel: UNKNOWN_LINK_REL +}) + +export function LexicalItemContextProvider ({ imgproxyUrls, topLevel, outlawed, rel, children }) { + const value = useMemo(() => ({ + imgproxyUrls, + topLevel, + outlawed, + rel + }), [imgproxyUrls, topLevel, outlawed, rel]) + + return ( + + {children} + + ) +} + +export function useLexicalItemContext () { + return useContext(LexicalItemContext) +} diff --git a/components/lexical/contexts/preferences.js b/components/lexical/contexts/preferences.js new file mode 100644 index 0000000000..1fe380ef35 --- /dev/null +++ b/components/lexical/contexts/preferences.js @@ -0,0 +1,54 @@ +import { createContext, useContext, useMemo, useState, useCallback } from 'react' + +export const DEFAULT_PREFERENCES = { + startInMarkdown: true, + showToolbar: false, + showFloatingToolbar: true +} + +const PREFERENCES_STORAGE_KEY = 'sn-lexical-preferences' + +const LexicalPreferencesContext = createContext({ + setOption: (name, value) => {}, + prefs: DEFAULT_PREFERENCES +}) + +export const LexicalPreferencesContextProvider = ({ children }) => { + const [prefs, setPrefs] = useState(() => { + try { + if (typeof window === 'undefined') return DEFAULT_PREFERENCES // SSR + const stored = window.localStorage.getItem(PREFERENCES_STORAGE_KEY) + return stored ? { ...DEFAULT_PREFERENCES, ...JSON.parse(stored) } : DEFAULT_PREFERENCES + } catch (error) { + console.warn('failed to load preferences from local:', error) + return DEFAULT_PREFERENCES // fallback + } + }) + + const setOption = useCallback((name, value) => { + setPrefs((prev) => { + const newPrefs = { ...prev, [name]: value } + // save to local + try { + window.localStorage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify(newPrefs)) + } catch (error) { + console.warn('failed to save preferences in local:', error) + } + return newPrefs + }) + }, []) + + const preferencesContextValue = useMemo(() => { + return { setOption, prefs } + }, [setOption, prefs]) + + return ( + + {children} + + ) +} + +export const useLexicalPreferences = () => { + return useContext(LexicalPreferencesContext) +} diff --git a/components/lexical/contexts/sharedhistory.js b/components/lexical/contexts/sharedhistory.js new file mode 100644 index 0000000000..69476f9522 --- /dev/null +++ b/components/lexical/contexts/sharedhistory.js @@ -0,0 +1,20 @@ +import { createContext, useContext, useMemo } from 'react' +import { createEmptyHistoryState } from '@lexical/react/LexicalHistoryPlugin' + +const SharedHistoryContext = createContext({ + historyState: null +}) + +export const SharedHistoryContextProvider = ({ children }) => { + const historyContext = useMemo(() => ({ historyState: createEmptyHistoryState() }), []) + + return ( + + {children} + + ) +} + +export const useSharedHistoryContext = () => { + return useContext(SharedHistoryContext) +} diff --git a/components/lexical/contexts/table.js b/components/lexical/contexts/table.js new file mode 100644 index 0000000000..aa16958ae7 --- /dev/null +++ b/components/lexical/contexts/table.js @@ -0,0 +1,34 @@ +import { createContext, useState, useMemo, useContext } from 'react' + +export const TableContext = createContext({ + cellConfig: null, + cellPlugins: null, + setCellConfig: () => {} +}) + +export function TableContextProvider ({ children }) { + const [contextValue, setContextValue] = useState({ + cellConfig: null, + cellPlugins: null + }) + + const value = useMemo(() => { + return { + cellConfig: contextValue.cellConfig, + cellPlugins: contextValue.cellPlugins, + set: (cellConfig, cellPlugins) => { + setContextValue({ cellConfig, cellPlugins }) + } + } + }, [contextValue.cellConfig, contextValue.cellPlugins]) + + return ( + + {children} + + ) +} + +export function useTableContext () { + return useContext(TableContext) +} diff --git a/components/lexical/contexts/toolbar.js b/components/lexical/contexts/toolbar.js new file mode 100644 index 0000000000..1762ccd5a1 --- /dev/null +++ b/components/lexical/contexts/toolbar.js @@ -0,0 +1,53 @@ +import { createContext, useContext, useMemo, useState, useCallback } from 'react' + +const INITIAL_STATE = { + isBold: false, + elementFormat: 'left', + isCode: false, + isHighlight: false, + isImageCaption: false, + isItalic: false, + isLink: false, + isRTL: false, + isStrikethrough: false, + isSubscript: false, + isSuperscript: false, + isUnderline: false, + isLowercase: false, + isUppercase: false, + isCapitalize: false, + blockType: 'paragraph', + codeLanguage: '', + markdownMode: false +} + +const ToolbarContext = createContext() + +export const ToolbarContextProvider = ({ children }) => { + const [toolbarState, setToolbarState] = useState(INITIAL_STATE) + + const batchUpdateToolbarState = useCallback((updates) => { + setToolbarState((prev) => ({ ...prev, ...updates })) + }, []) + + const updateToolbarState = useCallback((key, value) => { + setToolbarState((prev) => ({ + ...prev, + [key]: value + })) + }, []) + + const contextValue = useMemo(() => { + return { toolbarState, updateToolbarState, batchUpdateToolbarState } + }, [toolbarState, updateToolbarState]) + + return ( + + {children} + + ) +} + +export const useToolbarState = () => { + return useContext(ToolbarContext) +} diff --git a/components/lexical/editor.js b/components/lexical/editor.js new file mode 100644 index 0000000000..762e3aad83 --- /dev/null +++ b/components/lexical/editor.js @@ -0,0 +1,178 @@ +import { useFormikContext } from 'formik' +import { configExtension, defineExtension } from 'lexical' +import { ContentEditable } from '@lexical/react/LexicalContentEditable' +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' +import { LexicalExtensionComposer } from '@lexical/react/LexicalExtensionComposer' +import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin' +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' +import { useMemo, useState } from 'react' +import classNames from 'classnames' +import { useLexicalPreferences } from '@/components/lexical/contexts/preferences' +import { useSharedHistoryContext, SharedHistoryContextProvider } from '@/components/lexical/contexts/sharedhistory' +import { TableContextProvider } from '@/components/lexical/contexts/table' +import { ToolbarContextProvider } from '@/components/lexical/contexts/toolbar' +import { CodeShikiSNExtension } from '@/lib/lexical/extensions/core/code' +import { CodeThemePlugin } from '@/components/lexical/plugins/core/code-theme' +import FileUploadPlugin from '@/components/lexical/plugins/inserts/upload' +import FloatingToolbarPlugin from '@/components/lexical/plugins/toolbar/floating/floatingtoolbar' +import LinkEditorPlugin from '@/components/lexical/plugins/inserts/link' +import MentionsPlugin from '@/components/lexical/plugins/decorative/mention' +import ModeSwitcherPlugin from '@/components/lexical/plugins/core/mode/switch' +import { ShortcutsExtension } from '@/lib/lexical/extensions/core/shortcuts' +import { ToolbarPlugin } from '@/components/lexical/plugins/toolbar' +import { SNCommandsExtension } from '@/lib/lexical/extensions/core/commands' +import { $initializeEditorState } from '@/lib/lexical/universal/utils' +import DefaultNodes from '@/lib/lexical/nodes' +import SN_TRANSFORMERS from '@/lib/lexical/transformers' +import styles from './theme/theme.module.css' +import theme from './theme' +import { MaxLengthPlugin } from '@/components/lexical/plugins/misc/max-length' +import TransformerBridgePlugin from '@/components/lexical/plugins/core/transformer-bridge' +import { MarkdownModeExtension } from '@/lib/lexical/extensions/core/mode' +import { MediaCheckExtension } from '@/components/lexical/plugins/misc/media-check' +import LocalDraftPlugin from '@/components/lexical/plugins/core/local-draft' +import FormikBridgePlugin from '@/components/lexical/plugins/core/formik' +import { CheckListExtension, ListExtension } from '@lexical/list' +import { LinkExtension } from '@lexical/link' +import { TableExtension } from '@lexical/table' +import { AutoFocusExtension, HorizontalRuleExtension } from '@lexical/extension' +import { SNAutoLinkExtension } from '@/lib/lexical/extensions/decorative/autolink' +import PreferencesPlugin from '@/components/lexical/plugins/core/preferences' +import MediaDragDropPlugin from '@/components/lexical/plugins/content/media/dnd' +import TableActionMenuPlugin from '@/components/lexical/plugins/inserts/table/action' +import { TableOfContentsExtension } from '@/lib/lexical/extensions/toc' +import { SpoilerExtension } from '@/lib/lexical/extensions/formatting/spoiler' +import CodeActionsPlugin from './plugins/decorative/code-actions' + +/** + * main lexical editor component with formik integration + * @param {string} props.name - form field name + * @param {string} [props.appendValue] - value to append to initial content + * @param {boolean} [props.autoFocus] - whether to auto-focus the editor + * @returns {JSX.Element} lexical editor component + */ +export default function Editor ({ name, appendValue, autoFocus, topLevel, ...props }) { + const { prefs } = useLexicalPreferences() + const { values } = useFormikContext() + + const editor = useMemo(() => + defineExtension({ + $initialEditorState: (editor) => { + // append value takes precedence + if (appendValue) { + return $initializeEditorState(prefs.startInMarkdown, editor, appendValue) + } + // territory descriptions are always markdown + if (values.desc) { + return $initializeEditorState(true, editor, values.desc) + } + // existing lexical state + if (values.lexicalState) { + try { + const state = editor.parseEditorState(values.lexicalState) + if (!state.isEmpty()) { + editor.setEditorState(state) + return + } + } catch (error) { + console.error('failed to load initial state:', error) + } + } + + // default: initialize based on user preference + return $initializeEditorState(prefs.startInMarkdown) + }, + name: 'editor', + namespace: 'SN', + nodes: DefaultNodes, + dependencies: [ + SNAutoLinkExtension, + CodeShikiSNExtension, + MarkdownModeExtension, + MediaCheckExtension, + ShortcutsExtension, + ListExtension, + CheckListExtension, + LinkExtension, + TableExtension, + SNCommandsExtension, + HorizontalRuleExtension, + TableOfContentsExtension, + SpoilerExtension, + configExtension(AutoFocusExtension, { disabled: !autoFocus }) + ], + theme: { ...theme, topLevel: topLevel ? 'topLevel' : '' }, + onError: (error) => console.error('stacker news editor has encountered an error:', error) + }), [autoFocus, topLevel]) + + return ( + + + + + + + + + + ) +} + +/** + * editor content component containing all plugins and UI elements + * @param {string} props.name - form field name for draft saving + * @param {string} props.placeholder - placeholder text for empty editor + * @param {Object} props.lengthOptions - max length configuration + * @param {boolean} props.topLevel - whether this is a top-level editor + * @returns {JSX.Element} editor content with all plugins + */ +function EditorContent ({ name, placeholder, lengthOptions, topLevel }) { + const [floatingAnchorElem, setFloatingAnchorElem] = useState(null) + const { historyState } = useSharedHistoryContext() + + const onRef = (_floatingAnchorElem) => { + if (_floatingAnchorElem !== null) { + setFloatingAnchorElem(_floatingAnchorElem) + } + } + + return ( + <> +
+ + +
+ + {placeholder}
} + /> +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> +
+ + + + + + + + +
+ + +
+ + + + + + + + ) +} diff --git a/components/lexical/index.js b/components/lexical/index.js new file mode 100644 index 0000000000..cb6cd1be7e --- /dev/null +++ b/components/lexical/index.js @@ -0,0 +1,41 @@ +import { forwardRef, useMemo } from 'react' +import dynamic from 'next/dynamic' +import { applySNCustomizations } from '@/lib/lexical/html/customs' +import { useRouter } from 'next/router' +import { LexicalPreferencesContextProvider } from './contexts/preferences' +import { LexicalItemContextProvider } from './contexts/item' +import Editor from './editor' + +export const LexicalEditor = ({ ...props }) => { + return ( + + + + ) +} + +export const LexicalReader = forwardRef(function LexicalReader ({ html, children, outlawed, imgproxyUrls, topLevel, rel, ...props }, ref) { + const router = useRouter() + const snCustomizedHTML = useMemo(() => ( +
+ ), [html, outlawed, imgproxyUrls, topLevel, props.className]) + + // debug html with ?html + if (router.query.html) return snCustomizedHTML + + const Reader = useMemo(() => dynamic(() => import('./reader'), { ssr: false, loading: () => snCustomizedHTML }), []) + + return ( + + + + {children} + + + ) +}) diff --git a/components/lexical/plugins/content/media/dnd.js b/components/lexical/plugins/content/media/dnd.js new file mode 100644 index 0000000000..7459eaa863 --- /dev/null +++ b/components/lexical/plugins/content/media/dnd.js @@ -0,0 +1,184 @@ +import { mergeRegister, isHTMLElement, $findMatchingParent } from '@lexical/utils' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { useEffect } from 'react' +import { MediaNode, $createMediaNode, $isMediaNode } from '@/lib/lexical/nodes/content/media' +import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link' +import { + COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_EDITOR, DRAGSTART_COMMAND, DRAGOVER_COMMAND, DROP_COMMAND, $isRootOrShadowRoot, $wrapNodeInElement, $createParagraphNode, $insertNodes, + $isNodeSelection, $getSelection, $createRangeSelection, $setSelection, getDOMSelectionFromTarget, createCommand +} from 'lexical' +import styles from '@/components/lexical/theme/theme.module.css' + +export const SN_INSERT_MEDIA_COMMAND = createCommand('SN_INSERT_MEDIA_COMMAND') + +// governs the insertion of media nodes +// drag and drop works by re-inserting the media node at a new location +export default function MediaDragDropPlugin () { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + if (!editor.hasNodes([MediaNode])) { + throw new Error('MediaDragDropPlugin requires MediaNode to be registered') + } + + return mergeRegister( + editor.registerCommand( + SN_INSERT_MEDIA_COMMAND, + (payload) => { + const mediaNode = $createMediaNode(payload) + $insertNodes([mediaNode]) + if ($isRootOrShadowRoot(mediaNode.getParentOrThrow())) { + $wrapNodeInElement(mediaNode, $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 + ) + ) + }, [editor]) +} + +function $onDragStart (event) { + const node = $getMediaNodeInSelection() + if (!node) { + return false + } + const dataTransfer = event.dataTransfer + if (!dataTransfer) { + return false + } + dataTransfer.setData('text/plain', '_') + // we create a transparent image to not obstruct the view while dragging + const transparentImg = new window.Image() + transparentImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + dataTransfer.setDragImage(transparentImg, 0, 0) + dataTransfer.setData( + 'application/x-lexical-drag', + JSON.stringify({ + data: { + altText: node.__altText, + caption: node.__caption, + height: node.__height, + key: node.getKey(), + maxWidth: node.__maxWidth, + showCaption: node.__showCaption, + src: node.__src, + width: node.__width + }, + type: 'image' + }) + ) + + return true +} + +function $onDragover (event) { + const node = $getMediaNodeInSelection() + if (!node) { + return false + } + if (!canDropImage(event)) { + event.preventDefault() + } + return true +} + +function $onDrop (event, editor) { + const node = $getMediaNodeInSelection() + if (!node) { + return false + } + const data = getDragImageData(event) + if (!data) { + return false + } + const existingLink = $findMatchingParent( + node, + (parent) => + !$isAutoLinkNode(parent) && $isLinkNode(parent) + ) + 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(SN_INSERT_MEDIA_COMMAND, data) + if (existingLink) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, existingLink.getURL()) + } + } + return true +} + +function $getMediaNodeInSelection () { + const selection = $getSelection() + if (!$isNodeSelection(selection)) { + return null + } + const nodes = selection.getNodes() + const node = nodes[0] + return $isMediaNode(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 !!( + isHTMLElement(target) && + !target.closest('code, span.sn__mediaContainer') && + isHTMLElement(target.parentElement) && + target.parentElement.closest(`.${styles.editorInput}`) + ) +} + +function getDragSelection (event) { + let range + const domSelection = getDOMSelectionFromTarget(event.target) + 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/components/lexical/plugins/content/media/index.js b/components/lexical/plugins/content/media/index.js new file mode 100644 index 0000000000..0ff832de44 --- /dev/null +++ b/components/lexical/plugins/content/media/index.js @@ -0,0 +1,300 @@ +import { Suspense, useCallback, useEffect, useRef, useState, useMemo } from 'react' +import { mergeRegister } from '@lexical/utils' +import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin' +import { ContentEditable } from '@lexical/react/LexicalContentEditable' +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' +import { LexicalNestedComposer } from '@lexical/react/LexicalNestedComposer' +import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin' +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { useLexicalEditable } from '@lexical/react/useLexicalEditable' +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection' +import { + $getNodeByKey, $isNodeSelection, + $getSelection, $isRangeSelection, $setSelection, + CLICK_COMMAND, COMMAND_PRIORITY_LOW, DRAGSTART_COMMAND, KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, RIGHT_CLICK_IMAGE_COMMAND, SELECTION_CHANGE_COMMAND +} from 'lexical' +import MentionsPlugin from '@/components/lexical/plugins/decorative/mention' +import MediaOrLink, { LinkRaw } from '@/components/media-or-link' +import { useSharedHistoryContext } from '@/components/lexical/contexts/sharedhistory' +import { $isMediaNode } from '../../../../../lib/lexical/nodes/content/media' +import MediaResizer from './resizer' +import styles from '@/components/lexical/theme/media.module.css' +import { useLexicalItemContext } from '@/components/lexical/contexts/item' +import { IMGPROXY_URL_REGEXP, decodeProxyUrl } from '@/lib/url' + +/** + * wrapper component that handles media rendering with item-specific logic + * like imgproxy, outlawed and rel (link) user settings + + * @param {string} props.src - media source url + * @param {string} props.status - media status (error, pending, etc.) + * @returns {JSX.Element} media component or fallback + */ +export default function Media ({ src, status, ...props }) { + const { imgproxyUrls, rel, outlawed, topLevel } = useLexicalItemContext() + const url = IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src + const srcSet = imgproxyUrls?.[url] + + if (outlawed) { + return

{url}

+ } + + if (status === 'error') { + return {url} + } + + return +} + +/** + * selectable, captionable and resizable media component + * TODO: refactor MediaOrLink legacy component + * + * @param {string} props.src - media source url + * @param {string} props.srcSet - responsive image source set + * @param {string} props.rel - link relationship attribute + * @param {string} props.altText - alternative text for media + * @param {string} props.kind - media type (image, video, etc.) + * @param {string} props.nodeKey - lexical node key + * @param {number} props.width - media width + * @param {number} props.height - media height + * @param {number} props.maxWidth - maximum media width + * @param {boolean} props.resizable - whether media is resizable + * @param {boolean} props.showCaption - whether to show caption editor + * @param {Object} props.caption - lexical editor instance for caption + * @param {boolean} props.captionsEnabled - whether captions are enabled + */ +export function MediaComponent ({ + src, + srcSet, + rel, + altText, + kind, + nodeKey, + width, + height, + maxWidth, + resizable, + showCaption, + caption, + captionsEnabled, + topLevel +}) { + const mediaRef = useRef(null) + const buttonRef = useRef(null) + const [isSelected, setSelected, clearSelection] = + useLexicalNodeSelection(nodeKey) + const [isResizing, setIsResizing] = useState(false) + const [editor] = useLexicalComposerContext() + const activeEditorRef = useRef(null) + const [isLoadError, setIsLoadError] = useState(false) + const isEditable = useLexicalEditable() + const isInNodeSelection = useMemo(() => { + return isSelected && editor.getEditorState().read(() => { + const sel = $getSelection() + return $isNodeSelection(sel) && sel.has(nodeKey) + }) + }, [isSelected, editor, nodeKey]) + + const $onEnter = useCallback((event) => { + const latestSelection = $getSelection() + const buttonElem = buttonRef.current + if (isSelected && $isNodeSelection(latestSelection) && latestSelection.getNodes().length === 1) { + if (showCaption) { + $setSelection(null) + event.preventDefault() + caption.focus() + return true + } else if (buttonElem !== null && buttonElem !== document.activeElement) { + event.preventDefault() + buttonElem.focus() + return true + } + return false + } + }, [isSelected, showCaption, caption]) + + 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]) + + const onClick = useCallback((payload) => { + const event = payload + if (isResizing) { + return true + } + if (event.target === mediaRef.current) { + if (event.shiftKey) { + setSelected(!isSelected) + } else { + clearSelection() + setSelected(true) + } + return true + } + return false + }, [isResizing, isSelected, clearSelection, setSelected]) + + const onRightClick = useCallback((event) => { + editor.getEditorState().read(() => { + const latestSelection = $getSelection() + const domElement = event.target + if ((domElement.tagName === 'IMG' || domElement.tagName === 'VIDEO') && + $isRangeSelection(latestSelection) && latestSelection.getNodes().length === 1) { + editor.dispatchCommand(RIGHT_CLICK_IMAGE_COMMAND, event) + } + }) + }, [editor]) + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_, activeEditor) => { + activeEditorRef.current = activeEditor + return false + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + DRAGSTART_COMMAND, + (event) => { + if (event.target === mediaRef.current) { + event.preventDefault() + return true + } + return false + }, + COMMAND_PRIORITY_LOW + ) + ) + }, [editor]) + + useEffect(() => { + let rootCleanup = () => {} + return mergeRegister( + editor.registerCommand( + CLICK_COMMAND, + onClick, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + RIGHT_CLICK_IMAGE_COMMAND, + onClick, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand(KEY_ENTER_COMMAND, $onEnter, COMMAND_PRIORITY_LOW), + editor.registerCommand(KEY_ESCAPE_COMMAND, $onEscape, COMMAND_PRIORITY_LOW), + editor.registerRootListener((rootElement) => { + if (rootElement) { + rootElement.addEventListener('contextmenu', onRightClick) + rootCleanup = () => + rootElement.removeEventListener('contextmenu', onRightClick) + } + }), + () => rootCleanup() + ) + }, [editor, $onEnter, $onEscape, onClick, onRightClick]) + + const setShowCaption = () => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) + if ($isMediaNode(node)) { + node.setShowCaption(true) + } + }) + } + + const onResizeEnd = (nextWidth, nextHeight) => { + setTimeout(() => { + setIsResizing(false) + }, 200) + + editor.update(() => { + const node = $getNodeByKey(nodeKey) + if ($isMediaNode(node)) { + node.setWidthAndHeight(nextWidth, nextHeight) + } + }) + } + + const onResizeStart = () => { + setIsResizing(true) + } + + const { historyState } = useSharedHistoryContext() + + const draggable = isInNodeSelection && !isResizing + const isFocused = (isSelected || isResizing) && isEditable + + if (isLoadError) { + return {src} + } + + return ( + + <> +
+ setIsLoadError(true)} + className={isFocused ? `focused ${isInNodeSelection ? 'draggable' : ''}` : null} + imageRef={mediaRef} + topLevel={topLevel} + /> +
+ + {showCaption && ( +
+ + + + + + + } + ErrorBoundary={LexicalErrorBoundary} + /> + +
+ )} + {resizable && isInNodeSelection && isFocused && ( + + )} + +
+ ) +} diff --git a/components/lexical/plugins/content/media/resizer.js b/components/lexical/plugins/content/media/resizer.js new file mode 100644 index 0000000000..fb5e8f69eb --- /dev/null +++ b/components/lexical/plugins/content/media/resizer.js @@ -0,0 +1,292 @@ +import { useRef } from 'react' +import styles from '@/components/lexical/theme/media.module.css' +import { calculateZoomLevel } from '@lexical/utils' +import classNames from 'classnames' + +function clamp (value, min, max) { + return Math.min(Math.max(value, min), max) +} + +const Direction = { + east: 1 << 0, + north: 1 << 3, + south: 1 << 1, + west: 1 << 2 +} + +export default function MediaResizer ({ + onResizeStart, + onResizeEnd, + buttonRef, + imageRef, + editor, + showCaption, + setShowCaption, + captionsEnabled +}) { + const controlWrapperRef = useRef(null) + const userSelect = useRef({ + priority: '', + value: 'default' + }) + const positioningRef = useRef({ + currentHeight: 'inherit', + currentWidth: 'inherit', + direction: 0, + isResizing: false, + ratio: 0, + startHeight: 0, + startWidth: 0, + startX: 0, + startY: 0 + }) + + const editorRootElement = editor.getRootElement() + const maxWidthContainer = (editorRootElement !== null + ? editorRootElement.getBoundingClientRect().width - 20 + : 100) + + const MAX_MEDIA_WIDTH = 500 + const maxWidthDragLimit = Math.min(maxWidthContainer, MAX_MEDIA_WIDTH) + + const maxHeightContainer = editorRootElement !== null + ? editorRootElement.getBoundingClientRect().height - 20 + : 100 + + const minWidth = 100 + const minHeight = 100 + + const setStartCursor = (direction) => { + const ew = direction === Direction.east || direction === Direction.west + const ns = direction === Direction.north || direction === Direction.south + const nwse = + (direction & Direction.north && direction & Direction.west) || + (direction & Direction.south && direction & Direction.east) + + const cursorDir = ew ? 'ew' : ns ? 'ns' : nwse ? 'nwse' : 'nesw' + + if (editorRootElement !== null) { + editorRootElement.style.setProperty( + 'cursor', + `${cursorDir}-resize`, + 'important' + ) + } + if (document.body !== null) { + document.body.style.setProperty( + 'cursor', + `${cursorDir}-resize`, + 'important' + ) + userSelect.current.value = document.body.style.getPropertyValue( + '-webkit-user-select' + ) + userSelect.current.priority = document.body.style.getPropertyPriority( + '-webkit-user-select' + ) + document.body.style.setProperty( + '-webkit-user-select', + 'none', + 'important' + ) + } + } + + const setEndCursor = () => { + if (editorRootElement !== null) { + editorRootElement.style.setProperty('cursor', 'text') + } + if (document.body !== null) { + document.body.style.setProperty('cursor', 'default') + document.body.style.setProperty( + '-webkit-user-select', + userSelect.current.value, + userSelect.current.priority + ) + } + } + + const handlePointerDown = (event, direction) => { + if (!editor.isEditable()) return + + const image = imageRef.current + const controlWrapper = controlWrapperRef.current + + if (image === null || controlWrapper === null) return + + event.preventDefault() + + const { width, height } = image.getBoundingClientRect() + const zoom = calculateZoomLevel(image) + const positioning = positioningRef.current + positioning.startWidth = width + positioning.startHeight = height + positioning.ratio = width / height + positioning.currentWidth = width + positioning.currentHeight = height + positioning.startX = event.clientX / zoom + positioning.startY = event.clientY / zoom + positioning.direction = direction + positioning.isResizing = true + + setStartCursor(direction) + onResizeStart() + + controlWrapper.classList.add(styles.imageControlWrapperResizing) + image.style.height = `${height}px` + image.style.width = `${width}px` + image.style.maxWidth = `${maxWidthDragLimit}px` + + document.addEventListener('pointermove', handlePointerMove) + document.addEventListener('pointerup', handlePointerUp) + // BUG this didn't actually work, and sometimes the pointer can get stuck on resizing without ever exiting. + document.addEventListener('pointercancel', handlePointerUp) + } + + const handlePointerMove = (event) => { + const image = imageRef.current + const positioning = positioningRef.current + + const isHorizontal = + positioning.direction & (Direction.east | Direction.west) + const isVertical = + positioning.direction & (Direction.south | Direction.north) + + if (image !== null && positioning.isResizing) { + const zoom = calculateZoomLevel(image) + // Corner cursor + if (isHorizontal && isVertical) { + let diff = Math.floor(positioning.startX - event.clientX / zoom) + diff = positioning.direction & Direction.east ? -diff : diff + + const width = clamp( + positioning.startWidth + diff, + minWidth, + maxWidthDragLimit + ) + + const height = width / positioning.ratio + image.style.width = `${width}px` + image.style.height = `${height}px` + positioning.currentHeight = height + positioning.currentWidth = width + } else if (isVertical) { + let diff = Math.floor(positioning.startY - event.clientY / zoom) + diff = positioning.direction & Direction.south ? -diff : diff + + const height = clamp( + positioning.startHeight + diff, + minHeight, + maxHeightContainer + ) + + image.style.height = `${height}px` + positioning.currentHeight = height + } else { + let diff = Math.floor(positioning.startX - event.clientX / zoom) + diff = positioning.direction & Direction.east ? -diff : diff + + const width = clamp( + positioning.startWidth + diff, + minWidth, + maxWidthDragLimit + ) + + image.style.width = `${width}px` + positioning.currentWidth = width + } + } + } + + const handlePointerUp = () => { + console.log('handlePointerUp') + const image = imageRef.current + const positioning = positioningRef.current + const controlWrapper = controlWrapperRef.current + if (image !== null && controlWrapper !== null && positioning.isResizing) { + const width = positioning.currentWidth + const height = positioning.currentHeight + positioning.startWidth = 0 + positioning.startHeight = 0 + positioning.ratio = 0 + positioning.startX = 0 + positioning.startY = 0 + positioning.currentWidth = 0 + positioning.currentHeight = 0 + positioning.isResizing = false + + controlWrapper.classList.remove(styles.imageControlWrapperResizing) + + setEndCursor() + onResizeEnd(width, height) + + document.removeEventListener('pointermove', handlePointerMove) + document.removeEventListener('pointerup', handlePointerUp) + document.removeEventListener('pointercancel', handlePointerUp) + } + } + + return ( +
+ {!showCaption && captionsEnabled && ( + + )} +
{ + handlePointerDown(event, Direction.north) + }} + /> +
{ + handlePointerDown(event, Direction.north | Direction.east) + }} + /> +
{ + handlePointerDown(event, Direction.east) + }} + /> +
{ + handlePointerDown(event, Direction.south | Direction.east) + }} + /> +
{ + handlePointerDown(event, Direction.south) + }} + /> +
{ + handlePointerDown(event, Direction.south | Direction.west) + }} + /> +
{ + handlePointerDown(event, Direction.west) + }} + /> +
{ + handlePointerDown(event, Direction.north | Direction.west) + }} + /> +
+ ) +} diff --git a/components/lexical/plugins/core/code-theme.js b/components/lexical/plugins/core/code-theme.js new file mode 100644 index 0000000000..cd4d8ebd2f --- /dev/null +++ b/components/lexical/plugins/core/code-theme.js @@ -0,0 +1,18 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { useEffect } from 'react' +import useDarkMode from '@/components/dark-mode' + +/** syncs code block syntax highlighting theme with site dark mode */ +export function CodeThemePlugin () { + const [editor] = useLexicalComposerContext() + const [darkMode] = useDarkMode() + + const theme = darkMode ? 'github-dark-default' : 'github-light-default' + + useEffect(() => { + if (!editor._updateCodeTheme) return + return editor._updateCodeTheme(theme) + }, [darkMode, theme]) + + return null +} diff --git a/components/lexical/plugins/core/draggable-block.js b/components/lexical/plugins/core/draggable-block.js new file mode 100644 index 0000000000..9bb81ffeb1 --- /dev/null +++ b/components/lexical/plugins/core/draggable-block.js @@ -0,0 +1,74 @@ +import { useRef, useState } from 'react' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $getNearestNodeFromDOMNode, $createParagraphNode } from 'lexical' +import styles from '@/components/lexical/theme/theme.module.css' +import DraggableIcon from '@/svgs/lexical/draggable.svg' +import { DraggableBlockPlugin_EXPERIMENTAL as LexicalDraggableBlockPlugin } from '@lexical/react/LexicalDraggableBlockPlugin' +import AddIcon from '@/svgs/add-fill.svg' + +/** + * checks if element is within the draggable block menu + * @param {HTMLElement} element - dom element to check + * @returns {boolean} true if element is in menu + */ +function isOnMenu (element) { + return !!element.closest(`.${styles.draggableBlockMenu}`) +} + +/** + * plugin that enables drag-and-drop reordering of editor blocks + + * @param {HTMLElement} props.anchorElem - anchor element for positioning the drag menu + * @returns {JSX.Element|null} draggable block plugin component or null + */ +export default function DraggableBlockPlugin ({ anchorElem }) { + const [editor] = useLexicalComposerContext() + const menuRef = useRef(null) + const targetLineRef = useRef(null) + const [draggableElement, setDraggableElement] = useState(null) + + /** + * inserts a new paragraph block before or after the draggable element + * @param {Event} e - click event (modifier keys control position) + */ + function insertBlock (e) { + if (!draggableElement || !editor) return + + editor.update(() => { + const node = $getNearestNodeFromDOMNode(draggableElement) + if (!node) return + + const pNode = $createParagraphNode() + if (e.altKey || e.ctrlKey || e.metaKey) { + node.insertBefore(pNode) + } else { + node.insertAfter(pNode) + } + + pNode.select() + }) + } + + if (!anchorElem) return null + + return ( + + + + + +
+ } + targetLineComponent={ +
+ } + isOnMenu={isOnMenu} + onElementChanged={setDraggableElement} + /> + ) +} diff --git a/components/lexical/plugins/core/formik.js b/components/lexical/plugins/core/formik.js new file mode 100644 index 0000000000..6f333bc2cb --- /dev/null +++ b/components/lexical/plugins/core/formik.js @@ -0,0 +1,83 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { useEffect, useRef } from 'react' +import { useField } from 'formik' +import { $initializeEditorState, $isMarkdownMode, $isRootEmpty } from '@/lib/lexical/universal/utils' +import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown' +import SN_TRANSFORMERS from '@/lib/lexical/transformers' +import { $getRoot } from 'lexical' +import useHeadlessBridge from './use-headless-bridge' +import { MediaCheckExtension } from '@/components/lexical/plugins/misc/media-check' + +/** + * converts markdown to lexical state using a temporary bridge editor + * @param {React.RefObject} bridge - headless editor instance + * @param {string} markdown - markdown string to convert + * @returns {string} serialized lexical state as JSON + */ +function $prepareMarkdown (bridge, markdown) { + let lexicalState = '' + + try { + // convert the markdown to a lexical state + bridge.current.update(() => { + $convertFromMarkdownString(markdown, SN_TRANSFORMERS, undefined, false) + }) + + bridge.current.read(() => { + lexicalState = bridge.current.getEditorState().toJSON() + }) + } catch (error) { + console.error('cannot prepare markdown using bridge:', error) + } + + return lexicalState +} + +/** syncs lexical editor state with formik form field values */ +export default function FormikBridgePlugin () { + const [editor] = useLexicalComposerContext() + const bridge = useHeadlessBridge({ extensions: [MediaCheckExtension] }) + const [lexicalField,, lexicalHelpers] = useField({ name: 'lexicalState' }) + const hadContent = useRef(false) + + // keep formik in sync, so it doesn't yell at us + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + const isMarkdownMode = $isMarkdownMode() + + // if editor is empty, set empty string for formik validation + if ($isRootEmpty()) { + lexicalHelpers.setValue('') + return + } + + let markdown = '' + let lexicalState = editorState.toJSON() + + if (isMarkdownMode) { + markdown = $getRoot().getFirstChild()?.getTextContent() || '' + lexicalState = $prepareMarkdown(bridge, markdown) + } else { + markdown = $convertToMarkdownString(SN_TRANSFORMERS, undefined, false) + } + + lexicalHelpers.setValue(JSON.stringify(lexicalState)) + }) + }) + }, [editor, lexicalHelpers, bridge]) + + // reset the editor state if the field is/goes empty + useEffect(() => { + if (lexicalField.value !== '') { + hadContent.current = true + } + + if (lexicalField.value === '' && hadContent.current) { + hadContent.current = false + editor.update(() => $initializeEditorState($isMarkdownMode())) + } + }, [editor, lexicalField.value]) + + return null +} diff --git a/components/lexical/plugins/core/local-draft.js b/components/lexical/plugins/core/local-draft.js new file mode 100644 index 0000000000..5c1a300f0c --- /dev/null +++ b/components/lexical/plugins/core/local-draft.js @@ -0,0 +1,66 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { useContext, useCallback, useEffect } from 'react' +import { StorageKeyPrefixContext } from '@/components/form' +import { $isRootEmpty, $initializeEditorState } from '@/lib/lexical/universal/utils' + +/** + * plugin that auto-saves and restores editor drafts to/from local storage + + * @param {string} props.name - storage key suffix for the draft + */ +export default function LocalDraftPlugin ({ name }) { + const [editor] = useLexicalComposerContext() + + // local storage keys, e.g. 'reply-123456-text' + const storageKeyPrefix = useContext(StorageKeyPrefixContext) + const storageKey = storageKeyPrefix ? storageKeyPrefix + '-' + name : undefined + + /** + * saves or removes draft from local storage based on editor emptiness + * @param {Object} lexicalState - serialized lexical editor state + */ + const upsertDraft = useCallback((lexicalState) => { + if (!storageKey) return + + // if the editor is empty, remove the draft + if ($isRootEmpty()) { + window.localStorage.removeItem(storageKey) + } else { + window.localStorage.setItem(storageKey, JSON.stringify(lexicalState)) + } + }, [storageKey]) + + // load the draft from local storage + useEffect(() => { + if (storageKey) { + const value = window.localStorage.getItem(storageKey) + if (value) { + editor.update(() => { + // MIGRATION: if the value is not JSON, let's assume it's markdown and convert it to JSON + // it's not part of the try catch parse because we don't want to mistakenly paste JSON into the editor + if (!value.startsWith('{')) $initializeEditorState(true, editor, value) + try { + const state = editor.parseEditorState(value) + if (!state.isEmpty()) { + editor.setEditorState(state) + } + } catch (error) { + console.error('error parsing editor state:', error) + } + }) + } + } + }, [editor, storageKey]) + + // save the draft to local storage + useEffect(() => { + // whenever the editor state changes, save the draft + return editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + upsertDraft(editorState.toJSON()) + }) + }) + }, [editor, upsertDraft]) + + return null +} diff --git a/components/lexical/plugins/core/mode/switch.js b/components/lexical/plugins/core/mode/switch.js new file mode 100644 index 0000000000..d78e2d2970 --- /dev/null +++ b/components/lexical/plugins/core/mode/switch.js @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { getShortcutCombo } from '../../../../../lib/lexical/extensions/core/shortcuts/keyboard' +import { SN_TOGGLE_MODE_COMMAND } from '@/lib/lexical/extensions/core/mode' +import styles from '@/components/lexical/theme/theme.module.css' +import { $isMarkdownMode } from '@/lib/lexical/universal/utils' +import classNames from 'classnames' + +/** displays and toggles between markdown and rich text modes */ +export default function ModeSwitcherPlugin ({ className }) { + const [editor] = useLexicalComposerContext() + const [isMD, setIsMD] = useState(false) + + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + setIsMD($isMarkdownMode()) + }) + }) + }, [editor]) + + const modeText = isMD ? 'markdown mode' : 'rich mode' + const title = `${modeText} ${getShortcutCombo('toggleMode')}` + + return ( + editor.dispatchCommand(SN_TOGGLE_MODE_COMMAND)} + className={classNames(styles.bottomBarItem, className)} + title={title} + > + {modeText} + + ) +} diff --git a/components/lexical/plugins/core/preferences/index.js b/components/lexical/plugins/core/preferences/index.js new file mode 100644 index 0000000000..75db53ee02 --- /dev/null +++ b/components/lexical/plugins/core/preferences/index.js @@ -0,0 +1,60 @@ +import Dropdown from 'react-bootstrap/Dropdown' +import CheckIcon from '@/svgs/check-line.svg' +import styles from '@/components/lexical/theme/theme.module.css' +import { useLexicalPreferences } from '@/components/lexical/contexts/preferences' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { useToast } from '@/components/toast' +import { useState } from 'react' +import classNames from 'classnames' +import { MenuAlternateDimension } from '@/components/lexical/plugins/toolbar/formatting' + +/** DEV: dropdown menu for toggling editor preferences and debug options */ +export default function PreferencesPlugin ({ className }) { + const [dropdownOpen, setDropdownOpen] = useState(false) + const toaster = useToast() + const [editor] = useLexicalComposerContext() + const { prefs, setOption } = useLexicalPreferences() + + return ( + setDropdownOpen(isOpen)} show={dropdownOpen}> + e.preventDefault()} className={classNames(styles.bottomBarItem, className)}> + DEV debug options + + + setOption('startInMarkdown', !prefs.startInMarkdown)} className={styles.dropdownExtraItem}> + + {prefs.startInMarkdown && } + start in markdown + + + setOption('showToolbar', !prefs.showToolbar)} className={styles.dropdownExtraItem}> + + {prefs.showToolbar && } + show full toolbar + + + setOption('showFloatingToolbar', !prefs.showFloatingToolbar)} className={styles.dropdownExtraItem}> + + {prefs.showFloatingToolbar && } + show floating toolbar + + +
+ { + editor.read(() => { + const json = editor.getEditorState().toJSON() + navigator.clipboard.writeText(JSON.stringify(json, null, 2)) + toaster.success('editor state copied to clipboard') + }) + }} + className={styles.dropdownExtraItem} + > + + copy editor state JSON + + +
+
+ ) +} diff --git a/components/lexical/plugins/core/transformer-bridge.js b/components/lexical/plugins/core/transformer-bridge.js new file mode 100644 index 0000000000..44cdda101e --- /dev/null +++ b/components/lexical/plugins/core/transformer-bridge.js @@ -0,0 +1,76 @@ +import { useEffect } from 'react' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { createCommand, $selectAll, $getSelection, COMMAND_PRIORITY_EDITOR, $getRoot } from 'lexical' +import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown' +import { $findTopLevelElement } from '@/lib/lexical/universal/utils' +import SN_TRANSFORMERS from '@/lib/lexical/transformers' +import { $formatBlock } from '@/lib/lexical/universal/commands/formatting/blocks' +import { CodeHighlighterShikiExtension } from '@lexical/code-shiki' +import useHeadlessBridge from './use-headless-bridge' + +/** command to transform markdown selections using a headless lexical editor + * @param {Object} params.selection - selection to transform + * @param {string} params.formatType - format type to transform + * @param {string} params.transformation - transformation to apply + * @returns {boolean} true if transformation was applied + */ +export const USE_TRANSFORMER_BRIDGE = createCommand('USE_TRANSFORMER_BRIDGE') + +/** bridge plugin that transforms markdown selections using a headless lexical editor, + * registers USE_TRANSFORMER_BRIDGE command to transform markdown selections + */ +export default function TransformerBridgePlugin () { + const [editor] = useLexicalComposerContext() + const bridge = useHeadlessBridge({ extensions: [CodeHighlighterShikiExtension] }) + + // Markdown Transformer Bridge + // uses markdown transformers to apply transformations to a markdown selection + useEffect(() => { + return editor.registerCommand(USE_TRANSFORMER_BRIDGE, ({ selection, formatType, transformation }) => { + if (!selection) selection = $getSelection() + // get the markdown from the selection + const markdown = selection.getTextContent() + console.log('markdown', markdown) + + // new markdown to be inserted in the original editor + let newMarkdown = '' + + // update the bridge editor with single update cycle + bridge.current.update(() => { + // make sure we're working with a clean bridge + $getRoot().clear() + + $convertFromMarkdownString(markdown, SN_TRANSFORMERS, undefined, false) + $selectAll() + const innerSelection = $getSelection() + + switch (formatType) { + case 'format': + innerSelection.formatText(transformation) + break + case 'block': + $formatBlock(bridge.current, transformation) + break + case 'elementFormat': + innerSelection.getNodes()?.forEach(node => { + const element = $findTopLevelElement(node) + if (element && element.setFormat) { + element.setFormat(transformation || 'left') + } + }) + break + } + + newMarkdown = $convertToMarkdownString(SN_TRANSFORMERS, undefined, false) + // we're done, clear the bridge + $getRoot().clear() + }) + + // insert the new markdown in the original editor + selection.insertText(newMarkdown) + return true + }, COMMAND_PRIORITY_EDITOR) + }, [editor, bridge]) + + return null +} diff --git a/components/lexical/plugins/core/use-headless-bridge.js b/components/lexical/plugins/core/use-headless-bridge.js new file mode 100644 index 0000000000..67fe5ea05b --- /dev/null +++ b/components/lexical/plugins/core/use-headless-bridge.js @@ -0,0 +1,58 @@ +import { useRef, useCallback, useEffect } from 'react' +import { buildEditorFromExtensions, defineExtension } from '@lexical/extension' +import { RichTextExtension } from '@lexical/rich-text' +import { ListExtension, CheckListExtension } from '@lexical/list' +import DefaultNodes from '@/lib/lexical/nodes' +import DefaultTheme from '@/components/lexical/theme' + +/** + * shared hook that creates and manages a headless bridge editor + * @param {Object} [opts] - optional configuration for the bridge editor + * @param {Array} [opts.nodes] - custom nodes to use (defaults to DefaultNodes) + * @param {Object} [opts.theme] - theme configuration (defaults to DefaultTheme) + * @param {Array} [opts.extensions] - additional extensions to use (defaults to []) + * @param {string} [opts.name] - name of the bridge editor (defaults to 'sn-headless-bridge') + * @returns {React.RefObject} ref to the bridge editor instance + */ +export default function useHeadlessBridge (opts = {}) { + const { + nodes = DefaultNodes, + theme = DefaultTheme, + extensions = [], + name = 'sn-headless-bridge' + } = opts + const bridge = useRef(null) + + // creates or returns existing headless bridge editor + const createBridge = useCallback(() => { + if (bridge.current) return bridge.current + bridge.current = buildEditorFromExtensions( + defineExtension({ + onError: (error) => console.error('editor bridge has encountered an error:', error), + name, + dependencies: [ + RichTextExtension, + ListExtension, + CheckListExtension, + ...extensions + ], + nodes, + theme + }) + ) + return bridge.current + }, [nodes, theme, extensions]) + + // create the bridge if it doesn't exist and dispose of it when we're done + useEffect(() => { + createBridge() + return () => { + if (bridge.current) { + bridge.current.dispose() + bridge.current = null + } + } + }, [createBridge]) + + return bridge +} diff --git a/components/lexical/plugins/decorative/code-actions.js b/components/lexical/plugins/decorative/code-actions.js new file mode 100644 index 0000000000..45c6d1d2c4 --- /dev/null +++ b/components/lexical/plugins/decorative/code-actions.js @@ -0,0 +1,221 @@ +// TODO: inspired from lexical playground +import { + $isCodeNode, + CodeNode, + getLanguageFriendlyName +} from '@lexical/code' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $getNearestNodeFromDOMNode, isHTMLElement } from 'lexical' +import { useEffect, useRef, useState, useCallback, useMemo } from 'react' +import { createPortal } from 'react-dom' +import styles from '../../theme/theme.module.css' +import { CopyButton } from '@/components/form' +import ActionTooltip from '@/components/action-tooltip' +import Dropdown from 'react-bootstrap/Dropdown' +import classNames from 'classnames' +import ArrowDownIcon from '@/svgs/arrow-down-s-line.svg' +import { getCodeLanguageOptions } from '@lexical/code-shiki' +import { useLexicalEditable } from '@lexical/react/useLexicalEditable' + +function getMouseInfo (event) { + const target = event.target + + if (isHTMLElement(target)) { + const codeDOMNode = target.closest('code.sn__codeBlock') + const isOutside = !( + codeDOMNode || target.closest(`div.${styles.codeActionMenuContainer}`) + ) + + return { codeDOMNode, isOutside } + } else { + return { codeDOMNode: null, isOutside: true } + } +} + +function CodeLanguageDropdown ({ langs, selectedLang, className, setLang }) { + const [dropdownOpen, setDropdownOpen] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + + const filteredOptions = searchTerm + ? langs.filter(([value, name]) => + value.toLowerCase().includes(searchTerm.toLowerCase()) || + name.toLowerCase().includes(searchTerm.toLowerCase()) + ) + : langs.filter(([value]) => ['python', 'java', 'javascript', 'cpp', 'go'].includes(value)) + + return ( + language options {selectedLang}} placement='top' noWrapper showDelay={500} transition disable={dropdownOpen}> + setDropdownOpen(isOpen)} show={dropdownOpen}> + e.preventDefault()} className={className}> + {selectedLang} + + + +
+ setSearchTerm(e.target.value)} + className={styles.dropdownSearchInput} + /> +
+ {filteredOptions.map(([value, name]) => ( + setLang(value)} + className={classNames(styles.dropdownExtraItem, selectedLang === value ? styles.active : '')} + onPointerDown={e => e.preventDefault()} + > + + {name} + + + ))} +
+
+
+ ) +} + +// TODO: in editable mode, the dropdown should always be shown +// the problem is that we're basing this off mouse events +// instead we should use a mutation listener to detect when the code node is created or destroyed +// and then show the dropdown if the code node is created +// and hide the dropdown if the code node is destroyed +// this way we can always have the dropdown showing in editable mode +function CodeActionMenuContainer ({ anchorElem }) { + const [editor] = useLexicalComposerContext() + const isEditable = useLexicalEditable() + const [lang, setLang] = useState('') + const [isShown, setShown] = useState(false) + const [shouldListenMouseMove, setShouldListenMouseMove] = useState(false) + const [position, setPosition] = useState({ + right: '0', + top: '0' + }) + const codeBlocks = useRef(new Set()) + const codeDOMNodeRef = useRef(null) + const langs = useMemo(() => getCodeLanguageOptions(), []) + + const getCodeValue = useCallback(() => { + let content = '' + editor.update(() => { + const maybeCodeNode = $getNearestNodeFromDOMNode(codeDOMNodeRef.current) + if ($isCodeNode(maybeCodeNode)) { + content = maybeCodeNode.getTextContent() + } + }) + return content + }, [editor, codeDOMNodeRef]) + + const updateLanguage = useCallback((newLang) => { + setLang(newLang) + editor.update(() => { + const maybeCodeNode = $getNearestNodeFromDOMNode(codeDOMNodeRef.current) + if ($isCodeNode(maybeCodeNode)) { + maybeCodeNode.setLanguage(newLang) + } + }) + }, [editor]) + + const onMouseMove = (event) => { + const { codeDOMNode, isOutside } = getMouseInfo(event) + if (isOutside) { + setShown(false) + return + } + + if (!codeDOMNode) { + return + } + + codeDOMNodeRef.current = codeDOMNode + + let codeNode = null + let _lang = '' + + editor.update(() => { + const maybeCodeNode = $getNearestNodeFromDOMNode(codeDOMNode) + + if ($isCodeNode(maybeCodeNode)) { + codeNode = maybeCodeNode + _lang = codeNode.getLanguage() || '' + } + }) + + if (codeNode) { + const { + y: editorElemY, + right: editorElemRight + } = anchorElem.getBoundingClientRect() + const { y, right } = codeDOMNode.getBoundingClientRect() + setLang(_lang) + setShown(true) + setPosition({ + right: `${editorElemRight - right + 8}px`, + top: `${y - editorElemY}px` + }) + } + } + + useEffect(() => { + if (!shouldListenMouseMove) return + + document.addEventListener('mousemove', onMouseMove) + return () => { + document.removeEventListener('mousemove', onMouseMove) + } + }, [onMouseMove, shouldListenMouseMove]) + + useEffect(() => { + return editor.registerMutationListener( + CodeNode, + mutations => { + editor.getEditorState().read(() => { + for (const [key, type] of mutations) { + switch (type) { + case 'created': + codeBlocks.current.add(key) + break + + case 'destroyed': + codeBlocks.current.delete(key) + setShown(false) + break + + default: + break + } + } + }) + setShouldListenMouseMove(codeBlocks.current.size > 0) + }, + { skipInitialization: false } + ) + }, [editor]) + + const codeFriendlyName = getLanguageFriendlyName(lang) + + return isShown && ( + <> +
+ {isEditable + ? + :
{codeFriendlyName}
} +
+ getCodeValue()} /> +
+
+ + ) +} + +export default function CodeActionsPlugin ({ anchorElem = document.body }) { + if (!anchorElem) return null + return createPortal( + , + anchorElem + ) +} diff --git a/components/lexical/plugins/decorative/mention/autocompleter.js b/components/lexical/plugins/decorative/mention/autocompleter.js new file mode 100644 index 0000000000..e0eed72f1b --- /dev/null +++ b/components/lexical/plugins/decorative/mention/autocompleter.js @@ -0,0 +1,149 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $createTextNode, $getSelection, $isRangeSelection, $isTextNode, $isLineBreakNode, $isParagraphNode } from 'lexical' +import { useEffect, useState, useCallback } from 'react' +import { $createUserMentionNode, $isUserMentionNode } from '@/lib/lexical/nodes/decorative/mentions/user' +import { $createTerritoryMentionNode, $isTerritoryMentionNode } from '@/lib/lexical/nodes/decorative/mentions/territory' +import { $isMarkdownMode } from '@/lib/lexical/universal/utils' + +function extractTextUpToCursor (selection) { + const anchor = selection.anchor + const anchorNode = anchor.getNode() + + if ($isTextNode(anchorNode)) { + const fullText = anchorNode.getTextContent() + const cursorOffset = anchor.offset + + // don't trigger autocomplete if cursor is in the middle of a word + if (cursorOffset < fullText.length) { + const charAfterCursor = fullText[cursorOffset] + if (/[a-zA-Z0-9]/.test(charAfterCursor)) { + return null + } + } + + let text = fullText.slice(0, cursorOffset) + + // walk backwards to handle spaces/punctuation + let prev = anchorNode.getPreviousSibling() + while (prev && !$isLineBreakNode(prev) && !$isParagraphNode(prev)) { + if ($isTextNode(prev)) { + text = prev.getTextContent() + text + } else if ($isUserMentionNode(prev) || $isTerritoryMentionNode(prev)) { + break + } + prev = prev.getPreviousSibling() + } + + return text + } +} + +function checkForMentionPattern (text) { + const mentionRegex = /(^|\s|\()([@~]\w{0,75})$/ + const match = mentionRegex.exec(text) + + if (match && match[2].length >= 2) { + return { + matchingString: match[2], + query: match[2].slice(1), // remove @ or ~ + isUser: match[2].startsWith('@') + } + } + + return null +} + +export default function useUniversalAutocomplete () { + const [editor] = useLexicalComposerContext() + const [entityData, setEntityData] = useState(null) + + const handleSelect = useCallback((item, isUser) => { + editor.update(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection)) return + + // remove trigger (@nym or ~territory) + const anchor = selection.anchor + const anchorNode = anchor.getNode() + + if ($isTextNode(anchorNode)) { + const textContent = anchorNode.getTextContent() + const cursorOffset = anchor.offset + const matchLength = entityData.matchLength + + // split text node + const beforeMatch = textContent.slice(0, cursorOffset - matchLength) + const afterMatch = textContent.slice(cursorOffset) + + // composing the mention node + // users: item has { id, name } structure + // territories: same as users, without id + console.log('item', item) + const mentionNode = $isMarkdownMode() + ? $createTextNode(`${isUser ? '@' : '~'}${item.name || item}`) + : isUser + ? $createUserMentionNode(item.id || item, item.name || item) + : $createTerritoryMentionNode(item.name || item) + + // rebuilding the structure + if (beforeMatch) { + anchorNode.setTextContent(beforeMatch) + anchorNode.insertAfter(mentionNode) + if (afterMatch) { + mentionNode.insertAfter($createTextNode(afterMatch)) + } + } else if (afterMatch) { + anchorNode.setTextContent(afterMatch) + anchorNode.insertBefore(mentionNode) + } else { + anchorNode.replace(mentionNode) + } + + // moving cursor after mention + mentionNode.selectNext() + } + }) + + setEntityData(null) + }, [editor, entityData]) + + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + setEntityData(null) + return + } + + const textUpToCursor = extractTextUpToCursor(selection) + const match = checkForMentionPattern(textUpToCursor) + + if (match) { + // calculate dropdown position from DOM + const domSelection = window.getSelection() + const range = domSelection.getRangeAt(0) + const rect = range.getBoundingClientRect() + + setEntityData({ + query: match.query, + isUser: match.isUser, + matchLength: match.matchingString.length, + style: { + position: 'absolute', + top: `${rect.bottom + window.scrollY}px`, + left: `${rect.left + window.scrollX}px` + } + }) + } else { + setEntityData(null) + } + }) + }) + }, [editor]) + + return { + entityData, + handleSelect + } +} diff --git a/components/lexical/plugins/decorative/mention/index.js b/components/lexical/plugins/decorative/mention/index.js new file mode 100644 index 0000000000..6aab9923a9 --- /dev/null +++ b/components/lexical/plugins/decorative/mention/index.js @@ -0,0 +1,109 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import useUniversalAutocomplete from './autocompleter' +import { BaseSuggest } from '@/components/form' +import { useLazyQuery } from '@apollo/client' +import { USER_SUGGESTIONS } from '@/fragments/users' +import { SUB_SUGGESTIONS } from '@/fragments/subs' +import { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { KEY_DOWN_COMMAND, COMMAND_PRIORITY_HIGH } from 'lexical' + +// bridges BageSuggest to the Universal Autocomplete hook +function SuggestWrapper ({ + q, onSelect, dropdownStyle, selectWithTab = false, onSuggestionsChange, children, + getSuggestionsQuery, itemsField +}) { + // fetch suggestions on-demand + // getSuggestionsQuery is the query to be used to fetch suggestions + const [getSuggestions] = useLazyQuery(getSuggestionsQuery, { + onCompleted: data => { + if (onSuggestionsChange) { + // itemsField is the field in the data that contains the suggestions + onSuggestionsChange(data[itemsField]) + } + } + }) + + // watch query changes and fetch suggestions + // strip prefixes (@ or ~) and trailing spaces + useEffect(() => { + if (q !== undefined) { + getSuggestions({ variables: { q, limit: 5 } }) + } + }, [q, getSuggestions]) + + // will display the dropdown, calling onSelect when a mention is selected + return ( + + {children} + + ) +} + +export default function MentionsPlugin () { + const [editor] = useLexicalComposerContext() + const { entityData, handleSelect } = useUniversalAutocomplete({ editor }) + const keyDownHandlerRef = useRef() + const resetSuggestionsRef = useRef() + const [currentSuggestions, setCurrentSuggestions] = useState([]) + + // we receive the name from BaseSuggest + // then we find the full item from our stored suggestions + const handleItemSelect = useCallback((name) => { + const fullItem = currentSuggestions.find(item => item.name === name) + console.log('fullItem', fullItem) + if (fullItem) { + handleSelect(fullItem, entityData?.isUser) + } + }, [handleSelect, entityData, currentSuggestions]) + + // clear suggestions when entity data is null + useEffect(() => { + if (!entityData) { + if (resetSuggestionsRef.current) { + resetSuggestionsRef.current() + } + setCurrentSuggestions([]) + } + }, [entityData]) + + useEffect(() => { + return editor.registerCommand( + KEY_DOWN_COMMAND, + (event) => { + if (keyDownHandlerRef.current && entityData) { + keyDownHandlerRef.current(event) + return true + } + return false + }, + COMMAND_PRIORITY_HIGH + ) + }, [editor, entityData, keyDownHandlerRef]) + + if (!entityData) return null + + return createPortal( + + {({ onKeyDown, resetSuggestions }) => { + keyDownHandlerRef.current = onKeyDown + resetSuggestionsRef.current = resetSuggestions + return null + }} + , document.body) +} diff --git a/components/lexical/plugins/decorative/toc.js b/components/lexical/plugins/decorative/toc.js new file mode 100644 index 0000000000..0cdd8b537e --- /dev/null +++ b/components/lexical/plugins/decorative/toc.js @@ -0,0 +1,52 @@ +import Link from 'next/link' +import { buildNestedTocStructure } from '@/lib/lexical/nodes/misc/toc' + +/** + * recursively renders table of contents items with nested structure + + * @param {Object} props.item - toc item with text, slug, and optional children + * @param {number} props.index - item index for key generation + * @returns {JSX.Element} list item with nested children + */ +function TocItem ({ item, index }) { + const hasChildren = item.children && item.children.length > 0 + return ( +
  • + + {item.text} + + {hasChildren && ( +
      + {item.children.map((child, idx) => ( + + ))} +
    + )} +
  • + ) +} + +/** + * displays a collapsible table of contents from heading data + + * @param {Array} props.headings - array of heading objects with text, depth, and slug + * @returns {JSX.Element} collapsible details element with toc list + */ +export function TableOfContents ({ headings }) { + const tocItems = buildNestedTocStructure(headings) + + return ( +
    + table of contents + {tocItems.length > 0 + ? ( +
      + {tocItems.map((item, index) => ( + + ))} +
    + ) + :
    no headings
    } +
    + ) +} diff --git a/components/lexical/plugins/formatting/math/editor.js b/components/lexical/plugins/formatting/math/editor.js new file mode 100644 index 0000000000..ffd144bf4b --- /dev/null +++ b/components/lexical/plugins/formatting/math/editor.js @@ -0,0 +1,20 @@ +import { forwardRef } from 'react' +import { isHTMLElement } from '@lexical/utils' + +export default forwardRef(function MathEditor ({ math, setMath, inline }, ref) { + const onChange = (e) => { + setMath(e.target.value) + } + + return inline && isHTMLElement(ref) + ? ( +
    +