@savvycal/mjml-editor 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,15 +15,15 @@ A React-based visual editor for MJML email templates. Built for embedding in app
15
15
 
16
16
  ## Supported Components
17
17
 
18
- | Component | Description |
19
- |-----------|-------------|
20
- | `mj-section` | Row containers with background color/image |
21
- | `mj-column` | Responsive columns within sections |
22
- | `mj-text` | Text content with typography settings |
23
- | `mj-image` | Images with dimensions, alt text, and links |
24
- | `mj-button` | Call-to-action buttons with styling |
25
- | `mj-divider` | Horizontal separators |
26
- | `mj-spacer` | Vertical spacing |
18
+ | Component | Description |
19
+ | ------------ | ------------------------------------------- |
20
+ | `mj-section` | Row containers with background color/image |
21
+ | `mj-column` | Responsive columns within sections |
22
+ | `mj-text` | Text content with typography settings |
23
+ | `mj-image` | Images with dimensions, alt text, and links |
24
+ | `mj-button` | Call-to-action buttons with styling |
25
+ | `mj-divider` | Horizontal separators |
26
+ | `mj-spacer` | Vertical spacing |
27
27
 
28
28
  ## Installation
29
29
 
@@ -48,20 +48,22 @@ This library is designed to work with Tailwind CSS v4. Instead of bundling all s
48
48
  Add the following imports to your app's main CSS file:
49
49
 
50
50
  ```css
51
- @import "@savvycal/mjml-editor/preset.css";
52
- @import "tailwindcss";
53
- @import "tw-animate-css";
54
- @import "@savvycal/mjml-editor/components.css";
51
+ @import '@savvycal/mjml-editor/preset.css';
52
+ @import 'tailwindcss';
53
+ @import 'tw-animate-css';
54
+ @import '@savvycal/mjml-editor/components.css';
55
55
  ```
56
56
 
57
57
  **Note:** `preset.css` must come before `tailwindcss` so that `@theme` tokens are registered before Tailwind generates its utilities.
58
58
 
59
59
  The `preset.css` file includes:
60
+
60
61
  - `@source` directive that tells Tailwind to scan the library's dist files for utility classes (works with npm, yarn, and pnpm)
61
62
  - `@theme` tokens that map CSS variables to Tailwind utilities
62
63
  - Custom utilities (`bg-checkered`, `shadow-framer`, etc.)
63
64
 
64
65
  The `components.css` file includes:
66
+
65
67
  - Scoped CSS variables for the editor theme (light/dark mode)
66
68
  - Tiptap/ProseMirror editor styles
67
69
 
@@ -74,26 +76,23 @@ import { MjmlEditor } from '@savvycal/mjml-editor';
74
76
  function App() {
75
77
  const [mjml, setMjml] = useState(initialMjml);
76
78
 
77
- return (
78
- <MjmlEditor
79
- value={mjml}
80
- onChange={setMjml}
81
- />
82
- );
79
+ return <MjmlEditor value={mjml} onChange={setMjml} />;
83
80
  }
84
81
  ```
85
82
 
86
83
  ### Props
87
84
 
88
- | Prop | Type | Description |
89
- |------|------|-------------|
90
- | `value` | `string` | MJML markup string (required) |
91
- | `onChange` | `(mjml: string) => void` | Called when the document changes (required) |
92
- | `className` | `string` | Optional CSS class for the container |
93
- | `defaultTheme` | `'light' \| 'dark' \| 'system'` | Theme preference (default: `'system'`) |
94
- | `liquidSchema` | `LiquidSchema` | Optional schema for Liquid template autocomplete |
95
- | `extensions` | `EditorExtensions` | Optional extensions for custom features beyond standard MJML |
96
- | `applyThemeToDocument` | `boolean` | Whether to apply theme class to `document.documentElement`. Needed for dropdown/popover theming. Set to `false` if your app manages document-level theme classes. (default: `true`) |
85
+ | Prop | Type | Description |
86
+ | ---------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
87
+ | `value` | `string` | MJML markup string (required) |
88
+ | `onChange` | `(mjml: string) => void` | Called when the document changes (required) |
89
+ | `className` | `string` | Optional CSS class for the container |
90
+ | `defaultTheme` | `'light' \| 'dark' \| 'system'` | Theme preference (default: `'system'`) |
91
+ | `liquidSchema` | `LiquidSchema` | Optional schema for Liquid template autocomplete |
92
+ | `extensions` | `EditorExtensions` | Optional extensions for custom features beyond standard MJML |
93
+ | `applyThemeToDocument` | `boolean` | Whether to apply theme class to `document.documentElement`. Needed for dropdown/popover theming. Set to `false` if your app manages document-level theme classes. (default: `true`) |
94
+
95
+ When using the Source tab, valid MJML edits are applied automatically as you type.
97
96
 
98
97
  ## Liquid Template Support
99
98
 
@@ -119,11 +118,7 @@ function App() {
119
118
  const [mjml, setMjml] = useState(initialMjml);
120
119
 
121
120
  return (
122
- <MjmlEditor
123
- value={mjml}
124
- onChange={setMjml}
125
- liquidSchema={liquidSchema}
126
- />
121
+ <MjmlEditor value={mjml} onChange={setMjml} liquidSchema={liquidSchema} />
127
122
  );
128
123
  }
129
124
  ```
@@ -159,11 +154,13 @@ function App() {
159
154
  Enables the `sc-if` attribute for server-side conditional rendering using Liquid expressions.
160
155
 
161
156
  When enabled:
157
+
162
158
  - A "Condition (Liquid)" field appears in the Advanced section of the inspector for all block types
163
159
  - Blocks with conditions display an "if" badge indicator in both the canvas and outline tree
164
160
  - The Advanced section auto-expands when a block has a condition
165
161
 
166
162
  **How it works:**
163
+
167
164
  - The `sc-if` attribute is preserved in the MJML output for server-side processing
168
165
  - The attribute is stripped from preview rendering to avoid MJML validation warnings
169
166
  - Your server processes the Liquid condition and conditionally renders the block
@@ -195,12 +192,12 @@ The library exports TypeScript types for integration:
195
192
 
196
193
  ```tsx
197
194
  import type {
198
- MjmlNode, // MJML document node structure
199
- MjmlTagName, // Union of supported MJML tag names
200
- ContentBlockType, // Union of content block types
201
- EditorExtensions, // Extensions configuration
202
- LiquidSchema, // Schema for Liquid autocomplete
203
- LiquidSchemaItem, // Individual variable/tag definition
195
+ MjmlNode, // MJML document node structure
196
+ MjmlTagName, // Union of supported MJML tag names
197
+ ContentBlockType, // Union of content block types
198
+ EditorExtensions, // Extensions configuration
199
+ LiquidSchema, // Schema for Liquid autocomplete
200
+ LiquidSchemaItem, // Individual variable/tag definition
204
201
  } from '@savvycal/mjml-editor';
205
202
  ```
206
203
 
@@ -216,13 +213,13 @@ interface EditorExtensions {
216
213
 
217
214
  ```typescript
218
215
  interface LiquidSchemaItem {
219
- name: string; // Variable or tag name (e.g., "user.name")
216
+ name: string; // Variable or tag name (e.g., "user.name")
220
217
  description?: string; // Description shown in autocomplete
221
218
  }
222
219
 
223
220
  interface LiquidSchema {
224
221
  variables: LiquidSchemaItem[]; // {{ variable }} syntax
225
- tags: LiquidSchemaItem[]; // {% tag %} syntax
222
+ tags: LiquidSchemaItem[]; // {% tag %} syntax
226
223
  }
227
224
  ```
228
225
 
@@ -234,22 +231,22 @@ The library exports theme utilities if you need to integrate with or control the
234
231
  import { ThemeProvider, useTheme, ThemeToggle } from '@savvycal/mjml-editor';
235
232
  ```
236
233
 
237
- | Export | Description |
238
- |--------|-------------|
239
- | `ThemeProvider` | Context provider for theme management |
240
- | `useTheme()` | Hook returning `{ theme, setTheme }` |
241
- | `ThemeToggle` | Pre-built UI component for theme switching |
234
+ | Export | Description |
235
+ | --------------- | ------------------------------------------ |
236
+ | `ThemeProvider` | Context provider for theme management |
237
+ | `useTheme()` | Hook returning `{ theme, setTheme }` |
238
+ | `ThemeToggle` | Pre-built UI component for theme switching |
242
239
 
243
240
  Note: `MjmlEditor` includes its own `ThemeProvider`, so you don't need to wrap it. These exports are for advanced use cases where you need theme access outside the editor.
244
241
 
245
242
  ## Keyboard Shortcuts
246
243
 
247
- | Shortcut | Action |
248
- |----------|--------|
249
- | `Cmd/Ctrl + Z` | Undo |
250
- | `Cmd/Ctrl + Shift + Z` | Redo |
244
+ | Shortcut | Action |
245
+ | ---------------------- | --------------------- |
246
+ | `Cmd/Ctrl + Z` | Undo |
247
+ | `Cmd/Ctrl + Shift + Z` | Redo |
251
248
  | `Delete` / `Backspace` | Delete selected block |
252
- | `Escape` | Deselect block |
249
+ | `Escape` | Deselect block |
253
250
 
254
251
  ## Contributing
255
252
 
@@ -17,7 +17,7 @@ function q({
17
17
  showThemeToggle: c = !0
18
18
  }) {
19
19
  const { undo: m, redo: u, canUndo: h, canRedo: f } = E(), [n, p] = x("desktop");
20
- return /* @__PURE__ */ r("div", { className: "flex flex-col h-full", children: [
20
+ return /* @__PURE__ */ r("div", { className: "relative flex flex-col h-full", children: [
21
21
  /* @__PURE__ */ r("div", { className: "h-11 px-4 flex items-center gap-1 border-b border-border bg-background relative", children: [
22
22
  /* @__PURE__ */ e(
23
23
  "button",
@@ -1 +1 @@
1
- {"version":3,"file":"SourceEditor.d.ts","sourceRoot":"","sources":["../../../src/components/editor/SourceEditor.tsx"],"names":[],"mappings":"AAOA,wBAAgB,YAAY,4CAkF3B"}
1
+ {"version":3,"file":"SourceEditor.d.ts","sourceRoot":"","sources":["../../../src/components/editor/SourceEditor.tsx"],"names":[],"mappings":"AA2DA,wBAAgB,YAAY,4CA4H3B"}
@@ -1,70 +1,133 @@
1
- import { jsxs as s, jsx as r } from "react/jsx-runtime";
2
- import { useState as n, useEffect as p } from "react";
3
- import { AlertTriangle as h } from "lucide-react";
4
- import { useEditor as g } from "../../context/EditorContext.js";
5
- import { serializeMjml as x, parseMjml as b } from "../../lib/mjml/parser.js";
6
- import { ResizableSplitPane as v } from "../ui/resizable-split-pane.js";
7
- import { SourcePreview as N } from "./SourcePreview.js";
8
- function E() {
9
- const { state: m, setDocument: c } = g(), [a, i] = n(""), [o, t] = n(null), [d, l] = n(!1);
1
+ import { jsxs as n, jsx as t } from "react/jsx-runtime";
2
+ import { useState as f, useRef as g, useEffect as p, useCallback as b, useMemo as N } from "react";
3
+ import { AlertTriangle as y } from "lucide-react";
4
+ import k from "@uiw/react-codemirror";
5
+ import { EditorState as M } from "@codemirror/state";
6
+ import { indentUnit as E, bracketMatching as j, syntaxHighlighting as w, HighlightStyle as C } from "@codemirror/language";
7
+ import { lineNumbers as L, highlightActiveLineGutter as D, highlightActiveLine as W, EditorView as R, keymap as A } from "@codemirror/view";
8
+ import { history as T, indentWithTab as z, defaultKeymap as H, historyKeymap as J } from "@codemirror/commands";
9
+ import { highlightSelectionMatches as K, searchKeymap as U } from "@codemirror/search";
10
+ import { xml as V } from "@codemirror/lang-xml";
11
+ import { tags as r } from "@lezer/highlight";
12
+ import { useEditor as _ } from "../../context/EditorContext.js";
13
+ import { serializeMjml as x, parseMjml as B } from "../../lib/mjml/parser.js";
14
+ import { ResizableSplitPane as I } from "../ui/resizable-split-pane.js";
15
+ import { SourcePreview as O } from "./SourcePreview.js";
16
+ const P = 350, Y = C.define([
17
+ {
18
+ tag: r.tagName,
19
+ color: "var(--cm-tag, oklch(0.74 0.1 158))",
20
+ fontWeight: "600"
21
+ },
22
+ {
23
+ tag: r.attributeName,
24
+ color: "var(--cm-attribute-name, oklch(0.76 0.09 80))"
25
+ },
26
+ {
27
+ tag: [r.attributeValue, r.string],
28
+ color: "var(--cm-attribute-value, oklch(0.77 0.1 255))"
29
+ },
30
+ {
31
+ tag: [r.angleBracket, r.bracket],
32
+ color: "var(--cm-punctuation, oklch(0.7 0.02 250))"
33
+ },
34
+ { tag: r.comment, color: "var(--cm-comment, oklch(0.63 0.01 250))" },
35
+ {
36
+ tag: r.invalid,
37
+ color: "var(--cm-invalid, oklch(0.7 0.16 25))",
38
+ textDecoration: "underline"
39
+ }
40
+ ]);
41
+ function le() {
42
+ const { state: s, setDocument: u } = _(), [a, d] = f(""), [c, i] = f(null), l = g(!1), m = g("");
10
43
  p(() => {
11
- const e = x(m.document);
12
- i(e), l(!1), t(null);
13
- }, [m.document]);
14
- const u = () => {
15
- try {
16
- const e = b(a);
17
- if (e.tagName !== "mjml") {
18
- t("Invalid MJML: Document must have an <mjml> root element");
19
- return;
20
- }
21
- c(e), t(null), l(!1);
22
- } catch (e) {
23
- t(e instanceof Error ? e.message : "Failed to parse MJML");
44
+ if (l.current) {
45
+ l.current = !1;
46
+ return;
24
47
  }
25
- }, f = (e) => {
26
- i(e), l(!0), t(null);
27
- };
28
- return /* @__PURE__ */ s(
29
- v,
48
+ const e = x(s.document);
49
+ m.current = e, d(e), i(null);
50
+ }, [s.document]);
51
+ const h = b(
52
+ (e) => {
53
+ try {
54
+ const o = B(e);
55
+ if (o.tagName !== "mjml") {
56
+ i("Invalid MJML: Document must have an <mjml> root element");
57
+ return;
58
+ }
59
+ m.current = x(o), l.current = !0, u(o), i(null);
60
+ } catch (o) {
61
+ i(o instanceof Error ? o.message : "Failed to parse MJML");
62
+ }
63
+ },
64
+ [u]
65
+ ), v = N(
66
+ () => [
67
+ L(),
68
+ D(),
69
+ W(),
70
+ T(),
71
+ M.tabSize.of(2),
72
+ E.of(" "),
73
+ j(),
74
+ K(),
75
+ w(Y),
76
+ R.lineWrapping,
77
+ A.of([
78
+ z,
79
+ ...H,
80
+ ...J,
81
+ ...U
82
+ ]),
83
+ V()
84
+ ],
85
+ []
86
+ ), S = b((e) => {
87
+ d(e), i(null);
88
+ }, []);
89
+ return p(() => {
90
+ if (a === m.current)
91
+ return;
92
+ const e = window.setTimeout(() => {
93
+ h(a);
94
+ }, P);
95
+ return () => window.clearTimeout(e);
96
+ }, [a, h]), /* @__PURE__ */ n(
97
+ I,
30
98
  {
31
99
  defaultLeftWidth: 50,
32
100
  minLeftWidth: 30,
33
101
  maxLeftWidth: 70,
34
102
  children: [
35
- /* @__PURE__ */ s("div", { className: "flex flex-col h-full bg-background", children: [
36
- /* @__PURE__ */ r("div", { className: "px-4 pt-4", children: /* @__PURE__ */ s("div", { className: "flex items-start gap-3 p-3 rounded-md bg-amber-50 border border-amber-200 text-amber-800", children: [
37
- /* @__PURE__ */ r(h, { className: "h-4 w-4 mt-0.5 flex-shrink-0" }),
38
- /* @__PURE__ */ r("p", { className: "text-sm", children: "You are editing the raw MJML source. Changes may affect the visual editor." })
103
+ /* @__PURE__ */ n("div", { className: "flex flex-col h-full bg-background", children: [
104
+ /* @__PURE__ */ t("div", { className: "px-4 pt-4", children: /* @__PURE__ */ n("div", { className: "flex items-start gap-3 p-3 rounded-md bg-amber-50 border border-amber-200 text-amber-800", children: [
105
+ /* @__PURE__ */ t(y, { className: "h-4 w-4 mt-0.5 flex-shrink-0" }),
106
+ /* @__PURE__ */ t("p", { className: "text-sm", children: "You are editing raw MJML source. Valid changes sync to the visual editor automatically." })
39
107
  ] }) }),
40
- /* @__PURE__ */ r("div", { className: "flex-1 min-h-0 p-4", children: /* @__PURE__ */ r(
41
- "textarea",
108
+ /* @__PURE__ */ t("div", { className: "flex-1 min-h-0 p-4", children: /* @__PURE__ */ t(
109
+ k,
42
110
  {
43
111
  value: a,
44
- onChange: (e) => f(e.target.value),
45
- className: "w-full h-full p-4 font-mono text-sm bg-muted text-foreground border border-border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-ring",
112
+ onChange: S,
113
+ extensions: v,
114
+ basicSetup: !1,
115
+ theme: "none",
116
+ className: "source-editor h-full overflow-hidden rounded-md border border-border bg-muted text-foreground",
117
+ height: "100%",
46
118
  spellCheck: !1
47
119
  }
48
120
  ) }),
49
- /* @__PURE__ */ s("div", { className: "px-4 pb-4 flex items-center gap-3", children: [
50
- /* @__PURE__ */ r(
51
- "button",
52
- {
53
- onClick: u,
54
- disabled: !d,
55
- className: "px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed",
56
- children: "Apply"
57
- }
58
- ),
59
- d && !o && /* @__PURE__ */ r("span", { className: "text-sm text-foreground-muted", children: "Unsaved changes" }),
60
- o && /* @__PURE__ */ r("span", { className: "text-sm text-destructive", children: o })
121
+ /* @__PURE__ */ n("div", { className: "px-4 pb-4 flex items-center gap-3", children: [
122
+ !c && /* @__PURE__ */ t("span", { className: "text-sm text-foreground-muted", children: "Changes sync automatically" }),
123
+ c && /* @__PURE__ */ t("span", { className: "text-sm text-destructive", children: c })
61
124
  ] })
62
125
  ] }),
63
- /* @__PURE__ */ r(N, { mjmlSource: a, debounceMs: 300 })
126
+ /* @__PURE__ */ t(O, { mjmlSource: a, debounceMs: 300 })
64
127
  ]
65
128
  }
66
129
  );
67
130
  }
68
131
  export {
69
- E as SourceEditor
132
+ le as SourceEditor
70
133
  };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=SourceEditor.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SourceEditor.test.d.ts","sourceRoot":"","sources":["../../../src/components/editor/SourceEditor.test.tsx"],"names":[],"mappings":""}
@@ -70,9 +70,21 @@
70
70
 
71
71
  /* Block states */
72
72
  --block-hover: oklch(0.97 0.02 250);
73
- --block-selected: oklch(0.40 0.15 250);
73
+ --block-selected: oklch(0.4 0.15 250);
74
74
  --block-selected-bg: oklch(0.96 0.02 250);
75
75
 
76
+ /* Source editor syntax colors */
77
+ --cm-tag: oklch(0.56 0.11 158);
78
+ --cm-attribute-name: oklch(0.53 0.11 78);
79
+ --cm-attribute-value: oklch(0.53 0.11 255);
80
+ --cm-punctuation: oklch(0.46 0.02 250);
81
+ --cm-comment: oklch(0.57 0.01 250);
82
+ --cm-invalid: oklch(0.56 0.2 25);
83
+ --cm-caret: oklch(0.48 0.14 250);
84
+ --cm-selection-bg: color-mix(in oklab, var(--ring) 24%, transparent);
85
+ --cm-selection-fg: var(--foreground);
86
+ --cm-active-line-bg: color-mix(in oklab, var(--ring) 12%, transparent);
87
+
76
88
  /* Chart colors */
77
89
  --chart-1: oklch(0.646 0.222 41.116);
78
90
  --chart-2: oklch(0.6 0.118 184.704);
@@ -92,10 +104,11 @@
92
104
  }
93
105
 
94
106
  /* Dark mode at root level (for Radix UI portals that render outside .mjml-editor) */
95
- :root.dark, .dark {
107
+ :root.dark,
108
+ .dark {
96
109
  --background: var(--color-zinc-950);
97
110
  --background-subtle: var(--color-zinc-900);
98
- --canvas: oklch(0.10 0.005 250);
111
+ --canvas: oklch(0.1 0.005 250);
99
112
  --surface: var(--color-zinc-800);
100
113
  --foreground: var(--color-zinc-50);
101
114
  --foreground-muted: var(--color-zinc-400);
@@ -115,22 +128,32 @@
115
128
  --border: var(--color-zinc-700);
116
129
  --border-subtle: var(--color-zinc-800);
117
130
  --border-strong: var(--color-zinc-600);
118
- --ring: oklch(0.60 0.15 250);
131
+ --ring: oklch(0.6 0.15 250);
119
132
  --selection: oklch(0.25 0.06 250);
120
133
  --selection-foreground: oklch(0.75 0.12 250);
121
134
  --input: var(--color-zinc-800);
122
- --destructive: oklch(0.60 0.22 25);
135
+ --destructive: oklch(0.6 0.22 25);
123
136
  --inspector: var(--color-zinc-950);
124
137
  --inspector-header: var(--color-zinc-900);
125
138
  --toolbar: var(--color-zinc-950);
126
139
  --toolbar-border: var(--color-zinc-800);
127
- --block-hover: oklch(0.20 0.03 250);
128
- --block-selected: oklch(0.50 0.15 250);
140
+ --block-hover: oklch(0.2 0.03 250);
141
+ --block-selected: oklch(0.5 0.15 250);
129
142
  --block-selected-bg: oklch(0.22 0.04 250);
130
- --chart-1: oklch(0.70 0.20 41);
143
+ --cm-tag: oklch(0.78 0.11 158);
144
+ --cm-attribute-name: oklch(0.81 0.1 80);
145
+ --cm-attribute-value: oklch(0.79 0.11 255);
146
+ --cm-punctuation: oklch(0.72 0.02 250);
147
+ --cm-comment: oklch(0.64 0.01 250);
148
+ --cm-invalid: oklch(0.72 0.17 25);
149
+ --cm-caret: oklch(0.82 0.11 250);
150
+ --cm-selection-bg: color-mix(in oklab, var(--ring) 44%, transparent);
151
+ --cm-selection-fg: var(--foreground);
152
+ --cm-active-line-bg: color-mix(in oklab, var(--ring) 24%, transparent);
153
+ --chart-1: oklch(0.7 0.2 41);
131
154
  --chart-2: oklch(0.65 0.12 185);
132
- --chart-3: oklch(0.50 0.08 227);
133
- --chart-4: oklch(0.80 0.17 84);
155
+ --chart-3: oklch(0.5 0.08 227);
156
+ --chart-4: oklch(0.8 0.17 84);
134
157
  --chart-5: oklch(0.75 0.17 70);
135
158
  --sidebar: var(--color-zinc-950);
136
159
  --sidebar-foreground: var(--color-zinc-50);
@@ -139,7 +162,7 @@
139
162
  --sidebar-accent: var(--color-zinc-800);
140
163
  --sidebar-accent-foreground: var(--color-zinc-200);
141
164
  --sidebar-border: var(--color-zinc-800);
142
- --sidebar-ring: oklch(0.60 0.15 250);
165
+ --sidebar-ring: oklch(0.6 0.15 250);
143
166
  }
144
167
 
145
168
  /* Scoped CSS variable defaults - applied to .mjml-editor container */
@@ -203,9 +226,21 @@
203
226
 
204
227
  /* Block states */
205
228
  --block-hover: oklch(0.97 0.02 250);
206
- --block-selected: oklch(0.40 0.15 250);
229
+ --block-selected: oklch(0.4 0.15 250);
207
230
  --block-selected-bg: oklch(0.96 0.02 250);
208
231
 
232
+ /* Source editor syntax colors */
233
+ --cm-tag: oklch(0.56 0.11 158);
234
+ --cm-attribute-name: oklch(0.53 0.11 78);
235
+ --cm-attribute-value: oklch(0.53 0.11 255);
236
+ --cm-punctuation: oklch(0.46 0.02 250);
237
+ --cm-comment: oklch(0.57 0.01 250);
238
+ --cm-invalid: oklch(0.56 0.2 25);
239
+ --cm-caret: oklch(0.48 0.14 250);
240
+ --cm-selection-bg: color-mix(in oklab, var(--ring) 24%, transparent);
241
+ --cm-selection-fg: var(--foreground);
242
+ --cm-active-line-bg: color-mix(in oklab, var(--ring) 12%, transparent);
243
+
209
244
  /* Chart colors */
210
245
  --chart-1: oklch(0.646 0.222 41.116);
211
246
  --chart-2: oklch(0.6 0.118 184.704);
@@ -230,7 +265,7 @@
230
265
  /* Core backgrounds */
231
266
  --background: var(--color-zinc-950);
232
267
  --background-subtle: var(--color-zinc-900);
233
- --canvas: oklch(0.10 0.005 250);
268
+ --canvas: oklch(0.1 0.005 250);
234
269
  --surface: var(--color-zinc-800);
235
270
 
236
271
  /* Foregrounds */
@@ -266,13 +301,13 @@
266
301
  --border-strong: var(--color-zinc-600);
267
302
 
268
303
  /* Focus/Selection */
269
- --ring: oklch(0.60 0.15 250);
304
+ --ring: oklch(0.6 0.15 250);
270
305
  --selection: oklch(0.25 0.06 250);
271
306
  --selection-foreground: oklch(0.75 0.12 250);
272
307
  --input: var(--color-zinc-800);
273
308
 
274
309
  /* Destructive */
275
- --destructive: oklch(0.60 0.22 25);
310
+ --destructive: oklch(0.6 0.22 25);
276
311
 
277
312
  /* Inspector */
278
313
  --inspector: var(--color-zinc-950);
@@ -283,15 +318,27 @@
283
318
  --toolbar-border: var(--color-zinc-800);
284
319
 
285
320
  /* Block states */
286
- --block-hover: oklch(0.20 0.03 250);
287
- --block-selected: oklch(0.50 0.15 250);
321
+ --block-hover: oklch(0.2 0.03 250);
322
+ --block-selected: oklch(0.5 0.15 250);
288
323
  --block-selected-bg: oklch(0.22 0.04 250);
289
324
 
325
+ /* Source editor syntax colors */
326
+ --cm-tag: oklch(0.78 0.11 158);
327
+ --cm-attribute-name: oklch(0.81 0.1 80);
328
+ --cm-attribute-value: oklch(0.79 0.11 255);
329
+ --cm-punctuation: oklch(0.72 0.02 250);
330
+ --cm-comment: oklch(0.64 0.01 250);
331
+ --cm-invalid: oklch(0.72 0.17 25);
332
+ --cm-caret: oklch(0.82 0.11 250);
333
+ --cm-selection-bg: color-mix(in oklab, var(--ring) 44%, transparent);
334
+ --cm-selection-fg: var(--foreground);
335
+ --cm-active-line-bg: color-mix(in oklab, var(--ring) 24%, transparent);
336
+
290
337
  /* Chart colors - adjusted for dark bg */
291
- --chart-1: oklch(0.70 0.20 41);
338
+ --chart-1: oklch(0.7 0.2 41);
292
339
  --chart-2: oklch(0.65 0.12 185);
293
- --chart-3: oklch(0.50 0.08 227);
294
- --chart-4: oklch(0.80 0.17 84);
340
+ --chart-3: oklch(0.5 0.08 227);
341
+ --chart-4: oklch(0.8 0.17 84);
295
342
  --chart-5: oklch(0.75 0.17 70);
296
343
 
297
344
  /* Sidebar */
@@ -302,7 +349,7 @@
302
349
  --sidebar-accent: var(--color-zinc-800);
303
350
  --sidebar-accent-foreground: var(--color-zinc-200);
304
351
  --sidebar-border: var(--color-zinc-800);
305
- --sidebar-ring: oklch(0.60 0.15 250);
352
+ --sidebar-ring: oklch(0.6 0.15 250);
306
353
  }
307
354
 
308
355
  /* Force light mode within .light scope (e.g., email canvas preview) */
@@ -336,10 +383,87 @@
336
383
  --input: var(--color-zinc-300);
337
384
  --destructive: oklch(0.55 0.22 25);
338
385
  --block-hover: oklch(0.97 0.02 250);
339
- --block-selected: oklch(0.40 0.15 250);
386
+ --block-selected: oklch(0.4 0.15 250);
340
387
  --block-selected-bg: oklch(0.96 0.02 250);
341
388
  }
342
389
 
390
+ /* Source editor (CodeMirror) */
391
+ .mjml-editor .source-editor .cm-editor {
392
+ height: 100%;
393
+ background: color-mix(in oklab, var(--muted) 78%, var(--background) 22%);
394
+ color: var(--foreground);
395
+ font-family:
396
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
397
+ 'Courier New', monospace;
398
+ font-size: 14px;
399
+ line-height: 1.6;
400
+ }
401
+
402
+ .mjml-editor .source-editor .cm-scroller {
403
+ font-family: inherit;
404
+ line-height: inherit;
405
+ }
406
+
407
+ .mjml-editor .source-editor .cm-content,
408
+ .mjml-editor .source-editor .cm-gutterElement {
409
+ font-family: inherit;
410
+ font-size: inherit;
411
+ letter-spacing: normal;
412
+ font-variant-ligatures: none;
413
+ }
414
+
415
+ .mjml-editor .source-editor .cm-content {
416
+ caret-color: var(--cm-caret);
417
+ }
418
+
419
+ .mjml-editor .source-editor .cm-line {
420
+ overflow-wrap: normal;
421
+ word-break: normal;
422
+ }
423
+
424
+ .mjml-editor .source-editor .cm-focused {
425
+ outline: none;
426
+ }
427
+
428
+ .mjml-editor .source-editor .cm-gutters {
429
+ border-right: 1px solid var(--border);
430
+ background: color-mix(in oklab, var(--muted) 90%, var(--background) 10%);
431
+ color: var(--foreground-subtle);
432
+ }
433
+
434
+ .mjml-editor .source-editor .cm-activeLine,
435
+ .mjml-editor .source-editor .cm-activeLineGutter {
436
+ background: var(--cm-active-line-bg);
437
+ }
438
+
439
+ .mjml-editor .source-editor .cm-selectionBackground,
440
+ .mjml-editor .source-editor .cm-content ::selection {
441
+ background: var(--cm-selection-bg);
442
+ }
443
+
444
+ .mjml-editor .source-editor .cm-content ::selection {
445
+ color: var(--cm-selection-fg);
446
+ }
447
+
448
+ .mjml-editor .source-editor .cm-searchMatch {
449
+ background: color-mix(in oklab, var(--selection) 70%, var(--accent) 30%);
450
+ border: 1px solid var(--border-strong);
451
+ }
452
+
453
+ .mjml-editor .source-editor .cm-searchMatch.cm-searchMatch-selected {
454
+ background: color-mix(in oklab, var(--selection) 60%, var(--accent) 40%);
455
+ }
456
+
457
+ .mjml-editor .source-editor .cm-matchingBracket {
458
+ background: color-mix(in oklab, var(--accent) 70%, transparent);
459
+ outline: 1px solid var(--border-strong);
460
+ }
461
+
462
+ .mjml-editor .source-editor .cm-cursor,
463
+ .mjml-editor .source-editor .cm-dropCursor {
464
+ border-left-color: var(--cm-caret) !important;
465
+ }
466
+
343
467
  /* Tiptap Editor Styles */
344
468
  .ProseMirror {
345
469
  outline: none;
@@ -1 +1 @@
1
- {"version":3,"file":"html-utils.d.ts","sourceRoot":"","sources":["../../src/lib/html-utils.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAmBxD;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAcxD;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAgBxD"}
1
+ {"version":3,"file":"html-utils.d.ts","sourceRoot":"","sources":["../../src/lib/html-utils.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAmBxD;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAcxD;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAwExD"}
@@ -1,25 +1,55 @@
1
- function p(i) {
2
- if (!i || i === "<p></p>")
1
+ function x(e) {
2
+ if (!e || e === "<p></p>")
3
3
  return "";
4
- let r = i.replace(/<p>/g, "");
5
- return r = r.replace(/<\/p>/g, "<br />"), r = r.replace(/(<br\s*\/?>)+$/, ""), r = r.replace(/<br>/gi, "<br />"), r;
4
+ let t = e.replace(/<p>/g, "");
5
+ return t = t.replace(/<\/p>/g, "<br />"), t = t.replace(/(<br\s*\/?>)+$/, ""), t = t.replace(/<br>/gi, "<br />"), t;
6
6
  }
7
- function l(i) {
8
- return !i || i.trim() === "" ? "<p></p>" : i.includes("<p>") ? i : i.split(/<br\s*\/?>/gi).map((e) => `<p>${e}</p>`).join("");
7
+ function T(e) {
8
+ return !e || e.trim() === "" ? "<p></p>" : e.includes("<p>") ? e : e.split(/<br\s*\/?>/gi).map((i) => `<p>${i}</p>`).join("");
9
9
  }
10
- function s(i) {
11
- if (!i) return i;
12
- let r = i.replace(
13
- /(\{\{[^{}]*\}\})/g,
14
- '<span class="liquid-highlight">$1</span>'
15
- );
16
- return r = r.replace(
17
- /(\{%[^{}]*%\})/g,
18
- '<span class="liquid-highlight">$1</span>'
19
- ), r;
10
+ function N(e) {
11
+ if (!e || typeof document > "u")
12
+ return e;
13
+ try {
14
+ const t = document.createElement("div");
15
+ t.innerHTML = e;
16
+ const i = /(\{\{[^{}]*\}\}|\{%[^{}]*%\})/g, p = [], u = document.createTreeWalker(
17
+ t,
18
+ NodeFilter.SHOW_TEXT,
19
+ null
20
+ );
21
+ let l = u.nextNode();
22
+ for (; l; )
23
+ p.push(l), l = u.nextNode();
24
+ for (const r of p) {
25
+ const s = r.parentElement;
26
+ if (!s) continue;
27
+ const f = s.tagName.toLowerCase();
28
+ if (f === "script" || f === "style" || s.closest(".liquid-highlight")) continue;
29
+ const o = r.textContent || "";
30
+ i.lastIndex = 0;
31
+ const m = Array.from(o.matchAll(i));
32
+ if (m.length === 0) continue;
33
+ const c = document.createDocumentFragment();
34
+ let n = 0;
35
+ for (const g of m) {
36
+ const h = g[0], a = g.index ?? -1;
37
+ if (a < 0) continue;
38
+ a > n && c.appendChild(
39
+ document.createTextNode(o.slice(n, a))
40
+ );
41
+ const d = document.createElement("span");
42
+ d.className = "liquid-highlight", d.textContent = h, c.appendChild(d), n = a + h.length;
43
+ }
44
+ n < o.length && c.appendChild(document.createTextNode(o.slice(n))), r.parentNode?.replaceChild(c, r);
45
+ }
46
+ return t.innerHTML;
47
+ } catch {
48
+ return e;
49
+ }
20
50
  }
21
51
  export {
22
- s as highlightLiquidTags,
23
- l as mjmlToTiptapHtml,
24
- p as sanitizeHtmlForMjml
52
+ N as highlightLiquidTags,
53
+ T as mjmlToTiptapHtml,
54
+ x as sanitizeHtmlForMjml
25
55
  };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=html-utils.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html-utils.test.d.ts","sourceRoot":"","sources":["../../src/lib/html-utils.test.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvycal/mjml-editor",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,14 @@
35
35
  "react-dom": "^18.0.0 || ^19.0.0"
36
36
  },
37
37
  "dependencies": {
38
+ "@codemirror/commands": "^6.10.2",
39
+ "@codemirror/lang-xml": "^6.1.0",
40
+ "@codemirror/language": "^6.12.1",
41
+ "@codemirror/search": "^6.6.0",
42
+ "@codemirror/state": "^6.5.4",
43
+ "@codemirror/view": "^6.39.14",
38
44
  "@floating-ui/react": "^0.27.16",
45
+ "@lezer/highlight": "^1.2.3",
39
46
  "@radix-ui/react-collapsible": "^1.1.11",
40
47
  "@radix-ui/react-label": "^2.1.8",
41
48
  "@radix-ui/react-popover": "^1.1.15",
@@ -51,6 +58,7 @@
51
58
  "@tiptap/react": "^2.10.0",
52
59
  "@tiptap/starter-kit": "^2.10.0",
53
60
  "@tiptap/suggestion": "^2.27.2",
61
+ "@uiw/react-codemirror": "^4.25.4",
54
62
  "class-variance-authority": "^0.7.1",
55
63
  "clsx": "^2.1.1",
56
64
  "lucide-react": "^0.562.0",
@@ -61,6 +69,7 @@
61
69
  },
62
70
  "devDependencies": {
63
71
  "@tailwindcss/vite": "^4.1.18",
72
+ "@testing-library/react": "^16.3.2",
64
73
  "@types/react": "^19.2.5",
65
74
  "@types/react-dom": "^19.2.3",
66
75
  "@types/uuid": "^11.0.0",