feat: Add improved html editor using CodeMirror

This commit is contained in:
Dries Augustyns
2025-12-09 10:15:33 +01:00
parent 433e796c04
commit 672f1e6657
6 changed files with 395 additions and 12 deletions
+4
View File
@@ -11,6 +11,9 @@
"clean": "rimraf node_modules .next .turbo"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.11",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.0",
"@plunk/db": "*",
"@plunk/shared": "*",
"@plunk/ui": "*",
@@ -31,6 +34,7 @@
"@tiptap/starter-kit": "^3.11.0",
"@tiptap/suggestion": "^3.11.0",
"@types/dagre": "^0.7.53",
"@uiw/react-codemirror": "^4.25.3",
"@xyflow/react": "^12.9.3",
"dagre": "^0.8.5",
"framer-motion": "^12.23.24",
@@ -10,6 +10,7 @@ import {Variable} from './VariableExtension';
import {setAvailableVariables, VariableMention} from './VariableMention';
import {Toolbar} from './Toolbar';
import {ResizableImage} from './ResizableImage';
import {HtmlEditor} from './HtmlEditor';
import {useContactFields, useContacts} from '../../lib/hooks/useContacts';
import {useConfig} from '../../lib/hooks/useConfig';
import {useEffect, useRef, useState} from 'react';
@@ -556,12 +557,11 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
</>
) : (
<>
<div className="min-h-[400px] max-h-[600px] overflow-y-auto">
<textarea
<div className="min-h-[400px] max-h-[600px] overflow-hidden">
<HtmlEditor
value={htmlContent}
onChange={e => handleHtmlChange(e.target.value)}
onChange={handleHtmlChange}
placeholder={placeholder || 'Your next email starts here!'}
className="w-full h-full px-4 py-3 text-sm font-mono resize-none min-h-[400px] focus:outline-none"
/>
</div>
{selectedContactId && (
@@ -0,0 +1,103 @@
import CodeMirror from '@uiw/react-codemirror';
import {html} from '@codemirror/lang-html';
import {EditorView} from '@codemirror/view';
import {useMemo} from 'react';
interface HtmlEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function HtmlEditor({value, onChange, placeholder}: HtmlEditorProps) {
// Custom theme configuration for better integration with the app
const theme = EditorView.theme({
'&': {
fontSize: '14px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
height: '100%',
},
'.cm-content': {
minHeight: '400px',
padding: '12px 16px',
caretColor: '#1f2937',
},
'.cm-line': {
padding: '0',
lineHeight: '1.6',
},
'.cm-gutters': {
backgroundColor: '#f9fafb',
color: '#9ca3af',
border: 'none',
borderRight: '1px solid #e5e7eb',
paddingRight: '8px',
paddingLeft: '8px',
},
'.cm-activeLineGutter': {
backgroundColor: '#f3f4f6',
color: '#374151',
},
'.cm-activeLine': {
backgroundColor: '#f9fafb',
},
'.cm-selectionBackground, ::selection': {
backgroundColor: '#dbeafe !important',
},
'.cm-focused .cm-selectionBackground, .cm-focused ::selection': {
backgroundColor: '#bfdbfe !important',
},
'.cm-cursor': {
borderLeftColor: '#1f2937',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-scroller': {
overflow: 'auto',
maxHeight: '600px',
},
});
// Extensions with HTML support and line numbers
const extensions = useMemo(
() => [
html(),
theme,
EditorView.lineWrapping, // Enable line wrapping for long lines
],
[theme]
);
return (
<div className="html-editor-container">
<CodeMirror
value={value}
height="100%"
extensions={extensions}
onChange={onChange}
placeholder={placeholder}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightActiveLine: true,
foldGutter: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
rectangularSelection: true,
crosshairCursor: true,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
searchKeymap: true,
foldKeymap: true,
completionKeymap: true,
lintKeymap: true,
}}
/>
</div>
);
}
-1
View File
@@ -577,7 +577,6 @@ export default function CampaignDetailsPage() {
setEditedCampaign({...editedCampaign, body});
setHasChanges(true);
}}
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
/>
</CardContent>
</Card>
+1 -5
View File
@@ -197,11 +197,7 @@ export default function CreateCampaignPage() {
<Label htmlFor="body">
Email Body <span className="text-red-500">*</span>
</Label>
<EmailEditor
value={body}
onChange={setBody}
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
/>
<EmailEditor value={body} onChange={setBody} />
</div>
</CardContent>
</Card>
+283 -2
View File
@@ -957,6 +957,13 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.18.6":
version: 7.28.4
resolution: "@babel/runtime@npm:7.28.4"
checksum: 10c0/792ce7af9750fb9b93879cc9d1db175701c4689da890e6ced242ea0207c9da411ccf16dc04e689cc01158b28d7898c40d75598f4559109f761c12ce01e959bf7
languageName: node
linkType: hard
"@babel/template@npm:^7.26.9, @babel/template@npm:^7.27.0, @babel/template@npm:^7.27.2":
version: 7.27.2
resolution: "@babel/template@npm:7.27.2"
@@ -1015,6 +1022,144 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/autocomplete@npm:^6.0.0":
version: 6.20.0
resolution: "@codemirror/autocomplete@npm:6.20.0"
dependencies:
"@codemirror/language": "npm:^6.0.0"
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.17.0"
"@lezer/common": "npm:^1.0.0"
checksum: 10c0/d0d1cf3eca6269811eb66edcf742ffa0a5423d7d115ab82b0d62a24d6cfcfb2a4c3779333b2cb68e3004af46556ac6203049f581d35785c46ffd1b852f6e8076
languageName: node
linkType: hard
"@codemirror/commands@npm:^6.0.0, @codemirror/commands@npm:^6.1.0":
version: 6.10.0
resolution: "@codemirror/commands@npm:6.10.0"
dependencies:
"@codemirror/language": "npm:^6.0.0"
"@codemirror/state": "npm:^6.4.0"
"@codemirror/view": "npm:^6.27.0"
"@lezer/common": "npm:^1.1.0"
checksum: 10c0/5122c026ef3578049a9d166a0afe9cd66abcc20fd5a2a084c97a60b519db6f38e3b8f05af813e5bdbbf249d3a95dce07a52cd30f77083da4a2da660c1b424a88
languageName: node
linkType: hard
"@codemirror/lang-css@npm:^6.0.0":
version: 6.3.1
resolution: "@codemirror/lang-css@npm:6.3.1"
dependencies:
"@codemirror/autocomplete": "npm:^6.0.0"
"@codemirror/language": "npm:^6.0.0"
"@codemirror/state": "npm:^6.0.0"
"@lezer/common": "npm:^1.0.2"
"@lezer/css": "npm:^1.1.7"
checksum: 10c0/339387c5a1b90076ae41017e66d7da70dd2aca4e5e4d012c95df33d0f6e740410cf1fb53c4845e3814636d587ce6eff05ebca3173dcfc564a1f646d24f299180
languageName: node
linkType: hard
"@codemirror/lang-html@npm:^6.4.11":
version: 6.4.11
resolution: "@codemirror/lang-html@npm:6.4.11"
dependencies:
"@codemirror/autocomplete": "npm:^6.0.0"
"@codemirror/lang-css": "npm:^6.0.0"
"@codemirror/lang-javascript": "npm:^6.0.0"
"@codemirror/language": "npm:^6.4.0"
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.17.0"
"@lezer/common": "npm:^1.0.0"
"@lezer/css": "npm:^1.1.0"
"@lezer/html": "npm:^1.3.12"
checksum: 10c0/d117b5c91377dd81e6551cbfaf881138787826c0d0c90fc2a7a69550decfc49af193ec1cd5c12b90d91de1acf09c42d4c5fec5bea848d354246b4ec778a4a88f
languageName: node
linkType: hard
"@codemirror/lang-javascript@npm:^6.0.0":
version: 6.2.4
resolution: "@codemirror/lang-javascript@npm:6.2.4"
dependencies:
"@codemirror/autocomplete": "npm:^6.0.0"
"@codemirror/language": "npm:^6.6.0"
"@codemirror/lint": "npm:^6.0.0"
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.17.0"
"@lezer/common": "npm:^1.0.0"
"@lezer/javascript": "npm:^1.0.0"
checksum: 10c0/af6faaa9566c57e233459d48e8afbdbf99b6d666695fa49b2d352cda15b6022edbe319e1b296f76526f48c313627bbcd2a340872e6627a17358edabc08e8e129
languageName: node
linkType: hard
"@codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.4.0, @codemirror/language@npm:^6.6.0":
version: 6.11.3
resolution: "@codemirror/language@npm:6.11.3"
dependencies:
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.23.0"
"@lezer/common": "npm:^1.1.0"
"@lezer/highlight": "npm:^1.0.0"
"@lezer/lr": "npm:^1.0.0"
style-mod: "npm:^4.0.0"
checksum: 10c0/0cbc2a98bd9e94e8e186af30613741a553fc45479e3875c79bddc32340d4d75ecc36229c66e2ee5fcffbc8fdd49b442e43847799d1d68651e3b4f8ec20d3d092
languageName: node
linkType: hard
"@codemirror/lint@npm:^6.0.0":
version: 6.9.2
resolution: "@codemirror/lint@npm:6.9.2"
dependencies:
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.35.0"
crelt: "npm:^1.0.5"
checksum: 10c0/f938391b6d216784d3196e9494c7d492d5524109fa36566708fba73f48e69d5d18332f7e9993f472eb34fe4271727b5636b6390abb804cae7ef70120427fece2
languageName: node
linkType: hard
"@codemirror/search@npm:^6.0.0":
version: 6.5.11
resolution: "@codemirror/search@npm:6.5.11"
dependencies:
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.0.0"
crelt: "npm:^1.0.5"
checksum: 10c0/8f25647ceb9a255a6e5797c20ec787587537e8496f651d8815d3f8f6c9fc5bf586b6552dadfcc7ad24364c659fcd12315c5fa235a098ba15840bb76bed35cc09
languageName: node
linkType: hard
"@codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.1.1, @codemirror/state@npm:^6.4.0, @codemirror/state@npm:^6.5.0":
version: 6.5.2
resolution: "@codemirror/state@npm:6.5.2"
dependencies:
"@marijn/find-cluster-break": "npm:^1.0.0"
checksum: 10c0/1ef773394e32c077a8cfc1ec6d881aefb1918876f82161748e505c38d143aa1c6893c314cfec91097d28f704ec07b2a6c6b75abd435086208974256dee997282
languageName: node
linkType: hard
"@codemirror/theme-one-dark@npm:^6.0.0, @codemirror/theme-one-dark@npm:^6.1.3":
version: 6.1.3
resolution: "@codemirror/theme-one-dark@npm:6.1.3"
dependencies:
"@codemirror/language": "npm:^6.0.0"
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.0.0"
"@lezer/highlight": "npm:^1.0.0"
checksum: 10c0/de8483c69911bcd61a19679384de663ced9c8bed3c776f08581a8b724e9f456a17053b1cf6e9d1f2a475fa6bc42e905ec8ba1ee0a8b55213d18087d9d9150317
languageName: node
linkType: hard
"@codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.35.0, @codemirror/view@npm:^6.39.0":
version: 6.39.0
resolution: "@codemirror/view@npm:6.39.0"
dependencies:
"@codemirror/state": "npm:^6.5.0"
crelt: "npm:^1.0.6"
style-mod: "npm:^4.1.0"
w3c-keyname: "npm:^2.2.4"
checksum: 10c0/04d4392d3d8a9cd8ab6855f18e7ad2a8f84f69102fbea1fb6dbba7e1d1447a7cb9907012cc84adcd2dc34bd1f11220fd48c07593083d9d5efd13b943cec53b2b
languageName: node
linkType: hard
"@corex/deepmerge@npm:^4.0.43":
version: 4.0.43
resolution: "@corex/deepmerge@npm:4.0.43"
@@ -2489,6 +2634,64 @@ __metadata:
languageName: node
linkType: hard
"@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.3.0":
version: 1.4.0
resolution: "@lezer/common@npm:1.4.0"
checksum: 10c0/6b7a0b5f9b969d5eb87b1a6407abf90809ed4cb290afa4ce0fd412102176144b1d01748f243b27fef1300e0444eb690a519395388cda936fbbb536fd0abb8ebc
languageName: node
linkType: hard
"@lezer/css@npm:^1.1.0, @lezer/css@npm:^1.1.7":
version: 1.3.0
resolution: "@lezer/css@npm:1.3.0"
dependencies:
"@lezer/common": "npm:^1.2.0"
"@lezer/highlight": "npm:^1.0.0"
"@lezer/lr": "npm:^1.3.0"
checksum: 10c0/ca63614cbc1b3d884b80de86a76acfd35b19a0134ebeb0fb93f677f6dc034b79fb493168c3ef17143642264b59011dd28a26c6909861f02c8fd715e298477577
languageName: node
linkType: hard
"@lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.1.3":
version: 1.2.3
resolution: "@lezer/highlight@npm:1.2.3"
dependencies:
"@lezer/common": "npm:^1.3.0"
checksum: 10c0/3bcb4fce7a1a45b5973895d7cb2be47970a0098700f2a0970aef9878ffd37f540285a2d7388ec1f524726ec90cc5196b5701bbb9764b7e7300786d772b7d2ce2
languageName: node
linkType: hard
"@lezer/html@npm:^1.3.12":
version: 1.3.12
resolution: "@lezer/html@npm:1.3.12"
dependencies:
"@lezer/common": "npm:^1.2.0"
"@lezer/highlight": "npm:^1.0.0"
"@lezer/lr": "npm:^1.0.0"
checksum: 10c0/d0e873e1b55e477462889eb388971b8ca7ef7318560b8f37c998c856a0cc203dc7b00bd55c93fd7f571eef8f5dd276bf2ff5fa7b14d9cb854324ed2466fa3f83
languageName: node
linkType: hard
"@lezer/javascript@npm:^1.0.0":
version: 1.5.4
resolution: "@lezer/javascript@npm:1.5.4"
dependencies:
"@lezer/common": "npm:^1.2.0"
"@lezer/highlight": "npm:^1.1.3"
"@lezer/lr": "npm:^1.3.0"
checksum: 10c0/77b97c546d3661223ce77af15192efad42585d01e46022222273cd0c1cb20df8063d266037ae67774f051a6cb72db49804b6a46b06b926ee541807ef01741f6a
languageName: node
linkType: hard
"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.3.0":
version: 1.4.5
resolution: "@lezer/lr@npm:1.4.5"
dependencies:
"@lezer/common": "npm:^1.0.0"
checksum: 10c0/06a6fcba5093eb654303f0bcca5009c404fe865d19c5e302e095c3d317ca50ceafd5753bb05aa26403996fc039dffa12288154fb9e201f49a94dea798d0cb6bc
languageName: node
linkType: hard
"@lottiefiles/dotlottie-react@npm:0.13.3":
version: 0.13.3
resolution: "@lottiefiles/dotlottie-react@npm:0.13.3"
@@ -2507,6 +2710,13 @@ __metadata:
languageName: node
linkType: hard
"@marijn/find-cluster-break@npm:^1.0.0":
version: 1.0.2
resolution: "@marijn/find-cluster-break@npm:1.0.2"
checksum: 10c0/1a17a60b16083cc5f7ce89d7b7d8aa87ce4099723e3e9e34e229ef2cd8a980e69d481ca8ee90ffedfec5119af1aed581642fb60ed0365e7e90634c81ea6b630f
languageName: node
linkType: hard
"@mdx-js/mdx@npm:^3.1.1":
version: 3.1.1
resolution: "@mdx-js/mdx@npm:3.1.1"
@@ -6871,6 +7081,51 @@ __metadata:
languageName: node
linkType: hard
"@uiw/codemirror-extensions-basic-setup@npm:4.25.3":
version: 4.25.3
resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.25.3"
dependencies:
"@codemirror/autocomplete": "npm:^6.0.0"
"@codemirror/commands": "npm:^6.0.0"
"@codemirror/language": "npm:^6.0.0"
"@codemirror/lint": "npm:^6.0.0"
"@codemirror/search": "npm:^6.0.0"
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.0.0"
peerDependencies:
"@codemirror/autocomplete": ">=6.0.0"
"@codemirror/commands": ">=6.0.0"
"@codemirror/language": ">=6.0.0"
"@codemirror/lint": ">=6.0.0"
"@codemirror/search": ">=6.0.0"
"@codemirror/state": ">=6.0.0"
"@codemirror/view": ">=6.0.0"
checksum: 10c0/c982ec2fe2102d9ef292fdc5aa02a72bf8eebc021a7a3d55d54b1be3311da4cd6c09fb5a8a1a9608986f4e340e7a229c96d86a68b92a98d83a57b153d97fa104
languageName: node
linkType: hard
"@uiw/react-codemirror@npm:^4.25.3":
version: 4.25.3
resolution: "@uiw/react-codemirror@npm:4.25.3"
dependencies:
"@babel/runtime": "npm:^7.18.6"
"@codemirror/commands": "npm:^6.1.0"
"@codemirror/state": "npm:^6.1.1"
"@codemirror/theme-one-dark": "npm:^6.0.0"
"@uiw/codemirror-extensions-basic-setup": "npm:4.25.3"
codemirror: "npm:^6.0.0"
peerDependencies:
"@babel/runtime": ">=7.11.0"
"@codemirror/state": ">=6.0.0"
"@codemirror/theme-one-dark": ">=6.0.0"
"@codemirror/view": ">=6.0.0"
codemirror: ">=6.0.0"
react: ">=17.0.0"
react-dom: ">=17.0.0"
checksum: 10c0/f8c69da682018f240c065621df4d6eb09fcb741ee14e249d3989cfc21959eedeedc529117ec4a564cf0a604c1a4f2e031cf8051502ff5f5562e0deca1b61b2f0
languageName: node
linkType: hard
"@ungap/structured-clone@npm:^1.0.0":
version: 1.3.0
resolution: "@ungap/structured-clone@npm:1.3.0"
@@ -8440,6 +8695,21 @@ __metadata:
languageName: node
linkType: hard
"codemirror@npm:^6.0.0":
version: 6.0.2
resolution: "codemirror@npm:6.0.2"
dependencies:
"@codemirror/autocomplete": "npm:^6.0.0"
"@codemirror/commands": "npm:^6.0.0"
"@codemirror/language": "npm:^6.0.0"
"@codemirror/lint": "npm:^6.0.0"
"@codemirror/search": "npm:^6.0.0"
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.0.0"
checksum: 10c0/8d198d8aebc32e56c966ac57b0fe8f832b7d601a2f62819ba3a294570233982bf4d5b499a764194b6b26dbc5313a156c2611cbc542234ea6eae6accf07a651ab
languageName: node
linkType: hard
"collapse-white-space@npm:^2.0.0":
version: 2.1.0
resolution: "collapse-white-space@npm:2.1.0"
@@ -8703,7 +8973,7 @@ __metadata:
languageName: node
linkType: hard
"crelt@npm:^1.0.0":
"crelt@npm:^1.0.0, crelt@npm:^1.0.5, crelt@npm:^1.0.6":
version: 1.0.6
resolution: "crelt@npm:1.0.6"
checksum: 10c0/e0fb76dff50c5eb47f2ea9b786c17f9425c66276025adee80876bdbf4a84ab72e899e56d3928431ab0cb057a105ef704df80fe5726ef0f7b1658f815521bdf09
@@ -17426,6 +17696,13 @@ __metadata:
languageName: node
linkType: hard
"style-mod@npm:^4.0.0, style-mod@npm:^4.1.0":
version: 4.1.3
resolution: "style-mod@npm:4.1.3"
checksum: 10c0/36059006ea73cd96242ca8be06b625522d488bf8caca9c18436edf77092183381f08109577a4b3d35482f3395231099f195dbc854a46ce507fbf75c484f2cfcc
languageName: node
linkType: hard
"style-to-js@npm:^1.0.0":
version: 1.1.21
resolution: "style-to-js@npm:1.1.21"
@@ -18673,7 +18950,7 @@ __metadata:
languageName: node
linkType: hard
"w3c-keyname@npm:^2.2.0":
"w3c-keyname@npm:^2.2.0, w3c-keyname@npm:^2.2.4":
version: 2.2.8
resolution: "w3c-keyname@npm:2.2.8"
checksum: 10c0/37cf335c90efff31672ebb345577d681e2177f7ff9006a9ad47c68c5a9d265ba4a7b39d6c2599ceea639ca9315584ce4bd9c9fbf7a7217bfb7a599e71943c4c4
@@ -18716,6 +18993,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "web@workspace:apps/web"
dependencies:
"@codemirror/lang-html": "npm:^6.4.11"
"@codemirror/theme-one-dark": "npm:^6.1.3"
"@codemirror/view": "npm:^6.39.0"
"@plunk/db": "npm:*"
"@plunk/eslint-config": "npm:*"
"@plunk/shared": "npm:*"
@@ -18743,6 +19023,7 @@ __metadata:
"@types/node": "npm:^24.10.0"
"@types/react": "npm:^19.2.7"
"@types/react-dom": "npm:^19.2.3"
"@uiw/react-codemirror": "npm:^4.25.3"
"@xyflow/react": "npm:^12.9.3"
dagre: "npm:^0.8.5"
framer-motion: "npm:^12.23.24"