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,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
+ }
@@ -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,3 @@
1
+ TessEdit::Engine.routes.draw do
2
+ post "compile", to: "compilations#create", as: :compile
3
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :tess_edit do
3
+ # # Task goes here
4
+ # end
@@ -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
@@ -0,0 +1,3 @@
1
+ module TessEdit
2
+ VERSION = "0.1.1"
3
+ 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