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,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, "&").replace(/</g, "<").replace(/>/g, ">")))
|
|
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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">")))
|
|
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
|
+
}
|