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,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
|
+
}
|