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,111 @@
1
+ // Turns Tiptap/ProseMirror JSON into email-friendly HTML. Liquid ({{ … }} and
2
+ // {% … %}) is copied verbatim so quotes and comparison operators keep working.
3
+
4
+ const LIQUID = /(\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\})/g
5
+
6
+ export function escapeText(text) {
7
+ return String(text ?? "")
8
+ .split(LIQUID)
9
+ .map((part, i) => (i % 2 ? part : part.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")))
10
+ .join("")
11
+ }
12
+
13
+ export function escapeAttribute(value) {
14
+ return String(value ?? "")
15
+ .split(LIQUID)
16
+ .map((part, i) => (i % 2 ? part.replace(/"/g, "'") : part.replace(/&(?!(?:[a-z]+|#\d+|#x[0-9a-f]+);)/gi, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;")))
17
+ .join("")
18
+ }
19
+
20
+ function style(declarations) {
21
+ const css = Object.entries(declarations)
22
+ .filter(([, value]) => value !== undefined && value !== null && value !== "")
23
+ .map(([name, value]) => `${name}:${value}`)
24
+ .join(";")
25
+ return css ? ` style="${escapeAttribute(css)}"` : ""
26
+ }
27
+
28
+ function wrapMark(mark, html) {
29
+ const attrs = mark.attrs || {}
30
+ switch (mark.type) {
31
+ case "bold":
32
+ return `<strong>${html}</strong>`
33
+ case "italic":
34
+ return `<em>${html}</em>`
35
+ case "underline":
36
+ return `<u>${html}</u>`
37
+ case "strike":
38
+ return `<s>${html}</s>`
39
+ case "code":
40
+ return `<code>${html}</code>`
41
+ case "link": {
42
+ const target = attrs.target ? ` target="${escapeAttribute(attrs.target)}"` : ""
43
+ return `<a href="${escapeAttribute(attrs.href || "#")}"${target} style="color:inherit;">${html}</a>`
44
+ }
45
+ case "textStyle": {
46
+ const css = style({ color: attrs.color, "font-size": attrs.fontSize, "font-family": attrs.fontFamily, "background-color": attrs.backgroundColor })
47
+ return css ? `<span${css}>${html}</span>` : html
48
+ }
49
+ case "highlight":
50
+ return `<mark${style({ "background-color": attrs.color })}>${html}</mark>`
51
+ default:
52
+ return html
53
+ }
54
+ }
55
+
56
+ function inline(nodes = []) {
57
+ return nodes
58
+ .map((node) => {
59
+ if (node.type === "hardBreak") return "<br>"
60
+ if (node.type !== "text") return render(node)
61
+ // Apply marks so the link ends up outermost, matching how editors nest them.
62
+ const marks = [...(node.marks || [])].sort((a, b) => (a.type === "link") - (b.type === "link"))
63
+ return marks.reduce((html, mark) => wrapMark(mark, html), escapeText(node.text))
64
+ })
65
+ .join("")
66
+ }
67
+
68
+ function blockStyle(node, base = {}) {
69
+ return style({ ...base, "text-align": node.attrs?.textAlign && node.attrs.textAlign !== "left" ? node.attrs.textAlign : undefined })
70
+ }
71
+
72
+ const HEADING_SIZES = { 1: "2em", 2: "1.5em", 3: "1.25em", 4: "1.1em" }
73
+
74
+ function render(node) {
75
+ switch (node.type) {
76
+ case "doc":
77
+ return (node.content || []).map(render).join("")
78
+ case "paragraph": {
79
+ const body = inline(node.content)
80
+ return `<p${blockStyle(node, { margin: "0" })}>${body || "<br>"}</p>`
81
+ }
82
+ case "heading": {
83
+ const level = node.attrs?.level || 1
84
+ return `<h${level}${blockStyle(node, { margin: "0", "font-size": HEADING_SIZES[level], "line-height": "1.25" })}>${inline(node.content)}</h${level}>`
85
+ }
86
+ case "bulletList":
87
+ return `<ul style="margin:0;padding-left:1.5em;">${(node.content || []).map(render).join("")}</ul>`
88
+ case "orderedList": {
89
+ const start = node.attrs?.start && node.attrs.start !== 1 ? ` start="${node.attrs.start}"` : ""
90
+ return `<ol${start} style="margin:0;padding-left:1.5em;">${(node.content || []).map(render).join("")}</ol>`
91
+ }
92
+ case "listItem":
93
+ return `<li>${(node.content || []).map((child) => (child.type === "paragraph" ? inline(child.content) : render(child))).join("")}</li>`
94
+ case "blockquote":
95
+ return `<blockquote style="margin:0;padding-left:1em;border-left:3px solid #e5e7eb;">${(node.content || []).map(render).join("")}</blockquote>`
96
+ case "horizontalRule":
97
+ return '<hr style="border:none;border-top:1px solid #e5e7eb;margin:0.5em 0;">'
98
+ case "hardBreak":
99
+ return "<br>"
100
+ case "text":
101
+ return inline([node])
102
+ default:
103
+ return (node.content || []).map(render).join("")
104
+ }
105
+ }
106
+
107
+ export function richTextToHtml(content) {
108
+ if (!content) return ""
109
+ if (typeof content === "string") return content
110
+ return render(content)
111
+ }
@@ -0,0 +1,21 @@
1
+ // Entry point. In the host app:
2
+ //
3
+ // import { application } from "controllers/application"
4
+ // import { registerTessEdit } from "tess_edit"
5
+ // registerTessEdit(application)
6
+
7
+ import EditorController from "tess_edit/controllers/editor_controller"
8
+
9
+ export { EditorController }
10
+ export { toMjml } from "tess_edit/export/mjml"
11
+ export { compile } from "tess_edit/export/compile"
12
+ export { richTextToHtml } from "tess_edit/export/richtext"
13
+ export { createDocument, createNode, normalize } from "tess_edit/core/document"
14
+ export { registerBlock, registerCategory, getBlock, blockTypes, paletteItems } from "tess_edit/core/registry"
15
+ export * as fields from "tess_edit/blocks/fields"
16
+ export { imageField } from "tess_edit/core/media"
17
+ export { starterDocument } from "tess_edit/core/starter"
18
+
19
+ export function registerTessEdit(application, identifier = "tess-edit") {
20
+ application.register(identifier, EditorController)
21
+ }
@@ -0,0 +1,44 @@
1
+ // The Blocks panel: draggable tiles, grouped by category. Clicking a tile adds
2
+ // it after the selected block (or at the end of the page) when that is allowed.
3
+
4
+ import { categories, paletteItems } from "tess_edit/core/registry"
5
+ import { h } from "tess_edit/ui/dom"
6
+ import { icon } from "tess_edit/ui/icons"
7
+
8
+ export function renderBlocksPanel(editor, element) {
9
+ const items = paletteItems()
10
+ const groups = categories().map((category) =>
11
+ h(
12
+ "section",
13
+ { class: "px-3 pt-3" },
14
+ h("h3", { class: "mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500" }, category.label),
15
+ h(
16
+ "div",
17
+ { class: "grid grid-cols-3 gap-1.5" },
18
+ items
19
+ .filter((item) => item.category === category.key)
20
+ .map((item) =>
21
+ h(
22
+ "button",
23
+ {
24
+ type: "button",
25
+ draggable: true,
26
+ title: `Drag onto the canvas, or click to add`,
27
+ class: `flex flex-col items-center justify-center gap-1 h-16 rounded-lg border border-gray-200 bg-white text-gray-600 hover:border-blue-400 hover:text-blue-600 hover:shadow-sm cursor-grab active:cursor-grabbing ${category.key === "liquid" ? "text-amber-700" : ""}`,
28
+ ondragstart: (event) => {
29
+ event.dataTransfer.effectAllowed = "copy"
30
+ event.dataTransfer.setData("text/plain", item.key)
31
+ editor.startDrag({ kind: "new", key: item.key, type: item.type })
32
+ },
33
+ ondragend: () => editor.endDrag(),
34
+ onclick: () => editor.addFromPalette(item.key)
35
+ },
36
+ h("span", { html: icon(item.icon, "w-5 h-5") }),
37
+ h("span", { class: "text-[11px] leading-none" }, item.label)
38
+ )
39
+ )
40
+ )
41
+ )
42
+ )
43
+ element.replaceChildren(...groups, h("p", { class: "px-3 py-4 text-xs text-gray-500" }, "Drag blocks onto the canvas or into the Layers tree. Liquid blocks wrap other blocks in {% for %} or {% if %}."))
44
+ }
@@ -0,0 +1,437 @@
1
+ // The editing canvas: an iframe showing the compiled email, with an overlay for
2
+ // hover and selection outlines, block tools, plus buttons and drop indicators.
3
+ // Elements are mapped back to blocks through the "tess-<id>" classes that the
4
+ // editor-mode MJML puts on every element.
5
+
6
+ import { ancestors, canInsert, find, isDescendant, isHorizontal } from "tess_edit/core/document"
7
+ import { imageFields } from "tess_edit/core/media"
8
+ import { getBlock, label } from "tess_edit/core/registry"
9
+ import { icon } from "tess_edit/ui/icons"
10
+
11
+ const OVERLAY_CSS = `
12
+ html{scrollbar-width:thin;scrollbar-color:rgba(100,116,139,.35) transparent}
13
+ body{min-height:100vh;margin:0}
14
+ #tess-overlay{position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;z-index:2147483000;font:11px/1.2 system-ui,-apple-system,sans-serif}
15
+ .tess-box{position:absolute;box-sizing:border-box;pointer-events:none}
16
+ .tess-hover{outline:1px dashed #3b82f6;outline-offset:-1px}
17
+ .tess-selected{outline:2px solid #2563eb;outline-offset:-2px}
18
+ .tess-selected.tess-editing{outline-color:#f59e0b}
19
+ .tess-label{position:absolute;bottom:100%;left:0;background:#2563eb;color:#fff;padding:3px 6px;border-radius:4px 4px 0 0;white-space:nowrap}
20
+ .tess-hover .tess-label{background:#60a5fa}
21
+ .tess-editing .tess-label{background:#f59e0b}
22
+ .tess-inside .tess-label,.tess-inside .tess-tools{bottom:auto;top:0;border-radius:0 0 4px 4px}
23
+ .tess-tools{position:absolute;bottom:100%;right:0;display:flex;gap:1px;pointer-events:auto;background:#2563eb;border-radius:4px 4px 0 0;padding:1px}
24
+ .tess-tools [data-tool]{cursor:pointer;color:#fff;width:22px;height:20px;display:flex;align-items:center;justify-content:center;border-radius:3px;background:none;border:0;padding:0}
25
+ .tess-tools [data-tool]:hover{background:#1d4ed8}
26
+ .tess-tools [data-tool="drag"]{cursor:grab}
27
+ .tess-plus{position:absolute;left:50%;top:100%;transform:translate(-50%,-50%);pointer-events:auto;width:20px;height:20px;border-radius:999px;background:#2563eb;color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer;border:2px solid #fff;padding:0;box-shadow:0 1px 3px rgba(0,0,0,.25)}
28
+ .tess-plus:hover{background:#1d4ed8}
29
+ .tess-indicator{position:absolute;background:#2563eb;border-radius:2px;pointer-events:none;box-shadow:0 0 0 2px rgba(37,99,235,.2)}
30
+ .tess-indicator.tess-fill{background:rgba(37,99,235,.08);outline:2px dashed #2563eb;outline-offset:-2px;box-shadow:none}
31
+ [class*="tessph-"]{cursor:pointer}
32
+ [class*="tessph-"] td,[class*="tessph-"]{outline:1px dashed #cbd5e1;outline-offset:-4px}
33
+ a{cursor:default}
34
+ .ProseMirror{outline:none;white-space:pre-wrap;word-wrap:break-word;cursor:text}
35
+ .ProseMirror p{margin:0}
36
+ .ProseMirror ul,.ProseMirror ol{margin:0;padding-left:1.5em}
37
+ `
38
+
39
+ // A file dragged in from outside the browser (block drags set editor.drag)
40
+ function isFileDrag(event, editor) {
41
+ return !editor.drag && Array.from(event.dataTransfer?.types || []).includes("Files")
42
+ }
43
+
44
+ const CLASS = /\btess-(b[a-z0-9]+)\b/
45
+ const PLACEHOLDER = /\btessph-(b[a-z0-9]+)\b/
46
+
47
+ export class Canvas {
48
+ constructor(editor, frame) {
49
+ this.editor = editor
50
+ this.frame = frame
51
+ this.hoverId = null
52
+ this.mode = "edit"
53
+ }
54
+
55
+ get doc() {
56
+ return this.frame.contentDocument
57
+ }
58
+
59
+ get win() {
60
+ return this.frame.contentWindow
61
+ }
62
+
63
+ // Replaces the iframe's content, keeping the scroll position.
64
+ write(html, mode) {
65
+ const scrollY = this.win?.scrollY || 0
66
+ this.mode = mode
67
+ const doc = this.doc
68
+ doc.open()
69
+ doc.write(html)
70
+ doc.close()
71
+ const style = doc.createElement("style")
72
+ style.textContent = OVERLAY_CSS
73
+ doc.head.append(style)
74
+ this.overlay = doc.createElement("div")
75
+ this.overlay.id = "tess-overlay"
76
+ doc.body.append(this.overlay)
77
+ this.win.scrollTo(0, scrollY)
78
+ if (mode === "edit") this.listen()
79
+ else doc.addEventListener("click", (event) => event.target.closest("a") && event.preventDefault(), true)
80
+ this.refresh()
81
+ }
82
+
83
+ listen() {
84
+ const doc = this.doc
85
+ doc.addEventListener("mousemove", (event) => this.onMove(event))
86
+ doc.addEventListener("mouseleave", () => this.setHover(null))
87
+ doc.addEventListener("click", (event) => this.onClick(event), true)
88
+ doc.addEventListener("dblclick", (event) => this.onDoubleClick(event))
89
+ doc.addEventListener("dragover", (event) => this.onDragOver(event))
90
+ doc.addEventListener("dragleave", (event) => {
91
+ if (!event.relatedTarget) this.hideIndicator()
92
+ })
93
+ doc.addEventListener("drop", (event) => this.onDrop(event))
94
+ doc.addEventListener("keydown", (event) => this.editor.onKeydown(event))
95
+ // Events in the iframe don't reach the host page, so close the insert menu from here.
96
+ doc.addEventListener("mousedown", () => this.editor.closeMenu({ outside: true }), true)
97
+ this.win.addEventListener("scroll", () => this.editor.closeMenu(), { passive: true })
98
+ this.win.addEventListener("resize", () => this.refresh())
99
+ for (const image of doc.images) image.addEventListener("load", () => this.refresh(), { once: true })
100
+ }
101
+
102
+ // --- mapping between DOM and blocks -------------------------------------
103
+
104
+ hit(target) {
105
+ let element = target?.nodeType === 1 ? target : target?.parentElement
106
+ while (element && element !== this.doc.documentElement) {
107
+ if (element.closest?.("#tess-overlay")) return null
108
+ const className = typeof element.className === "string" ? element.className : ""
109
+ const placeholder = className.match(PLACEHOLDER)
110
+ if (placeholder) return { id: placeholder[1], placeholder: true, element }
111
+ const match = className.match(CLASS)
112
+ if (match && find(this.editor.doc, match[1])) return { id: match[1], placeholder: false, element }
113
+ element = element.parentElement
114
+ }
115
+ return null
116
+ }
117
+
118
+ elementOf(id) {
119
+ return this.doc?.querySelector(`.tess-${id}`) || this.doc?.querySelector(`.tessph-${id}`)
120
+ }
121
+
122
+ // The rectangle of a block in viewport coordinates of the iframe. Blocks
123
+ // without their own element (loops, conditions) use the union of their children.
124
+ rectOf(id) {
125
+ const hit = find(this.editor.doc, id)
126
+ if (!hit) return null
127
+ if (hit.node.type === "root") {
128
+ const body = this.doc.body
129
+ const rect = body.getBoundingClientRect()
130
+ return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.top + body.scrollHeight, width: rect.width, height: body.scrollHeight }
131
+ }
132
+ const element = this.elementOf(id)
133
+ if (element) return element.getBoundingClientRect()
134
+ const rects = (hit.node.children || []).map((child) => this.rectOf(child.id)).filter(Boolean)
135
+ if (!rects.length) return null
136
+ const left = Math.min(...rects.map((r) => r.left))
137
+ const top = Math.min(...rects.map((r) => r.top))
138
+ const right = Math.max(...rects.map((r) => r.right))
139
+ const bottom = Math.max(...rects.map((r) => r.bottom))
140
+ return { left, top, right, bottom, width: right - left, height: bottom - top }
141
+ }
142
+
143
+ textContainer(id) {
144
+ const cell = this.doc?.querySelector(`.tess-${id}`)
145
+ return cell?.querySelector(":scope > div") || cell
146
+ }
147
+
148
+ // --- overlay ---------------------------------------------------------------
149
+
150
+ box(rect, className) {
151
+ const box = this.doc.createElement("div")
152
+ box.className = `tess-box ${className}`
153
+ const sx = this.win.scrollX
154
+ const sy = this.win.scrollY
155
+ Object.assign(box.style, { left: `${rect.left + sx}px`, top: `${rect.top + sy}px`, width: `${rect.width}px`, height: `${rect.height}px` })
156
+ if (rect.top + sy < 24) box.classList.add("tess-inside")
157
+ return box
158
+ }
159
+
160
+ refresh() {
161
+ if (!this.overlay) return
162
+ this.overlay.replaceChildren()
163
+ if (this.mode !== "edit") return
164
+ const { selectedId, editingId } = this.editor
165
+
166
+ if (this.hoverId && this.hoverId !== selectedId) {
167
+ const hit = find(this.editor.doc, this.hoverId)
168
+ const rect = hit && this.rectOf(this.hoverId)
169
+ if (rect && hit.node.type !== "root") {
170
+ const box = this.box(rect, "tess-hover")
171
+ box.append(Object.assign(this.doc.createElement("div"), { className: "tess-label", textContent: getBlock(hit.node.type)?.name || hit.node.type }))
172
+ this.overlay.append(box)
173
+ }
174
+ }
175
+
176
+ const hit = selectedId && find(this.editor.doc, selectedId)
177
+ if (!hit || hit.node.type === "root") return
178
+ const rect = this.rectOf(selectedId)
179
+ if (!rect) return
180
+ const box = this.box(rect, editingId === selectedId ? "tess-selected tess-editing" : "tess-selected")
181
+ const free = this.editor.isFree(selectedId)
182
+ box.append(Object.assign(this.doc.createElement("div"), { className: "tess-label", textContent: editingId === selectedId ? "Editing text · Esc to finish" : free ? label(hit.node) : `${label(hit.node)} · Layout` }))
183
+
184
+ if (editingId !== selectedId) {
185
+ const tools = this.doc.createElement("div")
186
+ tools.className = "tess-tools"
187
+ tools.innerHTML = [
188
+ ["parent", "Select parent"],
189
+ ...(free ? [["drag", "Drag to move"], ["duplicate", "Duplicate"], ["delete", "Delete"]] : [])
190
+ ]
191
+ .map(([tool, title]) => `<span data-tool="${tool}" title="${title}" ${tool === "drag" ? 'draggable="true"' : ""}>${icon({ parent: "parent", drag: "move", duplicate: "copy", delete: "trash" }[tool], "tess-icon")}</span>`)
192
+ .join("")
193
+ tools.querySelectorAll("svg").forEach((svg) => svg.setAttribute("style", "width:14px;height:14px"))
194
+ tools.addEventListener("mousedown", (event) => event.stopPropagation())
195
+ tools.addEventListener("click", (event) => {
196
+ const tool = event.target.closest("[data-tool]")?.dataset.tool
197
+ event.preventDefault()
198
+ event.stopPropagation()
199
+ if (tool === "parent") this.editor.selectParent()
200
+ if (tool === "duplicate") this.editor.duplicate(selectedId)
201
+ if (tool === "delete") this.editor.remove(selectedId)
202
+ })
203
+ tools.querySelector('[data-tool="drag"]')?.addEventListener("dragstart", (event) => {
204
+ event.dataTransfer.effectAllowed = "move"
205
+ event.dataTransfer.setData("text/plain", selectedId)
206
+ const element = this.elementOf(selectedId)
207
+ if (element) event.dataTransfer.setDragImage(element, 10, 10)
208
+ this.editor.startDrag({ kind: "move", id: selectedId, type: hit.node.type })
209
+ })
210
+ tools.querySelector('[data-tool="drag"]')?.addEventListener("dragend", () => this.editor.endDrag())
211
+ box.append(tools)
212
+
213
+ if (hit.parent && this.editor.canFill(hit.parent.id)) {
214
+ const plus = this.doc.createElement("button")
215
+ plus.className = "tess-plus"
216
+ plus.title = "Add a block below"
217
+ plus.innerHTML = icon("plus")
218
+ plus.querySelector("svg").setAttribute("style", "width:12px;height:12px")
219
+ plus.addEventListener("click", (event) => {
220
+ event.preventDefault()
221
+ event.stopPropagation()
222
+ this.editor.openInsertMenu(hit.parent.id, hit.index + 1, this.toParentRect(plus.getBoundingClientRect()))
223
+ })
224
+ box.append(plus)
225
+ }
226
+ }
227
+ this.overlay.append(box)
228
+ }
229
+
230
+ setHover(id) {
231
+ if (this.hoverId === id) return
232
+ this.hoverId = id
233
+ this.refresh()
234
+ }
235
+
236
+ // Converts a rect in iframe viewport coordinates to the parent page's viewport.
237
+ toParentRect(rect) {
238
+ const frame = this.frame.getBoundingClientRect()
239
+ return { left: rect.left + frame.left, top: rect.top + frame.top, right: rect.right + frame.left, bottom: rect.bottom + frame.top, width: rect.width, height: rect.height }
240
+ }
241
+
242
+ // --- events ---------------------------------------------------------------
243
+
244
+ onMove(event) {
245
+ if (this.editor.drag) return
246
+ const hit = this.hit(event.target)
247
+ this.setHover(hit && !hit.placeholder ? hit.id : null)
248
+ }
249
+
250
+ onClick(event) {
251
+ if (this.editor.editingId && event.target.closest?.(".ProseMirror")) return
252
+ if (event.target.closest?.("#tess-overlay")) return
253
+ event.preventDefault()
254
+ const hit = this.hit(event.target)
255
+ if (hit?.placeholder) {
256
+ const rect = this.toParentRect(hit.element.getBoundingClientRect())
257
+ this.editor.select(hit.id)
258
+ this.editor.openInsertMenu(hit.id, 0, rect)
259
+ return
260
+ }
261
+ if (hit && hit.id === this.editor.selectedId && getBlock(find(this.editor.doc, hit.id).node.type)?.richText) {
262
+ this.editor.startEditing(hit.id)
263
+ return
264
+ }
265
+ this.editor.select(hit ? hit.id : this.editor.doc.id, { reveal: true })
266
+ }
267
+
268
+ onDoubleClick(event) {
269
+ const hit = this.hit(event.target)
270
+ if (hit && !hit.placeholder && getBlock(find(this.editor.doc, hit.id).node.type)?.richText) this.editor.startEditing(hit.id)
271
+ }
272
+
273
+ onDragOver(event) {
274
+ if (isFileDrag(event, this.editor)) return this.onFileDragOver(event)
275
+ const drag = this.editor.drag
276
+ if (!drag) return
277
+ const target = this.dropTarget(event.clientX, event.clientY, drag)
278
+ if (!target) {
279
+ this.hideIndicator()
280
+ return
281
+ }
282
+ event.preventDefault()
283
+ event.dataTransfer.dropEffect = drag.kind === "move" ? "move" : "copy"
284
+ this.showIndicator(target)
285
+ }
286
+
287
+ onDrop(event) {
288
+ if (isFileDrag(event, this.editor)) return this.onFileDrop(event)
289
+ const drag = this.editor.drag
290
+ if (!drag) return
291
+ event.preventDefault()
292
+ const target = this.dropTarget(event.clientX, event.clientY, drag)
293
+ this.hideIndicator()
294
+ if (target) this.editor.drop(drag, target.parentId, target.index)
295
+ this.editor.endDrag()
296
+ }
297
+
298
+ // An image file dragged from the desktop onto a block with an image field
299
+ // uploads it there: an Image block's image, or the background of the hero,
300
+ // section or wrapper the pointer is in. Elsewhere the drop is refused, so
301
+ // the browser doesn't open the file.
302
+ onFileDragOver(event) {
303
+ event.preventDefault()
304
+ const target = this.imageTarget(event.target)
305
+ event.dataTransfer.dropEffect = target ? "copy" : "none"
306
+ const rect = target && this.rectOf(target.id)
307
+ if (rect) this.showIndicator({ fill: rect })
308
+ else this.hideIndicator()
309
+ }
310
+
311
+ onFileDrop(event) {
312
+ event.preventDefault()
313
+ this.hideIndicator()
314
+ const target = this.imageTarget(event.target)
315
+ const file = Array.from(event.dataTransfer.files).find((file) => file.type.startsWith("image/"))
316
+ if (target && file) this.editor.dropImage(target.id, file, target.field)
317
+ }
318
+
319
+ // The nearest block, from the one under the pointer outwards, with an image
320
+ // field that can be edited
321
+ imageTarget(target) {
322
+ if (!this.editor.media.enabled) return null
323
+ const hit = this.hit(target)
324
+ if (!hit) return null
325
+ const doc = this.editor.doc
326
+ for (const node of [find(doc, hit.id).node, ...ancestors(doc, hit.id)]) {
327
+ const field = imageFields(getBlock(node.type)).find((field) => this.editor.canEditField(node.id, field.key))
328
+ if (field) return { id: node.id, field }
329
+ }
330
+ return null
331
+ }
332
+
333
+ // Shows another image in a block before the upload finishes (until the
334
+ // next render): where the block shows its current image, or else as the
335
+ // block's background
336
+ preview(id, current, url) {
337
+ const element = this.elementOf(id)
338
+ if (!element) return
339
+ let shown = false
340
+ for (const child of [element, ...element.querySelectorAll("*")]) {
341
+ if (current && child.tagName === "IMG" && child.getAttribute("src") === current) {
342
+ child.removeAttribute("srcset")
343
+ child.src = url
344
+ shown = true
345
+ }
346
+ if (current && child.getAttribute("background") === current) child.setAttribute("background", url)
347
+ const style = child.getAttribute("style")
348
+ if (current && style?.includes(current)) {
349
+ child.setAttribute("style", style.split(current).join(url))
350
+ shown = true
351
+ }
352
+ }
353
+ if (!shown) Object.assign(element.style, { backgroundImage: `url("${url}")`, backgroundSize: "cover", backgroundPosition: "center" })
354
+ element.style.opacity = "0.6"
355
+ }
356
+
357
+ // --- dropping --------------------------------------------------------------
358
+
359
+ allowed(parentId, drag) {
360
+ const doc = this.editor.doc
361
+ if (!this.editor.canFill(parentId) || !canInsert(doc, parentId, drag.type)) return false
362
+ if (drag.kind === "move" && (parentId === drag.id || isDescendant(doc, drag.id, parentId))) return false
363
+ return true
364
+ }
365
+
366
+ // Finds where a block of drag.type would land for a pointer at (x, y).
367
+ dropTarget(x, y, drag) {
368
+ const doc = this.editor.doc
369
+ const hit = this.hit(this.doc.elementFromPoint(x, y))
370
+ if (hit?.placeholder) return this.allowed(hit.id, drag) ? { parentId: hit.id, index: 0, fill: hit.element.getBoundingClientRect() } : null
371
+
372
+ const startId = hit ? hit.id : doc.id
373
+ const chain = [find(doc, startId).node, ...ancestors(doc, startId)]
374
+
375
+ // 1. Pointer over a container that accepts the block: insert among its children.
376
+ const first = chain[0]
377
+ if (this.allowed(first.id, drag) && (first.children || []).length) {
378
+ return this.among(first, x, y)
379
+ }
380
+ // 2. Otherwise go up until a parent accepts it, and insert before or after.
381
+ for (let i = 0; i < chain.length - 1; i++) {
382
+ const node = chain[i]
383
+ const parent = chain[i + 1]
384
+ if (drag.kind === "move" && node.id === drag.id) continue
385
+ if (!this.allowed(parent.id, drag)) continue
386
+ const rect = this.rectOf(node.id)
387
+ if (!rect) continue
388
+ const horizontal = isHorizontal(doc, parent.id)
389
+ const after = horizontal ? x > rect.left + rect.width / 2 : y > rect.top + rect.height / 2
390
+ const index = parent.children.indexOf(node) + (after ? 1 : 0)
391
+ return { parentId: parent.id, index, line: this.line(rect, horizontal, after) }
392
+ }
393
+ if (this.allowed(doc.id, drag)) return this.among(doc, x, y)
394
+ return null
395
+ }
396
+
397
+ among(container, x, y) {
398
+ const horizontal = isHorizontal(this.editor.doc, container.id)
399
+ const children = container.children || []
400
+ let index = children.length
401
+ let line = null
402
+ for (let i = 0; i < children.length; i++) {
403
+ const rect = this.rectOf(children[i].id)
404
+ if (!rect) continue
405
+ const before = horizontal ? x < rect.left + rect.width / 2 : y < rect.top + rect.height / 2
406
+ if (before) {
407
+ index = i
408
+ line = this.line(rect, horizontal, false)
409
+ break
410
+ }
411
+ }
412
+ if (!line && children.length) {
413
+ const rect = this.rectOf(children[children.length - 1].id)
414
+ if (rect) line = this.line(rect, horizontal, true)
415
+ }
416
+ if (!line) return { parentId: container.id, index, fill: this.rectOf(container.id) }
417
+ return { parentId: container.id, index, line }
418
+ }
419
+
420
+ line(rect, horizontal, after) {
421
+ if (horizontal) return { left: (after ? rect.right : rect.left) - 2, top: rect.top, width: 4, height: rect.height }
422
+ return { left: rect.left, top: (after ? rect.bottom : rect.top) - 2, width: rect.width, height: 4 }
423
+ }
424
+
425
+ showIndicator(target) {
426
+ this.hideIndicator()
427
+ const rect = target.line || target.fill
428
+ if (!rect) return
429
+ this.indicator = this.box(rect, target.line ? "tess-indicator" : "tess-indicator tess-fill")
430
+ this.overlay.append(this.indicator)
431
+ }
432
+
433
+ hideIndicator() {
434
+ this.indicator?.remove()
435
+ this.indicator = null
436
+ }
437
+ }