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
@@ -0,0 +1,758 @@
1
+ // <div data-controller="tess-edit"> … </div>
2
+ //
3
+ // Values:
4
+ // document JSON of the document to load (falls back to the json input, then a sample)
5
+ // sample load the sample document when there is nothing else (default true)
6
+ // panels "split" (Blocks and Layers left, Inspect right), "left" or "right" (all panels
7
+ // as tabs on one side). The default; a choice made in the editor's
8
+ // top bar is remembered per browser. Below NARROW_WIDTH the editor
9
+ // puts all panels on the left, whatever the choice, to leave room
10
+ // for the canvas.
11
+ // compileUrl the endpoint that compiles MJML to HTML (default /tess_edit/compile,
12
+ // the engine mounted at /tess_edit)
13
+ // mediaUrl the host's images, chosen and uploaded in Image blocks (see
14
+ // core/media.js); without it images only have a URL
15
+ // lock JSON: only the insides of some blocks and some root fields can
16
+ // change, e.g. {"except": ["content"], "fields": ["data.subject"]}
17
+ // (see core/lock.js)
18
+ // Targets (all optional, usually hidden inputs in a form):
19
+ // jsonInput, mjmlInput, htmlInput kept up to date with every change
20
+ // Events:
21
+ // tess-edit:change detail: { json, mjml, html, errors, document }; html is
22
+ // empty when the MJML could not be compiled, with errors saying why
23
+
24
+ import { Controller } from "@hotwired/stimulus"
25
+ import { accepted, ancestors, createDocument, createFromPalette, duplicate, find, getValue, insert, itemType, move, normalize, remove, setValue, walk } from "tess_edit/core/document"
26
+ import { History } from "tess_edit/core/history"
27
+ import { canEditField, canFill, isFree, parseLock } from "tess_edit/core/lock"
28
+ import { getBlock, paletteItems } from "tess_edit/core/registry"
29
+ import { starterDocument } from "tess_edit/core/starter"
30
+ import { toMjml } from "tess_edit/export/mjml"
31
+ import { COMPILE_URL, compile } from "tess_edit/export/compile"
32
+ import { IMAGE_FIELD, fetchMedia, uploadMedia } from "tess_edit/core/media"
33
+ import { Canvas } from "tess_edit/ui/canvas"
34
+ import { Tree } from "tess_edit/ui/tree"
35
+ import { Inspector } from "tess_edit/ui/inspector"
36
+ import { TextEditing } from "tess_edit/ui/text_editing"
37
+ import { renderBlocksPanel } from "tess_edit/ui/blocks_panel"
38
+ import { debounce, h } from "tess_edit/ui/dom"
39
+ import { icon } from "tess_edit/ui/icons"
40
+
41
+ const TOOL = "w-8 h-8 flex items-center justify-center rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 disabled:opacity-30 disabled:hover:bg-transparent"
42
+ const TAB = "px-3 h-12 text-[13px] font-medium border-b-2 -mb-px"
43
+ const PANELS = ["left", "split", "right"]
44
+ const PANELS_KEY = "tess_edit:panels"
45
+ // Two sidebars (256px + 320px) leave too little canvas below this editor width.
46
+ const NARROW_WIDTH = 1000
47
+
48
+ export default class extends Controller {
49
+ static values = { document: String, sample: { type: Boolean, default: true }, panels: { type: String, default: "split" }, compileUrl: { type: String, default: COMPILE_URL }, mediaUrl: String, lock: String }
50
+ static targets = ["jsonInput", "mjmlInput", "htmlInput"]
51
+
52
+ connect() {
53
+ this.lock = parseLock(this.lockValue)
54
+ this.history = new History()
55
+ this.doc = this.loadInitial()
56
+ this.selectedId = this.doc.id
57
+ this.editingId = null
58
+ this.drag = null
59
+ this.mode = "edit"
60
+ this.device = "desktop"
61
+ this.leftTab = "blocks"
62
+ this.panels = [this.storedPanels(), this.panelsValue].find((value) => PANELS.includes(value)) || "split"
63
+ this.narrow = false
64
+ this.requests = {}
65
+ this.media = { enabled: Boolean(this.mediaUrlValue), images: [], uploading: false, error: null }
66
+
67
+ this.scheduleCanvas = debounce(() => this.renderCanvas(), 60)
68
+ this.scheduleOutputs = debounce(() => this.writeOutputs(), 300)
69
+
70
+ this.build()
71
+ this.renderAll()
72
+ this.writeOutputs()
73
+ if (this.media.enabled) this.loadMedia()
74
+ this.element.tessEdit = this
75
+ }
76
+
77
+ async loadMedia() {
78
+ try {
79
+ this.media.images = await fetchMedia(this.mediaUrlValue)
80
+ this.media.error = null
81
+ } catch (error) {
82
+ this.media.error = error.message
83
+ }
84
+ this.inspector?.render()
85
+ }
86
+
87
+ // Puts one of the host's images in a block's image field (an Image block's
88
+ // image by default): its id and its URL
89
+ chooseImage(id, image, field = IMAGE_FIELD) {
90
+ if (!this.canEditField(id, field.key)) return
91
+ this.commit((doc) => {
92
+ setValue(doc, id, field.source, "upload")
93
+ setValue(doc, id, field.image, image.id)
94
+ setValue(doc, id, field.key, image.url)
95
+ })
96
+ }
97
+
98
+ // An image file dropped on a block: shown at once, then uploaded
99
+ async dropImage(id, file, field = IMAGE_FIELD) {
100
+ this.select(id)
101
+ const url = URL.createObjectURL(file)
102
+ this.canvas.preview(id, getValue(find(this.doc, id).node, field.key), url)
103
+ await this.uploadImage(id, file, field)
104
+ // On failure the canvas goes back to the block's own image
105
+ if (this.media.error) this.renderCanvas()
106
+ URL.revokeObjectURL(url)
107
+ }
108
+
109
+ async uploadImage(id, file, field = IMAGE_FIELD) {
110
+ this.media.uploading = true
111
+ this.media.error = null
112
+ this.inspector.render()
113
+ try {
114
+ const image = await uploadMedia(this.mediaUrlValue, file)
115
+ this.media.images = [...this.media.images.filter((other) => other.id !== image.id), image]
116
+ this.chooseImage(id, image, field)
117
+ } catch (error) {
118
+ this.media.error = error.message
119
+ } finally {
120
+ this.media.uploading = false
121
+ this.inspector.render()
122
+ }
123
+ }
124
+
125
+ disconnect() {
126
+ this.scheduleCanvas.cancel()
127
+ this.scheduleOutputs.cancel()
128
+ Object.values(this.requests).forEach((request) => request.abort())
129
+ this.textEditing?.stop()
130
+ this.closeMenu()
131
+ this.resizeObserver?.disconnect()
132
+ document.removeEventListener("mousedown", this.outsideClick)
133
+ document.removeEventListener("keydown", this.menuKeydown, true)
134
+ window.removeEventListener("resize", this.closeMenuOnResize)
135
+ this.root?.remove()
136
+ delete this.element.tessEdit
137
+ }
138
+
139
+ loadInitial() {
140
+ const source = this.documentValue || (this.hasJsonInputTarget && this.jsonInputTarget.value)
141
+ if (source && source.trim()) {
142
+ try {
143
+ return normalize(source)
144
+ } catch (error) {
145
+ console.error("TessEdit: could not load the document", error)
146
+ }
147
+ }
148
+ return this.sampleValue ? starterDocument() : createDocument()
149
+ }
150
+
151
+ // --- public API -------------------------------------------------------------
152
+
153
+ getDocument() {
154
+ return JSON.parse(JSON.stringify(this.doc))
155
+ }
156
+
157
+ setDocument(input) {
158
+ this.stopEditing()
159
+ this.history.record(JSON.stringify(this.doc))
160
+ this.doc = normalize(input)
161
+ this.selectedId = this.doc.id
162
+ this.renderAll()
163
+ }
164
+
165
+ async export() {
166
+ const mjml = toMjml(this.doc)
167
+ const { html, errors } = await compile(mjml, { url: this.compileUrlValue })
168
+ return { json: JSON.stringify(this.doc), mjml, html: html || "", errors }
169
+ }
170
+
171
+ // --- locking ------------------------------------------------------------------
172
+
173
+ isFree(id) {
174
+ return id !== this.doc.id && isFree(this.doc, this.lock, id)
175
+ }
176
+
177
+ canFill(parentId) {
178
+ return canFill(this.doc, this.lock, parentId)
179
+ }
180
+
181
+ canEditField(id, key) {
182
+ return canEditField(this.doc, this.lock, id, key)
183
+ }
184
+
185
+ // The block types that may be added to the node: none when it is locked.
186
+ accepted(parentId) {
187
+ return this.canFill(parentId) ? accepted(this.doc, parentId) : []
188
+ }
189
+
190
+ // --- layout -------------------------------------------------------------------
191
+
192
+ build() {
193
+ this.blocksElement = h("div", { class: "flex-1 overflow-y-auto pb-4" })
194
+ this.treeElement = h("div", { class: "flex-1 overflow-y-auto" })
195
+ this.leftTabs = h("div", { class: "flex border-b border-gray-200 px-2 shrink-0" })
196
+ this.inspectorElement = h("div", { class: "flex flex-1 flex-col min-h-0", dataset: { tessEditPanel: "inspect" } })
197
+ this.sidePanel = h("aside")
198
+ this.inspectPanel = h("aside")
199
+ this.textToolbar = h("div", { class: "hidden absolute left-1/2 top-3 z-20 -translate-x-1/2 flex items-center gap-0.5 rounded-lg border border-gray-200 bg-white px-1.5 py-1 shadow-md" })
200
+ this.frame = h("iframe", { class: "block h-full w-full border-0 bg-white transition-[width] duration-200", title: "Email canvas" })
201
+ this.frameWrap = h("div", { class: "relative h-full mx-auto" }, this.frame)
202
+ this.topBar = h("div", { class: "flex items-center gap-1 h-12 px-3 border-b border-gray-200 bg-white shrink-0" })
203
+ this.body = h("div", { class: "flex flex-1 min-h-0" })
204
+
205
+ this.root = h(
206
+ "div",
207
+ { class: "tess-edit relative flex flex-col h-full min-h-[560px] overflow-hidden rounded-lg border border-gray-200 bg-gray-50 text-sm text-gray-800 antialiased", tabindex: "-1", onkeydown: (event) => this.onKeydown(event) },
208
+ this.topBar,
209
+ this.body
210
+ )
211
+ this.errorBar = h("div", { class: "hidden absolute inset-x-3 bottom-3 z-20 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 shadow-sm", role: "alert" })
212
+ this.main = h("main", { class: "relative flex-1 min-w-0 bg-gray-100", ondragover: (event) => this.drag && event.preventDefault() }, this.textToolbar, this.frameWrap, this.errorBar)
213
+ this.arrangePanels()
214
+ this.element.append(this.root)
215
+
216
+ this.canvas = new Canvas(this, this.frame)
217
+ this.tree = new Tree(this, this.treeElement)
218
+ this.inspector = new Inspector(this, this.inspectorElement)
219
+ this.textEditing = new TextEditing(this.textToolbar)
220
+ renderBlocksPanel(this, this.blocksElement)
221
+ this.observeWidth()
222
+
223
+ // The insert menu closes on a mousedown outside it (here or in the canvas,
224
+ // see Canvas#listen), on Escape anywhere, and when the window resizes.
225
+ this.outsideClick = (event) => {
226
+ if (this.menu && !this.menu.contains(event.target)) this.closeMenu({ outside: true })
227
+ }
228
+ this.menuKeydown = (event) => {
229
+ if (this.menu && event.key === "Escape") {
230
+ event.preventDefault()
231
+ event.stopPropagation()
232
+ this.closeMenu()
233
+ }
234
+ }
235
+ this.closeMenuOnResize = () => this.closeMenu()
236
+ document.addEventListener("mousedown", this.outsideClick)
237
+ document.addEventListener("keydown", this.menuKeydown, true)
238
+ window.addEventListener("resize", this.closeMenuOnResize)
239
+ }
240
+
241
+ // Split: Blocks and Layers on the left, Inspect on the right. Left or right:
242
+ // one sidebar on that side with Blocks, Layers and Inspect as tabs.
243
+ arrangePanels() {
244
+ const panelClass = (border, width) => `${width} shrink-0 flex flex-col ${border} border-gray-200 bg-white min-h-0`
245
+ // Only the sidebars move: moving <main> would reload the canvas iframe and blank it.
246
+ if (!this.main.isConnected) this.body.append(this.main)
247
+ this.sidePanel.replaceChildren(this.leftTabs, this.blocksElement, this.treeElement)
248
+ if (this.layout === "split") {
249
+ this.sidePanel.className = panelClass("border-r", "w-64")
250
+ this.inspectPanel.className = panelClass("border-l", "w-80")
251
+ this.inspectPanel.replaceChildren(this.inspectorElement)
252
+ this.main.before(this.sidePanel)
253
+ this.main.after(this.inspectPanel)
254
+ } else {
255
+ this.sidePanel.className = panelClass(this.layout === "left" ? "border-r" : "border-l", "w-80")
256
+ this.sidePanel.append(this.inspectorElement)
257
+ this.inspectPanel.remove()
258
+ this.layout === "left" ? this.main.before(this.sidePanel) : this.main.after(this.sidePanel)
259
+ }
260
+ }
261
+
262
+ setPanels(panels) {
263
+ if (!PANELS.includes(panels)) return
264
+ this.panels = panels
265
+ try {
266
+ localStorage.setItem(PANELS_KEY, panels)
267
+ } catch {}
268
+ this.applyPanels()
269
+ }
270
+
271
+ applyPanels() {
272
+ if (!this.combined && this.leftTab === "inspect") this.leftTab = "blocks"
273
+ this.arrangePanels()
274
+ this.renderTopBar()
275
+ this.renderLeftTabs()
276
+ }
277
+
278
+ // Combines the panels while the editor is narrower than NARROW_WIDTH.
279
+ observeWidth() {
280
+ if (typeof ResizeObserver === "undefined") return
281
+ this.resizeObserver = new ResizeObserver(([entry]) => {
282
+ const narrow = entry.contentRect.width < NARROW_WIDTH
283
+ if (narrow === this.narrow) return
284
+ this.narrow = narrow
285
+ this.applyPanels()
286
+ this.canvas.refresh()
287
+ })
288
+ this.resizeObserver.observe(this.root)
289
+ }
290
+
291
+ // The layout in use: the chosen one, unless the editor is too narrow to split.
292
+ get layout() {
293
+ return this.narrow && this.panels === "split" ? "left" : this.panels
294
+ }
295
+
296
+ storedPanels() {
297
+ try {
298
+ return localStorage.getItem(PANELS_KEY)
299
+ } catch {
300
+ return null
301
+ }
302
+ }
303
+
304
+ get combined() {
305
+ return this.layout !== "split"
306
+ }
307
+
308
+ renderAll() {
309
+ this.renderTopBar()
310
+ this.renderLeftTabs()
311
+ this.tree.render()
312
+ this.inspector.render()
313
+ this.scheduleCanvas()
314
+ this.scheduleOutputs()
315
+ }
316
+
317
+ renderTopBar() {
318
+ const toggle = (current, options, onchange) =>
319
+ h(
320
+ "div",
321
+ { class: "flex rounded-lg bg-gray-100 p-0.5" },
322
+ options.map(([value, name, title, disabled = false]) =>
323
+ h("button", {
324
+ type: "button",
325
+ title,
326
+ disabled,
327
+ class: `w-8 h-7 flex items-center justify-center rounded-md disabled:opacity-40 disabled:pointer-events-none ${current === value ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-800"}`,
328
+ html: icon(name),
329
+ onclick: () => onchange(value)
330
+ })
331
+ )
332
+ )
333
+
334
+ this.topBar.replaceChildren(
335
+ h("span", { class: "font-semibold text-gray-900 mr-3" }, "Email"),
336
+ toggle(this.mode, [["edit", "edit", "Edit"], ["preview", "eye", "Preview the email as sent, with Liquid tags"]], (mode) => this.setMode(mode)),
337
+ toggle(this.device, [["desktop", "desktop", "Desktop"], ["mobile", "mobile", "Mobile"]], (device) => this.setDevice(device)),
338
+ h("div", { class: "flex-1" }),
339
+ toggle(
340
+ this.layout,
341
+ [
342
+ ["left", "panels-left", "All panels on the left"],
343
+ ["split", "panels-split", this.narrow ? "Too narrow to split the panels" : "Blocks on the left, Inspect on the right", this.narrow],
344
+ ["right", "panels-right", "All panels on the right"]
345
+ ],
346
+ (panels) => this.setPanels(panels)
347
+ ),
348
+ h("span", { class: "mx-1 h-5 w-px bg-gray-200" }),
349
+ h("button", { type: "button", title: "Undo (⌘Z)", class: TOOL, disabled: !this.history.canUndo, html: icon("undo"), onclick: () => this.undo() }),
350
+ h("button", { type: "button", title: "Redo (⇧⌘Z)", class: TOOL, disabled: !this.history.canRedo, html: icon("redo"), onclick: () => this.redo() }),
351
+ h(
352
+ "button",
353
+ { type: "button", class: "ml-2 flex items-center gap-1.5 h-8 rounded-md bg-gray-900 px-3 text-[13px] font-medium text-white hover:bg-gray-700", onclick: () => this.openCodeDialog() },
354
+ h("span", { html: icon("export") }),
355
+ "Code"
356
+ )
357
+ )
358
+ }
359
+
360
+ renderLeftTabs() {
361
+ const tab = (key, name) =>
362
+ h("button", { type: "button", class: `${TAB} ${this.leftTab === key ? "border-blue-600 text-blue-700" : "border-transparent text-gray-500 hover:text-gray-800"}`, onclick: () => this.setLeftTab(key) }, name)
363
+ // replaceChildren would turn a null into the text "null", so only pass real tabs.
364
+ const tabs = [tab("blocks", "Blocks"), tab("layers", "Layers")]
365
+ if (this.combined) tabs.push(tab("inspect", "Inspect"))
366
+ this.leftTabs.replaceChildren(...tabs)
367
+ this.blocksElement.classList.toggle("hidden", this.leftTab !== "blocks")
368
+ this.treeElement.classList.toggle("hidden", this.leftTab !== "layers")
369
+ this.inspectorElement.classList.toggle("hidden", this.combined && this.leftTab !== "inspect")
370
+ }
371
+
372
+ setLeftTab(tab) {
373
+ this.leftTab = tab
374
+ this.renderLeftTabs()
375
+ }
376
+
377
+ setMode(mode) {
378
+ this.stopEditing()
379
+ this.mode = mode
380
+ this.renderTopBar()
381
+ this.renderCanvas()
382
+ }
383
+
384
+ setDevice(device) {
385
+ this.device = device
386
+ // Desktop fills the canvas area; mobile shows a full-height 375px frame in the middle of it.
387
+ const mobile = device === "mobile"
388
+ this.frameWrap.style.width = mobile ? "375px" : ""
389
+ this.frameWrap.classList.toggle("shadow-sm", mobile)
390
+ this.main.classList.toggle("px-4", mobile)
391
+ this.renderTopBar()
392
+ setTimeout(() => this.canvas.refresh(), 250)
393
+ }
394
+
395
+ // --- rendering --------------------------------------------------------------
396
+
397
+ // Compiles on the server. A newer request on the same channel aborts the
398
+ // older one, which then resolves to null.
399
+ async compileMjml(mjml, channel) {
400
+ this.requests[channel]?.abort()
401
+ const request = (this.requests[channel] = new AbortController())
402
+ const result = await compile(mjml, { url: this.compileUrlValue, signal: request.signal })
403
+ return request.signal.aborted ? null : result
404
+ }
405
+
406
+ async renderCanvas() {
407
+ if (this.editingId) return
408
+ const result = await this.compileMjml(toMjml(this.doc, { editor: this.mode === "edit" }), "canvas")
409
+ if (!result || this.editingId) return
410
+ this.showErrors(result.errors)
411
+ // Without HTML the canvas keeps the last email that compiled.
412
+ if (result.html !== null) this.canvas.write(result.html, this.mode)
413
+ }
414
+
415
+ showErrors(errors) {
416
+ this.errorBar.textContent = errors.length ? `The email could not be compiled: ${errors.join("; ")}` : ""
417
+ this.errorBar.classList.toggle("hidden", !errors.length)
418
+ }
419
+
420
+ async writeOutputs() {
421
+ const json = JSON.stringify(this.doc)
422
+ const mjml = toMjml(this.doc)
423
+ const result = await this.compileMjml(mjml, "outputs")
424
+ if (!result) return
425
+ const html = result.html || ""
426
+ if (this.hasJsonInputTarget) this.jsonInputTarget.value = json
427
+ if (this.hasMjmlInputTarget) this.mjmlInputTarget.value = mjml
428
+ if (this.hasHtmlInputTarget) this.htmlInputTarget.value = html
429
+ this.dispatch("change", { detail: { json, mjml, html, errors: result.errors, document: this.doc } })
430
+ }
431
+
432
+ // Applies a change to the document as one undo step.
433
+ commit(mutate, { coalesce = null, skipInspector = false } = {}) {
434
+ const before = JSON.stringify(this.doc)
435
+ mutate(this.doc)
436
+ if (JSON.stringify(this.doc) === before) return
437
+ this.history.record(before, coalesce)
438
+ this.renderTopBar()
439
+ this.tree.render()
440
+ if (!skipInspector) this.inspector.render()
441
+ this.scheduleCanvas()
442
+ this.scheduleOutputs()
443
+ }
444
+
445
+ // --- selection and editing ---------------------------------------------------
446
+
447
+ // `reveal` (a click on the canvas) shows the Inspect tab when the panels are combined.
448
+ select(id, { reveal = false } = {}) {
449
+ if (reveal && this.combined && this.leftTab !== "inspect") this.setLeftTab("inspect")
450
+ if (this.editingId && this.editingId !== id) this.stopEditing()
451
+ if (!find(this.doc, id)) id = this.doc.id
452
+ if (this.selectedId === id) return
453
+ this.selectedId = id
454
+ for (const node of ancestors(this.doc, id)) this.tree.collapsed.delete(node.id)
455
+ this.tree.render()
456
+ this.inspector.render()
457
+ this.canvas.refresh()
458
+ }
459
+
460
+ selectParent() {
461
+ const parent = find(this.doc, this.selectedId)?.parent
462
+ if (parent) this.select(parent.id)
463
+ }
464
+
465
+ setValue(id, key, value, { coalesce = true } = {}) {
466
+ if (!this.canEditField(id, key)) return
467
+ if (this.editingId) this.stopEditing()
468
+ this.commit((doc) => setValue(doc, id, key, value), { coalesce: coalesce ? `${id}:${key}` : null, skipInspector: true })
469
+ }
470
+
471
+ duplicate(id) {
472
+ if (!this.isFree(id)) return
473
+ let copy
474
+ this.commit((doc) => (copy = duplicate(doc, id)))
475
+ if (copy) this.select(copy.id)
476
+ }
477
+
478
+ remove(id) {
479
+ const hit = find(this.doc, id)
480
+ if (!hit?.parent || !this.isFree(id)) return
481
+ this.stopEditing()
482
+ this.commit((doc) => remove(doc, id))
483
+ const siblings = hit.parent.children
484
+ this.select(siblings[Math.min(hit.index, siblings.length - 1)]?.id || hit.parent.id)
485
+ }
486
+
487
+ startEditing(id) {
488
+ const hit = find(this.doc, id)
489
+ const key = hit && getBlock(hit.node.type)?.richText
490
+ if (!key || this.mode !== "edit" || !this.isFree(id)) return
491
+ if (this.editingId === id) return
492
+ this.stopEditing()
493
+ this.select(id)
494
+ const container = this.canvas.textContainer(id)
495
+ if (!container) return
496
+ this.editingId = id
497
+ this.editSnapshot = JSON.stringify(this.doc)
498
+ this.textEditing.start(container, getValue(hit.node, key), (json) => {
499
+ setValue(this.doc, id, key, json)
500
+ this.tree.render()
501
+ this.canvas.refresh()
502
+ this.scheduleOutputs()
503
+ })
504
+ this.canvas.refresh()
505
+ }
506
+
507
+ stopEditing() {
508
+ if (!this.editingId) return
509
+ this.textEditing.stop()
510
+ this.editingId = null
511
+ if (this.editSnapshot !== JSON.stringify(this.doc)) this.history.record(this.editSnapshot)
512
+ this.editSnapshot = null
513
+ this.renderTopBar()
514
+ this.inspector.render()
515
+ this.renderCanvas()
516
+ }
517
+
518
+ undo() {
519
+ this.stopEditing()
520
+ const snapshot = this.history.undo(JSON.stringify(this.doc))
521
+ if (snapshot) this.restore(snapshot)
522
+ }
523
+
524
+ redo() {
525
+ this.stopEditing()
526
+ const snapshot = this.history.redo(JSON.stringify(this.doc))
527
+ if (snapshot) this.restore(snapshot)
528
+ }
529
+
530
+ restore(snapshot) {
531
+ this.doc = JSON.parse(snapshot)
532
+ if (!find(this.doc, this.selectedId)) this.selectedId = this.doc.id
533
+ this.renderAll()
534
+ }
535
+
536
+ // --- adding and moving blocks ------------------------------------------------
537
+
538
+ startDrag(drag) {
539
+ if (drag.kind === "move" && !this.isFree(drag.id)) return
540
+ this.closeMenu()
541
+ if (this.editingId) this.stopEditing()
542
+ this.drag = drag
543
+ this.canvas.setHover(null)
544
+ }
545
+
546
+ endDrag() {
547
+ this.drag = null
548
+ this.canvas.hideIndicator()
549
+ this.tree.clearMarker()
550
+ }
551
+
552
+ drop(drag, parentId, index) {
553
+ if (!drag || !this.canFill(parentId) || (drag.kind === "move" && !this.isFree(drag.id))) return
554
+ if (drag.kind === "new") {
555
+ const node = createFromPalette(drag.key)
556
+ this.commit((doc) => insert(doc, parentId, index, node))
557
+ this.select(node.id)
558
+ } else {
559
+ this.commit((doc) => move(doc, drag.id, parentId, index))
560
+ this.select(drag.id)
561
+ }
562
+ }
563
+
564
+ insertAt(key, parentId, index) {
565
+ if (!this.canFill(parentId)) return
566
+ const node = createFromPalette(key)
567
+ this.commit((doc) => insert(doc, parentId, index, node))
568
+ this.select(node.id)
569
+ this.leftTab === "layers" && this.tree.render()
570
+ }
571
+
572
+ // Clicking a tile: add inside the selection when it fits, otherwise after the
573
+ // nearest ancestor that fits, otherwise at the end of the root, or with a
574
+ // lock, at the end of the first unlocked block it fits in.
575
+ addFromPalette(key) {
576
+ const type = itemType(key)
577
+ const chain = [find(this.doc, this.selectedId)?.node || this.doc, ...ancestors(this.doc, this.selectedId)]
578
+ if (this.accepted(chain[0].id).includes(type)) return this.insertAt(key, chain[0].id, chain[0].children.length)
579
+ for (let i = 0; i < chain.length - 1; i++) {
580
+ if (this.accepted(chain[i + 1].id).includes(type)) return this.insertAt(key, chain[i + 1].id, chain[i + 1].children.indexOf(chain[i]) + 1)
581
+ }
582
+ if (this.accepted(this.doc.id).includes(type)) return this.insertAt(key, this.doc.id, this.doc.children.length)
583
+ if (this.lock) {
584
+ let target = null
585
+ walk(this.doc, (node) => {
586
+ if (target) return false
587
+ if (this.lock.except.includes(node.type) && this.accepted(node.id).includes(type)) target = node
588
+ })
589
+ if (target) return this.insertAt(key, target.id, target.children.length)
590
+ }
591
+ this.flash(`${getBlock(type)?.name || key} has to go inside a column. Select one first, or drag it onto the canvas.`)
592
+ }
593
+
594
+ openInsertMenu(parentId, index, rect) {
595
+ const anchor = `${parentId}:${index}`
596
+ // A second click on the same plus button or placeholder closes the menu.
597
+ // Its mousedown already closed it as an outside click, so check for that.
598
+ const closed = this.menuClosed
599
+ if (this.menu?.dataset.anchor === anchor || (closed?.anchor === anchor && Date.now() - closed.at < 500)) {
600
+ this.closeMenu()
601
+ this.menuClosed = null
602
+ return
603
+ }
604
+ this.closeMenu()
605
+ const allowed = this.accepted(parentId)
606
+ const items = paletteItems().filter((item) => allowed.includes(item.type))
607
+ if (!items.length) return
608
+ this.menu = h(
609
+ "div",
610
+ { class: "fixed z-50 w-56 max-h-80 overflow-y-auto rounded-lg border border-gray-200 bg-white p-1 shadow-lg", role: "menu", "data-anchor": anchor, "data-tess-edit-menu": "insert" },
611
+ h("div", { class: "px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-gray-500" }, "Add block"),
612
+ items.map((item) =>
613
+ h(
614
+ "button",
615
+ {
616
+ type: "button",
617
+ role: "menuitem",
618
+ class: "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] text-gray-700 hover:bg-blue-50 hover:text-blue-700",
619
+ onclick: () => {
620
+ this.closeMenu()
621
+ this.insertAt(item.key, parentId, index)
622
+ }
623
+ },
624
+ h("span", { class: "text-gray-400", html: icon(item.icon) }),
625
+ item.label
626
+ )
627
+ )
628
+ )
629
+ document.body.append(this.menu)
630
+ const width = 224
631
+ const left = Math.min(Math.max(8, rect.left + rect.width / 2 - width / 2), window.innerWidth - width - 8)
632
+ const below = rect.bottom + 6
633
+ const height = Math.min(this.menu.offsetHeight, 320)
634
+ const top = below + height > window.innerHeight - 8 ? Math.max(8, rect.top - height - 6) : below
635
+ Object.assign(this.menu.style, { left: `${left}px`, top: `${top}px` })
636
+ }
637
+
638
+ closeMenu({ outside = false } = {}) {
639
+ if (this.menu && outside) this.menuClosed = { anchor: this.menu.dataset.anchor, at: Date.now() }
640
+ this.menu?.remove()
641
+ this.menu = null
642
+ }
643
+
644
+ flash(message) {
645
+ const note = h("div", { class: "absolute bottom-4 left-1/2 z-30 -translate-x-1/2 rounded-md bg-gray-900 px-3 py-2 text-xs text-white shadow-lg" }, message)
646
+ this.root.append(note)
647
+ setTimeout(() => note.remove(), 3500)
648
+ }
649
+
650
+ // --- keyboard -----------------------------------------------------------------
651
+
652
+ onKeydown(event) {
653
+ const target = event.target
654
+ const typing = target.closest?.("input, textarea, select, [contenteditable='true']")
655
+ const mod = event.metaKey || event.ctrlKey
656
+
657
+ if (this.menu && event.key === "Escape") {
658
+ event.preventDefault()
659
+ this.closeMenu()
660
+ return
661
+ }
662
+ if (this.editingId) {
663
+ if (event.key === "Escape") {
664
+ event.preventDefault()
665
+ this.stopEditing()
666
+ }
667
+ return
668
+ }
669
+ if (typing) return
670
+ if (mod && event.key.toLowerCase() === "z") {
671
+ event.preventDefault()
672
+ event.shiftKey ? this.redo() : this.undo()
673
+ } else if (mod && event.key.toLowerCase() === "y") {
674
+ event.preventDefault()
675
+ this.redo()
676
+ } else if (mod && event.key.toLowerCase() === "d") {
677
+ event.preventDefault()
678
+ if (this.selectedId !== this.doc.id) this.duplicate(this.selectedId)
679
+ } else if (event.key === "Delete" || event.key === "Backspace") {
680
+ if (this.selectedId !== this.doc.id) {
681
+ event.preventDefault()
682
+ this.remove(this.selectedId)
683
+ }
684
+ } else if (event.key === "Enter" && getBlock(find(this.doc, this.selectedId)?.node.type)?.richText) {
685
+ event.preventDefault()
686
+ this.startEditing(this.selectedId)
687
+ } else if (event.key === "Escape") {
688
+ this.closeMenu()
689
+ this.selectParent()
690
+ }
691
+ }
692
+
693
+ // --- code dialog ----------------------------------------------------------------
694
+
695
+ async openCodeDialog(tab = "json") {
696
+ this.stopEditing()
697
+ const { json, mjml, html, errors } = await this.export()
698
+ const contents = { json: JSON.stringify(JSON.parse(json), null, 2), mjml, html }
699
+ const textarea = h("textarea", { class: "flex-1 w-full resize-none rounded-md border border-gray-300 p-3 font-mono text-xs text-gray-800 outline-none focus:border-blue-500", spellcheck: false })
700
+ const status = h("span", { class: "text-xs text-gray-500" })
701
+ const tabs = h("div", { class: "flex gap-1" })
702
+ const apply = h("button", { type: "button", class: "rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700", onclick: () => {
703
+ try {
704
+ this.setDocument(textarea.value)
705
+ close()
706
+ } catch (error) {
707
+ status.textContent = error.message
708
+ status.className = "text-xs text-red-600"
709
+ }
710
+ } }, "Load this JSON")
711
+
712
+ const show = (key) => {
713
+ tab = key
714
+ textarea.value = contents[key]
715
+ // Loading JSON could replace locked parts, so a locked editor only shows it.
716
+ const loadable = key === "json" && !this.lock
717
+ textarea.readOnly = !loadable
718
+ apply.classList.toggle("hidden", !loadable)
719
+ status.className = "text-xs text-gray-500"
720
+ status.textContent =
721
+ key === "json" ? (this.lock ? "The document. Parts of it are locked, so it can't be loaded here." : "The document. Edit and load it, or paste one in.") : key === "mjml" ? "Liquid loops and conditions are in <mj-raw> tags." : errors.length ? `The MJML could not be compiled: ${errors.join("; ")}` : "Liquid is left in place for rendering at send time."
722
+ tabs.replaceChildren(
723
+ ...["json", "mjml", "html"].map((name) =>
724
+ h("button", { type: "button", class: `rounded-md px-3 py-1.5 text-xs font-medium ${name === tab ? "bg-gray-900 text-white" : "text-gray-600 hover:bg-gray-100"}`, onclick: () => show(name) }, name.toUpperCase())
725
+ )
726
+ )
727
+ }
728
+
729
+ const close = () => dialog.remove()
730
+ const download = () => {
731
+ const blob = new Blob([textarea.value], { type: tab === "json" ? "application/json" : tab === "html" ? "text/html" : "text/plain" })
732
+ const link = h("a", { href: URL.createObjectURL(blob), download: `email.${tab}` })
733
+ link.click()
734
+ URL.revokeObjectURL(link.href)
735
+ }
736
+
737
+ const dialog = h(
738
+ "div",
739
+ { class: "absolute inset-0 z-40 flex items-center justify-center bg-gray-900/40 p-6", onmousedown: (event) => event.target === dialog && close(), onkeydown: (event) => event.key === "Escape" && close() },
740
+ h(
741
+ "div",
742
+ { class: "flex h-full max-h-[720px] w-full max-w-4xl flex-col gap-3 rounded-xl bg-white p-4 shadow-xl" },
743
+ h("div", { class: "flex items-center gap-3" }, tabs, status, h("button", { type: "button", class: "ml-auto " + TOOL, title: "Close", html: icon("close"), onclick: close })),
744
+ textarea,
745
+ h(
746
+ "div",
747
+ { class: "flex items-center justify-end gap-2" },
748
+ h("button", { type: "button", class: "rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50", onclick: () => navigator.clipboard?.writeText(textarea.value).then(() => (status.textContent = "Copied.")) }, "Copy"),
749
+ h("button", { type: "button", class: "rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50", onclick: download }, "Download"),
750
+ apply
751
+ )
752
+ )
753
+ )
754
+ show(tab)
755
+ this.root.append(dialog)
756
+ textarea.focus()
757
+ }
758
+ }