feat: allow for using image urls and editing source code

This commit is contained in:
Chris Anderson 2025-08-11 09:19:17 -05:00
parent 5404984850
commit 1c91855e74
4 changed files with 140 additions and 30 deletions

View file

@ -170,6 +170,8 @@
"hour_one": "{{count}} Hour",
"hour_other": "{{count}} Hours",
"images": "Images",
"image_upload": "Click or drag file in to upload a new image. Note, files are uploaded at their original resolution and filesize.",
"image_url": "Enter an external URL for use for your image.",
"import_users": "Import Users",
"in_timezone": "In Timezone",
"integrations": "Integrations",

View file

@ -6,13 +6,22 @@ import { ProjectContext } from '../../contexts'
import api from '../../api'
import './ImageGalleryModal.css'
import { Image } from '../../types'
import { Tabs } from '../../ui'
import TextInput from '../../ui/form/TextInput'
import FormWrapper from '../../ui/form/FormWrapper'
import { useTranslation } from 'react-i18next'
interface ImageGalleryProps extends ModalStateProps {
onInsert?: (image: Image) => void
export type ImageUpload = Pick<Image, 'url' | 'alt' | 'name'>
interface OnInsert {
onInsert: (image: ImageUpload) => void
}
export default function ImageGalleryModal({ open, onClose, onInsert }: ImageGalleryProps) {
type ImageGalleryProps = ModalStateProps & OnInsert
const Gallery = ({ onInsert }: OnInsert) => {
const [project] = useContext(ProjectContext)
const { t } = useTranslation()
const { reload, results } = useSearchTableState(useCallback(async params => await api.images.search(project.id, params), [project]))
const [upload, setUpload] = useState<FileList | undefined>()
@ -23,33 +32,80 @@ export default function ImageGalleryModal({ open, onClose, onInsert }: ImageGall
setUpload(undefined)
}
return (
<div className="image-gallery">
<p>{t('image_upload')}</p>
<UploadField
value={upload}
onChange={uploadImage}
name="file"
label="File"
isUploading={upload !== undefined}
accept={'image/*'}
required />
{results && <div className="images">
{results.results.map(image => <>
<div className="image"
key={`image-${image.id}`}
onClick={() => onInsert?.(image) }>
<img src={image.url} alt={image.alt} />
</div>
</>)}
</div>}
</div>
)
}
interface URLParams {
url: string
}
const URL = ({ onInsert }: OnInsert) => {
const { t } = useTranslation()
const handleSubmit = async (params: URLParams) => {
onInsert({
url: params.url,
alt: '',
name: params.url.split('/').pop() ?? 'Image',
})
}
return (
<div className="image-gallery">
<FormWrapper<URLParams>
onSubmit={handleSubmit}
submitLabel={t('save')}>
{form => <>
<p>{t('image_url')}</p>
<TextInput.Field form={form} name="url" label="Image URL" />
</>}
</FormWrapper>
</div>
)
}
export default function ImageGalleryModal({ open, onClose, onInsert }: ImageGalleryProps) {
const tabs = [
{
key: 'gallery',
label: 'Gallery',
children: <Gallery onInsert={onInsert} />,
},
{
key: 'url',
label: 'URL',
children: <URL onInsert={onInsert} />,
},
]
return (
<Modal
title="Images"
open={open}
onClose={onClose}
size="large">
<div className="image-gallery">
<p>Click or drag file in to upload a new image. Note, files are uploaded at their original resolution and filesize.</p>
<UploadField
value={upload}
onChange={uploadImage}
name="file"
label="File"
isUploading={upload !== undefined}
accept={'image/*'}
required />
{results && <div className="images">
{results.results.map(image => <>
<div className="image"
key={`image-${image.id}`}
onClick={() => onInsert?.(image) }>
<img src={image.url} alt={image.alt} />
</div>
</>)}
</div>}
</div>
<Tabs tabs={tabs} />
</Modal>
)
}

View file

@ -1,8 +1,8 @@
import { useState } from 'react'
import { Image, Template } from '../../../types'
import { Template } from '../../../types'
import { editor as Editor } from 'monaco-editor'
import Button from '../../../ui/Button'
import ImageGalleryModal from '../ImageGalleryModal'
import ImageGalleryModal, { ImageUpload } from '../ImageGalleryModal'
import Preview from '../../../ui/Preview'
import Tabs from '../../../ui/Tabs'
import { ImageIcon } from '../../../ui/icons'
@ -33,7 +33,7 @@ export default function HtmlEditor({ template, setTemplate }: { template: Templa
setTemplate(template)
}
function handleImageInsert(image: Image) {
function handleImageInsert(image: ImageUpload) {
setShowImages(false)
handleEditorInsert(`<img src="${image.url}" alt="${image.alt || image.name}" />`)
}

View file

@ -3,8 +3,8 @@ import './VisualEditor.css'
import grapesJS, { Editor } from 'grapesjs'
import grapesJSMJML from 'grapesjs-mjml'
import { useEffect, useState } from 'react'
import { Font, Image, Resource, Template } from '../../../types'
import ImageGalleryModal from '../ImageGalleryModal'
import { Font, Resource, Template } from '../../../types'
import ImageGalleryModal, { ImageUpload } from '../ImageGalleryModal'
interface GrapesAssetManagerProps {
event: 'open' | 'close'
@ -70,6 +70,57 @@ function GrapesReact({ id, mjml, onChange, setAssetState, fonts = [] }: GrapesRe
updateHead(editor, fonts)
}
const registerSourcePlugin = (editor: Editor) => {
editor.Commands.add('edit-raw-source', {
run(editor) {
const escapeHtml = (str: string): string => {
const div = document.createElement('div')
div.textContent = str
return div.innerHTML
}
const modal = editor.Modal
const html = editor.getHtml()
const css = editor.getCss()
const container = document.createElement('div')
container.style.padding = '8px'
container.innerHTML = `
<div style="margin-bottom: 6px">HTML</div>
<textarea id="raw-html" style="width:100%;height:220px">${escapeHtml(html)}</textarea>
<div style="margin: 10px 0 6px">CSS</div>
<textarea id="raw-css" style="width:100%;height:140px">${css}</textarea>
<button id="apply-code" class="gjs-btn gjs-btn-prim" style="margin-top:10px">Apply</button>
`
modal.open({ title: 'Edit Source', content: container })
const htmlEl = container.querySelector<HTMLTextAreaElement>('#raw-html')
const cssEl = container.querySelector<HTMLTextAreaElement>('#raw-css')
const applyBtn = container.querySelector<HTMLButtonElement>('#apply-code')
applyBtn?.addEventListener('click', () => {
const newHtml = htmlEl?.value ?? ''
const newCss = cssEl?.value ?? ''
editor.setComponents(newHtml)
editor.setStyle(newCss)
modal.close()
})
},
})
editor.Panels.addButton('options', {
id: 'edit-raw-source',
label: '<i class="fa fa-code"></i>',
command: 'edit-raw-source',
togglable: false,
})
editor.Panels.getButton('options', 'export-template')?.set({
className: 'fa fa-solid fa-html5',
attributes: { title: 'View MJML & HTML' }, // Optional tooltip
})
}
useEffect(() => {
if (!editor) {
const editor = grapesJS.init({
@ -99,6 +150,7 @@ function GrapesReact({ id, mjml, onChange, setAssetState, fonts = [] }: GrapesRe
editor.on('load', () => {
editor.Panels.getButton('views', 'open-blocks')
?.set('active', true)
registerSourcePlugin(editor)
setLoaded(true)
})
editor.render()
@ -130,7 +182,7 @@ export default function VisualEditor({ template, setTemplate, resources }: Visua
setTemplate({ ...template, data: { ...template.data, mjml, html } })
}
function handleImageInsert(image: Image) {
function handleImageInsert(image: ImageUpload) {
assetManager?.select({ src: image.url })
handleHideImages()
}