tess_edit 0.1.1

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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +225 -0
  4. data/Rakefile +3 -0
  5. data/app/assets/tailwind/tess_edit/engine.css +10 -0
  6. data/app/controllers/tess_edit/application_controller.rb +5 -0
  7. data/app/controllers/tess_edit/compilations_controller.rb +10 -0
  8. data/app/helpers/tess_edit/application_helper.rb +4 -0
  9. data/app/javascript/tess_edit/blocks/builtin.js +26 -0
  10. data/app/javascript/tess_edit/blocks/button.js +32 -0
  11. data/app/javascript/tess_edit/blocks/column.js +24 -0
  12. data/app/javascript/tess_edit/blocks/condition.js +15 -0
  13. data/app/javascript/tess_edit/blocks/divider.js +24 -0
  14. data/app/javascript/tess_edit/blocks/fields.js +42 -0
  15. data/app/javascript/tess_edit/blocks/hero.js +60 -0
  16. data/app/javascript/tess_edit/blocks/image.js +29 -0
  17. data/app/javascript/tess_edit/blocks/loop.js +23 -0
  18. data/app/javascript/tess_edit/blocks/navbar.js +49 -0
  19. data/app/javascript/tess_edit/blocks/raw.js +18 -0
  20. data/app/javascript/tess_edit/blocks/root.js +83 -0
  21. data/app/javascript/tess_edit/blocks/section.js +40 -0
  22. data/app/javascript/tess_edit/blocks/social.js +39 -0
  23. data/app/javascript/tess_edit/blocks/spacer.js +10 -0
  24. data/app/javascript/tess_edit/blocks/text.js +39 -0
  25. data/app/javascript/tess_edit/blocks/wrapper.js +24 -0
  26. data/app/javascript/tess_edit/controllers/editor_controller.js +758 -0
  27. data/app/javascript/tess_edit/core/document.js +192 -0
  28. data/app/javascript/tess_edit/core/history.js +46 -0
  29. data/app/javascript/tess_edit/core/lock.js +36 -0
  30. data/app/javascript/tess_edit/core/media.js +54 -0
  31. data/app/javascript/tess_edit/core/registry.js +94 -0
  32. data/app/javascript/tess_edit/core/starter.js +89 -0
  33. data/app/javascript/tess_edit/export/compile.js +28 -0
  34. data/app/javascript/tess_edit/export/mjml.js +116 -0
  35. data/app/javascript/tess_edit/export/richtext.js +111 -0
  36. data/app/javascript/tess_edit/index.js +21 -0
  37. data/app/javascript/tess_edit/ui/blocks_panel.js +44 -0
  38. data/app/javascript/tess_edit/ui/canvas.js +437 -0
  39. data/app/javascript/tess_edit/ui/code_input.js +137 -0
  40. data/app/javascript/tess_edit/ui/dom.js +35 -0
  41. data/app/javascript/tess_edit/ui/icons.js +63 -0
  42. data/app/javascript/tess_edit/ui/inspector.js +377 -0
  43. data/app/javascript/tess_edit/ui/text_editing.js +124 -0
  44. data/app/javascript/tess_edit/ui/tree.js +138 -0
  45. data/config/importmap.rb +6 -0
  46. data/config/routes.rb +3 -0
  47. data/lib/tasks/tess_edit_tasks.rake +4 -0
  48. data/lib/tess_edit/engine.rb +18 -0
  49. data/lib/tess_edit/version.rb +3 -0
  50. data/lib/tess_edit.rb +28 -0
  51. data/vendor/javascript/tess_edit/tiptap.js +147 -0
  52. metadata +149 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 50c19876ccbb3401a38a0f73b8ae1806e80f4c79cccdd98a5242b7dc8d5e87b9
4
+ data.tar.gz: 5178aa9ca3d9ea111742b0aadd623b35fb453921bdd6a15b91d2a150d291a77b
5
+ SHA512:
6
+ metadata.gz: b7dc4f718aa387537e33c7a8b02cae8dfd5ca57a0e0e9681270bdb2b1f6e52bca4e63bdd914ad9d15cbfece7217964cc16366b45e12f89eb54068efe849af286
7
+ data.tar.gz: edf5c6f4d1c0c164ab355caf88311ab4992ce1615ccd612c751adda75c575c693453b480ad212ac8527456a6d7058ba76ae750b6c615965f1b75527de7a4e307
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Tom de Grunt
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # TessEdit
2
+
3
+ TessEdit is a drag-and-drop email editor for Rails, written in plain JavaScript with Stimulus. You build an email from blocks; the editor stores the design as JSON and exports [MJML](https://mjml.io) and responsive HTML. Blocks can hold [Liquid](https://shopify.github.io/liquid/), which survives into the HTML so it can be rendered at send time.
4
+
5
+ The interface follows familiar layout: blocks and a layers tree on the left, the email in the middle, and Inspect, with the selected block's settings, on the right.
6
+
7
+ ![TessEdit interface](docs/overview.png)
8
+
9
+ ## Features
10
+
11
+ - Blocks: Section (1-4 columns), Column, Wrapper, Hero, Text, Heading, Image, Button, Divider, Spacer, Social, Navbar and raw HTML.
12
+ - Liquid blocks: **Loop** (`{% for line in order.lines %}`) and **Condition** (`{% if order.total > 100 %}`) wrap other blocks.
13
+ - Drag from the Blocks panel onto the canvas or into the Layers tree; move blocks on the canvas (drag handle) or in the tree; plus buttons and empty-column placeholders open an "Add block" menu.
14
+ - Rich text editing on the canvas with [Tiptap](https://tiptap.dev): bold, italic, underline, strikethrough, colour, links (Liquid allowed in the URL), alignment, lists and headings.
15
+ - Undo/redo, duplicate, delete, keyboard shortcuts (⌘Z, ⇧⌘Z, ⌘D, Delete, Enter to edit text, Esc).
16
+ - Desktop and mobile preview, and a preview of the HTML as sent.
17
+ - Panels on both sides, or combined as tabs in one sidebar on the left or right to give the canvas more room.
18
+ - Code dialog to view, copy or download the JSON, MJML and HTML, and to load JSON.
19
+
20
+ ## Installation
21
+
22
+ ```ruby
23
+ gem "tess_edit", github: "tdegrunt/tess_edit"
24
+ ```
25
+
26
+ The engine adds its pins to your import map. Mount it for the compile endpoint (see [Compiling](#compiling)):
27
+
28
+ ```ruby
29
+ # config/routes.rb
30
+ mount TessEdit::Engine => "/tess_edit"
31
+ ```
32
+
33
+ Register the controller with your Stimulus application:
34
+
35
+ ```js
36
+ // app/javascript/application.js
37
+ import { application } from "controllers/application"
38
+ import { registerTessEdit } from "tess_edit"
39
+
40
+ registerTessEdit(application)
41
+ ```
42
+
43
+ ### Tailwind CSS 4 required
44
+
45
+ The editor builds its interface with Tailwind classes, so it needs Tailwind CSS 4 through [`tailwindcss-rails`](https://github.com/rails/tailwindcss-rails) 4.3 or later. Tailwind 3 is not supported.
46
+
47
+ ```ruby
48
+ gem "tailwindcss-rails", ">= 4.3"
49
+ ```
50
+
51
+ Import the editor's styles after Tailwind itself:
52
+
53
+ ```css
54
+ /* app/assets/tailwind/application.css */
55
+ @import "tailwindcss";
56
+ @import "../builds/tailwind/tess_edit";
57
+ ```
58
+
59
+ `app/assets/builds/tailwind/tess_edit.css` is generated by `bin/rails tailwindcss:build` (and `tailwindcss:watch`, `bin/dev`) from the gem's `app/assets/tailwind/tess_edit/engine.css`. It points to wherever the gem is installed, so the line above is the same on every machine. Leave `app/assets/builds` out of version control, as Rails apps do by default. If the import fails because the file is missing, run `bin/rails tailwindcss:engines` or a build once.
60
+
61
+ ## Usage
62
+
63
+ Give the controller an element with a height and, optionally, hidden inputs to keep up to date:
64
+
65
+ ```erb
66
+ <div data-controller="tess-edit" style="height: 80vh">
67
+ <%= form.hidden_field :json, data: { tess_edit_target: "jsonInput" } %>
68
+ <%= form.hidden_field :mjml, data: { tess_edit_target: "mjmlInput" } %>
69
+ <%= form.hidden_field :html, data: { tess_edit_target: "htmlInput" } %>
70
+ </div>
71
+ ```
72
+
73
+ The editor loads the document from the `jsonInput` (or from `data-tess-edit-document-value`). With neither, it starts with a sample email; set `data-tess-edit-sample-value="false"` to start with an empty email.
74
+
75
+ `data-tess-edit-panels-value` sets the default panel layout: `split` (Blocks and Layers on the left, Inspect on the right, the default), `left` or `right` (Blocks, Layers and Inspect as tabs in one sidebar on that side). People can switch with the buttons in the top bar; their choice is remembered in the browser. When the editor is narrower than 1000px, it puts all panels on the left so the canvas keeps its room, and goes back to the chosen layout when it is wide enough again.
76
+
77
+ Every change updates the inputs and dispatches `tess-edit:change` with `{ json, mjml, html, errors, document }` in `event.detail`. When the MJML can't be compiled, `html` is empty and `errors` says why; the editor shows the reason under the canvas. The controller instance is available as `element.tessEdit`, with `getDocument()`, `setDocument(json)` and `export()`.
78
+
79
+ ### What gets stored
80
+
81
+ | Field | Contents |
82
+ |---|---|
83
+ | `json` | The block tree. The source of truth; load it back into the editor. |
84
+ | `mjml` | MJML generated from the JSON. Loops and conditions are `<mj-raw>{% ... %}</mj-raw>`. |
85
+ | `html` | The MJML compiled to HTML on the server, with Liquid left in place. |
86
+
87
+ Render the HTML with Liquid when sending. A host app can also store only the JSON or MJML and compile on its own with `TessEdit.compile(mjml)`.
88
+
89
+ ### Compiling
90
+
91
+ The editor turns the JSON into MJML in the browser and has the server compile the MJML to HTML, with [MRML](https://github.com/jdrouet/mrml) (the `mrml` gem, precompiled, no Node or Rust needed). The canvas, the preview and the `html` input all come from the same compiler the host app uses when it sends:
92
+
93
+ ```ruby
94
+ result = TessEdit.compile(mjml)
95
+ result.html # nil when the MJML can't be parsed
96
+ result.errors # [] or the reasons
97
+ ```
98
+
99
+ The editor posts to `/tess_edit/compile`, debounced, with the page's CSRF token (`csrf_meta_tags`). Mounted elsewhere, give the editor the path with `data-tess-edit-compile-url-value`. The endpoint's controller inherits from `ActionController::Base`; make it inherit from one of your controllers so it sits behind the same sign-in and authorization:
100
+
101
+ ```ruby
102
+ # config/initializers/tess_edit.rb
103
+ TessEdit.parent_controller = "Admin::BaseController"
104
+ ``` Liquid inside HTML attributes, such as a button link of `{{ order.url }}`, has its double quotes changed to single quotes, which Liquid treats the same.
105
+
106
+ ### The document format
107
+
108
+ ```json
109
+ {
110
+ "version": 1, "id": "b1x2y3z", "type": "root",
111
+ "attributes": { "width": "600px", "background-color": "#f3f4f6" },
112
+ "data": { "subject": "...", "preheader": "...", "fontFamily": "..." },
113
+ "children": [
114
+ { "id": "...", "type": "section", "attributes": { "padding": "20px 0px" }, "data": {}, "children": [
115
+ { "id": "...", "type": "column", "attributes": {}, "data": {}, "children": [
116
+ { "id": "...", "type": "text", "attributes": { "align": "center" }, "data": { "content": { "type": "doc", "content": [] } }, "children": [] }
117
+ ] }
118
+ ] }
119
+ ]
120
+ }
121
+ ```
122
+
123
+ The top node is the `root` block, which renders the `<mjml>` document with the subject, preview text and theme. Documents from before its rename, with `"type": "page"`, still load. `attributes` map onto MJML attributes; `data` holds everything else. Text content is Tiptap (ProseMirror) JSON, so other renderers, such as a PDF renderer, can walk it.
124
+
125
+ ### Locking a layout
126
+
127
+ To let people fill in one part of a design that is otherwise fixed, such as a template's own content inside a shared layout, give the editor a lock:
128
+
129
+ ```erb
130
+ <div data-controller="tess-edit"
131
+ data-tess-edit-lock-value='<%= {except: ["content"], fields: ["data.subject", "data.preheader"]}.to_json %>'>
132
+ ```
133
+
134
+ Only what is inside blocks of the types in `except` can change: those blocks take new blocks, and everything in them can be edited, moved, duplicated and deleted. The blocks themselves stay where they are. Of the root, only the listed `fields` can change. Everything else still shows on the canvas and can be selected, with its settings disabled in Inspect and a lock in Layers. A tile clicked while a locked block is selected goes at the end of the first block it is allowed in, and the Code dialog shows the JSON without loading it. The editor still stores the whole document; taking the open part out of it is up to the host app. The `except` types are usually a custom block, like the demo's Content block in `test/dummy/app/javascript/blocks/content.js`.
135
+
136
+ ### Images
137
+
138
+ By default images are URLs. To let people choose from your app's images and upload new ones, give the editor an endpoint with `data-tess-edit-media-url-value`. Every image then has a source, Uploaded (a dropdown of your images with an Upload button) or Web (a URL): the Image block's image and the background images of heroes, sections and wrappers. New Image blocks start as Uploaded. An image file dragged from the desktop onto the canvas is uploaded and put in the nearest block with an image, an Image block or else the background of the hero, section or wrapper it's dropped on, and shows straight away while it uploads.
139
+
140
+ The endpoint answers two requests, both as JSON:
141
+
142
+ - `GET`: the images to choose from, `{"images": [{"id": "…", "url": "https://…", "name": "logo.png"}]}`
143
+ - `POST` (multipart, the file in `file`, with the page's CSRF token): the uploaded image, `{"id": "…", "url": "…", "name": "…"}`, or `{"errors": ["…"]}` with an error status
144
+
145
+ A block stores the image's `url` in the attribute (`src`, `background-url`) and its `id` next to it in `data`: `data.image` for the Image block, `data.background` for backgrounds. How images are stored and who may upload them is up to your app; the dummy app's `MediaController` is a minimal example (`/?media=1`).
146
+
147
+ A block of your own gets the same with an image field, `imageField("attributes.background-url", "Background image", "data.background")`, imported from `tess_edit`.
148
+
149
+ ### Blocks and custom blocks
150
+
151
+ Every block is a plugin: one module in `app/javascript/tess_edit/blocks/` with its defaults, its Inspect fields, its Blocks panel tiles and a `render.mjml` hook. The built-in blocks are registered the same way a host app registers its own, so register yours before the editor connects:
152
+
153
+ ```js
154
+ import { application } from "controllers/application"
155
+ import { fields, registerBlock, registerCategory, registerTessEdit } from "tess_edit"
156
+
157
+ registerCategory({ key: "shop", label: "Shop", order: 40 })
158
+
159
+ registerBlock({
160
+ type: "product", // stored in the document JSON
161
+ name: "Product",
162
+ icon: "image", // a built-in icon name, or SVG markup (24×24 viewBox)
163
+ category: "shop", // gives it one tile; use `palette: [...]` for more, or none
164
+ roles: ["content"], // goes where content goes: columns, heroes, Liquid blocks in them
165
+ defaults: () => ({ attributes: { padding: "10px 25px" }, data: { sku: "" } }),
166
+ fields: [{ title: "Product", fields: [{ key: "data.sku", label: "SKU", type: "text" }, fields.padding] }],
167
+ label: (node) => `Product · ${node.data.sku}`,
168
+ render: {
169
+ mjml: (node, { element, escapeText }) =>
170
+ element("mj-text", node, `{{ products['${escapeText(node.data.sku)}'].title }}`)
171
+ }
172
+ })
173
+
174
+ registerTessEdit(application)
175
+ ```
176
+
177
+ Roles say where a block may go: `body` (in the root or a wrapper, like sections and heroes), `wrapper`, `column` (in a section) and `content` (in a column or hero). A container lists the roles it takes in `accepts`; Loop and Condition use `"inherit"`. The full definition, and the helpers a render hook gets (`element`, `container`, `children`, `liquid`, `attrs`, `richText`, ...), are described at the top of `core/registry.js` and `export/mjml.js`. Blocks render only through their `render` hooks, so a later renderer (PDF) adds a hook per block next to `mjml`. Stored documents don't change; a document with a block type that isn't registered fails `normalize`.
178
+
179
+ ## Development
180
+
181
+ The dummy app in `test/dummy` serves a demo page:
182
+
183
+ ```bash
184
+ cd test/dummy && bin/dev
185
+ ```
186
+
187
+ `/?lock=1` opens a locked layout (`test/dummy/config/demo_layout.json`) with only its Content block, subject and preview text open.
188
+
189
+ `/?media=1` gives the editor the demo's image endpoint (`MediaController`), so images can be chosen from a list, uploaded, or dragged onto the canvas from the desktop; uploads go to `test/dummy/tmp/media`. Without it, images are URLs only. The options combine, e.g. `/?lock=1&media=1`.
190
+
191
+ Tests:
192
+
193
+ ```bash
194
+ npm test # JavaScript: document model, export, MJML snapshots (Node 22+)
195
+ bin/rails test # Rails: engine, compile endpoint, MRML and Liquid rendering of the snapshots
196
+ bin/rails test:system # The editor in Chrome, through Selenium
197
+ ```
198
+
199
+ `npm test` compares the MJML of every block, and of the sample email, with the files in `test/javascript/snapshots`. The Rails tests compile those files with MRML and render the sample with Liquid. After an intended change in output, regenerate them and review the diff:
200
+
201
+ ```bash
202
+ UPDATE_SNAPSHOTS=1 npm test
203
+ ```
204
+
205
+ System tests use a local headless Chrome. With `SELENIUM_URL` set, such as `http://selenium:4444/` in a devcontainer, they use that Selenium server instead and serve the app on this machine's address (or `CAPYBARA_APP_HOST`). `HEADLESS=0` shows the browser.
206
+
207
+ Tiptap is vendored as a single ES module bundle in `vendor/javascript/tess_edit`. To update it:
208
+
209
+ ```bash
210
+ cd script/vendor && npm install && node build.mjs
211
+ ```
212
+
213
+ ## Backlog
214
+
215
+ Ideas for later, not planned yet:
216
+
217
+ - **Importing MJML.** Turn an existing MJML template into a TessEdit document, so designs made elsewhere can be edited here. Parts without a matching block could become HTML blocks.
218
+ - **Images whose address is Liquid.** An image with a `src` like `{{ product.image_url }}` shows as a broken image on the canvas. It should show a placeholder there instead, and in Inspect say that the address is filled in when the email is sent.
219
+ - **A translation block.** Text that differs per language in one design, e.g. a block with a version of its text per locale, so a host app doesn't need a design per language.
220
+ - **Text extensions from the host app.** Tiptap (the vendored bundle exports `Node`, `Mark` and `Extension`) can be extended, but the editor's extensions, the email HTML of rich text (`export/richtext.js`) and the text toolbar are fixed. A call like `registerTextExtension({ extension, toHtml(node, children), button })` would add all three, as `registerBlock` does for blocks. A Liquid variable shown as a chip (`{{ customer.first_name }}`) would be a first example; a translation could be one too. Extensions that need ProseMirror itself (plugins, node views) also need those exported from the same bundle.
221
+ - **Dropping an image file where there's no image.** A file dropped on an empty spot in the canvas could add a new Image block there with the upload, instead of being refused.
222
+
223
+ ## License
224
+
225
+ MIT. The vendored Tiptap is MIT licensed too; MRML, through the `mrml` gem, is MIT licensed.
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,10 @@
1
+ /* TessEdit builds its interface with Tailwind classes in JavaScript.
2
+ *
3
+ * tailwindcss-rails (4.3 or later) finds this file and generates
4
+ * app/assets/builds/tailwind/tess_edit.css in the host app, which imports it by
5
+ * its installed path. The host app adds to app/assets/tailwind/application.css:
6
+ *
7
+ * @import "../builds/tailwind/tess_edit";
8
+ *
9
+ * Paths here are relative to this file, so they work wherever the gem is installed. */
10
+ @source "../../../javascript/tess_edit";
@@ -0,0 +1,5 @@
1
+ module TessEdit
2
+ class ApplicationController < TessEdit.parent_controller.constantize
3
+ protect_from_forgery with: :exception
4
+ end
5
+ end
@@ -0,0 +1,10 @@
1
+ module TessEdit
2
+ # POST /tess_edit/compile with { mjml: "..." }: the HTML the editor shows on
3
+ # its canvas and stores in its html input.
4
+ class CompilationsController < ApplicationController
5
+ def create
6
+ result = TessEdit.compile(params.require(:mjml))
7
+ render json: {html: result.html, errors: result.errors}, status: result.errors.empty? ? :ok : :unprocessable_content
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,4 @@
1
+ module TessEdit
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,26 @@
1
+ // The built-in blocks, registered by core/registry.js in this order. The
2
+ // order is also the order of tiles in the Blocks panel.
3
+
4
+ import root from "tess_edit/blocks/root"
5
+ import wrapper from "tess_edit/blocks/wrapper"
6
+ import section from "tess_edit/blocks/section"
7
+ import column from "tess_edit/blocks/column"
8
+ import hero from "tess_edit/blocks/hero"
9
+ import text from "tess_edit/blocks/text"
10
+ import image from "tess_edit/blocks/image"
11
+ import button from "tess_edit/blocks/button"
12
+ import divider from "tess_edit/blocks/divider"
13
+ import spacer from "tess_edit/blocks/spacer"
14
+ import social from "tess_edit/blocks/social"
15
+ import navbar from "tess_edit/blocks/navbar"
16
+ import raw from "tess_edit/blocks/raw"
17
+ import loop from "tess_edit/blocks/loop"
18
+ import condition from "tess_edit/blocks/condition"
19
+
20
+ export const builtinCategories = [
21
+ { key: "layout", label: "Layout", order: 10 },
22
+ { key: "content", label: "Content", order: 20 },
23
+ { key: "liquid", label: "Liquid", order: 30 }
24
+ ]
25
+
26
+ export const builtinBlocks = [root, section, column, wrapper, hero, text, image, button, divider, spacer, social, navbar, raw, loop, condition]
@@ -0,0 +1,32 @@
1
+ import { align, border, padding, typography } from "tess_edit/blocks/fields"
2
+
3
+ export default {
4
+ type: "button",
5
+ name: "Button",
6
+ icon: "button",
7
+ category: "content",
8
+ roles: ["content"],
9
+ defaults: () => ({
10
+ attributes: { href: "#", "background-color": "#2563eb", color: "#ffffff", "border-radius": "6px", "inner-padding": "12px 24px", padding: "10px 25px", "font-weight": "600" },
11
+ data: { content: "Click here" }
12
+ }),
13
+ fields: [
14
+ {
15
+ title: "Button",
16
+ fields: [
17
+ { key: "data.content", label: "Label", type: "text" },
18
+ { key: "attributes.href", label: "Link", type: "text" },
19
+ { key: "attributes.target", label: "Open in", type: "segmented", options: [{ value: "_blank", label: "New tab" }, { value: "_self", label: "Same tab" }] },
20
+ { key: "attributes.background-color", label: "Button color", type: "color" },
21
+ { key: "attributes.width", label: "Width", type: "size", placeholder: "Auto" },
22
+ align,
23
+ { key: "attributes.inner-padding", label: "Inner padding", type: "spacing" },
24
+ padding
25
+ ]
26
+ },
27
+ typography(),
28
+ border
29
+ ],
30
+ label: (node) => `Button · ${node.data.content || ""}`,
31
+ render: { mjml: (node, { element, escapeText }) => element("mj-button", node, escapeText(node.data.content)) }
32
+ }
@@ -0,0 +1,24 @@
1
+ import { VERTICAL_ALIGN, background, border, padding } from "tess_edit/blocks/fields"
2
+
3
+ export default {
4
+ type: "column",
5
+ name: "Column",
6
+ icon: "column",
7
+ category: "layout",
8
+ roles: ["column"],
9
+ accepts: ["content"],
10
+ defaults: () => ({ attributes: {} }),
11
+ fields: [
12
+ {
13
+ title: "Column",
14
+ fields: [
15
+ { key: "attributes.width", label: "Width", type: "text", placeholder: "Auto", hint: "In px or %, e.g. 50%." },
16
+ { key: "attributes.vertical-align", label: "Vertical align", type: "segmented", options: VERTICAL_ALIGN },
17
+ background,
18
+ padding
19
+ ]
20
+ },
21
+ border
22
+ ],
23
+ render: { mjml: (node, { container }) => container("mj-column", node) }
24
+ }
@@ -0,0 +1,15 @@
1
+ export default {
2
+ type: "condition",
3
+ name: "Condition",
4
+ icon: "condition",
5
+ category: "liquid",
6
+ roles: ["body", "content"],
7
+ accepts: "inherit",
8
+ defaults: () => ({ data: { expression: "customer.vip" } }),
9
+ fields: [{ title: "Condition", fields: [{ key: "data.expression", label: "Show when", type: "text", hint: "A Liquid condition, e.g. order.total > 100." }] }],
10
+ hint: "Shows its blocks only when the condition is true: {% if expression %}.",
11
+ label: (node) => `If ${node.data.expression || "…"}`,
12
+ render: {
13
+ mjml: (node, { liquid }) => liquid(node, `{% if ${node.data.expression || "true"} %}`, "{% endif %}")
14
+ }
15
+ }
@@ -0,0 +1,24 @@
1
+ import { align, padding } from "tess_edit/blocks/fields"
2
+
3
+ export default {
4
+ type: "divider",
5
+ name: "Divider",
6
+ icon: "divider",
7
+ category: "content",
8
+ roles: ["content"],
9
+ defaults: () => ({ attributes: { "border-color": "#e5e7eb", "border-width": "1px", padding: "10px 25px" } }),
10
+ fields: [
11
+ {
12
+ title: "Divider",
13
+ fields: [
14
+ { key: "attributes.border-color", label: "Color", type: "color" },
15
+ { key: "attributes.border-width", label: "Thickness", type: "size" },
16
+ { key: "attributes.border-style", label: "Style", type: "segmented", options: [{ value: "solid", label: "Solid" }, { value: "dashed", label: "Dashed" }, { value: "dotted", label: "Dotted" }] },
17
+ { key: "attributes.width", label: "Width", type: "text", placeholder: "100%" },
18
+ align,
19
+ padding
20
+ ]
21
+ }
22
+ ],
23
+ render: { mjml: (node, { element }) => element("mj-divider", node) }
24
+ }
@@ -0,0 +1,42 @@
1
+ // Field definitions shared by the built-in blocks, and usable by custom ones.
2
+ // A field: { key: "attributes.padding" | "data.subject", label, type, ... }.
3
+ // Types: text, textarea, code, size, color, select, segmented, toggle,
4
+ // spacing and list; see ui/inspector.js.
5
+
6
+ export const ALIGN = [
7
+ { value: "left", label: "Left" },
8
+ { value: "center", label: "Center" },
9
+ { value: "right", label: "Right" }
10
+ ]
11
+
12
+ export const VERTICAL_ALIGN = [
13
+ { value: "top", label: "Top" },
14
+ { value: "middle", label: "Middle" },
15
+ { value: "bottom", label: "Bottom" }
16
+ ]
17
+
18
+ export const padding = { key: "attributes.padding", label: "Padding", type: "spacing" }
19
+ export const align = { key: "attributes.align", label: "Align", type: "segmented", options: ALIGN }
20
+ export const background = { key: "attributes.background-color", label: "Background", type: "color" }
21
+
22
+ export const typography = (extra = []) => ({
23
+ title: "Typography",
24
+ fields: [
25
+ { key: "attributes.font-family", label: "Font family", type: "text", placeholder: "Inherit" },
26
+ { key: "attributes.font-size", label: "Font size", type: "size", placeholder: "13px" },
27
+ { key: "attributes.line-height", label: "Line height", type: "text", placeholder: "1" },
28
+ { key: "attributes.letter-spacing", label: "Letter spacing", type: "size" },
29
+ { key: "attributes.font-weight", label: "Font weight", type: "select", options: ["", "300", "400", "500", "600", "700", "800"] },
30
+ { key: "attributes.color", label: "Text color", type: "color" },
31
+ ...extra
32
+ ]
33
+ })
34
+
35
+ export const border = {
36
+ title: "Border",
37
+ collapsed: true,
38
+ fields: [
39
+ { key: "attributes.border", label: "Border", type: "text", placeholder: "1px solid #e5e7eb" },
40
+ { key: "attributes.border-radius", label: "Radius", type: "size" }
41
+ ]
42
+ }
@@ -0,0 +1,60 @@
1
+ import { VERTICAL_ALIGN, background, padding } from "tess_edit/blocks/fields"
2
+ import { imageField } from "tess_edit/core/media"
3
+
4
+ const headline = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "A big headline" }] }] }
5
+
6
+ export default {
7
+ type: "hero",
8
+ name: "Hero",
9
+ icon: "hero",
10
+ roles: ["body"],
11
+ accepts: ["content"],
12
+ defaults: () => ({
13
+ attributes: {
14
+ mode: "fixed-height",
15
+ height: "320px",
16
+ "background-url": "https://placehold.co/1200x640/1f2937/1f2937.png",
17
+ "background-width": "600px",
18
+ "background-height": "320px",
19
+ "background-color": "#1f2937",
20
+ "vertical-align": "middle",
21
+ padding: "40px 0px"
22
+ }
23
+ }),
24
+ fields: [
25
+ {
26
+ title: "Hero",
27
+ fields: [
28
+ { key: "attributes.mode", label: "Mode", type: "segmented", options: [{ value: "fixed-height", label: "Fixed height" }, { value: "fluid-height", label: "Fluid" }] },
29
+ { key: "attributes.height", label: "Height", type: "size" },
30
+ { key: "attributes.vertical-align", label: "Vertical align", type: "segmented", options: VERTICAL_ALIGN },
31
+ padding
32
+ ]
33
+ },
34
+ {
35
+ title: "Background",
36
+ fields: [
37
+ imageField("attributes.background-url", "Image", "data.background"),
38
+ { key: "attributes.background-width", label: "Image width", type: "size" },
39
+ { key: "attributes.background-height", label: "Image height", type: "size" },
40
+ { key: "attributes.background-position", label: "Position", type: "text", placeholder: "center center" },
41
+ background
42
+ ]
43
+ }
44
+ ],
45
+ palette: [
46
+ {
47
+ key: "hero",
48
+ label: "Hero",
49
+ category: "layout",
50
+ create: ({ createNode }) =>
51
+ createNode("hero", {
52
+ children: [
53
+ createNode("text", { attributes: { align: "center", color: "#ffffff", "font-size": "32px", "font-weight": "700", padding: "10px 25px" }, data: { content: headline } }),
54
+ createNode("button", { attributes: { align: "center", "background-color": "#ffffff", color: "#111827" }, data: { content: "Find out more" } })
55
+ ]
56
+ })
57
+ }
58
+ ],
59
+ render: { mjml: (node, { container }) => container("mj-hero", node) }
60
+ }
@@ -0,0 +1,29 @@
1
+ import { align, padding } from "tess_edit/blocks/fields"
2
+ import { IMAGE_FIELD } from "tess_edit/core/media"
3
+
4
+ export default {
5
+ type: "image",
6
+ name: "Image",
7
+ icon: "image",
8
+ category: "content",
9
+ roles: ["content"],
10
+ defaults: () => ({ attributes: { src: "https://placehold.co/1200x600/e5e7eb/9ca3af.png?text=Image", alt: "", padding: "10px 25px" } }),
11
+ // A new image from the Blocks panel starts as an uploaded one (when the host keeps images)
12
+ palette: [{ key: "image", label: "Image", create: ({ createNode }) => createNode("image", { data: { source: "upload" } }) }],
13
+ fields: [
14
+ {
15
+ title: "Image",
16
+ fields: [
17
+ { ...IMAGE_FIELD, label: "Image", type: "image" },
18
+ { key: "attributes.alt", label: "Alt text", type: "text" },
19
+ { key: "attributes.href", label: "Link", type: "text" },
20
+ { key: "attributes.width", label: "Width", type: "size", placeholder: "Full" },
21
+ { key: "attributes.height", label: "Height", type: "size", placeholder: "Auto" },
22
+ align,
23
+ padding
24
+ ]
25
+ },
26
+ { title: "Border", collapsed: true, fields: [{ key: "attributes.border", label: "Border", type: "text" }, { key: "attributes.border-radius", label: "Radius", type: "size" }, { key: "attributes.container-background-color", label: "Background", type: "color" }] }
27
+ ],
28
+ render: { mjml: (node, { element }) => element("mj-image", node) }
29
+ }
@@ -0,0 +1,23 @@
1
+ export default {
2
+ type: "loop",
3
+ name: "Loop",
4
+ icon: "loop",
5
+ category: "liquid",
6
+ roles: ["body", "content"],
7
+ accepts: "inherit",
8
+ defaults: () => ({ data: { variable: "item", collection: "items" } }),
9
+ fields: [
10
+ {
11
+ title: "Loop",
12
+ fields: [
13
+ { key: "data.variable", label: "Variable", type: "text", hint: "The name each item gets, e.g. line." },
14
+ { key: "data.collection", label: "Collection", type: "text", hint: "What to loop over, e.g. invoice.lines." }
15
+ ]
16
+ }
17
+ ],
18
+ hint: "Repeats its blocks for each item: {% for variable in collection %}.",
19
+ label: (node) => `For ${node.data.variable || "item"} in ${node.data.collection || "…"}`,
20
+ render: {
21
+ mjml: (node, { liquid }) => liquid(node, `{% for ${node.data.variable || "item"} in ${node.data.collection || "items"} %}`, "{% endfor %}")
22
+ }
23
+ }
@@ -0,0 +1,49 @@
1
+ import { align } from "tess_edit/blocks/fields"
2
+
3
+ export default {
4
+ type: "navbar",
5
+ name: "Navbar",
6
+ icon: "navbar",
7
+ category: "content",
8
+ roles: ["content"],
9
+ defaults: () => ({
10
+ attributes: { align: "center" },
11
+ data: {
12
+ links: [
13
+ { label: "Home", href: "https://example.com/" },
14
+ { label: "Products", href: "https://example.com/products" },
15
+ { label: "Contact", href: "https://example.com/contact" }
16
+ ],
17
+ color: "#111827",
18
+ fontSize: "14px",
19
+ linkPadding: "10px 15px"
20
+ }
21
+ }),
22
+ fields: [
23
+ {
24
+ title: "Navbar",
25
+ fields: [
26
+ { key: "data.links", label: "Links", type: "list", item: [{ name: "label", label: "Label" }, { name: "href", label: "URL" }], newItem: { label: "Link", href: "https://" } },
27
+ align,
28
+ { key: "attributes.hamburger", label: "Hamburger on mobile", type: "toggle", on: "hamburger", off: "" }
29
+ ]
30
+ },
31
+ {
32
+ title: "Links",
33
+ fields: [
34
+ { key: "data.color", label: "Color", type: "color" },
35
+ { key: "data.fontSize", label: "Font size", type: "size" },
36
+ { key: "data.fontFamily", label: "Font family", type: "text", placeholder: "Inherit" },
37
+ { key: "data.linkPadding", label: "Padding", type: "spacing" }
38
+ ]
39
+ }
40
+ ],
41
+ render: {
42
+ mjml(node, { element, attrs, escapeText }) {
43
+ const d = node.data
44
+ const linkAttrs = { color: d.color, "font-size": d.fontSize, "font-family": d.fontFamily, padding: d.linkPadding }
45
+ const links = (d.links || []).map((link) => ` <mj-navbar-link${attrs({ href: link.href, ...linkAttrs })}>${escapeText(link.label)}</mj-navbar-link>`)
46
+ return element("mj-navbar", node, `\n${links.join("\n")}\n`)
47
+ }
48
+ }
49
+ }