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,124 @@
|
|
|
1
|
+
// Inline rich-text editing with Tiptap, mounted inside the canvas iframe on the
|
|
2
|
+
// text block's own element, plus the formatting toolbar above the canvas.
|
|
3
|
+
|
|
4
|
+
import { Color, Editor, StarterKit, TextAlign, TextStyle } from "tess_edit/vendor/tiptap"
|
|
5
|
+
import { h } from "tess_edit/ui/dom"
|
|
6
|
+
import { icon } from "tess_edit/ui/icons"
|
|
7
|
+
|
|
8
|
+
const BUTTON = "w-7 h-7 flex items-center justify-center rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
|
9
|
+
const ACTIVE = "bg-blue-50 text-blue-700"
|
|
10
|
+
|
|
11
|
+
export class TextEditing {
|
|
12
|
+
constructor(toolbar) {
|
|
13
|
+
this.toolbar = toolbar
|
|
14
|
+
this.tiptap = null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get active() {
|
|
18
|
+
return !!this.tiptap
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
start(container, content, onUpdate) {
|
|
22
|
+
this.stop()
|
|
23
|
+
container.replaceChildren()
|
|
24
|
+
this.tiptap = new Editor({
|
|
25
|
+
element: container,
|
|
26
|
+
content: content || { type: "doc", content: [{ type: "paragraph" }] },
|
|
27
|
+
extensions: [
|
|
28
|
+
StarterKit.configure({
|
|
29
|
+
code: false,
|
|
30
|
+
codeBlock: false,
|
|
31
|
+
heading: { levels: [1, 2, 3] },
|
|
32
|
+
// Liquid in links ({{ url }}) is fine: do not filter URLs.
|
|
33
|
+
link: { openOnClick: false, autolink: false, linkOnPaste: true, isAllowedUri: () => true }
|
|
34
|
+
}),
|
|
35
|
+
TextStyle,
|
|
36
|
+
Color,
|
|
37
|
+
TextAlign.configure({ types: ["heading", "paragraph"] })
|
|
38
|
+
],
|
|
39
|
+
autofocus: "end",
|
|
40
|
+
onUpdate: ({ editor }) => onUpdate(editor.getJSON()),
|
|
41
|
+
onTransaction: () => this.renderToolbar()
|
|
42
|
+
})
|
|
43
|
+
this.renderToolbar()
|
|
44
|
+
return this.tiptap
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
stop() {
|
|
48
|
+
if (!this.tiptap) return null
|
|
49
|
+
const json = this.tiptap.getJSON()
|
|
50
|
+
this.tiptap.destroy()
|
|
51
|
+
this.tiptap = null
|
|
52
|
+
this.toolbar.replaceChildren()
|
|
53
|
+
this.toolbar.classList.add("hidden")
|
|
54
|
+
return json
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
renderToolbar() {
|
|
58
|
+
const editor = this.tiptap
|
|
59
|
+
if (!editor) return
|
|
60
|
+
const chain = () => editor.chain().focus()
|
|
61
|
+
const button = (name, title, active, run) =>
|
|
62
|
+
h("button", {
|
|
63
|
+
type: "button",
|
|
64
|
+
title,
|
|
65
|
+
class: `${BUTTON} ${active ? ACTIVE : ""}`,
|
|
66
|
+
html: icon(name),
|
|
67
|
+
onmousedown: (event) => event.preventDefault(),
|
|
68
|
+
onclick: run
|
|
69
|
+
})
|
|
70
|
+
const separator = () => h("span", { class: "mx-1 h-5 w-px bg-gray-200" })
|
|
71
|
+
|
|
72
|
+
const level = [1, 2, 3].find((l) => editor.isActive("heading", { level: l }))
|
|
73
|
+
const style = h(
|
|
74
|
+
"select",
|
|
75
|
+
{
|
|
76
|
+
class: "h-7 rounded-md border border-gray-300 bg-white px-1.5 text-xs text-gray-800",
|
|
77
|
+
title: "Paragraph style",
|
|
78
|
+
onchange: (event) => {
|
|
79
|
+
const value = Number(event.target.value)
|
|
80
|
+
value ? chain().setHeading({ level: value }).run() : chain().setParagraph().run()
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
h("option", { value: "0", selected: !level }, "Paragraph"),
|
|
84
|
+
[1, 2, 3].map((l) => h("option", { value: String(l), selected: level === l }, `Heading ${l}`))
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const currentColor = editor.getAttributes("textStyle").color
|
|
88
|
+
const color = h("input", {
|
|
89
|
+
type: "color",
|
|
90
|
+
title: "Text color",
|
|
91
|
+
class: "h-7 w-8 cursor-pointer rounded border border-gray-300 bg-white p-0.5",
|
|
92
|
+
value: /^#[0-9a-f]{6}$/i.test(currentColor || "") ? currentColor : "#000000",
|
|
93
|
+
onchange: (event) => chain().setColor(event.target.value).run()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
this.toolbar.replaceChildren(...[
|
|
97
|
+
style,
|
|
98
|
+
separator(),
|
|
99
|
+
button("bold", "Bold (⌘B)", editor.isActive("bold"), () => chain().toggleBold().run()),
|
|
100
|
+
button("italic", "Italic (⌘I)", editor.isActive("italic"), () => chain().toggleItalic().run()),
|
|
101
|
+
button("underline", "Underline (⌘U)", editor.isActive("underline"), () => chain().toggleUnderline().run()),
|
|
102
|
+
button("strike", "Strikethrough", editor.isActive("strike"), () => chain().toggleStrike().run()),
|
|
103
|
+
color,
|
|
104
|
+
separator(),
|
|
105
|
+
button("link", "Link", editor.isActive("link"), () => {
|
|
106
|
+
const previous = editor.getAttributes("link").href || "https://"
|
|
107
|
+
const href = window.prompt("Link URL (Liquid allowed, e.g. {{ order.url }})", previous)
|
|
108
|
+
if (href === null) return
|
|
109
|
+
href ? chain().extendMarkRange("link").setLink({ href }).run() : chain().extendMarkRange("link").unsetLink().run()
|
|
110
|
+
}),
|
|
111
|
+
editor.isActive("link") ? button("unlink", "Remove link", false, () => chain().extendMarkRange("link").unsetLink().run()) : null,
|
|
112
|
+
separator(),
|
|
113
|
+
button("align-left", "Align left", editor.isActive({ textAlign: "left" }), () => chain().setTextAlign("left").run()),
|
|
114
|
+
button("align-center", "Align center", editor.isActive({ textAlign: "center" }), () => chain().setTextAlign("center").run()),
|
|
115
|
+
button("align-right", "Align right", editor.isActive({ textAlign: "right" }), () => chain().setTextAlign("right").run()),
|
|
116
|
+
separator(),
|
|
117
|
+
button("list-bullet", "Bulleted list", editor.isActive("bulletList"), () => chain().toggleBulletList().run()),
|
|
118
|
+
button("list-ordered", "Numbered list", editor.isActive("orderedList"), () => chain().toggleOrderedList().run()),
|
|
119
|
+
separator(),
|
|
120
|
+
button("clear", "Clear formatting", false, () => chain().unsetAllMarks().clearNodes().run())
|
|
121
|
+
].filter(Boolean))
|
|
122
|
+
this.toolbar.classList.remove("hidden")
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The Layers panel: the document as a collapsible tree that can be reordered by
|
|
2
|
+
// dragging. Rows also take blocks dragged from the Blocks panel.
|
|
3
|
+
|
|
4
|
+
import { canInsert, find, isDescendant } from "tess_edit/core/document"
|
|
5
|
+
import { getBlock, isContainer, label } from "tess_edit/core/registry"
|
|
6
|
+
import { h } from "tess_edit/ui/dom"
|
|
7
|
+
import { icon } from "tess_edit/ui/icons"
|
|
8
|
+
|
|
9
|
+
export class Tree {
|
|
10
|
+
constructor(editor, element) {
|
|
11
|
+
this.editor = editor
|
|
12
|
+
this.element = element
|
|
13
|
+
this.collapsed = new Set()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
render() {
|
|
17
|
+
const { doc } = this.editor
|
|
18
|
+
this.element.replaceChildren(h("ul", { class: "py-1", role: "tree" }, this.row(doc, 0)))
|
|
19
|
+
this.element.querySelector("[aria-selected='true']")?.scrollIntoView({ block: "nearest" })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
row(node, depth) {
|
|
23
|
+
const { selectedId } = this.editor
|
|
24
|
+
const children = node.children || []
|
|
25
|
+
const open = !this.collapsed.has(node.id)
|
|
26
|
+
const selected = node.id === selectedId
|
|
27
|
+
const container = isContainer(node.type)
|
|
28
|
+
// Layout parts under a lock; the root shows no lock, as some of its fields may be open.
|
|
29
|
+
const locked = !!this.editor.lock && node.id !== this.editor.doc.id && !this.editor.canFill(node.id)
|
|
30
|
+
|
|
31
|
+
const toggle = h(
|
|
32
|
+
"button",
|
|
33
|
+
{
|
|
34
|
+
type: "button",
|
|
35
|
+
class: `w-4 h-4 shrink-0 flex items-center justify-center text-gray-400 hover:text-gray-700 ${children.length ? "" : "invisible"}`,
|
|
36
|
+
onclick: (event) => {
|
|
37
|
+
event.stopPropagation()
|
|
38
|
+
open ? this.collapsed.add(node.id) : this.collapsed.delete(node.id)
|
|
39
|
+
this.render()
|
|
40
|
+
},
|
|
41
|
+
html: icon("chevron", `w-3 h-3 transition-transform ${open ? "rotate-90" : ""}`)
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const add = container && this.editor.accepted(node.id).length
|
|
46
|
+
? h("button", {
|
|
47
|
+
type: "button",
|
|
48
|
+
title: "Add a block inside",
|
|
49
|
+
class: "ml-auto hidden group-hover:flex w-5 h-5 items-center justify-center rounded text-gray-500 hover:bg-gray-200 hover:text-gray-900",
|
|
50
|
+
html: icon("plus", "w-3.5 h-3.5"),
|
|
51
|
+
onclick: (event) => {
|
|
52
|
+
event.stopPropagation()
|
|
53
|
+
this.editor.openInsertMenu(node.id, children.length, event.currentTarget.getBoundingClientRect())
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
: null
|
|
57
|
+
|
|
58
|
+
const line = h(
|
|
59
|
+
"div",
|
|
60
|
+
{
|
|
61
|
+
class: `group relative flex items-center gap-1.5 pr-2 h-7 cursor-pointer select-none rounded-md mx-1 ${selected ? "bg-blue-50 text-blue-700" : "text-gray-700 hover:bg-gray-100"}`,
|
|
62
|
+
style: `padding-left:${depth * 14 + 4}px`,
|
|
63
|
+
role: "treeitem",
|
|
64
|
+
"aria-selected": selected ? "true" : "false",
|
|
65
|
+
draggable: this.editor.isFree(node.id),
|
|
66
|
+
dataset: { id: node.id },
|
|
67
|
+
onclick: () => this.editor.select(node.id),
|
|
68
|
+
ondragstart: (event) => {
|
|
69
|
+
event.stopPropagation()
|
|
70
|
+
event.dataTransfer.effectAllowed = "move"
|
|
71
|
+
event.dataTransfer.setData("text/plain", node.id)
|
|
72
|
+
this.editor.startDrag({ kind: "move", id: node.id, type: node.type })
|
|
73
|
+
},
|
|
74
|
+
ondragend: () => this.editor.endDrag(),
|
|
75
|
+
ondragover: (event) => this.onDragOver(event, node),
|
|
76
|
+
ondragleave: () => this.clearMarker(),
|
|
77
|
+
ondrop: (event) => this.onDrop(event, node),
|
|
78
|
+
onmouseenter: () => this.editor.canvas?.setHover(node.id),
|
|
79
|
+
onmouseleave: () => this.editor.canvas?.setHover(null)
|
|
80
|
+
},
|
|
81
|
+
toggle,
|
|
82
|
+
h("span", { class: `shrink-0 ${getBlock(node.type)?.accepts === "inherit" ? "text-amber-600" : "text-gray-400"}`, html: icon(getBlock(node.type)?.icon, "w-4 h-4") }),
|
|
83
|
+
h("span", { class: `truncate text-[13px] ${locked ? "text-gray-400" : ""}` }, label(node)),
|
|
84
|
+
locked ? h("span", { class: "ml-auto shrink-0 text-gray-300", title: "Part of the layout", html: icon("lock", "w-3.5 h-3.5") }) : add,
|
|
85
|
+
h("div", { class: "tess-tree-marker pointer-events-none absolute left-1 right-1 hidden" })
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return h("li", { role: "none" }, line, open && children.length ? h("ul", { role: "group" }, children.map((child) => this.row(child, depth + 1))) : null)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Top third: before, bottom third: after, middle: inside (when allowed).
|
|
92
|
+
position(event, node) {
|
|
93
|
+
const drag = this.editor.drag
|
|
94
|
+
if (!drag) return null
|
|
95
|
+
const doc = this.editor.doc
|
|
96
|
+
const ok = (parentId) => this.editor.canFill(parentId) && canInsert(doc, parentId, drag.type) && !(drag.kind === "move" && (parentId === drag.id || isDescendant(doc, drag.id, parentId)))
|
|
97
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
98
|
+
const ratio = (event.clientY - rect.top) / rect.height
|
|
99
|
+
const hit = find(doc, node.id)
|
|
100
|
+
if (drag.kind === "move" && drag.id === node.id) return null
|
|
101
|
+
const inside = ok(node.id) ? { parentId: node.id, index: (node.children || []).length, marker: "inside" } : null
|
|
102
|
+
const sibling = (after) => (hit.parent && ok(hit.parent.id) ? { parentId: hit.parent.id, index: hit.index + (after ? 1 : 0), marker: after ? "after" : "before" } : null)
|
|
103
|
+
if (ratio < 0.3) return sibling(false) || inside
|
|
104
|
+
// Below an expanded container the next row is its first child, so "after" means "first inside".
|
|
105
|
+
const expanded = !this.collapsed.has(node.id) && node.children?.length
|
|
106
|
+
if (ratio > 0.7) return (expanded && inside ? { ...inside, index: 0 } : null) || sibling(true) || inside
|
|
107
|
+
return inside || sibling(ratio > 0.5)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
onDragOver(event, node) {
|
|
111
|
+
const target = this.position(event, node)
|
|
112
|
+
this.clearMarker()
|
|
113
|
+
if (!target) return
|
|
114
|
+
event.preventDefault()
|
|
115
|
+
event.stopPropagation()
|
|
116
|
+
const marker = event.currentTarget.querySelector(".tess-tree-marker")
|
|
117
|
+
marker.classList.remove("hidden")
|
|
118
|
+
marker.className = `tess-tree-marker pointer-events-none absolute left-1 right-1 ${
|
|
119
|
+
target.marker === "inside" ? "inset-y-0 rounded-md ring-2 ring-blue-500 bg-blue-500/5" : target.marker === "before" ? "top-0 h-0.5 bg-blue-600" : "bottom-0 h-0.5 bg-blue-600"
|
|
120
|
+
}`
|
|
121
|
+
this.markerElement = marker
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onDrop(event, node) {
|
|
125
|
+
const target = this.position(event, node)
|
|
126
|
+
this.clearMarker()
|
|
127
|
+
if (!target) return
|
|
128
|
+
event.preventDefault()
|
|
129
|
+
event.stopPropagation()
|
|
130
|
+
this.editor.drop(this.editor.drag, target.parentId, target.index)
|
|
131
|
+
this.editor.endDrag()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
clearMarker() {
|
|
135
|
+
if (this.markerElement) this.markerElement.className = "tess-tree-marker pointer-events-none absolute left-1 right-1 hidden"
|
|
136
|
+
this.markerElement = null
|
|
137
|
+
}
|
|
138
|
+
}
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Pins for the tess_edit gem. The vendored bundles are built by script/vendor/build.mjs.
|
|
2
|
+
|
|
3
|
+
pin "tess_edit", to: "tess_edit/index.js"
|
|
4
|
+
pin_all_from TessEdit::Engine.root.join("app/javascript/tess_edit"), under: "tess_edit", to: "tess_edit"
|
|
5
|
+
|
|
6
|
+
pin "tess_edit/vendor/tiptap", to: "tess_edit/tiptap.js"
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module TessEdit
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace TessEdit
|
|
4
|
+
|
|
5
|
+
initializer "tess_edit.assets" do |app|
|
|
6
|
+
if app.config.respond_to?(:assets)
|
|
7
|
+
app.config.assets.paths << root.join("app/javascript")
|
|
8
|
+
app.config.assets.paths << root.join("vendor/javascript")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
initializer "tess_edit.importmap", before: "importmap" do |app|
|
|
13
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
14
|
+
app.config.importmap.cache_sweepers << root.join("app/javascript")
|
|
15
|
+
app.config.importmap.cache_sweepers << root.join("vendor/javascript")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/tess_edit.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "importmap-rails"
|
|
2
|
+
require "stimulus-rails"
|
|
3
|
+
require "mrml"
|
|
4
|
+
require "tess_edit/version"
|
|
5
|
+
require "tess_edit/engine"
|
|
6
|
+
|
|
7
|
+
module TessEdit
|
|
8
|
+
Result = Data.define(:html, :errors)
|
|
9
|
+
|
|
10
|
+
# The controller the engine's controllers inherit from, as a string. Set it
|
|
11
|
+
# to a controller of the host app, such as "Admin::BaseController", so the
|
|
12
|
+
# compile endpoint is behind the same sign-in and authorization:
|
|
13
|
+
#
|
|
14
|
+
# # config/initializers/tess_edit.rb
|
|
15
|
+
# TessEdit.parent_controller = "Admin::BaseController"
|
|
16
|
+
mattr_accessor :parent_controller, default: "ActionController::Base"
|
|
17
|
+
|
|
18
|
+
# Compiles MJML to HTML with MRML. Liquid passes through untouched. When the
|
|
19
|
+
# MJML can't be parsed, html is nil and errors holds the reason.
|
|
20
|
+
#
|
|
21
|
+
# Browsers submit form fields with CRLF line endings, which MRML can't parse
|
|
22
|
+
# in mj-head, so line endings become LF first.
|
|
23
|
+
def self.compile(mjml)
|
|
24
|
+
Result.new(html: MRML::Template.new(mjml.to_s.gsub(/\r\n?/, "\n")).to_html, errors: [])
|
|
25
|
+
rescue MRML::Error => error
|
|
26
|
+
Result.new(html: nil, errors: [error.message])
|
|
27
|
+
end
|
|
28
|
+
end
|