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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +225 -0
- data/Rakefile +3 -0
- data/app/assets/tailwind/tess_edit/engine.css +10 -0
- data/app/controllers/tess_edit/application_controller.rb +5 -0
- data/app/controllers/tess_edit/compilations_controller.rb +10 -0
- data/app/helpers/tess_edit/application_helper.rb +4 -0
- data/app/javascript/tess_edit/blocks/builtin.js +26 -0
- data/app/javascript/tess_edit/blocks/button.js +32 -0
- data/app/javascript/tess_edit/blocks/column.js +24 -0
- data/app/javascript/tess_edit/blocks/condition.js +15 -0
- data/app/javascript/tess_edit/blocks/divider.js +24 -0
- data/app/javascript/tess_edit/blocks/fields.js +42 -0
- data/app/javascript/tess_edit/blocks/hero.js +60 -0
- data/app/javascript/tess_edit/blocks/image.js +29 -0
- data/app/javascript/tess_edit/blocks/loop.js +23 -0
- data/app/javascript/tess_edit/blocks/navbar.js +49 -0
- data/app/javascript/tess_edit/blocks/raw.js +18 -0
- data/app/javascript/tess_edit/blocks/root.js +83 -0
- data/app/javascript/tess_edit/blocks/section.js +40 -0
- data/app/javascript/tess_edit/blocks/social.js +39 -0
- data/app/javascript/tess_edit/blocks/spacer.js +10 -0
- data/app/javascript/tess_edit/blocks/text.js +39 -0
- data/app/javascript/tess_edit/blocks/wrapper.js +24 -0
- data/app/javascript/tess_edit/controllers/editor_controller.js +758 -0
- data/app/javascript/tess_edit/core/document.js +192 -0
- data/app/javascript/tess_edit/core/history.js +46 -0
- data/app/javascript/tess_edit/core/lock.js +36 -0
- data/app/javascript/tess_edit/core/media.js +54 -0
- data/app/javascript/tess_edit/core/registry.js +94 -0
- data/app/javascript/tess_edit/core/starter.js +89 -0
- data/app/javascript/tess_edit/export/compile.js +28 -0
- data/app/javascript/tess_edit/export/mjml.js +116 -0
- data/app/javascript/tess_edit/export/richtext.js +111 -0
- data/app/javascript/tess_edit/index.js +21 -0
- data/app/javascript/tess_edit/ui/blocks_panel.js +44 -0
- data/app/javascript/tess_edit/ui/canvas.js +437 -0
- data/app/javascript/tess_edit/ui/code_input.js +137 -0
- data/app/javascript/tess_edit/ui/dom.js +35 -0
- data/app/javascript/tess_edit/ui/icons.js +63 -0
- data/app/javascript/tess_edit/ui/inspector.js +377 -0
- data/app/javascript/tess_edit/ui/text_editing.js +124 -0
- data/app/javascript/tess_edit/ui/tree.js +138 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +3 -0
- data/lib/tasks/tess_edit_tasks.rake +4 -0
- data/lib/tess_edit/engine.rb +18 -0
- data/lib/tess_edit/version.rb +3 -0
- data/lib/tess_edit.rb +28 -0
- data/vendor/javascript/tess_edit/tiptap.js +147 -0
- metadata +149 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The document is a plain JSON tree:
|
|
2
|
+
// { id, type, attributes: {}, data: {}, children: [] }
|
|
3
|
+
// `attributes` map one-to-one onto MJML attributes, `data` holds everything else
|
|
4
|
+
// (rich text, Liquid expressions, link lists). All functions here are pure apart
|
|
5
|
+
// from the mutating helpers, which change the tree they are given in place.
|
|
6
|
+
|
|
7
|
+
import { getBlock, paletteItem, typesForRoles } from "tess_edit/core/registry"
|
|
8
|
+
|
|
9
|
+
export const VERSION = 1
|
|
10
|
+
|
|
11
|
+
export function uid() {
|
|
12
|
+
return "b" + Math.random().toString(36).slice(2, 9)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function clone(value) {
|
|
16
|
+
return JSON.parse(JSON.stringify(value))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Builds a node of `type` with the block's defaults; `overrides` are merged in.
|
|
20
|
+
export function createNode(type, overrides = {}) {
|
|
21
|
+
const definition = getBlock(type)
|
|
22
|
+
if (!definition) throw new Error(`Unknown block type: ${type}`)
|
|
23
|
+
const defaults = definition.defaults ? definition.defaults() : {}
|
|
24
|
+
return {
|
|
25
|
+
id: uid(),
|
|
26
|
+
type,
|
|
27
|
+
attributes: { ...(defaults.attributes || {}), ...(overrides.attributes || {}) },
|
|
28
|
+
data: { ...(defaults.data || {}), ...(overrides.data || {}) },
|
|
29
|
+
children: overrides.children || defaults.children || []
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createDocument() {
|
|
34
|
+
return { version: VERSION, ...createNode("root") }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Calls fn(node, parent, index, depth) for every node, depth first.
|
|
38
|
+
export function walk(node, fn, parent = null, index = 0, depth = 0) {
|
|
39
|
+
if (fn(node, parent, index, depth) === false) return
|
|
40
|
+
;(node.children || []).forEach((child, i) => walk(child, fn, node, i, depth + 1))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function find(doc, id) {
|
|
44
|
+
let result = null
|
|
45
|
+
walk(doc, (node, parent, index) => {
|
|
46
|
+
if (result) return false
|
|
47
|
+
if (node.id === id) {
|
|
48
|
+
result = { node, parent, index }
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
return result
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Ancestors of `id`, nearest first (not including the node itself).
|
|
56
|
+
export function ancestors(doc, id) {
|
|
57
|
+
const path = []
|
|
58
|
+
const visit = (node, trail) => {
|
|
59
|
+
if (node.id === id) {
|
|
60
|
+
path.push(...trail.slice().reverse())
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
return (node.children || []).some((child) => visit(child, [...trail, node]))
|
|
64
|
+
}
|
|
65
|
+
visit(doc, [])
|
|
66
|
+
return path
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isDescendant(doc, ancestorId, id) {
|
|
70
|
+
return ancestors(doc, id).some((node) => node.id === ancestorId)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The roles that may be placed directly inside the node with `parentId`.
|
|
74
|
+
// Loop and Condition ("inherit") are transparent: they accept what their
|
|
75
|
+
// nearest other ancestor accepts, except columns.
|
|
76
|
+
export function acceptedRoles(doc, parentId) {
|
|
77
|
+
const hit = find(doc, parentId)
|
|
78
|
+
if (!hit) return []
|
|
79
|
+
const accepts = getBlock(hit.node.type)?.accepts
|
|
80
|
+
if (accepts !== "inherit") return accepts || []
|
|
81
|
+
const owner = ancestors(doc, parentId).find((node) => getBlock(node.type)?.accepts !== "inherit")
|
|
82
|
+
return (owner ? getBlock(owner.type).accepts : getBlock("root").accepts).filter((role) => role !== "column")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The block types that may be placed directly inside the node with `parentId`.
|
|
86
|
+
export function accepted(doc, parentId) {
|
|
87
|
+
return typesForRoles(acceptedRoles(doc, parentId))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function canInsert(doc, parentId, type) {
|
|
91
|
+
return accepted(doc, parentId).includes(type)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Whether children of `parentId` flow horizontally (columns in a section).
|
|
95
|
+
export function isHorizontal(doc, parentId) {
|
|
96
|
+
const hit = find(doc, parentId)
|
|
97
|
+
return hit ? !!getBlock(hit.node.type)?.horizontal : false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Gives a node and all of its descendants fresh ids, for duplicating and pasting.
|
|
101
|
+
export function renumber(node) {
|
|
102
|
+
const copy = clone(node)
|
|
103
|
+
walk(copy, (n) => {
|
|
104
|
+
n.id = uid()
|
|
105
|
+
})
|
|
106
|
+
return copy
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function insert(doc, parentId, index, node) {
|
|
110
|
+
const hit = find(doc, parentId)
|
|
111
|
+
if (!hit) throw new Error(`No node ${parentId}`)
|
|
112
|
+
hit.node.children ||= []
|
|
113
|
+
const at = Math.max(0, Math.min(index ?? hit.node.children.length, hit.node.children.length))
|
|
114
|
+
hit.node.children.splice(at, 0, node)
|
|
115
|
+
return node
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function remove(doc, id) {
|
|
119
|
+
const hit = find(doc, id)
|
|
120
|
+
if (!hit || !hit.parent) return null
|
|
121
|
+
hit.parent.children.splice(hit.index, 1)
|
|
122
|
+
return hit
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Moves node `id` to position `index` in `parentId`. `index` is counted in the
|
|
126
|
+
// target's children as they are before the move.
|
|
127
|
+
export function move(doc, id, parentId, index) {
|
|
128
|
+
if (id === parentId || isDescendant(doc, id, parentId)) return false
|
|
129
|
+
const hit = find(doc, id)
|
|
130
|
+
if (!hit || !hit.parent) return false
|
|
131
|
+
let at = index
|
|
132
|
+
if (hit.parent.id === parentId && hit.index < index) at -= 1
|
|
133
|
+
hit.parent.children.splice(hit.index, 1)
|
|
134
|
+
insert(doc, parentId, at, hit.node)
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function duplicate(doc, id) {
|
|
139
|
+
const hit = find(doc, id)
|
|
140
|
+
if (!hit || !hit.parent) return null
|
|
141
|
+
const copy = renumber(hit.node)
|
|
142
|
+
hit.parent.children.splice(hit.index + 1, 0, copy)
|
|
143
|
+
return copy
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Sets a value on a node. `key` is "attributes.padding" or "data.subject".
|
|
147
|
+
// An empty string removes an attribute, so MJML falls back to its default.
|
|
148
|
+
export function setValue(doc, id, key, value) {
|
|
149
|
+
const hit = find(doc, id)
|
|
150
|
+
if (!hit) return
|
|
151
|
+
const [scope, name] = key.split(".")
|
|
152
|
+
hit.node[scope] ||= {}
|
|
153
|
+
if (scope === "attributes" && (value === "" || value === null || value === undefined)) {
|
|
154
|
+
delete hit.node[scope][name]
|
|
155
|
+
} else {
|
|
156
|
+
hit.node[scope][name] = value
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function getValue(node, key) {
|
|
161
|
+
const [scope, name] = key.split(".")
|
|
162
|
+
return node[scope]?.[name]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Light validation of a document read from storage or an import.
|
|
166
|
+
export function normalize(input) {
|
|
167
|
+
const doc = typeof input === "string" ? JSON.parse(input) : clone(input)
|
|
168
|
+
// The root block was called "page" before; such documents still load.
|
|
169
|
+
if (doc?.type === "page") doc.type = "root"
|
|
170
|
+
if (!doc || doc.type !== "root") throw new Error("Not a TessEdit document: the top node must be the root block")
|
|
171
|
+
walk(doc, (node) => {
|
|
172
|
+
if (!getBlock(node.type)) throw new Error(`Unknown block type: ${node.type}`)
|
|
173
|
+
node.id ||= uid()
|
|
174
|
+
node.attributes ||= {}
|
|
175
|
+
node.data ||= {}
|
|
176
|
+
node.children ||= []
|
|
177
|
+
})
|
|
178
|
+
doc.version ||= VERSION
|
|
179
|
+
return doc
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The node (or small tree) a Blocks panel tile creates.
|
|
183
|
+
export function createFromPalette(key) {
|
|
184
|
+
const item = paletteItem(key)
|
|
185
|
+
if (!item) throw new Error(`Unknown palette item: ${key}`)
|
|
186
|
+
return item.create({ createNode })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The block type of the node a tile creates, used to check where it may go.
|
|
190
|
+
export function itemType(key) {
|
|
191
|
+
return paletteItem(key)?.type || null
|
|
192
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Undo/redo over whole-document snapshots. Changes with the same coalesce key
|
|
2
|
+
// within a second (typing in one field) become a single undo step.
|
|
3
|
+
|
|
4
|
+
export class History {
|
|
5
|
+
constructor(limit = 100) {
|
|
6
|
+
this.limit = limit
|
|
7
|
+
this.undoStack = []
|
|
8
|
+
this.redoStack = []
|
|
9
|
+
this.lastKey = null
|
|
10
|
+
this.lastTime = 0
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
record(snapshot, coalesceKey = null) {
|
|
14
|
+
const now = Date.now()
|
|
15
|
+
const coalesce = coalesceKey && coalesceKey === this.lastKey && now - this.lastTime < 1000
|
|
16
|
+
if (!coalesce) {
|
|
17
|
+
this.undoStack.push(snapshot)
|
|
18
|
+
if (this.undoStack.length > this.limit) this.undoStack.shift()
|
|
19
|
+
}
|
|
20
|
+
this.redoStack = []
|
|
21
|
+
this.lastKey = coalesceKey
|
|
22
|
+
this.lastTime = now
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
undo(current) {
|
|
26
|
+
if (!this.undoStack.length) return null
|
|
27
|
+
this.redoStack.push(current)
|
|
28
|
+
this.lastKey = null
|
|
29
|
+
return this.undoStack.pop()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
redo(current) {
|
|
33
|
+
if (!this.redoStack.length) return null
|
|
34
|
+
this.undoStack.push(current)
|
|
35
|
+
this.lastKey = null
|
|
36
|
+
return this.redoStack.pop()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get canUndo() {
|
|
40
|
+
return this.undoStack.length > 0
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get canRedo() {
|
|
44
|
+
return this.redoStack.length > 0
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Locking a document, for editing one part of a design that is otherwise
|
|
2
|
+
// fixed, such as a template's own content in its layout:
|
|
3
|
+
//
|
|
4
|
+
// { "except": ["content"], "fields": ["data.subject", "data.preheader"] }
|
|
5
|
+
//
|
|
6
|
+
// With a lock, only what is inside a block whose type is in `except` can
|
|
7
|
+
// change: those blocks take new children, and everything below them can be
|
|
8
|
+
// edited, moved, duplicated and deleted. The `except` blocks themselves stay
|
|
9
|
+
// where they are. Of the root, only the listed `fields` can change. Without a
|
|
10
|
+
// lock (null) everything can change.
|
|
11
|
+
|
|
12
|
+
import { ancestors, find } from "tess_edit/core/document"
|
|
13
|
+
|
|
14
|
+
export function parseLock(value) {
|
|
15
|
+
if (!value) return null
|
|
16
|
+
const lock = typeof value === "string" ? JSON.parse(value) : value
|
|
17
|
+
return { except: lock.except || [], fields: lock.fields || [] }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Whether the node may be edited, moved, duplicated or deleted.
|
|
21
|
+
export function isFree(doc, lock, id) {
|
|
22
|
+
if (!lock) return true
|
|
23
|
+
return ancestors(doc, id).some((node) => lock.except.includes(node.type))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Whether blocks may be added to, or moved into, the node.
|
|
27
|
+
export function canFill(doc, lock, parentId) {
|
|
28
|
+
if (!lock) return true
|
|
29
|
+
const hit = find(doc, parentId)
|
|
30
|
+
return !!hit && (lock.except.includes(hit.node.type) || isFree(doc, lock, parentId))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function canEditField(doc, lock, id, key) {
|
|
34
|
+
if (!lock || isFree(doc, lock, id)) return true
|
|
35
|
+
return id === doc.id && lock.fields.includes(key)
|
|
36
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Images to choose from, kept by the host app. The editor's mediaUrl answers:
|
|
2
|
+
//
|
|
3
|
+
// GET → { images: [{ id, url, name }] }
|
|
4
|
+
// POST multipart/form-data, "file" → { id, url, name }, or { errors: [...] }
|
|
5
|
+
//
|
|
6
|
+
// `id` is the host's own reference to the image, `url` is where the email
|
|
7
|
+
// loads it from. An image field (type "image") stores both: the URL in its
|
|
8
|
+
// key, the id and where the image comes from in its `image` and `source` keys.
|
|
9
|
+
|
|
10
|
+
import { getValue } from "tess_edit/core/document"
|
|
11
|
+
|
|
12
|
+
function csrfHeaders() {
|
|
13
|
+
const token = document.querySelector("meta[name='csrf-token']")?.content
|
|
14
|
+
return token ? { "X-CSRF-Token": token } : {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function json(response, action) {
|
|
18
|
+
const result = (response.headers.get("Content-Type") || "").includes("json") ? await response.json() : null
|
|
19
|
+
if (!response.ok || !result) throw new Error(result?.errors?.join(", ") || `${action} failed (${response.status} ${response.statusText})`)
|
|
20
|
+
return result
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function fetchMedia(url) {
|
|
24
|
+
const response = await fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" })
|
|
25
|
+
return (await json(response, "Loading the images")).images || []
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function uploadMedia(url, file) {
|
|
29
|
+
const body = new FormData()
|
|
30
|
+
body.append("file", file)
|
|
31
|
+
const response = await fetch(url, { method: "POST", headers: { Accept: "application/json", ...csrfHeaders() }, body, credentials: "same-origin" })
|
|
32
|
+
return json(response, "The upload")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The Image block's image field
|
|
36
|
+
export const IMAGE_FIELD = { key: "attributes.src", image: "data.image", source: "data.source" }
|
|
37
|
+
|
|
38
|
+
// An image field for an image attribute, e.g. a background:
|
|
39
|
+
// imageField("attributes.background-url", "Background image", "data.background")
|
|
40
|
+
export function imageField(key, label, image) {
|
|
41
|
+
return { key, label, type: "image", image, source: `${image}Source` }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The image fields of a block definition
|
|
45
|
+
export function imageFields(definition) {
|
|
46
|
+
return (definition?.fields || []).flatMap((group) => group.fields || []).filter((field) => field.type === "image")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Where a field's image comes from: "upload" (one of the host's images) or
|
|
50
|
+
// "web" (any URL). Without a mediaUrl there are only web images.
|
|
51
|
+
export function imageSource(node, editor, field = IMAGE_FIELD) {
|
|
52
|
+
if (!editor?.media?.enabled) return "web"
|
|
53
|
+
return getValue(node, field.source) || (getValue(node, field.image) ? "upload" : "web")
|
|
54
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The block registry. Every block, built-in or added by a host app, is one
|
|
2
|
+
// definition registered here:
|
|
3
|
+
//
|
|
4
|
+
// registerBlock({
|
|
5
|
+
// type: "product", // stored in the document JSON
|
|
6
|
+
// name: "Product", // shown in Layers, Inspect and on the canvas
|
|
7
|
+
// icon: "image", // a built-in icon name, or SVG markup for a 24×24 viewBox
|
|
8
|
+
// roles: ["content"], // where it may be placed, see below
|
|
9
|
+
// accepts: undefined, // roles it takes as children, "inherit", or undefined for a leaf
|
|
10
|
+
// defaults: () => ({ attributes: {}, data: {} }),
|
|
11
|
+
// fields: [{ title: "Product", fields: [{ key: "data.sku", label: "SKU", type: "text" }] }],
|
|
12
|
+
// // a field's visible: (node, editor) => boolean shows it only when it applies;
|
|
13
|
+
// // imageField(key, label, idKey) (core/media.js) makes an image field;
|
|
14
|
+
// // a "code" field takes language: "html" (default) or "css"
|
|
15
|
+
// hint: "Shown at the top of Inspect.",
|
|
16
|
+
// label: (node) => `Product · ${node.data.sku}`, // optional, for Layers and the canvas
|
|
17
|
+
// richText: "data.content", // optional: a Tiptap JSON value edited on the canvas by double-click
|
|
18
|
+
// palette: [{ key, label, icon, category, create: ({ createNode }) => node }], // optional
|
|
19
|
+
// render: { mjml: (node, ctx) => "<mj-text …>" } // ctx: see export/mjml.js
|
|
20
|
+
// })
|
|
21
|
+
//
|
|
22
|
+
// Roles: "body" (inside the page or a wrapper: sections, heroes), "wrapper",
|
|
23
|
+
// "column" (inside a section) and "content" (inside a column or hero). A
|
|
24
|
+
// container accepts the roles in its `accepts`; Loop and Condition use
|
|
25
|
+
// "inherit" and accept what their nearest other container accepts.
|
|
26
|
+
//
|
|
27
|
+
// Without a `palette`, a block with a `category` gets one tile in the Blocks
|
|
28
|
+
// panel; `palette: []` gives it none. Registering a type again replaces it.
|
|
29
|
+
|
|
30
|
+
import { builtinBlocks, builtinCategories } from "tess_edit/blocks/builtin"
|
|
31
|
+
|
|
32
|
+
const definitions = new Map()
|
|
33
|
+
const categoryList = new Map()
|
|
34
|
+
|
|
35
|
+
export function registerCategory({ key, label, order = 100 }) {
|
|
36
|
+
if (!key || !label) throw new Error("A category needs a key and a label")
|
|
37
|
+
categoryList.set(key, { key, label, order })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function registerBlock(definition) {
|
|
41
|
+
const { type, name, render } = definition || {}
|
|
42
|
+
if (!type || typeof type !== "string") throw new Error("A block needs a type")
|
|
43
|
+
if (!name) throw new Error(`Block ${type} needs a name`)
|
|
44
|
+
if (typeof render?.mjml !== "function") throw new Error(`Block ${type} needs render.mjml(node, ctx)`)
|
|
45
|
+
if (type !== "root" && !(definition.roles || []).length) throw new Error(`Block ${type} needs roles, e.g. ["content"]`)
|
|
46
|
+
|
|
47
|
+
const palette = (definition.palette ?? (definition.category ? [{ key: type, label: name, icon: definition.icon, category: definition.category }] : [])).map((item) => ({
|
|
48
|
+
icon: definition.icon,
|
|
49
|
+
category: definition.category,
|
|
50
|
+
create: ({ createNode }) => createNode(type),
|
|
51
|
+
...item,
|
|
52
|
+
type
|
|
53
|
+
}))
|
|
54
|
+
definitions.set(type, { roles: [], ...definition, palette })
|
|
55
|
+
return definitions.get(type)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getBlock(type) {
|
|
59
|
+
return definitions.get(type)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function blockTypes() {
|
|
63
|
+
return [...definitions.keys()]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isContainer(type) {
|
|
67
|
+
return !!getBlock(type)?.accepts
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The block types that may go where the given roles are accepted.
|
|
71
|
+
export function typesForRoles(roles) {
|
|
72
|
+
return [...definitions.values()].filter((definition) => definition.roles.some((role) => roles.includes(role))).map((definition) => definition.type)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function categories() {
|
|
76
|
+
return [...categoryList.values()].sort((a, b) => a.order - b.order)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Tiles for the Blocks panel, in registration order.
|
|
80
|
+
export function paletteItems() {
|
|
81
|
+
return [...definitions.values()].flatMap((definition) => definition.palette)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function paletteItem(key) {
|
|
85
|
+
return paletteItems().find((item) => item.key === key)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function label(node) {
|
|
89
|
+
const definition = getBlock(node.type)
|
|
90
|
+
return definition?.label?.(node) || definition?.name || node.type
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
builtinCategories.forEach(registerCategory)
|
|
94
|
+
builtinBlocks.forEach(registerBlock)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// A sample document for new, empty editors and the demo page.
|
|
2
|
+
|
|
3
|
+
import { createDocument, createNode } from "tess_edit/core/document"
|
|
4
|
+
|
|
5
|
+
const paragraph = (...content) => ({ type: "paragraph", content })
|
|
6
|
+
const text = (value, marks) => (marks ? { type: "text", text: value, marks } : { type: "text", text: value })
|
|
7
|
+
const doc = (...content) => ({ type: "doc", content })
|
|
8
|
+
|
|
9
|
+
const section = (attributes, ...columns) => createNode("section", { attributes, children: columns })
|
|
10
|
+
const column = (attributes, ...children) => createNode("column", { attributes, children })
|
|
11
|
+
|
|
12
|
+
export function starterDocument() {
|
|
13
|
+
const page = createDocument()
|
|
14
|
+
page.data.subject = "Your order {{ order.number }}"
|
|
15
|
+
page.data.preheader = "Thanks for shopping with us, {{ customer.first_name }}."
|
|
16
|
+
|
|
17
|
+
page.children = [
|
|
18
|
+
section(
|
|
19
|
+
{ padding: "24px 0px 8px" },
|
|
20
|
+
column(
|
|
21
|
+
{},
|
|
22
|
+
createNode("text", {
|
|
23
|
+
attributes: { align: "center", "font-size": "20px", "font-weight": "700", "letter-spacing": "1px" },
|
|
24
|
+
data: { content: doc(paragraph(text("ACME STORE"))) }
|
|
25
|
+
})
|
|
26
|
+
)
|
|
27
|
+
),
|
|
28
|
+
section(
|
|
29
|
+
{ "background-color": "#ffffff", padding: "32px 0px", "border-radius": "8px" },
|
|
30
|
+
column(
|
|
31
|
+
{},
|
|
32
|
+
createNode("text", {
|
|
33
|
+
attributes: { "font-size": "26px", "font-weight": "700", "line-height": "1.3" },
|
|
34
|
+
data: { content: doc(paragraph(text("Thanks for your order, {{ customer.first_name }}!"))) }
|
|
35
|
+
}),
|
|
36
|
+
createNode("text", {
|
|
37
|
+
data: {
|
|
38
|
+
content: doc(
|
|
39
|
+
paragraph(text("We've received order "), text("{{ order.number }}", [{ type: "bold" }]), text(" and will let you know when it ships.")),
|
|
40
|
+
paragraph(),
|
|
41
|
+
paragraph(text("Here's what you ordered:"))
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}),
|
|
45
|
+
createNode("loop", {
|
|
46
|
+
data: { variable: "line", collection: "order.lines" },
|
|
47
|
+
children: [
|
|
48
|
+
createNode("text", {
|
|
49
|
+
attributes: { padding: "4px 25px" },
|
|
50
|
+
data: { content: doc(paragraph(text("{{ line.quantity }} × {{ line.name }} — {{ line.total | money }}"))) }
|
|
51
|
+
})
|
|
52
|
+
]
|
|
53
|
+
}),
|
|
54
|
+
createNode("divider", { attributes: { padding: "16px 25px" } }),
|
|
55
|
+
createNode("button", { attributes: { href: "{{ order.url }}" }, data: { content: "View your order" } })
|
|
56
|
+
)
|
|
57
|
+
),
|
|
58
|
+
createNode("condition", {
|
|
59
|
+
data: { expression: "customer.orders_count == 1" },
|
|
60
|
+
children: [
|
|
61
|
+
section(
|
|
62
|
+
{ padding: "24px 0px 0px" },
|
|
63
|
+
column(
|
|
64
|
+
{ width: "50%" },
|
|
65
|
+
createNode("image", { attributes: { src: "https://placehold.co/600x400/dbeafe/1e3a8a.png?text=Welcome", padding: "0px 12px 0px 25px", "border-radius": "6px" } })
|
|
66
|
+
),
|
|
67
|
+
column(
|
|
68
|
+
{ width: "50%", "vertical-align": "middle" },
|
|
69
|
+
createNode("text", {
|
|
70
|
+
data: { content: doc(paragraph(text("Welcome!", [{ type: "bold" }])), paragraph(text("This is your first order. Here's 10% off the next one with code WELCOME10."))) }
|
|
71
|
+
})
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
]
|
|
75
|
+
}),
|
|
76
|
+
section(
|
|
77
|
+
{ padding: "24px 0px" },
|
|
78
|
+
column(
|
|
79
|
+
{},
|
|
80
|
+
createNode("social"),
|
|
81
|
+
createNode("text", {
|
|
82
|
+
attributes: { align: "center", "font-size": "12px", color: "#6b7280" },
|
|
83
|
+
data: { content: doc(paragraph(text("ACME Store · Main Street 1 · Amsterdam")), paragraph(text("Unsubscribe", [{ type: "link", attrs: { href: "{{ unsubscribe_url }}" } }]))) }
|
|
84
|
+
})
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
]
|
|
88
|
+
return page
|
|
89
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// MJML → HTML on the server. The editor posts the MJML to the engine's compile
|
|
2
|
+
// endpoint, which compiles it with MRML (TessEdit.compile in Ruby), so the
|
|
3
|
+
// canvas shows the same HTML the host app sends.
|
|
4
|
+
//
|
|
5
|
+
// Resolves to { html, errors }: html is null when the MJML could not be
|
|
6
|
+
// compiled or the request failed, with the reasons in errors. Resolves to null
|
|
7
|
+
// when the request was aborted through options.signal.
|
|
8
|
+
|
|
9
|
+
export const COMPILE_URL = "/tess_edit/compile"
|
|
10
|
+
|
|
11
|
+
export async function compile(mjml, { url = COMPILE_URL, signal } = {}) {
|
|
12
|
+
const token = document.querySelector("meta[name='csrf-token']")?.content
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch(url, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json", Accept: "application/json", ...(token ? { "X-CSRF-Token": token } : {}) },
|
|
17
|
+
body: JSON.stringify({ mjml }),
|
|
18
|
+
credentials: "same-origin",
|
|
19
|
+
signal
|
|
20
|
+
})
|
|
21
|
+
const result = (response.headers.get("Content-Type") || "").includes("json") ? await response.json() : null
|
|
22
|
+
if (!result) return { html: null, errors: [`The compile request failed (${response.status} ${response.statusText})`] }
|
|
23
|
+
return { html: result.html ?? null, errors: result.errors || [] }
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error.name === "AbortError") return null
|
|
26
|
+
return { html: null, errors: [`The compile request failed: ${error.message}`] }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// JSON document → MJML. Each block renders itself through its render.mjml(node,
|
|
2
|
+
// ctx) hook (see blocks/*.js); this file holds the shared helpers in `ctx`.
|
|
3
|
+
//
|
|
4
|
+
// With { editor: true } the output is meant for the editing canvas: every
|
|
5
|
+
// element gets a css-class "tess-<id>" so clicks map back to blocks, empty
|
|
6
|
+
// containers get a placeholder ("tessph-<id>"), and Liquid tags are left out so
|
|
7
|
+
// loops and conditions show their content once.
|
|
8
|
+
//
|
|
9
|
+
// Without it, Loop and Condition blocks emit their Liquid tags inside <mj-raw>,
|
|
10
|
+
// which MJML copies into the HTML unchanged, so Liquid can run on the compiled HTML.
|
|
11
|
+
//
|
|
12
|
+
// The ctx a block's render.mjml gets:
|
|
13
|
+
// editor true when rendering for the canvas
|
|
14
|
+
// context where the node sits: "body" (mj-body, mj-wrapper),
|
|
15
|
+
// "section" (mj-section) or "column" (mj-column, mj-hero)
|
|
16
|
+
// attrs(a, extra) ` name="value"` pairs, empty values left out
|
|
17
|
+
// cssClass(node) { "css-class": "tess-<id> <the block's own css-class>" }
|
|
18
|
+
// in the editor, {} otherwise
|
|
19
|
+
// element(tag, node, inner?) <tag> with the node's attributes and class;
|
|
20
|
+
// self-closing without inner
|
|
21
|
+
// container(tag, node) <tag> around the node's rendered children
|
|
22
|
+
// children(node, context?) the children's MJML, as an array
|
|
23
|
+
// liquid(node, open, close) children wrapped in Liquid tags (not in the editor)
|
|
24
|
+
// indent(text, level), escapeText, escapeAttribute, richText(content)
|
|
25
|
+
|
|
26
|
+
import { getBlock } from "tess_edit/core/registry"
|
|
27
|
+
import { escapeAttribute, escapeText, richTextToHtml } from "tess_edit/export/richtext"
|
|
28
|
+
|
|
29
|
+
const PLACEHOLDER_COLOR = "#94a3b8"
|
|
30
|
+
|
|
31
|
+
function attrs(attributes = {}, extra = {}) {
|
|
32
|
+
return Object.entries({ ...attributes, ...extra })
|
|
33
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== "")
|
|
34
|
+
.map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
|
|
35
|
+
.join("")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function indent(text, level) {
|
|
39
|
+
const pad = " ".repeat(level)
|
|
40
|
+
return text
|
|
41
|
+
.split("\n")
|
|
42
|
+
.map((line) => (line ? pad + line : line))
|
|
43
|
+
.join("\n")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// What a container's children render as, from the roles it accepts.
|
|
47
|
+
function childContext(definition, context) {
|
|
48
|
+
const accepts = definition?.accepts
|
|
49
|
+
if (!Array.isArray(accepts)) return context
|
|
50
|
+
if (accepts.includes("column")) return "section"
|
|
51
|
+
if (accepts.includes("content")) return "column"
|
|
52
|
+
return "body"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class Renderer {
|
|
56
|
+
constructor(options) {
|
|
57
|
+
this.editor = !!options.editor
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The marker class goes first, before the block's own css-class, so the
|
|
61
|
+
// canvas finds blocks by the start of the class attribute.
|
|
62
|
+
cssClass(node) {
|
|
63
|
+
if (!this.editor) return {}
|
|
64
|
+
return { "css-class": [`tess-${node.id}`, node.attributes?.["css-class"]].filter(Boolean).join(" ") }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
children(node, context) {
|
|
68
|
+
const kids = node.children || []
|
|
69
|
+
if (!kids.length && this.editor) return [this.placeholder(node, context)]
|
|
70
|
+
return kids.map((child) => this.node(child, context))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
placeholder(node, context) {
|
|
74
|
+
const text = context === "body" ? "Drop a section here" : context === "section" ? "Drop a column here" : "Drop content here"
|
|
75
|
+
const mjText = `<mj-text css-class="tessph-${node.id}" align="center" color="${PLACEHOLDER_COLOR}" font-size="13px" padding="18px 25px">${text}</mj-text>`
|
|
76
|
+
if (context === "column") return mjText
|
|
77
|
+
if (context === "section") return `<mj-column css-class="tessph-${node.id}">\n ${mjText}\n</mj-column>`
|
|
78
|
+
return `<mj-section css-class="tessph-${node.id}">\n <mj-column>\n ${mjText}\n </mj-column>\n</mj-section>`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
context(context) {
|
|
82
|
+
return {
|
|
83
|
+
editor: this.editor,
|
|
84
|
+
context,
|
|
85
|
+
attrs,
|
|
86
|
+
indent,
|
|
87
|
+
escapeText,
|
|
88
|
+
escapeAttribute,
|
|
89
|
+
richText: richTextToHtml,
|
|
90
|
+
cssClass: (node) => this.cssClass(node),
|
|
91
|
+
children: (node, childCtx) => this.children(node, childCtx ?? childContext(getBlock(node.type), context)),
|
|
92
|
+
element: (tag, node, inner) => {
|
|
93
|
+
const open = `<${tag}${attrs(node.attributes, this.cssClass(node))}`
|
|
94
|
+
return inner === undefined ? `${open} />` : `${open}>${inner}</${tag}>`
|
|
95
|
+
},
|
|
96
|
+
container: (tag, node) => {
|
|
97
|
+
const inner = this.children(node, childContext(getBlock(node.type), context)).join("\n")
|
|
98
|
+
return `<${tag}${attrs(node.attributes, this.cssClass(node))}>\n${indent(inner, 1)}\n</${tag}>`
|
|
99
|
+
},
|
|
100
|
+
liquid: (node, open, close) => {
|
|
101
|
+
const inner = this.children(node, context)
|
|
102
|
+
if (this.editor) return inner.join("\n")
|
|
103
|
+
return [`<mj-raw>${open}</mj-raw>`, ...inner, `<mj-raw>${close}</mj-raw>`].join("\n")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
node(node, context) {
|
|
109
|
+
const render = getBlock(node.type)?.render?.mjml
|
|
110
|
+
return render ? render(node, this.context(context)) : ""
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function toMjml(doc, options = {}) {
|
|
115
|
+
return new Renderer(options).node(doc, null)
|
|
116
|
+
}
|