From 2867b4c94ef167bd5385777264b5cb4d2975dfd8 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 23 Mar 2023 14:24:18 -0700 Subject: [PATCH 01/12] react quill markdown WIP --- .../react_quill_editor/react_quill_editor.tsx | 190 ++++++++++++------ .../scripts/views/modals/preview_modal.tsx | 80 +++----- .../react_quill/react_quill_editor.scss | 37 ++++ 3 files changed, 193 insertions(+), 114 deletions(-) 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..23862b786c3 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 @@ -2,121 +2,178 @@ import React, { useCallback, useRef, useState } from 'react'; import ReactQuill, { Quill } from 'react-quill'; import 'components/react_quill/react_quill_editor.scss'; -import imageDropAndPaste from 'quill-image-drop-and-paste' +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 { 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'] +const VALID_IMAGE_TYPES = ['jpeg', 'gif', 'png']; const LoadingIndicator = () => { return ( -
+
- ) -} + ); +}; 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: DeltaStatic; + setContentDelta: (d: DeltaStatic) => void; +}; // ReactQuillEditor is a custom wrapper for the react-quill component const ReactQuillEditor = ({ - className = '', - placeholder, - tabIndex, - contentDelta, - setContentDelta, - } : ReactQuillEditorProps) => { - - const editorRef = useRef() - - const [isUploading, setIsUploading] = useState(false) + className = '', + placeholder, + tabIndex, + contentDelta, + setContentDelta +}: ReactQuillEditorProps) => { + const editorRef = useRef(); + + const [isUploading, setIsUploading] = useState(false); + const [isMarkdownEnabled, setIsMarkdownEnabled] = useState(false); + const [isPreviewVisible, setIsPreviewVisible] = useState(false); const handleChange = (value, delta, source, editor) => { - setContentDelta(editor.getContents()) - } + setContentDelta(editor.getContents()); + }; // must use memoized function or else it'll render in an infinite loop - const handleImageDropAndPaste = useCallback(async (imageDataUrl, imageType) => { + const handleImageDropAndPaste = useCallback( + async (imageDataUrl, imageType) => { + const editor = editorRef.current?.editor; - 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) - - const file = base64ToFile(imageDataUrl, imageType) - - 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 - - } catch (err) { - console.error(err) - } finally { - editor.enable() - setIsUploading(false) - } + return true; + }); + setContentDelta({ ops: opsWithoutBase64Images } as DeltaStatic); + + const file = base64ToFile(imageDataUrl, imageType); + + 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 + } catch (err) { + console.error(err); + } finally { + editor.enable(); + setIsUploading(false); + } + }, + [editorRef, setContentDelta] + ); - }, [editorRef, setContentDelta]) + const handlePreviewModalClose = () => { + setIsPreviewVisible(false); + }; return ( -
- {isUploading && } +
+ {isUploading && } + + } + onClose={handlePreviewModalClose} + open={isPreviewVisible} + /> +
+ {isMarkdownEnabled && ( + { + setIsMarkdownEnabled(false); + }} + > + R + + )} + {!isMarkdownEnabled && ( + { + setIsMarkdownEnabled(true); + }} + > + M + + )} + { + e.preventDefault(); + setIsPreviewVisible(true); + }} + /> +
- ) - -} + ); +}; export default ReactQuillEditor; diff --git a/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx b/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx index 12d7d0a77d8..0599e25024e 100644 --- a/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx +++ b/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import 'modals/preview_modal.scss'; @@ -6,64 +6,50 @@ import { MarkdownFormattedText } from 'views/components/quill/markdown_formatted 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 { renderQuillDeltaToText } from 'shared/utils'; +import { getTextFromDelta } from '../components/react_quill_editor'; + +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') { + const docAsText = getTextFromDelta(doc); + if (docAsText.length === 0) { + console.warn('markdown doc empty'); + return ; + } + 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/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; From d408fac6d85098b762dce5df0c46ec87feedcb62 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 23 Mar 2023 14:41:10 -0700 Subject: [PATCH 02/12] render rich text preview works --- .../components/react_quill_editor/react_quill_editor.tsx | 2 +- .../client/scripts/views/modals/preview_modal.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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 23862b786c3..b7772c0b4af 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 @@ -115,7 +115,7 @@ const ReactQuillEditor = ({ diff --git a/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx b/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx index 0599e25024e..bb02aadc217 100644 --- a/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx +++ b/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx @@ -27,18 +27,18 @@ type PreviewModalProps = { }; export const PreviewModal = ({ doc, onModalClose, title }: PreviewModalProps) => { + console.log('modal doc: ', doc); const renderedContent = useMemo(() => { if (!doc) { return ; } // render as markdown if (typeof doc === 'string') { - const docAsText = getTextFromDelta(doc); - if (docAsText.length === 0) { + if (doc.length === 0) { console.warn('markdown doc empty'); return ; } - return ; + return ; } return ; }, [doc]); From b62bb8cf07c05b9bab0b7f73b46c0ceb493c4431 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 23 Mar 2023 15:23:29 -0700 Subject: [PATCH 03/12] convert mithril components WIP --- .../react_markdown_formatted_text.tsx | 189 ++++++++++++++++++ .../scripts/views/modals/preview_modal.tsx | 7 +- 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 packages/commonwealth/client/scripts/views/components/react_quill_editor/react_markdown_formatted_text.tsx diff --git a/packages/commonwealth/client/scripts/views/components/react_quill_editor/react_markdown_formatted_text.tsx b/packages/commonwealth/client/scripts/views/components/react_quill_editor/react_markdown_formatted_text.tsx new file mode 100644 index 00000000000..efe0a38a211 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/react_quill_editor/react_markdown_formatted_text.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useMemo } from 'react'; +/* eslint-disable no-useless-escape */ + +import 'components/quill/markdown_formatted_text.scss'; +import DOMPurify from 'dompurify'; +import { findAll } from 'highlight-words-core'; +import { marked } from 'marked'; +import smartTruncate from 'smart-truncate'; +import { CWIcon } from '../component_kit/cw_icons/cw_icon'; + +import { getClasses } from '../component_kit/helpers'; +import { countLinesMarkdown } from './helpers'; +import { MarkdownFormattedText } from '../quill/markdown_formatted_text'; + +const renderer = new marked.Renderer(); + +marked.setOptions({ + renderer, + gfm: true, // use github flavored markdown + smartypants: true, + smartLists: true, + xhtml: true, +}); + +type ReactMarkdownFormattedTextProps = { + collapse?: boolean; + doc: string; + hideFormatting?: boolean; + openLinksInNewTab?: boolean; + searchTerm?: string; + cutoffLines?: number; +}; + +export const ReactMarkdownFormattedText = ({ + doc, + hideFormatting, + collapse, + searchTerm, + openLinksInNewTab, + cutoffLines, +}: ReactMarkdownFormattedTextProps) => { + + 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 + }, [doc]) + + + if (!doc) return; + + const toggleDisplay = () => { + this.isTruncated = !this.isTruncated; + if (this.isTruncated) { + this.truncatedDoc = doc.slice( + 0, + doc.split('\n', cutoffLines).join('\n').length + ); + } else { + this.truncatedDoc = doc; + } + redraw(); + }; + + renderer.link = (href, title, text) => { + return `${text}`; + }; + + // if we're showing highlighted search terms, render the doc once, and cache the result + if (searchTerm) { + // TODO: Switch trim system to match QFT component + if (JSON.stringify(doc) !== this.cachedDocWithHighlights) { + const unsanitized = marked.parse(doc.toString()); + + const sanitized = DOMPurify.sanitize(unsanitized, { + ALLOWED_TAGS: ['a'], + ADD_ATTR: ['target'], + }); + + const vnodes = render.trust(sanitized); + + const root = document.createElement('div'); + + rootRender(root, vnodes); + + const textToHighlight = root.innerText + .replace(/\n/g, ' ') + .replace(/\ +/g, ' '); + + const chunks = findAll({ + searchWords: [searchTerm.trim()], + textToHighlight, + }); + + this.cachedDocWithHighlights = JSON.stringify(doc); + + this.cachedResultWithHighlights = chunks.map( + ({ end, highlight, start }, index) => { + const middle = 15; + + const subString = textToHighlight.substr(start, end - start); + + let text = smartTruncate( + subString, + chunks.length <= 1 ? 150 : 40 + searchTerm.trim().length, + chunks.length <= 1 + ? {} + : index === 0 + ? { position: 0 } + : index === chunks.length - 1 + ? {} + : { position: middle } + ); + + if (subString[subString.length - 1] === ' ') { + text += ' '; + } + + if (subString[0] === ' ') { + text = ` ${text}`; + } + + return highlight ? {text} : {text}; + } + ); + } + + return ( +
( + { collapsed: !!collapse }, + 'MarkdownFormattedText' + )} + > + {this.cachedResultWithHighlights} +
+ ); + } else { + if (!doc) return <>; + if (this.isTruncated) { + this.truncatedDoc = doc.slice( + 0, + doc.split('\n', cutoffLines).join('\n').length + ); + } + if (!this.truncatedDoc) return <>; + + const unsanitized = marked.parse(this.truncatedDoc.toString()); + + const sanitized = hideFormatting + ? DOMPurify.sanitize(unsanitized, { + ALLOWED_TAGS: ['a'], + ADD_ATTR: ['target'], + }) + : DOMPurify.sanitize(unsanitized, { + USE_PROFILES: { html: true }, + ADD_ATTR: ['target'], + }); + + const results = render.trust(sanitized); + + return ( + <> +
( + { collapsed: !!collapse }, + 'MarkdownFormattedText' + )} + > + {results} +
+ {this.isTruncated && ( +
+
+ +
Show More
+
+
+ )} + + ); +} diff --git a/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx b/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx index bb02aadc217..5ed2dffff05 100644 --- a/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx +++ b/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx @@ -27,7 +27,6 @@ type PreviewModalProps = { }; export const PreviewModal = ({ doc, onModalClose, title }: PreviewModalProps) => { - console.log('modal doc: ', doc); const renderedContent = useMemo(() => { if (!doc) { return ; @@ -38,8 +37,14 @@ export const PreviewModal = ({ doc, onModalClose, title }: PreviewModalProps) => console.warn('markdown doc empty'); return ; } + console.log('md doc: ', doc); return ; } + if (!doc.ops?.length) { + console.warn('richtext doc empty'); + return ; + } + console.log('rt doc: ', doc); return ; }, [doc]); From bba0e0ae07c4edb1b26e7b252827cfa7e42aa0db Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Fri, 24 Mar 2023 10:55:27 -0700 Subject: [PATCH 04/12] renders markdown and richtext in preview modal --- .../markdown_formatted_text.tsx | 82 ++++++++ .../react_markdown_formatted_text.tsx | 189 ------------------ .../react_quill_editor/react_quill_editor.tsx | 13 +- .../components/react_quill_editor/utils.ts | 5 + .../scripts/views/modals/preview_modal.tsx | 8 +- .../client/styles/modals/preview_modal.scss | 10 + 6 files changed, 105 insertions(+), 202 deletions(-) create mode 100644 packages/commonwealth/client/scripts/views/components/react_quill_editor/markdown_formatted_text.tsx delete mode 100644 packages/commonwealth/client/scripts/views/components/react_quill_editor/react_markdown_formatted_text.tsx 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/react_markdown_formatted_text.tsx b/packages/commonwealth/client/scripts/views/components/react_quill_editor/react_markdown_formatted_text.tsx deleted file mode 100644 index efe0a38a211..00000000000 --- a/packages/commonwealth/client/scripts/views/components/react_quill_editor/react_markdown_formatted_text.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import React, { useEffect, useMemo } from 'react'; -/* eslint-disable no-useless-escape */ - -import 'components/quill/markdown_formatted_text.scss'; -import DOMPurify from 'dompurify'; -import { findAll } from 'highlight-words-core'; -import { marked } from 'marked'; -import smartTruncate from 'smart-truncate'; -import { CWIcon } from '../component_kit/cw_icons/cw_icon'; - -import { getClasses } from '../component_kit/helpers'; -import { countLinesMarkdown } from './helpers'; -import { MarkdownFormattedText } from '../quill/markdown_formatted_text'; - -const renderer = new marked.Renderer(); - -marked.setOptions({ - renderer, - gfm: true, // use github flavored markdown - smartypants: true, - smartLists: true, - xhtml: true, -}); - -type ReactMarkdownFormattedTextProps = { - collapse?: boolean; - doc: string; - hideFormatting?: boolean; - openLinksInNewTab?: boolean; - searchTerm?: string; - cutoffLines?: number; -}; - -export const ReactMarkdownFormattedText = ({ - doc, - hideFormatting, - collapse, - searchTerm, - openLinksInNewTab, - cutoffLines, -}: ReactMarkdownFormattedTextProps) => { - - 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 - }, [doc]) - - - if (!doc) return; - - const toggleDisplay = () => { - this.isTruncated = !this.isTruncated; - if (this.isTruncated) { - this.truncatedDoc = doc.slice( - 0, - doc.split('\n', cutoffLines).join('\n').length - ); - } else { - this.truncatedDoc = doc; - } - redraw(); - }; - - renderer.link = (href, title, text) => { - return `${text}`; - }; - - // if we're showing highlighted search terms, render the doc once, and cache the result - if (searchTerm) { - // TODO: Switch trim system to match QFT component - if (JSON.stringify(doc) !== this.cachedDocWithHighlights) { - const unsanitized = marked.parse(doc.toString()); - - const sanitized = DOMPurify.sanitize(unsanitized, { - ALLOWED_TAGS: ['a'], - ADD_ATTR: ['target'], - }); - - const vnodes = render.trust(sanitized); - - const root = document.createElement('div'); - - rootRender(root, vnodes); - - const textToHighlight = root.innerText - .replace(/\n/g, ' ') - .replace(/\ +/g, ' '); - - const chunks = findAll({ - searchWords: [searchTerm.trim()], - textToHighlight, - }); - - this.cachedDocWithHighlights = JSON.stringify(doc); - - this.cachedResultWithHighlights = chunks.map( - ({ end, highlight, start }, index) => { - const middle = 15; - - const subString = textToHighlight.substr(start, end - start); - - let text = smartTruncate( - subString, - chunks.length <= 1 ? 150 : 40 + searchTerm.trim().length, - chunks.length <= 1 - ? {} - : index === 0 - ? { position: 0 } - : index === chunks.length - 1 - ? {} - : { position: middle } - ); - - if (subString[subString.length - 1] === ' ') { - text += ' '; - } - - if (subString[0] === ' ') { - text = ` ${text}`; - } - - return highlight ? {text} : {text}; - } - ); - } - - return ( -
( - { collapsed: !!collapse }, - 'MarkdownFormattedText' - )} - > - {this.cachedResultWithHighlights} -
- ); - } else { - if (!doc) return <>; - if (this.isTruncated) { - this.truncatedDoc = doc.slice( - 0, - doc.split('\n', cutoffLines).join('\n').length - ); - } - if (!this.truncatedDoc) return <>; - - const unsanitized = marked.parse(this.truncatedDoc.toString()); - - const sanitized = hideFormatting - ? DOMPurify.sanitize(unsanitized, { - ALLOWED_TAGS: ['a'], - ADD_ATTR: ['target'], - }) - : DOMPurify.sanitize(unsanitized, { - USE_PROFILES: { html: true }, - ADD_ATTR: ['target'], - }); - - const results = render.trust(sanitized); - - return ( - <> -
( - { collapsed: !!collapse }, - 'MarkdownFormattedText' - )} - > - {results} -
- {this.isTruncated && ( -
-
- -
Show More
-
-
- )} - - ); -} 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 b7772c0b4af..b557cb5c2b0 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,12 +1,8 @@ import React, { useCallback, useRef, useState } from 'react'; -import ReactQuill, { Quill } from 'react-quill'; -import 'components/react_quill/react_quill_editor.scss'; - +import type { DeltaOperation, DeltaStatic } from 'quill'; import imageDropAndPaste from 'quill-image-drop-and-paste'; +import ReactQuill, { Quill } from 'react-quill'; -import type { DeltaOperation, DeltaStatic } from 'quill'; -import type { QuillMode } from '../quill/types'; -import 'react-quill/dist/quill.snow.css'; import { base64ToFile, getTextFromDelta, uploadFileToS3 } from './utils'; import app from 'state'; @@ -15,6 +11,11 @@ import { CWIconButton } from '../component_kit/cw_icon_button'; import { PreviewModal } from '../../modals/preview_modal'; import { Modal } from '../component_kit/cw_modal'; +import 'components/react_quill/react_quill_editor.scss'; +import 'react-quill/dist/quill.snow.css'; + +export type QuillMode = 'markdown' | 'richText' | 'hybrid'; + const VALID_IMAGE_TYPES = ['jpeg', 'gif', 'png']; const LoadingIndicator = () => { 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..efaf0ece71b 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 @@ -93,3 +93,8 @@ 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; +}; diff --git a/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx b/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx index 5ed2dffff05..1f0df486e25 100644 --- a/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx +++ b/packages/commonwealth/client/scripts/views/modals/preview_modal.tsx @@ -2,13 +2,11 @@ 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 { renderQuillDeltaToText } from 'shared/utils'; -import { getTextFromDelta } from '../components/react_quill_editor'; +import { MarkdownFormattedText } from '../components/react_quill_editor/markdown_formatted_text'; const EmptyState = () => { return ( @@ -34,17 +32,13 @@ export const PreviewModal = ({ doc, onModalClose, title }: PreviewModalProps) => // render as markdown if (typeof doc === 'string') { if (doc.length === 0) { - console.warn('markdown doc empty'); return ; } - console.log('md doc: ', doc); return ; } if (!doc.ops?.length) { - console.warn('richtext doc empty'); return ; } - console.log('rt doc: ', doc); return ; }, [doc]); 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; From 77b69dbda6f85a21410c5f795da52d15dbbf087b Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Fri, 24 Mar 2023 11:16:03 -0700 Subject: [PATCH 05/12] fix remove formatting on paste --- .../react_quill_editor/react_quill_editor.tsx | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) 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 b557cb5c2b0..e364058114d 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,4 +1,4 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, 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'; @@ -28,6 +28,7 @@ const LoadingIndicator = () => { ); }; +const Delta = Quill.import('delta'); Quill.register('modules/imageDropAndPaste', imageDropAndPaste); type ReactQuillEditorProps = { @@ -106,10 +107,32 @@ const ReactQuillEditor = ({ [editorRef, setContentDelta] ); + const handleToggleMarkdown = () => { + setIsMarkdownEnabled(!isMarkdownEnabled); + }; + const handlePreviewModalClose = () => { setIsPreviewVisible(false); }; + 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 + }) + ); + } + ] + ]; + }, []); + return (
{isUploading && } @@ -131,9 +154,7 @@ const ReactQuillEditor = ({ fontWeight="semiBold" className="custom-button" title="Switch to RichText mode" - onClick={(e) => { - setIsMarkdownEnabled(false); - }} + onClick={handleToggleMarkdown} > R @@ -144,9 +165,7 @@ const ReactQuillEditor = ({ fontWeight="semiBold" className="custom-button" title="Switch to Markdown mode" - onClick={async () => { - setIsMarkdownEnabled(true); - }} + onClick={handleToggleMarkdown} > M @@ -178,6 +197,9 @@ const ReactQuillEditor = ({ ]), imageDropAndPaste: { handler: handleImageDropAndPaste + }, + clipboard: { + matchers: clipboardMatchers } }} /> From 8f183c7dfdbeb816a075ba5edc4c3338cba24305 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Fri, 24 Mar 2023 12:00:31 -0700 Subject: [PATCH 06/12] remove formatting on markdown enabled --- .../react_quill_editor/react_quill_editor.tsx | 23 +++++++++++++++++-- .../components/react_quill_editor/utils.ts | 2 +- 2 files changed, 22 insertions(+), 3 deletions(-) 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 e364058114d..bf3c03dd056 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 @@ -58,7 +58,7 @@ const ReactQuillEditor = ({ setContentDelta(editor.getContents()); }; - // must use memoized function or else it'll render in an infinite loop + // must be memoized or else infinite loop const handleImageDropAndPaste = useCallback( async (imageDataUrl, imageType) => { const editor = editorRef.current?.editor; @@ -108,13 +108,32 @@ const ReactQuillEditor = ({ ); const handleToggleMarkdown = () => { - setIsMarkdownEnabled(!isMarkdownEnabled); + 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()); + setContentDelta(editor.getContents()); + setIsMarkdownEnabled(newMarkdownEnabled); + } + } else { + setIsMarkdownEnabled(newMarkdownEnabled); + } }; const handlePreviewModalClose = () => { setIsPreviewVisible(false); }; + // must be memoized or else infinite loop const clipboardMatchers = useMemo(() => { return [ [ 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 efaf0ece71b..f77e37fa180 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 @@ -36,7 +36,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 From cb5b8545d2573fb8da1b1f4c7cb9de44587f4c15 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Mon, 27 Mar 2023 14:13:13 -0700 Subject: [PATCH 07/12] add serializable delta static for persistence --- .../views/components/comments/comment.tsx | 80 +++++-------------- .../components/comments/create_comment.tsx | 71 +++++----------- .../react_quill_editor/quill_renderer.tsx | 26 ++++++ .../react_quill_editor/react_quill_editor.tsx | 27 +++++-- .../components/react_quill_editor/utils.ts | 10 +++ 5 files changed, 98 insertions(+), 116 deletions(-) create mode 100644 packages/commonwealth/client/scripts/views/components/react_quill_editor/quill_renderer.tsx 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/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 bf3c03dd056..af09ac234b1 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,8 +1,9 @@ -import React, { useCallback, useMemo, 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 type { SerializableDeltaStatic } from './utils'; import { base64ToFile, getTextFromDelta, uploadFileToS3 } from './utils'; import app from 'state'; @@ -36,8 +37,8 @@ type ReactQuillEditorProps = { 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 @@ -55,7 +56,10 @@ const ReactQuillEditor = ({ const [isPreviewVisible, setIsPreviewVisible] = useState(false); const handleChange = (value, delta, source, editor) => { - setContentDelta(editor.getContents()); + setContentDelta({ + ...editor.getContents(), + ___isMarkdown: isMarkdownEnabled + } as SerializableDeltaStatic); }; // must be memoized or else infinite loop @@ -121,8 +125,11 @@ const ReactQuillEditor = ({ } if (confirmed) { editor.removeFormat(0, editor.getLength()); - setContentDelta(editor.getContents()); setIsMarkdownEnabled(newMarkdownEnabled); + setContentDelta({ + ...editor.getContents(), + ___isMarkdown: newMarkdownEnabled + }); } } else { setIsMarkdownEnabled(newMarkdownEnabled); @@ -152,6 +159,16 @@ const ReactQuillEditor = ({ ]; }, []); + useEffect(() => { + const editor = editorRef.current?.getEditor(); + if (editor) { + setContentDelta({ + ...editor.getContents(), + ___isMarkdown: isMarkdownEnabled + } as SerializableDeltaStatic); + } + }, [isMarkdownEnabled, setContentDelta]); + return (
{isUploading && } 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 f77e37fa180..b02f5461dfa 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 @@ -98,3 +98,13 @@ export const uploadFileToS3 = async (file: File, appServerUrl: string, jwtToken: export const countLinesMarkdown = (text: string) : number => { return text.split('\n').length - 1; }; + +export type SerializableDeltaStatic = DeltaStatic & { + ___isMarkdown?: boolean +} +export const serializeDelta = (delta: DeltaStatic) : string => { + if ((delta as SerializableDeltaStatic).___isMarkdown) { + return getTextFromDelta(delta) + } + return JSON.stringify(delta) +} From 712b27b2ced6488d51e00742e0f2ce9a23f474d0 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Mon, 27 Mar 2023 23:15:41 -0700 Subject: [PATCH 08/12] handle serialization and deserialization in parent components --- .../components/comments/edit_comment.tsx | 45 ++----- .../scripts/views/components/edit_profile.tsx | 117 +++++------------- .../react_quill_editor/react_quill_editor.tsx | 7 ++ .../components/react_quill_editor/utils.ts | 32 +++-- .../scripts/views/modals/edit_topic_modal.tsx | 58 ++------- .../scripts/views/modals/new_topic_modal.tsx | 57 ++++----- .../views/pages/view_thread/edit_body.tsx | 50 +++----- 7 files changed, 118 insertions(+), 248 deletions(-) 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/react_quill_editor.tsx b/packages/commonwealth/client/scripts/views/components/react_quill_editor/react_quill_editor.tsx index af09ac234b1..2b4f5e7e710 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 @@ -159,6 +159,7 @@ const ReactQuillEditor = ({ ]; }, []); + // when markdown is toggled, add markdown metadata to ops useEffect(() => { const editor = editorRef.current?.getEditor(); if (editor) { @@ -169,6 +170,12 @@ const ReactQuillEditor = ({ } }, [isMarkdownEnabled, setContentDelta]); + // when initialized, update markdown state to match content type + useEffect(() => { + setIsMarkdownEnabled(!!contentDelta?.___isMarkdown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
{isUploading && } 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 b02f5461dfa..f4eaeb364f0 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 @@ -99,12 +90,27 @@ 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 + return JSON.parse(str) + } catch (err) { + // otherwise, it's plain text markdown + console.warn('failed to parse string JSON, treating as plain text', err) + 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/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 (
- +
- - + +
); From d7b3f12042f6c6fe0ad7120c7422f6216bc4eb3d Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Tue, 28 Mar 2023 02:26:20 -0700 Subject: [PATCH 09/12] fix refresh bug when react quill modules prop is updated --- .../react_quill_editor/react_quill_editor.tsx | 75 ++++++++++++------- 1 file changed, 47 insertions(+), 28 deletions(-) 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 2b4f5e7e710..014e28e6b70 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 @@ -14,6 +14,7 @@ import { Modal } from '../component_kit/cw_modal'; import 'components/react_quill/react_quill_editor.scss'; import 'react-quill/dist/quill.snow.css'; +import { nextTick } from 'process'; export type QuillMode = 'markdown' | 'richText' | 'hybrid'; @@ -51,10 +52,22 @@ const ReactQuillEditor = ({ }: ReactQuillEditorProps) => { const editorRef = useRef(); + const [isVisible, setIsVisible] = useState(true); const [isUploading, setIsUploading] = useState(false); const [isMarkdownEnabled, setIsMarkdownEnabled] = useState(false); const [isPreviewVisible, setIsPreviewVisible] = 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(), @@ -99,7 +112,11 @@ const ReactQuillEditor = ({ const uploadedFileUrl = await uploadFileToS3(file, app.serverUrl(), app.user.jwt); // insert image op at the selected index - editor.insertEmbed(selectedIndex, 'image', uploadedFileUrl); + if (isMarkdownEnabled) { + editor.insertText(selectedIndex, `![image](${uploadedFileUrl})`); + } else { + editor.insertEmbed(selectedIndex, 'image', uploadedFileUrl); + } setContentDelta(editor.getContents()); // sync state with editor content } catch (err) { console.error(err); @@ -108,7 +125,7 @@ const ReactQuillEditor = ({ setIsUploading(false); } }, - [editorRef, setContentDelta] + [editorRef, isMarkdownEnabled, setContentDelta] ); const handleToggleMarkdown = () => { @@ -134,6 +151,7 @@ const ReactQuillEditor = ({ } else { setIsMarkdownEnabled(newMarkdownEnabled); } + refreshQuillComponent(); }; const handlePreviewModalClose = () => { @@ -159,7 +177,7 @@ const ReactQuillEditor = ({ ]; }, []); - // when markdown is toggled, add markdown metadata to ops + // when markdown state is changed, add markdown metadata to delta ops useEffect(() => { const editor = editorRef.current?.getEditor(); if (editor) { @@ -170,11 +188,10 @@ const ReactQuillEditor = ({ } }, [isMarkdownEnabled, setContentDelta]); - // when initialized, update markdown state to match content type + // when delta markdown is changed, update markdown state to match useEffect(() => { setIsMarkdownEnabled(!!contentDelta?.___isMarkdown); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [contentDelta]); return (
@@ -224,28 +241,30 @@ const ReactQuillEditor = ({ }} />
- + {isVisible && ( + + )}
); }; From 831025e753195425645b427bfb396988a6160bf8 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Tue, 28 Mar 2023 06:19:24 -0700 Subject: [PATCH 10/12] fix upload markdown state bug --- .../react_quill_editor/react_quill_editor.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 014e28e6b70..95e836a1f9d 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 @@ -105,7 +105,10 @@ const ReactQuillEditor = ({ } return true; }); - setContentDelta({ ops: opsWithoutBase64Images } as DeltaStatic); + setContentDelta({ + ops: opsWithoutBase64Images, + ___isMarkdown: isMarkdownEnabled + } as SerializableDeltaStatic); const file = base64ToFile(imageDataUrl, imageType); @@ -117,7 +120,10 @@ const ReactQuillEditor = ({ } else { editor.insertEmbed(selectedIndex, 'image', uploadedFileUrl); } - setContentDelta(editor.getContents()); // sync state with editor content + setContentDelta({ + ...editor.getContents(), + ___isMarkdown: isMarkdownEnabled + } as SerializableDeltaStatic); // sync state with editor content } catch (err) { console.error(err); } finally { From 223a77bf3803d3b1c75e3da4a7d1c2bc77750785 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Tue, 28 Mar 2023 07:57:06 -0700 Subject: [PATCH 11/12] fix markdown state sync bug --- .../react_quill_editor/react_quill_editor.tsx | 11 +++++------ .../views/components/react_quill_editor/utils.ts | 7 +++++-- 2 files changed, 10 insertions(+), 8 deletions(-) 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 95e836a1f9d..13974727d13 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 @@ -16,8 +16,6 @@ import 'components/react_quill/react_quill_editor.scss'; import 'react-quill/dist/quill.snow.css'; import { nextTick } from 'process'; -export type QuillMode = 'markdown' | 'richText' | 'hybrid'; - const VALID_IMAGE_TYPES = ['jpeg', 'gif', 'png']; const LoadingIndicator = () => { @@ -37,7 +35,6 @@ type ReactQuillEditorProps = { className?: string; placeholder?: string; tabIndex?: number; - mode?: QuillMode; // Use in order to limit editor to only MD or RT support contentDelta: SerializableDeltaStatic; setContentDelta: (d: SerializableDeltaStatic) => void; }; @@ -157,7 +154,6 @@ const ReactQuillEditor = ({ } else { setIsMarkdownEnabled(newMarkdownEnabled); } - refreshQuillComponent(); }; const handlePreviewModalClose = () => { @@ -184,6 +180,7 @@ const ReactQuillEditor = ({ }, []); // when markdown state is changed, add markdown metadata to delta ops + // and refresh quill component useEffect(() => { const editor = editorRef.current?.getEditor(); if (editor) { @@ -192,12 +189,14 @@ const ReactQuillEditor = ({ ___isMarkdown: isMarkdownEnabled } as SerializableDeltaStatic); } + refreshQuillComponent(); }, [isMarkdownEnabled, setContentDelta]); - // when delta markdown is changed, update markdown state to match + // when initialized, update markdown state to match content type useEffect(() => { setIsMarkdownEnabled(!!contentDelta?.___isMarkdown); - }, [contentDelta]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return (
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 f4eaeb364f0..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 @@ -107,10 +107,13 @@ export const serializeDelta = (delta: DeltaStatic) : string => { export const deserializeDelta = (str: string) : DeltaStatic => { try { // is richtext delta object - return JSON.parse(str) + 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 - console.warn('failed to parse string JSON, treating as plain text', err) return createDeltaFromText(str, true) } } From b7e20f1c4332889b1a833d8367ce4dd96fd284d2 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Tue, 28 Mar 2023 10:54:30 -0700 Subject: [PATCH 12/12] hide toolbar buttons if markdown enabled --- .../react_quill_editor/react_quill_editor.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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 13974727d13..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 @@ -198,6 +198,18 @@ const ReactQuillEditor = ({ // 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 && } @@ -256,11 +268,7 @@ const ReactQuillEditor = ({ value={contentDelta} onChange={handleChange} modules={{ - toolbar: ([[{ header: 1 }, { header: 2 }]] as any).concat([ - ['bold', 'italic', 'strike'], - ['link', 'code-block', 'blockquote'], - [{ list: 'ordered' }, { list: 'bullet' }, { list: 'check' }] - ]), + toolbar, imageDropAndPaste: { handler: handleImageDropAndPaste },