import { RichTextEmbeds } from '@teleporthq/teleport-shared' import { EMBED_RUNTIME_IMPORT, EMBED_SUPPORT_SOURCE } from './embed-support-source' interface RichTextEditorOptions { /** * Emit the code-embed blot, preview and dialog. Off unless a rich-text field * in the project actually enables the `tq-embed` format, so an editor that * does not offer embeds is generated exactly as it always was. */ withEmbeds?: boolean } /** * Generates the RichTextEditor wrapper component source code. * * The component uses `react-quill-new` via `next/dynamic` to avoid SSR issues * (Quill accesses `document`). It builds a Quill toolbar configuration from the * `quillFormats` prop and supports both the "snow" and "bubble" themes. * * With embeds enabled it also registers the `tq-embed` block blot, adds the * toolbar button that opens the embed dialog, previews sandboxed embeds beside * their block, and normalizes those previews back out of the value before it * reaches `onChange`. */ export const generateRichTextEditorComponentCode = ( options: RichTextEditorOptions = {} ): string => { const { withEmbeds = false } = options const embedBlotName = RichTextEmbeds.EMBED_BLOT_NAME const imports = withEmbeds ? `import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import dynamic from 'next/dynamic' ${EMBED_RUNTIME_IMPORT}` : `import { useMemo } from 'react' import dynamic from 'next/dynamic' ` // The blot has to exist before Quill parses any content, so registration // rides the same dynamic import that loads the editor. const quillImport = withEmbeds ? `const ReactQuill = dynamic( async () => { const mod = await import('react-quill-new') const Editor = mod.default || mod registerEmbedBlot(mod.Quill || (mod.default && mod.default.Quill)) // \`next/dynamic\` attaches a \`ref\` to its OWN loadable wrapper, so a ref // written here never reaches the editor and \`getEditor()\` — the only way to // the Quill instance — is unreachable. Inserting an embed and mounting the // previews both need it, so the ref is handed over as an ordinary prop. const QuillWithRef = (props) => { const { forwardedRef, ...editorProps } = props return } return QuillWithRef }, { ssr: false, loading: () =>
, } )` : `const ReactQuill = dynamic(() => import('react-quill-new'), { ssr: false, loading: () =>
, })` const embedToolbarEntry = withEmbeds ? ` if (formats.includes(${JSON.stringify(embedBlotName)})) mediaGroup.push(${JSON.stringify( embedBlotName )})\n` : '' const body = withEmbeds ? richTextEditorWithEmbedsBody(embedBlotName) : richTextEditorPlainBody() const preamble = withEmbeds ? `${imports}\n${EMBED_SUPPORT_SOURCE}\n` : imports return `${preamble} ${quillImport} function buildToolbarFromFormats(formats) { if (!formats) { return undefined } if (formats.length === 0) { return false } const toolbar = [] const inlineGroup = [] if (formats.includes('bold')) inlineGroup.push('bold') if (formats.includes('italic')) inlineGroup.push('italic') if (formats.includes('underline')) inlineGroup.push('underline') if (formats.includes('strike')) inlineGroup.push('strike') if (formats.includes('code')) inlineGroup.push('code') if (inlineGroup.length > 0) toolbar.push(inlineGroup) const headerGroup = [] if (formats.includes('header')) { headerGroup.push({ header: [1, 2, 3, 4, 5, 6, false] }) } if (headerGroup.length > 0) toolbar.push(headerGroup) const blockGroup = [] if (formats.includes('blockquote')) blockGroup.push('blockquote') if (formats.includes('code-block')) blockGroup.push('code-block') if (blockGroup.length > 0) toolbar.push(blockGroup) const listGroup = [] if (formats.includes('list')) { listGroup.push({ list: 'ordered' }) listGroup.push({ list: 'bullet' }) listGroup.push({ list: 'check' }) } if (formats.includes('indent')) { listGroup.push({ indent: '-1' }) listGroup.push({ indent: '+1' }) } if (listGroup.length > 0) toolbar.push(listGroup) const styleGroup = [] if (formats.includes('color')) styleGroup.push({ color: [] }) if (formats.includes('background')) styleGroup.push({ background: [] }) if (formats.includes('font')) styleGroup.push({ font: [] }) if (formats.includes('size')) styleGroup.push({ size: ['small', false, 'large', 'huge'] }) if (formats.includes('script')) { styleGroup.push({ script: 'sub' }) styleGroup.push({ script: 'super' }) } if (styleGroup.length > 0) toolbar.push(styleGroup) const alignGroup = [] if (formats.includes('align')) alignGroup.push({ align: [] }) if (formats.includes('direction')) alignGroup.push({ direction: 'rtl' }) if (alignGroup.length > 0) toolbar.push(alignGroup) const mediaGroup = [] if (formats.includes('link')) mediaGroup.push('link') if (formats.includes('image')) mediaGroup.push('image') if (formats.includes('video')) mediaGroup.push('video') if (formats.includes('formula')) mediaGroup.push('formula') ${embedToolbarEntry} if (mediaGroup.length > 0) toolbar.push(mediaGroup) toolbar.push(['clean']) return toolbar } ${IMAGE_UPLOAD_SOURCE} ${body} export default RichTextEditor ` } /** * The image button, rewritten to UPLOAD. * * Quill's built-in handler inlines the picked file as a base64 data URL, which * makes the field's value hundreds of kilobytes or more. Two things then go * wrong, both silently: the workflow runtime refuses to carry a value that big * to a server segment (anything over its prune budget is replaced by a * truncation placeholder, and the column is written with THAT), and every later * read of the row pays for the blob. Uploading through the storage proxy stores * a URL instead, so the value stays a few dozen bytes whatever the picture * weighs. * * The data-URL fallback is deliberate: a project without runtime storage — or * one whose storage is momentarily down — keeps the behaviour it had rather * than dropping the author's image on the floor. * * Module scope, not a hook: react-quill rebuilds the editor whenever `modules` * stops being deep-equal, and it compares functions by reference. */ const IMAGE_UPLOAD_SOURCE = `function readFileAsDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(reader.result) reader.onerror = () => reject(reader.error || new Error('Could not read the file')) reader.readAsDataURL(file) }) } async function uploadRichTextImage(file) { const formData = new FormData() formData.append('file', file, file.name || 'image') const response = await fetch('/api/runtime-storage/upload', { method: 'POST', body: formData, }) // Read as text first: a gateway error answers with HTML, and a JSON parse // failure would otherwise hide the status that explains it. const raw = await response.text() let data = {} if (raw) { try { data = JSON.parse(raw) } catch (parseError) { data = {} } } if (!response.ok) { throw new Error(data.message || data.error || 'Upload failed with status ' + response.status) } const files = Array.isArray(data.files) ? data.files : [] const uploaded = files[0] || {} const url = uploaded.url || uploaded.publicUrl || uploaded.downloadUrl || '' if (!url) { throw new Error('Upload returned no file URL') } return url } // Called by Quill with \`this\` bound to the toolbar module, so it must stay a // plain function — an arrow would lose \`this.quill\`. function richTextImageHandler() { const quill = this && this.quill if (!quill) { return } const input = document.createElement('input') input.setAttribute('type', 'file') input.setAttribute('accept', 'image/*') input.onchange = async () => { const file = input.files && input.files[0] if (!file) { return } // Taken BEFORE the await: the selection is gone by the time the upload // resolves, and the picture has to land where the caret was. const selection = quill.getSelection(true) const index = selection ? selection.index : quill.getLength() let src = '' try { src = await uploadRichTextImage(file) } catch (uploadError) { console.warn('[rich-text] image upload failed, embedding the file instead', uploadError) try { src = await readFileAsDataURL(file) } catch (readError) { console.error('[rich-text] could not read the picked image', readError) return } } quill.insertEmbed(index, 'image', src, 'user') quill.setSelection(index + 1, 0, 'user') } input.click() } ` const richTextEditorPlainBody = (): string => `const RichTextEditor = (props) => { const { value, quillTheme = 'snow', quillFormats, onChange, readOnly = false, ...rest } = props // quillFormats: undefined/null → all formats (Quill default) // quillFormats: [] → no formats (plain text) // quillFormats: [...] → specific formats const formats = quillFormats !== undefined ? quillFormats : null // Keyed on the format NAMES: a page renders \`quillFormats\` as a fresh array // literal, so keying on the array itself rebuilds the toolbar every render. const formatsKey = Array.isArray(formats) ? formats.join('\\u0000') : String(formats) const toolbar = useMemo(() => buildToolbarFromFormats(formats), [formatsKey]) const modules = useMemo(() => { if (toolbar === undefined) { return {} } // \`false\` means "no toolbar at all" — it has no image button to rewrite. if (!toolbar) { return { toolbar } } return { toolbar: { container: toolbar, handlers: { image: richTextImageHandler }, }, } }, [toolbar]) return (
) } ` const richTextEditorWithEmbedsBody = ( embedBlotName: string ): string => `const RichTextEditor = (props) => { const { value, quillTheme = 'snow', quillFormats, onChange, readOnly = false, ...rest } = props const quillRef = useRef(null) const [isEditorReady, setEditorReady] = useState(false) const [isEmbedDialogOpen, setEmbedDialogOpen] = useState(false) const lastEmittedRef = useRef({ raw: null, stored: null }) // quillFormats: undefined/null → all formats (Quill default) // quillFormats: [] → no formats (plain text) // quillFormats: [...] → specific formats const formats = quillFormats !== undefined ? quillFormats : null const embedsEnabled = Array.isArray(formats) && formats.includes(${JSON.stringify(embedBlotName)}) // react-quill DESTROYS AND REBUILDS the editor whenever \`modules\` is not // deep-equal to the previous one, and its comparison holds functions to // reference equality. A page renders \`quillFormats\` as a fresh array literal, // so keying the memo on the array itself rebuilt the toolbar — and with it the // handlers object — on every keystroke, taking the caret and the scroll // position with it. The key is the format NAMES, and the handler is made once. const formatsKey = Array.isArray(formats) ? formats.join('\\u0000') : String(formats) const toolbar = useMemo(() => buildToolbarFromFormats(formats), [formatsKey]) const openEmbedDialog = useCallback(() => setEmbedDialogOpen(true), []) const modules = useMemo(() => { if (toolbar === undefined) { return {} } // \`false\` means "no toolbar at all" — there is no button to hand a handler. if (!toolbar) { return { toolbar } } const handlers = { image: richTextImageHandler } if (embedsEnabled) { handlers[${JSON.stringify(embedBlotName)}] = openEmbedDialog } return { toolbar: { container: toolbar, handlers, }, } }, [toolbar, embedsEnabled, openEmbedDialog]) const attachEditor = useCallback((instance) => { quillRef.current = instance setEditorReady(!!instance) }, []) const getEditor = useCallback(() => { const instance = quillRef.current return instance && typeof instance.getEditor === 'function' ? instance.getEditor() : null }, []) // Content loaded into the editor is PARSED into blots, which never runs the // blot's \`create\`, so previews are mounted from here instead — once the // editor exists, and after every value change. useEffect(() => { if (!embedsEnabled || !isEditorReady) { return } const editor = getEditor() if (editor) { mountEmbedPreviews(editor.root) } }, [embedsEnabled, isEditorReady, value, getEditor]) useEffect(() => { if (!embedsEnabled) { return undefined } const onMessage = (event) => { applyEmbedHeightMessage(event.data) } window.addEventListener('message', onMessage) return () => { window.removeEventListener('message', onMessage) } }, [embedsEnabled]) const handleChange = useCallback( (html, delta, source, editor) => { const stored = embedsEnabled ? normalizeEmbedsInEditorHtml(html) : html lastEmittedRef.current = { raw: html, stored: stored } if (!onChange) { return } onChange(stored, delta, source, editor) }, [onChange, embedsEnabled] ) // What is STORED has the previews and their activation markers stripped, so it // is never byte-identical to the html the editor emitted. react-quill compares // the two and replaces the whole document when they differ, which would be // every keystroke once a post holds an embed. When the form is handing back // exactly what this editor last produced, give the editor its own html back. const incoming = value || '' const lastEmitted = lastEmittedRef.current const editorValue = lastEmitted.stored !== null && incoming === lastEmitted.stored ? lastEmitted.raw : incoming const insertEmbed = useCallback((embedValue) => { setEmbedDialogOpen(false) const editor = getEditor() if (!editor) { return } const index = embedInsertIndex(editor) editor.insertEmbed(index, ${JSON.stringify(embedBlotName)}, embedValue, 'user') try { editor.setSelection(index + 1, 0, 'silent') } catch (err) { // An edge index can reject a selection; the embed is placed either way. } mountEmbedPreviews(editor.root) }, [getEditor]) return (
{isEmbedDialogOpen ? ( setEmbedDialogOpen(false)} onInsert={insertEmbed} /> ) : null}
) } `