diff --git a/packages/commonwealth/client/scripts/views/components/comments/comment.tsx b/packages/commonwealth/client/scripts/views/components/comments/comment.tsx index e36f26d0e41..615e8826ef9 100644 --- a/packages/commonwealth/client/scripts/views/components/comments/comment.tsx +++ b/packages/commonwealth/client/scripts/views/components/comments/comment.tsx @@ -18,6 +18,7 @@ import { User } from '../user/user'; import { EditComment } from './edit_comment'; import { clearEditingLocalStorage } from './helpers'; import { AnonymousUser } from '../user/anonymous_user'; +import { QuillRenderer } from '../react_quill_editor/quill_renderer'; type CommentAuthorProps = { comment: CommentType; @@ -29,21 +30,14 @@ const CommentAuthor = (props: CommentAuthorProps) => { // Check for accounts on forums that originally signed up on a different base chain, // Render them as anonymous as the forum is unable to support them. if (app.chain.meta.type === ChainType.Offchain) { - if ( - comment.authorChain !== app.chain.id && - comment.authorChain !== app.chain.base - ) { + if (comment.authorChain !== app.chain.id && comment.authorChain !== app.chain.base) { return ; } } const author: Account = app.chain.accounts.get(comment.author); - return comment.deleted ? ( - [deleted] - ) : ( - - ); + return comment.deleted ? [deleted] : ; }; type CommentProps = { @@ -58,20 +52,11 @@ type CommentProps = { }; export const Comment = (props: CommentProps) => { - const { - comment, - handleIsReplying, - isLast, - isLocked, - setIsGloballyEditing, - threadLevel, - updatedCommentsCallback, - } = props; - - const [isEditingComment, setIsEditingComment] = - React.useState(false); - const [shouldRestoreEdits, setShouldRestoreEdits] = - React.useState(false); + const { comment, handleIsReplying, isLast, isLocked, setIsGloballyEditing, threadLevel, updatedCommentsCallback } = + props; + + const [isEditingComment, setIsEditingComment] = React.useState(false); + const [shouldRestoreEdits, setShouldRestoreEdits] = React.useState(false); const [savedEdits, setSavedEdits] = React.useState(''); const handleSetIsEditingComment = (status: boolean) => { @@ -83,24 +68,21 @@ export const Comment = (props: CommentProps) => { app.user.isSiteAdmin || app.roles.isRoleOfCommunity({ role: 'admin', - chain: app.activeChainId(), + chain: app.activeChainId() }) || app.roles.isRoleOfCommunity({ role: 'moderator', - chain: app.activeChainId(), + chain: app.activeChainId() }); - const canReply = - !isLast && !isLocked && app.isLoggedIn() && app.user.activeAccount; + const canReply = !isLast && !isLocked && app.isLoggedIn() && app.user.activeAccount; - const canEditAndDelete = - !isLocked && - (comment.author === app.user.activeAccount?.address || isAdminOrMod); + const canEditAndDelete = !isLocked && (comment.author === app.user.activeAccount?.address || isAdminOrMod); const deleteComment = async () => { await app.comments.delete(comment); updatedCommentsCallback(); - } + }; return (
@@ -120,12 +102,7 @@ export const Comment = (props: CommentProps) => { {/* published on */} - + {moment(comment.createdAt).format('l')}
@@ -140,7 +117,7 @@ export const Comment = (props: CommentProps) => { ) : ( <> - {renderQuillTextBody(comment.text)} + {!comment.deleted && (
@@ -165,11 +142,7 @@ export const Comment = (props: CommentProps) => { {canEditAndDelete && ( ( - + )} menuItems={[ { @@ -178,32 +151,23 @@ export const Comment = (props: CommentProps) => { onClick: async (e) => { e.preventDefault(); setSavedEdits( - localStorage.getItem( - `${app.activeChainId()}-edit-comment-${ - comment.id - }-storedText` - ) + localStorage.getItem(`${app.activeChainId()}-edit-comment-${comment.id}-storedText`) ); if (savedEdits) { - clearEditingLocalStorage( - comment.id, - ContentType.Comment - ); + clearEditingLocalStorage(comment.id, ContentType.Comment); - const confirmationResult = window.confirm( - 'Previous changes found. Restore edits?' - ); + const confirmationResult = window.confirm('Previous changes found. Restore edits?'); setShouldRestoreEdits(confirmationResult); } handleSetIsEditingComment(true); - }, + } }, { label: 'Delete', iconLeft: 'trash', - onClick: deleteComment, - }, + onClick: deleteComment + } ]} /> )} diff --git a/packages/commonwealth/client/scripts/views/components/comments/create_comment.tsx b/packages/commonwealth/client/scripts/views/components/comments/create_comment.tsx index b5e2da2f15c..47c46c3ae32 100644 --- a/packages/commonwealth/client/scripts/views/components/comments/create_comment.tsx +++ b/packages/commonwealth/client/scripts/views/components/comments/create_comment.tsx @@ -17,11 +17,8 @@ import { CWButton } from '../component_kit/cw_button'; import { CWText } from '../component_kit/cw_text'; import { CWValidationText } from '../component_kit/cw_validation_text'; import { jumpHighlightComment } from './helpers'; -import { - createDeltaFromText, - getTextFromDelta, - ReactQuillEditor, -} from '../react_quill_editor'; +import { createDeltaFromText, getTextFromDelta, ReactQuillEditor } from '../react_quill_editor'; +import { serializeDelta } from '../react_quill_editor/utils'; type CreateCommmentProps = { handleIsReplying?: (isReplying: boolean, id?: number) => void; @@ -32,19 +29,12 @@ type CreateCommmentProps = { export const CreateComment = (props: CreateCommmentProps) => { const [errorMsg, setErrorMsg] = React.useState(null); - const [contentDelta, setContentDelta] = React.useState( - createDeltaFromText('') - ); + const [contentDelta, setContentDelta] = React.useState(createDeltaFromText('')); const [sendingComment, setSendingComment] = React.useState(false); const editorValue = getTextFromDelta(contentDelta); - const { - handleIsReplying, - parentCommentId, - rootProposal, - updatedCommentsCallback, - } = props; + const { handleIsReplying, parentCommentId, rootProposal, updatedCommentsCallback } = props; const author = app.user.activeAccount; @@ -61,7 +51,7 @@ export const CreateComment = (props: CreateCommmentProps) => { author.address, rootProposal.uniqueIdentifier, chainId, - JSON.stringify(contentDelta), + serializeDelta(contentDelta), parentCommentId ); @@ -88,79 +78,54 @@ export const CreateComment = (props: CreateCommmentProps) => { } }; - const activeTopicName = - rootProposal instanceof Thread ? rootProposal?.topic?.name : null; + const activeTopicName = rootProposal instanceof Thread ? rootProposal?.topic?.name : null; // token balance check if needed - const tokenPostingThreshold: BN = - TopicGateCheck.getTopicThreshold(activeTopicName); + const tokenPostingThreshold: BN = TopicGateCheck.getTopicThreshold(activeTopicName); const userBalance: BN = TopicGateCheck.getUserBalance(); const userFailsThreshold = - tokenPostingThreshold?.gtn(0) && - userBalance?.gtn(0) && - userBalance.lt(tokenPostingThreshold); + tokenPostingThreshold?.gtn(0) && userBalance?.gtn(0) && userBalance.lt(tokenPostingThreshold); - const disabled = - editorValue.length === 0 || sendingComment || userFailsThreshold; + const disabled = editorValue.length === 0 || sendingComment || userFailsThreshold; const decimals = getDecimals(app.chain); const cancel = (e) => { e.preventDefault(); - setContentDelta(createDeltaFromText('')) + setContentDelta(createDeltaFromText('')); if (handleIsReplying) { - handleIsReplying(false) + handleIsReplying(false); } - } + }; return (
- - {parentType === ContentType.Comment ? 'Reply as' : 'Comment as'} - + {parentType === ContentType.Comment ? 'Reply as' : 'Comment as'}
{errorMsg && }
- + {tokenPostingThreshold && tokenPostingThreshold.gt(new BN(0)) && ( - Commenting in {activeTopicName} requires{' '} - {weiToTokens(tokenPostingThreshold.toString(), decimals)}{' '} + Commenting in {activeTopicName} requires {weiToTokens(tokenPostingThreshold.toString(), decimals)}{' '} {app.chain.meta.default_symbol}.{' '} {userBalance && app.user.activeAccount && ( <> - You have {weiToTokens(userBalance.toString(), decimals)}{' '} - {app.chain.meta.default_symbol}. + You have {weiToTokens(userBalance.toString(), decimals)} {app.chain.meta.default_symbol}. )} )}
- { - editorValue.length > 0 && ( - - ) - } - + {editorValue.length > 0 && } +
diff --git a/packages/commonwealth/client/scripts/views/components/comments/edit_comment.tsx b/packages/commonwealth/client/scripts/views/components/comments/edit_comment.tsx index 55bdd40663f..eac88a1a623 100644 --- a/packages/commonwealth/client/scripts/views/components/comments/edit_comment.tsx +++ b/packages/commonwealth/client/scripts/views/components/comments/edit_comment.tsx @@ -9,7 +9,7 @@ import { CWButton } from '../component_kit/cw_button'; import { clearEditingLocalStorage } from './helpers'; import type { DeltaStatic } from 'quill'; import { ReactQuillEditor } from '../react_quill_editor'; -import { parseDeltaString } from '../react_quill_editor/utils'; +import { deserializeDelta, serializeDelta } from '../react_quill_editor/utils'; type EditCommentProps = { comment: Comment; @@ -20,16 +20,10 @@ type EditCommentProps = { }; export const EditComment = (props: EditCommentProps) => { - const { - comment, - savedEdits, - setIsEditing, - shouldRestoreEdits, - updatedCommentsCallback, - } = props; + const { comment, savedEdits, setIsEditing, shouldRestoreEdits, updatedCommentsCallback } = props; - const commentBody = (shouldRestoreEdits && savedEdits) ? savedEdits : comment.text; - const body = parseDeltaString(commentBody) + const commentBody = shouldRestoreEdits && savedEdits ? savedEdits : comment.text; + const body = deserializeDelta(commentBody); const [contentDelta, setContentDelta] = React.useState(body); const [saving, setSaving] = React.useState(); @@ -40,16 +34,14 @@ export const EditComment = (props: EditCommentProps) => { let cancelConfirmed = true; if (JSON.stringify(body) !== JSON.stringify(contentDelta)) { - cancelConfirmed = window.confirm( - 'Cancel editing? Changes will not be saved.' - ); + cancelConfirmed = window.confirm('Cancel editing? Changes will not be saved.'); } if (cancelConfirmed) { setIsEditing(false); clearEditingLocalStorage(comment.id, ContentType.Comment); } - } + }; const save = async (e: React.MouseEvent) => { e.preventDefault(); @@ -57,36 +49,23 @@ export const EditComment = (props: EditCommentProps) => { setSaving(true); try { - await app.comments.edit(comment, JSON.stringify(contentDelta)) + await app.comments.edit(comment, serializeDelta(contentDelta)); setIsEditing(false); clearEditingLocalStorage(comment.id, ContentType.Comment); updatedCommentsCallback(); } catch (err) { - console.error(err) + console.error(err); } finally { setSaving(false); } - - } + }; return (
- +
- - + +
); diff --git a/packages/commonwealth/client/scripts/views/components/edit_profile.tsx b/packages/commonwealth/client/scripts/views/components/edit_profile.tsx index dfafb1946e0..beb612f181c 100644 --- a/packages/commonwealth/client/scripts/views/components/edit_profile.tsx +++ b/packages/commonwealth/client/scripts/views/components/edit_profile.tsx @@ -8,12 +8,7 @@ import 'components/edit_profile.scss'; import app from 'state'; import { notifyError } from 'controllers/app/notifications'; -import { - NewProfile as Profile, - Account, - AddressInfo, - MinimumProfile, -} from '../../models'; +import { NewProfile as Profile, Account, AddressInfo, MinimumProfile } from '../../models'; import { CWButton } from '../components/component_kit/cw_button'; import { CWTextInput } from '../components/component_kit/cw_text_input'; import { AvatarUpload } from '../components/avatar_upload'; @@ -27,15 +22,12 @@ import type { ImageBehavior } from '../components/component_kit/cw_cover_image_u import { CWCoverImageUploader } from '../components/component_kit/cw_cover_image_uploader'; import { PageNotFound } from '../pages/404'; import { LinkedAddresses } from './linked_addresses'; -import { - createDeltaFromText, - getTextFromDelta, - ReactQuillEditor, -} from './react_quill_editor'; +import { createDeltaFromText, getTextFromDelta, ReactQuillEditor } from './react_quill_editor'; +import { deserializeDelta, serializeDelta } from './react_quill_editor/utils'; enum EditProfileError { None, - NoProfileFound, + NoProfileFound } const NoProfileFoundError = 'No profile found'; @@ -70,7 +62,7 @@ const EditProfileComponent = (props: EditNewProfileProps) => { const response = await axios.get(`${app.serverUrl()}/profile/v2`, { params: { profileId: query, - jwt: app.user.jwt, + jwt: app.user.jwt } }); @@ -79,19 +71,12 @@ const EditProfileComponent = (props: EditNewProfileProps) => { setEmail(response.data.result.profile.email || ''); setSocials(response.data.result.profile.socials); setAvatarUrl(response.data.result.profile.avatar_url); - setBio(response.data.result.profile.bio); + setBio(deserializeDelta(response.data.result.profile.bio)); backgroundImageRef.current = response.data.result.profile.background_image; setAddresses( response.data.result.addresses.map((a) => { try { - return new AddressInfo( - a.id, - a.address, - a.chain, - a.keytype, - a.wallet_id, - a.ghost_address - ); + return new AddressInfo(a.id, a.address, a.chain, a.keytype, a.wallet_id, a.ghost_address); } catch (err) { console.error(`Could not return AddressInfo: "${err}"`); return null; @@ -100,10 +85,7 @@ const EditProfileComponent = (props: EditNewProfileProps) => { ); setIsOwner(response.data.result.isOwner); } catch (err) { - if ( - err.status === 500 && - err.responseJSON?.error === NoProfileFoundError - ) { + if (err.status === 500 && err.responseJSON?.error === NoProfileFoundError) { setError(EditProfileError.NoProfileFound); } } @@ -115,7 +97,7 @@ const EditProfileComponent = (props: EditNewProfileProps) => { const response = await axios.post(`${app.serverUrl()}/updateProfile/v2`, { profileId: profile.id, ...profileUpdate, - jwt: app.user.jwt, + jwt: app.user.jwt }); if (response.data.status === 'Success') { @@ -139,25 +121,18 @@ const EditProfileComponent = (props: EditNewProfileProps) => { const checkForUpdates = () => { const profileUpdate: any = {}; - if (!_.isEqual(name, profile?.name) && name !== '') - profileUpdate.name = name; + if (!_.isEqual(name, profile?.name) && name !== '') profileUpdate.name = name; if (!_.isEqual(email, profile?.email)) profileUpdate.email = email; - if (!_.isEqual(getTextFromDelta(bio), profile?.bio)) { - profileUpdate.bio = getTextFromDelta(bio) || ''; - } + profileUpdate.bio = serializeDelta(bio); - if (!_.isEqual(avatarUrl, profile?.avatarUrl)) - profileUpdate.avatarUrl = avatarUrl; + if (!_.isEqual(avatarUrl, profile?.avatarUrl)) profileUpdate.avatarUrl = avatarUrl; - if (!_.isEqual(socials, profile?.socials)) - profileUpdate.socials = JSON.stringify(socials); + if (!_.isEqual(socials, profile?.socials)) profileUpdate.socials = JSON.stringify(socials); if (!_.isEqual(backgroundImageRef, profile?.backgroundImage)) - profileUpdate.backgroundImage = JSON.stringify( - backgroundImageRef.current - ); + profileUpdate.backgroundImage = JSON.stringify(backgroundImageRef.current); if (Object.keys(profileUpdate)?.length > 0) { updateProfile(profileUpdate); @@ -196,25 +171,17 @@ const EditProfileComponent = (props: EditNewProfileProps) => { // not the best solution because address is not always available // should refactor AvatarUpload to make it work with new profiles if (addresses?.length > 0) { - const oldProfile = new MinimumProfile( - addresses[0].chain.name, - addresses[0].address - ); + const oldProfile = new MinimumProfile(addresses[0].chain.name, addresses[0].address); - oldProfile.initialize( - name, - addresses[0].address, - avatarUrl, - profile.id, - addresses[0].chain.name, - null - ); + oldProfile.initialize(name, addresses[0].address, avatarUrl, profile.id, addresses[0].chain.name, null); - setAccount(new Account({ - chain: addresses[0].chain, - address: addresses[0].address, - profile: oldProfile, - })); + setAccount( + new Account({ + chain: addresses[0].chain, + address: addresses[0].address, + profile: oldProfile + }) + ); } else { setAccount(null); } @@ -320,9 +287,7 @@ const EditProfileComponent = (props: EditNewProfileProps) => { }} inputClassName={displayNameValid ? '' : 'failure'} manualStatusMessage={displayNameValid ? '' : 'No input'} - manualValidationStatus={ - displayNameValid ? 'success' : 'failure' - } + manualValidationStatus={displayNameValid ? 'success' : 'failure'} /> {
Bio - +
@@ -360,31 +321,22 @@ const EditProfileComponent = (props: EditNewProfileProps) => { />
- + Image upload Add a background image. { + uploadCompleteCallback={(url: string, imageBehavior: ImageBehavior) => { backgroundImageRef.current = { url, - imageBehavior, + imageBehavior }; }} - generatedImageCallback={( - url: string, - imageBehavior: ImageBehavior - ) => { + generatedImageCallback={(url: string, imageBehavior: ImageBehavior) => { backgroundImageRef.current = { url, - imageBehavior, + imageBehavior }; }} enableGenerativeAI @@ -392,18 +344,13 @@ const EditProfileComponent = (props: EditNewProfileProps) => { defaultImageBehavior={backgroundImageRef.current?.imageBehavior} /> - + { getProfile(props.profileId); - app.user.removeAddress( - addresses.find((a) => a.address === address) - ); + app.user.removeAddress(addresses.find((a) => a.address === address)); }} /> diff --git a/packages/commonwealth/client/scripts/views/components/react_quill_editor/markdown_formatted_text.tsx b/packages/commonwealth/client/scripts/views/components/react_quill_editor/markdown_formatted_text.tsx new file mode 100644 index 00000000000..09fab93b0a0 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/react_quill_editor/markdown_formatted_text.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useMemo } from 'react'; +import DOMPurify from 'dompurify'; +import { marked } from 'marked'; +import { CWIcon } from '../component_kit/cw_icons/cw_icon'; +import { getClasses } from '../component_kit/helpers'; +import { countLinesMarkdown } from './utils'; + +const OPEN_LINKS_IN_NEW_TAB = true; + +const markdownRenderer = new marked.Renderer(); +markdownRenderer.link = (href, title, text) => { + return `${text}`; +}; +marked.setOptions({ + renderer: markdownRenderer, + gfm: true, // use github flavored markdown + smartypants: true, + smartLists: true, + xhtml: true +}); + +type MarkdownFormattedTextProps = { + collapse?: boolean; + doc: string; + hideFormatting?: boolean; + openLinksInNewTab?: boolean; + searchTerm?: string; + cutoffLines?: number; +}; + +export const MarkdownFormattedText = ({ + collapse, + doc, + hideFormatting, + searchTerm, + cutoffLines +}: MarkdownFormattedTextProps) => { + const isTruncated = cutoffLines > 0 && cutoffLines < countLinesMarkdown(doc); + + const truncatedDoc = useMemo(() => { + if (isTruncated) { + return doc.slice(0, doc.split('\n', cutoffLines).join('\n').length); + } + return doc; + }, [cutoffLines, doc, isTruncated]); + + const unsanitizedHTML = marked.parse(truncatedDoc.toString()); + + const sanitizedHTML: string = useMemo(() => { + return hideFormatting + ? DOMPurify.sanitize(unsanitizedHTML, { + ALLOWED_TAGS: ['a'], + ADD_ATTR: ['target'] + }) + : DOMPurify.sanitize(unsanitizedHTML, { + USE_PROFILES: { html: true }, + ADD_ATTR: ['target'] + }); + }, [hideFormatting, unsanitizedHTML]); + + const toggleDisplay = () => { + console.log('toggleDisplay'); + }; + + return ( + <> +
({ collapsed: !!collapse }, 'MarkdownFormattedText')}> +
+
+ {isTruncated && ( +
+
+ +
Show More
+
+
+ )} + + ); +}; diff --git a/packages/commonwealth/client/scripts/views/components/react_quill_editor/quill_renderer.tsx b/packages/commonwealth/client/scripts/views/components/react_quill_editor/quill_renderer.tsx new file mode 100644 index 00000000000..38c98566bb9 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/react_quill_editor/quill_renderer.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { QuillFormattedText } from '../quill/quill_formatted_text'; +import { MarkdownFormattedText } from './markdown_formatted_text'; + +type QuillRendererProps = { + doc: string; +}; + +export const QuillRenderer = ({ doc }: QuillRendererProps) => { + let decodedTextbody: string; + try { + decodedTextbody = decodeURIComponent(doc); + } catch (e) { + decodedTextbody = doc; + } + + try { + const parsedDoc = JSON.parse(decodedTextbody); + if (!parsedDoc.ops) { + throw new Error('failed to parse doc as JSON'); + } + return ; + } catch (e) { + return ; + } +}; diff --git a/packages/commonwealth/client/scripts/views/components/react_quill_editor/react_quill_editor.tsx b/packages/commonwealth/client/scripts/views/components/react_quill_editor/react_quill_editor.tsx index 57c0f301943..7c64e68f627 100644 --- a/packages/commonwealth/client/scripts/views/components/react_quill_editor/react_quill_editor.tsx +++ b/packages/commonwealth/client/scripts/views/components/react_quill_editor/react_quill_editor.tsx @@ -1,131 +1,285 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { DeltaOperation, DeltaStatic } from 'quill'; +import imageDropAndPaste from 'quill-image-drop-and-paste'; import ReactQuill, { Quill } from 'react-quill'; -import 'components/react_quill/react_quill_editor.scss'; -import imageDropAndPaste from 'quill-image-drop-and-paste' - -import type { DeltaOperation, DeltaStatic } from 'quill'; -import type { QuillMode } from '../quill/types'; -import 'react-quill/dist/quill.snow.css'; -import { base64ToFile, uploadFileToS3 } from './utils'; +import type { SerializableDeltaStatic } from './utils'; +import { base64ToFile, getTextFromDelta, uploadFileToS3 } from './utils'; import app from 'state'; +import { CWText } from '../component_kit/cw_text'; +import { CWIconButton } from '../component_kit/cw_icon_button'; +import { PreviewModal } from '../../modals/preview_modal'; +import { Modal } from '../component_kit/cw_modal'; -const VALID_IMAGE_TYPES = ['jpeg', 'gif', 'png'] +import 'components/react_quill/react_quill_editor.scss'; +import 'react-quill/dist/quill.snow.css'; +import { nextTick } from 'process'; + +const VALID_IMAGE_TYPES = ['jpeg', 'gif', 'png']; const LoadingIndicator = () => { return ( -
+
- ) -} + ); +}; +const Delta = Quill.import('delta'); Quill.register('modules/imageDropAndPaste', imageDropAndPaste); type ReactQuillEditorProps = { - className?: string + className?: string; placeholder?: string; tabIndex?: number; - mode?: QuillMode; // Use in order to limit editor to only MD or RT support - contentDelta: DeltaStatic - setContentDelta: (d: DeltaStatic) => void -} + contentDelta: SerializableDeltaStatic; + setContentDelta: (d: SerializableDeltaStatic) => void; +}; // ReactQuillEditor is a custom wrapper for the react-quill component const ReactQuillEditor = ({ - className = '', - placeholder, - tabIndex, - contentDelta, - setContentDelta, - } : ReactQuillEditorProps) => { + className = '', + placeholder, + tabIndex, + contentDelta, + setContentDelta +}: ReactQuillEditorProps) => { + const editorRef = useRef(); - const editorRef = useRef() + const [isVisible, setIsVisible] = useState(true); + const [isUploading, setIsUploading] = useState(false); + const [isMarkdownEnabled, setIsMarkdownEnabled] = useState(false); + const [isPreviewVisible, setIsPreviewVisible] = useState(false); - const [isUploading, setIsUploading] = useState(false) + // refreshQuillComponent unmounts and remounts the + // React Quill component, as this is the only way + // to refresh the component if the 'modules' + // prop is changed + const refreshQuillComponent = () => { + setIsVisible(false); + nextTick(() => { + setIsVisible(true); + }); + }; const handleChange = (value, delta, source, editor) => { - setContentDelta(editor.getContents()) - } - - // must use memoized function or else it'll render in an infinite loop - const handleImageDropAndPaste = useCallback(async (imageDataUrl, imageType) => { + setContentDelta({ + ...editor.getContents(), + ___isMarkdown: isMarkdownEnabled + } as SerializableDeltaStatic); + }; - const editor = editorRef.current?.editor + // must be memoized or else infinite loop + const handleImageDropAndPaste = useCallback( + async (imageDataUrl, imageType) => { + const editor = editorRef.current?.editor; - try { - if (!editor) { - throw new Error('editor is not set') - } + try { + if (!editor) { + throw new Error('editor is not set'); + } - setIsUploading(true) + setIsUploading(true); - editor.disable() + editor.disable(); - if (!imageType) { - imageType = 'image/png' - } + if (!imageType) { + imageType = 'image/png'; + } - const selectedIndex = editor.getSelection()?.index || editor.getLength() || 0 + const selectedIndex = editor.getSelection()?.index || editor.getLength() || 0; - // filter out ops that contain a base64 image - const opsWithoutBase64Images : DeltaOperation[] = (editor.getContents() || []) - .filter((op) => { - for (const imageType of VALID_IMAGE_TYPES) { - const base64Prefix = `data:image/${imageType};base64` + // filter out ops that contain a base64 image + const opsWithoutBase64Images: DeltaOperation[] = (editor.getContents() || []).filter((op) => { + for (const opImageType of VALID_IMAGE_TYPES) { + const base64Prefix = `data:image/${opImageType};base64`; if (op.insert?.image?.startsWith(base64Prefix)) { - return false + return false; } } - return true - }) - setContentDelta({ ops: opsWithoutBase64Images } as DeltaStatic) + return true; + }); + setContentDelta({ + ops: opsWithoutBase64Images, + ___isMarkdown: isMarkdownEnabled + } as SerializableDeltaStatic); - const file = base64ToFile(imageDataUrl, imageType) + const file = base64ToFile(imageDataUrl, imageType); - const uploadedFileUrl = await uploadFileToS3(file, app.serverUrl(), app.user.jwt) + const uploadedFileUrl = await uploadFileToS3(file, app.serverUrl(), app.user.jwt); - // insert image op at the selected index - editor.insertEmbed(selectedIndex, 'image', uploadedFileUrl) - setContentDelta(editor.getContents()) // sync state with editor content + // insert image op at the selected index + if (isMarkdownEnabled) { + editor.insertText(selectedIndex, `![image](${uploadedFileUrl})`); + } else { + editor.insertEmbed(selectedIndex, 'image', uploadedFileUrl); + } + setContentDelta({ + ...editor.getContents(), + ___isMarkdown: isMarkdownEnabled + } as SerializableDeltaStatic); // sync state with editor content + } catch (err) { + console.error(err); + } finally { + editor.enable(); + setIsUploading(false); + } + }, + [editorRef, isMarkdownEnabled, setContentDelta] + ); - } catch (err) { - console.error(err) - } finally { - editor.enable() - setIsUploading(false) + const handleToggleMarkdown = () => { + const editor = editorRef.current?.getEditor(); + if (!editor) { + throw new Error('editor not set'); } + // if enabling markdown, confirm and remove formatting + const newMarkdownEnabled = !isMarkdownEnabled; + if (newMarkdownEnabled) { + let confirmed = true; + if (getTextFromDelta(editor.getContents()).length > 0) { + confirmed = window.confirm('All formatting and images will be lost. Continue?'); + } + if (confirmed) { + editor.removeFormat(0, editor.getLength()); + setIsMarkdownEnabled(newMarkdownEnabled); + setContentDelta({ + ...editor.getContents(), + ___isMarkdown: newMarkdownEnabled + }); + } + } else { + setIsMarkdownEnabled(newMarkdownEnabled); + } + }; + + const handlePreviewModalClose = () => { + setIsPreviewVisible(false); + }; + + // must be memoized or else infinite loop + const clipboardMatchers = useMemo(() => { + return [ + [ + Node.ELEMENT_NODE, + (node, delta) => { + return delta.compose( + new Delta().retain(delta.length(), { + header: false, + align: false, + color: false, + background: false + }) + ); + } + ] + ]; + }, []); + + // when markdown state is changed, add markdown metadata to delta ops + // and refresh quill component + useEffect(() => { + const editor = editorRef.current?.getEditor(); + if (editor) { + setContentDelta({ + ...editor.getContents(), + ___isMarkdown: isMarkdownEnabled + } as SerializableDeltaStatic); + } + refreshQuillComponent(); + }, [isMarkdownEnabled, setContentDelta]); - }, [editorRef, setContentDelta]) + // when initialized, update markdown state to match content type + useEffect(() => { + setIsMarkdownEnabled(!!contentDelta?.___isMarkdown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // if markdown is disabled, hide toolbar buttons + const toolbar = useMemo(() => { + if (isMarkdownEnabled) { + return []; + } + return ([[{ header: 1 }, { header: 2 }]] as any).concat([ + ['bold', 'italic', 'strike'], + ['link', 'code-block', 'blockquote'], + [{ list: 'ordered' }, { list: 'bullet' }, { list: 'check' }] + ]); + }, [isMarkdownEnabled]); return ( -
- {isUploading && } - + {isUploading && } + + } + onClose={handlePreviewModalClose} + open={isPreviewVisible} /> +
+ {isMarkdownEnabled && ( + + R + + )} + {!isMarkdownEnabled && ( + + M + + )} + { + e.preventDefault(); + setIsPreviewVisible(true); + }} + /> +
+ {isVisible && ( + + )}
- ) - -} + ); +}; export default ReactQuillEditor; diff --git a/packages/commonwealth/client/scripts/views/components/react_quill_editor/utils.ts b/packages/commonwealth/client/scripts/views/components/react_quill_editor/utils.ts index 575d79005bc..77820be3365 100644 --- a/packages/commonwealth/client/scripts/views/components/react_quill_editor/utils.ts +++ b/packages/commonwealth/client/scripts/views/components/react_quill_editor/utils.ts @@ -1,25 +1,16 @@ import axios from "axios"; import type { DeltaStatic } from "quill"; -// parseDelta creates a new DeltaStatic object from a JSON string -export const parseDeltaString = (str: string) : DeltaStatic => { - try { - return JSON.parse(str) - } catch (err) { - console.warn('failed to parse string JSON', err) - return createDeltaFromText(str) - } -} - // createDeltaFromText returns a new DeltaStatic object from a string -export const createDeltaFromText = (str: string) : DeltaStatic => { +export const createDeltaFromText = (str: string, isMarkdown?: boolean) : SerializableDeltaStatic => { return { ops: [ { insert: str } - ] - } as DeltaStatic + ], + ___isMarkdown: !!isMarkdown + } as SerializableDeltaStatic } // getTextFromDelta returns the text from a DeltaStatic @@ -36,7 +27,7 @@ export const getTextFromDelta = (delta: DeltaStatic) : string => { if (typeof op.insert === 'string') { return op.insert.trim().length > 0 } - if (op.insert.image) { + if (op.insert?.image) { return true } return false @@ -93,3 +84,36 @@ export const uploadFileToS3 = async (file: File, appServerUrl: string, jwtToken: throw err } } + +// countLinesMarkdown returns the number of lines for the text +export const countLinesMarkdown = (text: string) : number => { + return text.split('\n').length - 1; +}; + +// ----- + +export type SerializableDeltaStatic = DeltaStatic & { + ___isMarkdown?: boolean +} +// serializeDelta converts a delta object to a string for persistence +export const serializeDelta = (delta: DeltaStatic) : string => { + if ((delta as SerializableDeltaStatic).___isMarkdown) { + return getTextFromDelta(delta) + } + return JSON.stringify(delta) +} + +// parseDelta converts a string to a delta object for state +export const deserializeDelta = (str: string) : DeltaStatic => { + try { + // is richtext delta object + const delta: DeltaStatic = JSON.parse(str) + if (!delta.ops) { + throw new Error('object is not a delta static') + } + return delta + } catch (err) { + // otherwise, it's plain text markdown + return createDeltaFromText(str, true) + } +} diff --git a/packages/commonwealth/client/scripts/views/modals/edit_topic_modal.tsx b/packages/commonwealth/client/scripts/views/modals/edit_topic_modal.tsx index 590fbb962ca..8b0fd6be101 100644 --- a/packages/commonwealth/client/scripts/views/modals/edit_topic_modal.tsx +++ b/packages/commonwealth/client/scripts/views/modals/edit_topic_modal.tsx @@ -14,24 +14,22 @@ import { CWValidationText } from '../components/component_kit/cw_validation_text import { CWIconButton } from '../components/component_kit/cw_icon_button'; import { useCommonNavigate } from 'navigation/helpers'; import { createDeltaFromText, getTextFromDelta, ReactQuillEditor } from '../components/react_quill_editor'; -import { DeltaStatic } from 'quill'; +import type { DeltaStatic } from 'quill'; +import { deserializeDelta, serializeDelta } from '../components/react_quill_editor/utils'; type EditTopicModalProps = { onModalClose: () => void; topic: Topic; }; -export const EditTopicModal = ({ - topic, - onModalClose, -}: EditTopicModalProps) => { +export const EditTopicModal = ({ topic, onModalClose }: EditTopicModalProps) => { const { defaultOffchainTemplate, description: descriptionProp, featuredInNewPost: featuredInNewPostProp, featuredInSidebar: featuredInSidebarProp, id, - name: nameProp, + name: nameProp } = topic; const navigate = useCommonNavigate(); @@ -39,30 +37,14 @@ export const EditTopicModal = ({ const [errorMsg, setErrorMsg] = useState(null); const [isSaving, setIsSaving] = useState(false); - const [contentDelta, setContentDelta] = React.useState( - createDeltaFromText('') - ); + const [contentDelta, setContentDelta] = React.useState(deserializeDelta(defaultOffchainTemplate)); const [description, setDescription] = useState(descriptionProp); - const [featuredInNewPost, setFeaturedInNewPost] = useState( - featuredInNewPostProp - ); - const [featuredInSidebar, setFeaturedInSidebar] = useState( - featuredInSidebarProp - ); + const [featuredInNewPost, setFeaturedInNewPost] = useState(featuredInNewPostProp); + const [featuredInSidebar, setFeaturedInSidebar] = useState(featuredInSidebarProp); const [name, setName] = useState(nameProp); - const editorText = getTextFromDelta(contentDelta) - - useEffect(() => { - if (defaultOffchainTemplate) { - try { - setContentDelta(JSON.parse(defaultOffchainTemplate)); - } catch (e) { - setContentDelta(createDeltaFromText(defaultOffchainTemplate)); - } - } - }, [defaultOffchainTemplate]); + const editorText = getTextFromDelta(contentDelta); const handleSaveChanges = async () => { setIsSaving(true); @@ -80,9 +62,7 @@ export const EditTopicModal = ({ telegram: null, featured_in_sidebar: featuredInSidebar, featured_in_new_post: featuredInNewPost, - default_offchain_template: featuredInNewPost - ? JSON.stringify(contentDelta) - : null, + default_offchain_template: featuredInNewPost ? serializeDelta(contentDelta) : null }; try { @@ -105,7 +85,7 @@ export const EditTopicModal = ({ const topicInfo = { id, name: name, - chainId: app.activeChainId(), + chainId: app.activeChainId() }; await app.topics.remove(topicInfo); @@ -131,10 +111,7 @@ export const EditTopicModal = ({ const disallowedCharMatches = text.match(/["<>%{}|\\/^`]/g); if (disallowedCharMatches) { - newErrorMsg = `The ${pluralizeWithoutNumberPrefix( - disallowedCharMatches.length, - 'char' - )} + newErrorMsg = `The ${pluralizeWithoutNumberPrefix(disallowedCharMatches.length, 'char')} ${disallowedCharMatches.join(', ')} are not permitted`; setErrorMsg(newErrorMsg); return ['failure', newErrorMsg]; @@ -173,20 +150,11 @@ export const EditTopicModal = ({ value="" /> {featuredInNewPost && ( - + )}
- +
{errorMsg && }
diff --git a/packages/commonwealth/client/scripts/views/modals/new_topic_modal.tsx b/packages/commonwealth/client/scripts/views/modals/new_topic_modal.tsx index 2c5c585aeb3..acab7f25f67 100644 --- a/packages/commonwealth/client/scripts/views/modals/new_topic_modal.tsx +++ b/packages/commonwealth/client/scripts/views/modals/new_topic_modal.tsx @@ -14,8 +14,9 @@ import { CWLabel } from '../components/component_kit/cw_label'; import { CWValidationText } from '../components/component_kit/cw_validation_text'; import { CWIconButton } from '../components/component_kit/cw_icon_button'; import { useCommonNavigate } from 'navigation/helpers'; -import { DeltaStatic } from 'quill'; +import type { DeltaStatic } from 'quill'; import { createDeltaFromText, getTextFromDelta, ReactQuillEditor } from '../components/react_quill_editor'; +import { serializeDelta } from '../components/react_quill_editor/utils'; type NewTopicModalProps = { onModalClose: () => void; @@ -27,41 +28,36 @@ export const NewTopicModal = (props: NewTopicModalProps) => { const navigate = useCommonNavigate(); const [errorMsg, setErrorMsg] = React.useState(null); - const [contentDelta, setContentDelta] = React.useState( - createDeltaFromText('') - ); + const [contentDelta, setContentDelta] = React.useState(createDeltaFromText('')); const [isSaving, setIsSaving] = React.useState(false); const [description, setDescription] = React.useState(''); - const [featuredInNewPost, setFeaturedInNewPost] = - React.useState(false); - const [featuredInSidebar, setFeaturedInSidebar] = - React.useState(false); + const [featuredInNewPost, setFeaturedInNewPost] = React.useState(false); + const [featuredInSidebar, setFeaturedInSidebar] = React.useState(false); const [name, setName] = React.useState(''); const [tokenThreshold, setTokenThreshold] = React.useState('0'); - const [submitIsDisabled, setSubmitIsDisabled] = - React.useState(false); + const [submitIsDisabled, setSubmitIsDisabled] = React.useState(false); - const editorText = getTextFromDelta(contentDelta) + const editorText = getTextFromDelta(contentDelta); useEffect(() => { if (!name || !name.trim()) { - setErrorMsg('Name must be specified.') - return + setErrorMsg('Name must be specified.'); + return; } if (featuredInNewPost && editorText.length === 0) { - setErrorMsg('Must add template.') - return + setErrorMsg('Must add template.'); + return; } - setErrorMsg(null) - }, [name, featuredInNewPost, editorText]) + setErrorMsg(null); + }, [name, featuredInNewPost, editorText]); const decimals = app.chain?.meta?.decimals ? app.chain.meta.decimals : app.chain.network === ChainNetwork.ERC721 - ? 0 - : app.chain.base === ChainBase.CosmosSDK - ? 6 - : 18; + ? 0 + : app.chain.base === ChainBase.CosmosSDK + ? 6 + : 18; return (
@@ -90,10 +86,7 @@ export const NewTopicModal = (props: NewTopicModalProps) => { const disallowedCharMatches = text.match(/["<>%{}|\\/^`]/g); if (disallowedCharMatches) { - const err = `The ${pluralizeWithoutNumberPrefix( - disallowedCharMatches.length, - 'char' - )} + const err = `The ${pluralizeWithoutNumberPrefix(disallowedCharMatches.length, 'char')} ${disallowedCharMatches.join(', ')} are not permitted`; setErrorMsg(err); return ['failure', err]; @@ -118,9 +111,7 @@ export const NewTopicModal = (props: NewTopicModalProps) => { /> {app.activeChainId() && ( - + { value="" />
- {featuredInNewPost && ( - - )} + {featuredInNewPost && } { e.preventDefault(); try { - await app.topics.add( name, description, @@ -169,7 +154,7 @@ export const NewTopicModal = (props: NewTopicModalProps) => { featuredInSidebar, featuredInNewPost, tokenThreshold || '0', - JSON.stringify(contentDelta) + serializeDelta(contentDelta) ); navigate(`/discussions/${encodeURI(name.toString().trim())}`); diff --git a/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx b/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx index 12d7d0a77d8..1f0df486e25 100644 --- a/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx +++ b/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx @@ -1,69 +1,54 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import 'modals/preview_modal.scss'; -import { MarkdownFormattedText } from 'views/components/quill/markdown_formatted_text'; import { QuillFormattedText } from 'views/components/quill/quill_formatted_text'; import { CWText } from '../components/component_kit/cw_text'; import { CWIconButton } from '../components/component_kit/cw_icon_button'; +import type { DeltaStatic } from 'quill'; +import { MarkdownFormattedText } from '../components/react_quill_editor/markdown_formatted_text'; + +const EmptyState = () => { + return ( +
+ + Nothing to preview + +
+ ); +}; type PreviewModalProps = { - doc: string; + doc: DeltaStatic | string; onModalClose: () => void; title: string; }; -export const PreviewModal = ({ - doc, - onModalClose, - title, -}: PreviewModalProps) => { +export const PreviewModal = ({ doc, onModalClose, title }: PreviewModalProps) => { + const renderedContent = useMemo(() => { + if (!doc) { + return ; + } + // render as markdown + if (typeof doc === 'string') { + if (doc.length === 0) { + return ; + } + return ; + } + if (!doc.ops?.length) { + return ; + } + return ; + }, [doc]); + return (

{title ? `Preview: ${title}` : 'Preview'}

onModalClose()} />
-
- {(() => { - try { - const internalDoc = JSON.parse(doc); - if (!internalDoc.ops) throw new Error(); - if ( - internalDoc.ops.length === 1 && - internalDoc.ops[0].insert === '\n' - ) { - return ( -
- - Nothing to preview - -
- ); - } - return ; - } catch (e) { - if (doc.trim() === '') { - return ( -
- - Nothing to preview - -
- ); - } - return doc && ; - } - })()} -
+
{renderedContent}
); }; diff --git a/packages/commonwealth/client/scripts/views/pages/view_thread/edit_body.tsx b/packages/commonwealth/client/scripts/views/pages/view_thread/edit_body.tsx index 1d5d3a3a16b..e2fd3fff99a 100644 --- a/packages/commonwealth/client/scripts/views/pages/view_thread/edit_body.tsx +++ b/packages/commonwealth/client/scripts/views/pages/view_thread/edit_body.tsx @@ -7,9 +7,9 @@ import app from 'state'; import { ContentType } from 'types'; import { clearEditingLocalStorage } from '../../components/comments/helpers'; import { CWButton } from '../../components/component_kit/cw_button'; -import { DeltaStatic } from 'quill'; +import type { DeltaStatic } from 'quill'; import { ReactQuillEditor } from '../../components/react_quill_editor'; -import { parseDeltaString } from '../../components/react_quill_editor/utils'; +import { deserializeDelta } from '../../components/react_quill_editor/utils'; type EditBodyProps = { title: string; @@ -21,18 +21,10 @@ type EditBodyProps = { }; export const EditBody = (props: EditBodyProps) => { + const { title, shouldRestoreEdits, savedEdits, thread, cancelEditing, threadUpdatedCallback } = props; - const { - title, - shouldRestoreEdits, - savedEdits, - thread, - cancelEditing, - threadUpdatedCallback, - } = props; - - const threadBody = (shouldRestoreEdits && savedEdits) ? savedEdits : thread.body; - const body = parseDeltaString(threadBody) + const threadBody = shouldRestoreEdits && savedEdits ? savedEdits : thread.body; + const body = deserializeDelta(threadBody); const [contentDelta, setContentDelta] = React.useState(body); const [saving, setSaving] = React.useState(false); @@ -43,16 +35,14 @@ export const EditBody = (props: EditBodyProps) => { let cancelConfirmed = true; if (JSON.stringify(body) !== JSON.stringify(contentDelta)) { - cancelConfirmed = window.confirm( - 'Cancel editing? Changes will not be saved.' - ); + cancelConfirmed = window.confirm('Cancel editing? Changes will not be saved.'); } if (cancelConfirmed) { clearEditingLocalStorage(thread.id, ContentType.Thread); cancelEditing(); } - } + }; const save = async (e: React.MouseEvent) => { e.preventDefault(); @@ -60,36 +50,24 @@ export const EditBody = (props: EditBodyProps) => { setSaving(true); try { - const newBody = JSON.stringify(contentDelta) - await app.threads.edit(thread, newBody, title) + const newBody = JSON.stringify(contentDelta); + await app.threads.edit(thread, newBody, title); clearEditingLocalStorage(thread.id, ContentType.Thread); notifySuccess('Thread successfully edited'); threadUpdatedCallback(title, newBody); } catch (err) { - console.error(err) + console.error(err); } finally { setSaving(false); } - } + }; return (
- +
- - + +
); diff --git a/packages/commonwealth/client/styles/components/react_quill/react_quill_editor.scss b/packages/commonwealth/client/styles/components/react_quill/react_quill_editor.scss index 8573beb0bd0..30ea1010bed 100644 --- a/packages/commonwealth/client/styles/components/react_quill/react_quill_editor.scss +++ b/packages/commonwealth/client/styles/components/react_quill/react_quill_editor.scss @@ -6,6 +6,43 @@ min-height: 180px; } + .custom-buttons { + position: relative; + float: right; + margin-top: 10px; + margin-right: 10px; + display: flex; + flex-direction: row; + + .custom-button { + color: #333; + text-align: center; + font-size: 1.2em; + width: 1.2em; + height: 1.2em; + align-items: center; + justify-content: center; + margin-left: 5px; + + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + supported by Chrome, Edge, Opera and Firefox */ + + &.preview { + padding: 3px; + } + + &:hover { + color: #888; + } + } + + } + .LoadingIndicator { position: relative; diff --git a/packages/commonwealth/client/styles/modals/preview_modal.scss b/packages/commonwealth/client/styles/modals/preview_modal.scss index ae45b03655e..999c054aca1 100644 --- a/packages/commonwealth/client/styles/modals/preview_modal.scss +++ b/packages/commonwealth/client/styles/modals/preview_modal.scss @@ -1,6 +1,16 @@ @import '../shared'; .PreviewModal { + + max-height: 95vh; + overflow-y: scroll; + + .compact-modal-title { + position: sticky; + top: 0; + z-index: 1; + } + .compact-modal-body { .QuillFormattedText { margin: 25px 0;