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,137 @@
1
+ // A code field without an editor library: a textarea whose text is
2
+ // transparent, over a copy of the text with HTML, CSS and Liquid colored.
3
+ // Both wrap the same way and scroll together, so the caret and selection of
4
+ // the textarea line up with the colored text. Tab indents, Shift+Tab
5
+ // outdents, and Enter keeps the line's indentation.
6
+
7
+ import { h } from "tess_edit/ui/dom"
8
+
9
+ const INDENT = " "
10
+ // Shared by the textarea and the colored copy, so they lay out the same
11
+ const BOX = "m-0 w-full rounded-md border px-2 py-1.5 font-mono text-xs leading-5 whitespace-pre-wrap break-words [overflow-wrap:anywhere] [tab-size:2] [scrollbar-gutter:stable]"
12
+
13
+ const LIQUID = /\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\}/
14
+ const COLORS = {
15
+ liquid: "text-purple-700",
16
+ comment: "text-gray-400 italic",
17
+ tag: "text-blue-700",
18
+ attribute: "text-amber-700",
19
+ string: "text-green-700",
20
+ property: "text-blue-700",
21
+ at: "text-purple-700",
22
+ number: "text-amber-700"
23
+ }
24
+
25
+ function escape(text) {
26
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
27
+ }
28
+
29
+ function span(kind, html) {
30
+ return `<span class="${COLORS[kind]}">${html}</span>`
31
+ }
32
+
33
+ // Colors the parts of text that match one of the patterns ([kind, regexp]),
34
+ // in order of preference, and passes the rest to plain(text)
35
+ function tokenize(text, patterns, plain = escape) {
36
+ const source = patterns.map(([, pattern]) => `(${pattern.source})`).join("|")
37
+ const regexp = new RegExp(source, "g")
38
+ let html = ""
39
+ let last = 0
40
+ for (const match of text.matchAll(regexp)) {
41
+ html += plain(text.slice(last, match.index))
42
+ const index = match.slice(1).findIndex((group) => group !== undefined)
43
+ const [kind, , inner] = patterns[index]
44
+ html += inner ? inner(match[0]) : span(kind, escape(match[0]))
45
+ last = match.index + match[0].length
46
+ }
47
+ return html + plain(text.slice(last))
48
+ }
49
+
50
+ const liquidIn = (text) => tokenize(text, [["liquid", LIQUID]])
51
+
52
+ function htmlTag(tag) {
53
+ return tokenize(tag, [
54
+ ["liquid", LIQUID],
55
+ ["string", /"[^"]*"|'[^']*'/, (string) => span("string", liquidIn(string))],
56
+ ["tag", /^<\/?[\w-]+|\/?>$/],
57
+ ["attribute", /[\w:@-]+(?=\s*=)/]
58
+ ])
59
+ }
60
+
61
+ export function highlightHtml(text) {
62
+ return tokenize(text, [
63
+ ["liquid", LIQUID],
64
+ ["comment", /<!--[\s\S]*?-->/],
65
+ // Liquid and quoted values may hold a ">", so they're skipped as a whole
66
+ ["tag", /<\/?[a-zA-Z](?:\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\}|"[^"]*"|'[^']*'|[^>"'])*>?/, htmlTag]
67
+ ])
68
+ }
69
+
70
+ export function highlightCss(text) {
71
+ return tokenize(text, [
72
+ ["liquid", LIQUID],
73
+ ["comment", /\/\*[\s\S]*?\*\//],
74
+ ["string", /"[^"\n]*"|'[^'\n]*'/],
75
+ ["at", /@[\w-]+/],
76
+ ["property", /[\w-]+(?=\s*:[^:{};]*[;}])/],
77
+ ["number", /#[0-9a-fA-F]{3,8}\b|-?\d*\.?\d+(?:px|em|rem|%|vh|vw|pt)?\b/]
78
+ ])
79
+ }
80
+
81
+ const HIGHLIGHT = { html: highlightHtml, css: highlightCss }
82
+
83
+ // Replaces the selection, keeping the textarea's own undo where the browser allows
84
+ function insert(textarea, text) {
85
+ if (!document.execCommand?.("insertText", false, text)) textarea.setRangeText(text, textarea.selectionStart, textarea.selectionEnd, "end")
86
+ }
87
+
88
+ function onKeydown(event) {
89
+ const textarea = event.target
90
+ const { value, selectionStart: start, selectionEnd: end } = textarea
91
+ if (event.key === "Tab" && !event.altKey && !event.metaKey && !event.ctrlKey) {
92
+ event.preventDefault()
93
+ const lineStart = value.lastIndexOf("\n", start - 1) + 1
94
+ if (!event.shiftKey && start === end) return insert(textarea, INDENT)
95
+ // Every line of the selection, indented or outdented
96
+ textarea.setSelectionRange(lineStart, end)
97
+ const lines = value.slice(lineStart, end).split("\n")
98
+ const changed = lines.map((line) => (event.shiftKey ? line.replace(new RegExp(`^ {1,${INDENT.length}}|^\\t`), "") : INDENT + line)).join("\n")
99
+ insert(textarea, changed)
100
+ textarea.setSelectionRange(lineStart, lineStart + changed.length)
101
+ } else if (event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.isComposing) {
102
+ const lineStart = value.lastIndexOf("\n", start - 1) + 1
103
+ const indent = value.slice(lineStart, start).match(/^[ \t]*/)[0]
104
+ if (!indent) return
105
+ event.preventDefault()
106
+ insert(textarea, `\n${indent}`)
107
+ }
108
+ }
109
+
110
+ // value, language ("html" or "css"), oninput(value); class sizes the field
111
+ export function codeInput({ value = "", language = "html", oninput, class: className = "min-h-32", readOnly = false }) {
112
+ const highlight = HIGHLIGHT[language] || escape
113
+ const colored = h("pre", { "aria-hidden": "true", class: `${BOX} pointer-events-none absolute inset-0 h-full overflow-hidden border-transparent bg-white text-gray-800` })
114
+ const textarea = h("textarea", {
115
+ class: `${BOX} ${className} relative block resize-y overflow-y-auto border-gray-300 bg-transparent text-transparent caret-gray-900 outline-none selection:bg-blue-500/25 focus:border-blue-500 focus:ring-1 focus:ring-blue-500`,
116
+ spellcheck: false,
117
+ autocapitalize: "off",
118
+ autocomplete: "off",
119
+ readOnly,
120
+ value,
121
+ onkeydown: onKeydown,
122
+ onscroll: () => (colored.scrollTop = textarea.scrollTop),
123
+ oninput: () => {
124
+ paint()
125
+ oninput?.(textarea.value)
126
+ }
127
+ })
128
+ // A trailing newline needs a character after it to take up a line
129
+ const paint = () => {
130
+ colored.innerHTML = `${highlight(textarea.value)}\n `
131
+ colored.scrollTop = textarea.scrollTop
132
+ }
133
+ paint()
134
+ const field = h("div", { class: "relative" }, colored, textarea)
135
+ field.textarea = textarea
136
+ return field
137
+ }
@@ -0,0 +1,35 @@
1
+ // A tiny element builder: h("button", { class: "…", onclick: fn }, "Label", child)
2
+
3
+ export function h(tag, props = {}, ...children) {
4
+ const element = document.createElement(tag)
5
+ for (const [name, value] of Object.entries(props || {})) {
6
+ if (value === undefined || value === null || value === false) continue
7
+ if (name.startsWith("on") && typeof value === "function") {
8
+ element.addEventListener(name.slice(2).toLowerCase(), value)
9
+ } else if (name === "html") {
10
+ element.innerHTML = value
11
+ } else if (name === "dataset") {
12
+ Object.assign(element.dataset, value)
13
+ } else if (name === "value" || (name in element && typeof value !== "string")) {
14
+ // A textarea ignores a value attribute, so values are always set as the property
15
+ element[name] = value
16
+ } else {
17
+ element.setAttribute(name, value === true ? "" : value)
18
+ }
19
+ }
20
+ for (const child of children.flat()) {
21
+ if (child === null || child === undefined || child === false) continue
22
+ element.append(child instanceof Node ? child : document.createTextNode(String(child)))
23
+ }
24
+ return element
25
+ }
26
+
27
+ export function debounce(fn, wait) {
28
+ let timer
29
+ const debounced = (...args) => {
30
+ clearTimeout(timer)
31
+ timer = setTimeout(() => fn(...args), wait)
32
+ }
33
+ debounced.cancel = () => clearTimeout(timer)
34
+ return debounced
35
+ }
@@ -0,0 +1,63 @@
1
+ // Small inline SVG icons (24×24, stroke based) so the editor needs no icon font.
2
+
3
+ const paths = {
4
+ root: '<rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h4"/>',
5
+ section: '<rect x="3" y="6" width="18" height="12" rx="2"/>',
6
+ wrapper: '<rect x="2" y="3" width="20" height="18" rx="2"/><rect x="5" y="7" width="14" height="4" rx="1"/><rect x="5" y="13" width="14" height="4" rx="1"/>',
7
+ column: '<rect x="8" y="3" width="8" height="18" rx="2"/>',
8
+ hero: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 10h10M9 14h6"/>',
9
+ text: '<path d="M5 6V4h14v2M12 4v16M9 20h6"/>',
10
+ heading: '<path d="M6 4v16M18 4v16M6 12h12"/>',
11
+ image: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="m21 16-5-5-9 9"/>',
12
+ button: '<rect x="3" y="8" width="18" height="8" rx="4"/><path d="M9 12h6"/>',
13
+ divider: '<path d="M3 12h18"/><path d="M8 7h8M8 17h8" opacity=".4"/>',
14
+ spacer: '<path d="M12 4v16M8 8l4-4 4 4M8 16l4 4 4-4"/>',
15
+ social: '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="m8.2 10.9 7.6-3.8M8.2 13.1l7.6 3.8"/>',
16
+ navbar: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 9h18M7 7h0M10 7h0"/>',
17
+ code: '<path d="m8 8-4 4 4 4M16 8l4 4-4 4M14 5l-4 14"/>',
18
+ loop: '<path d="M17 2l3 3-3 3"/><path d="M4 11V9a4 4 0 0 1 4-4h12M7 22l-3-3 3-3"/><path d="M20 13v2a4 4 0 0 1-4 4H4"/>',
19
+ condition: '<path d="M6 3v6a6 6 0 0 0 6 6h6M6 21v-6M15 12l3 3-3 3"/>',
20
+ "cols-1": '<rect x="3" y="6" width="18" height="12" rx="1.5"/>',
21
+ "cols-2": '<rect x="3" y="6" width="8" height="12" rx="1.5"/><rect x="13" y="6" width="8" height="12" rx="1.5"/>',
22
+ "cols-3": '<rect x="2" y="6" width="5.5" height="12" rx="1"/><rect x="9.25" y="6" width="5.5" height="12" rx="1"/><rect x="16.5" y="6" width="5.5" height="12" rx="1"/>',
23
+ "cols-4": '<rect x="2" y="6" width="4" height="12" rx="1"/><rect x="7.33" y="6" width="4" height="12" rx="1"/><rect x="12.66" y="6" width="4" height="12" rx="1"/><rect x="18" y="6" width="4" height="12" rx="1"/>',
24
+ "cols-12": '<rect x="3" y="6" width="5.5" height="12" rx="1.5"/><rect x="10.5" y="6" width="10.5" height="12" rx="1.5"/>',
25
+ "cols-21": '<rect x="3" y="6" width="10.5" height="12" rx="1.5"/><rect x="15.5" y="6" width="5.5" height="12" rx="1.5"/>',
26
+ "panels-left": '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M10 4v16"/><path d="M5.5 8h2M5.5 11h2" opacity=".6"/>',
27
+ "panels-split": '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16M16 4v16"/>',
28
+ "panels-right": '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M14 4v16"/><path d="M16.5 8h2M16.5 11h2" opacity=".6"/>',
29
+ lock: '<rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
30
+ plus: '<path d="M12 5v14M5 12h14"/>',
31
+ trash: '<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>',
32
+ copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
33
+ move: '<path d="M12 3v18M3 12h18M9 6l3-3 3 3M9 18l3 3 3-3M6 9l-3 3 3 3M18 9l3 3-3 3"/>',
34
+ up: '<path d="M12 19V5M6 11l6-6 6 6"/>',
35
+ parent: '<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 0 10h-1"/>',
36
+ undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 0 10h-4"/>',
37
+ redo: '<path d="m15 14 5-5-5-5"/><path d="M20 9H9a5 5 0 0 0 0 10h4"/>',
38
+ desktop: '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
39
+ mobile: '<rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/>',
40
+ edit: '<path d="M4 20h4L19 9l-4-4L4 16z"/>',
41
+ eye: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>',
42
+ export: '<path d="M12 3v12M7 8l5-5 5 5M5 15v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/>',
43
+ chevron: '<path d="m9 6 6 6-6 6"/>',
44
+ close: '<path d="M6 6l12 12M18 6 6 18"/>',
45
+ bold: '<path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z"/>',
46
+ italic: '<path d="M14 5h-4M14 19h-4M14 5l-4 14"/>',
47
+ underline: '<path d="M7 4v7a5 5 0 0 0 10 0V4M5 20h14"/>',
48
+ strike: '<path d="M5 12h14M16 6a4 3 0 0 0-8 1c0 4 8 3 8 7a4 3 0 0 1-8 1"/>',
49
+ link: '<path d="M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1 1M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1-1"/>',
50
+ unlink: '<path d="M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1 1M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1-1M3 3l18 18"/>',
51
+ "align-left": '<path d="M4 6h16M4 10h10M4 14h16M4 18h10"/>',
52
+ "align-center": '<path d="M4 6h16M7 10h10M4 14h16M7 18h10"/>',
53
+ "align-right": '<path d="M4 6h16M10 10h10M4 14h16M10 18h10"/>',
54
+ "list-bullet": '<path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r="1"/><circle cx="4.5" cy="12" r="1"/><circle cx="4.5" cy="18" r="1"/>',
55
+ "list-ordered": '<path d="M10 6h10M10 12h10M10 18h10M4 4h1v4M4 14h2l-2 3h2"/>',
56
+ clear: '<path d="M5 6h12M11 6l-3 13M15 15l5 5M20 15l-5 5"/>'
57
+ }
58
+
59
+ export function icon(name, className = "w-4 h-4") {
60
+ // Custom blocks may pass their own SVG markup instead of a name.
61
+ const body = name?.trim().startsWith("<") ? name : paths[name] || paths.section
62
+ return `<svg class="${className}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`
63
+ }
@@ -0,0 +1,377 @@
1
+ // The Inspect panel: renders the selected block's fields from its
2
+ // definition (blocks/*.js, via core/registry.js) and writes changes back through the editor.
3
+
4
+ import { imageSource } from "tess_edit/core/media"
5
+ import { getBlock, label } from "tess_edit/core/registry"
6
+ import { find, getValue } from "tess_edit/core/document"
7
+ import { codeInput } from "tess_edit/ui/code_input"
8
+ import { h } from "tess_edit/ui/dom"
9
+ import { icon } from "tess_edit/ui/icons"
10
+
11
+ const INPUT = "w-full min-w-0 rounded-md border border-gray-300 bg-white px-2 py-1 text-[13px] text-gray-900 shadow-xs outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
12
+
13
+ // "12" → "12px"; anything else is left alone.
14
+ export function withUnit(value) {
15
+ const text = String(value ?? "").trim()
16
+ return /^-?\d+(\.\d+)?$/.test(text) ? `${text}px` : text
17
+ }
18
+
19
+ export function parseSpacing(value) {
20
+ const parts = String(value || "").trim().split(/\s+/).filter(Boolean)
21
+ if (!parts.length) return ["", "", "", ""]
22
+ const [top, right = top, bottom = top, left = right] = parts
23
+ return [top, right, bottom, left]
24
+ }
25
+
26
+ export function formatSpacing([top, right, bottom, left]) {
27
+ const values = [top, right, bottom, left].map((v) => withUnit(v) || "0px")
28
+ if (![top, right, bottom, left].some((v) => String(v).trim())) return ""
29
+ if (values.every((v) => v === values[0])) return values[0]
30
+ if (values[0] === values[2] && values[1] === values[3]) return `${values[0]} ${values[1]}`
31
+ return values.join(" ")
32
+ }
33
+
34
+ export class Inspector {
35
+ constructor(editor, element) {
36
+ this.editor = editor
37
+ this.element = element
38
+ this.openGroups = new Map()
39
+ }
40
+
41
+ render() {
42
+ const { doc, selectedId } = this.editor
43
+ const hit = find(doc, selectedId) || { node: doc }
44
+ const node = hit.node
45
+ const definition = getBlock(node.type)
46
+
47
+ const actions =
48
+ !this.editor.isFree(node.id)
49
+ ? []
50
+ : [
51
+ ["parent", "Select parent", () => this.editor.selectParent()],
52
+ ["copy", "Duplicate", () => this.editor.duplicate(node.id)],
53
+ ["trash", "Delete", () => this.editor.remove(node.id)]
54
+ ].map(([name, title, onclick]) =>
55
+ h("button", { type: "button", title, class: "w-7 h-7 flex items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900", html: icon(name), onclick })
56
+ )
57
+
58
+ const header = h(
59
+ "div",
60
+ { class: "flex items-center gap-2 px-4 h-12 border-b border-gray-200 shrink-0" },
61
+ h("span", { class: "text-gray-400", html: icon(definition.icon, "w-4 h-4") }),
62
+ h("h2", { class: "font-semibold text-gray-900 truncate" }, label(node)),
63
+ h("div", { class: "ml-auto flex items-center gap-0.5" }, actions)
64
+ )
65
+
66
+ const groups = (definition.fields || []).map((group) => this.group(node, group))
67
+ const locked = this.editor.lock && node.id !== doc.id && !this.editor.isFree(node.id)
68
+ ? h("p", { class: "mx-4 mt-3 flex items-center gap-2 rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800" }, h("span", { html: icon("lock", "w-3.5 h-3.5") }), "Part of the layout")
69
+ : null
70
+ // A block's hint explains how to edit it, which a locked block can't be.
71
+ const hint = definition.hint && !locked ? h("p", { class: "mx-4 mt-3 rounded-md bg-gray-50 px-3 py-2 text-xs text-gray-600" }, definition.hint) : null
72
+
73
+ this.element.replaceChildren(header, h("div", { class: "flex-1 overflow-y-auto pb-6" }, locked, hint, groups))
74
+ }
75
+
76
+ group(node, group) {
77
+ const key = `${node.type}:${group.title}`
78
+ const open = this.openGroups.has(key) ? this.openGroups.get(key) : !group.collapsed
79
+ const details = h(
80
+ "details",
81
+ { class: "group/field border-b border-gray-100", open, ontoggle: (event) => this.openGroups.set(key, event.currentTarget.open) },
82
+ h(
83
+ "summary",
84
+ { class: "flex items-center gap-1.5 px-4 py-2.5 cursor-pointer select-none text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-800 list-none [&::-webkit-details-marker]:hidden" },
85
+ h("span", { html: icon("chevron", "w-3 h-3 transition-transform group-open/field:rotate-90") }),
86
+ group.title
87
+ ),
88
+ // A field's visible(node, editor) shows it only when it applies
89
+ h("div", { class: "px-4 pb-4 space-y-3" }, group.fields.filter((field) => !field.visible || field.visible(node, this.editor)).map((field) => this.field(node, field)))
90
+ )
91
+ return details
92
+ }
93
+
94
+ field(node, field) {
95
+ const value = getValue(node, field.key) ?? ""
96
+ const set = (next, options = {}) => this.editor.setValue(node.id, field.key, next, options)
97
+ // A fieldset disables every input and button of a locked field at once.
98
+ const editable = this.editor.canEditField(node.id, field.key)
99
+ const control = editable
100
+ ? this.control(node, field, value, set)
101
+ : h("fieldset", { disabled: true, class: "opacity-60" }, this.control(node, field, value, set))
102
+ const expand = field.type === "code" && editable
103
+ ? h("button", { type: "button", title: `Edit ${field.label} in a larger window`, class: "absolute right-0 top-0 rounded px-1 text-[11px] font-medium text-blue-600 hover:bg-blue-50", onclick: () => this.expand(node, field) }, "Expand")
104
+ : null
105
+ // A div, not a label: a label would forward clicks to the first button or color picker inside it.
106
+ return h(
107
+ "div",
108
+ { class: "relative block" },
109
+ h("span", { class: "mb-1 block text-xs font-medium text-gray-700" }, field.label),
110
+ expand,
111
+ control,
112
+ field.hint ? h("span", { class: "mt-1 block text-[11px] leading-snug text-gray-500" }, field.hint) : null
113
+ )
114
+ }
115
+
116
+ control(node, field, value, set) {
117
+ switch (field.type) {
118
+ case "textarea":
119
+ return h("textarea", { class: `${INPUT} min-h-32`, rows: 4, value, oninput: (event) => set(event.target.value) })
120
+
121
+ case "code":
122
+ return codeInput({ value, language: field.language, oninput: set, class: "h-60" })
123
+
124
+ case "size":
125
+ return h("input", {
126
+ type: "text",
127
+ class: INPUT,
128
+ value,
129
+ placeholder: field.placeholder || "",
130
+ oninput: (event) => set(withUnit(event.target.value)),
131
+ onchange: (event) => (event.target.value = withUnit(event.target.value))
132
+ })
133
+
134
+ case "color":
135
+ return this.color(value, set)
136
+
137
+ case "select":
138
+ return h(
139
+ "select",
140
+ { class: INPUT, onchange: (event) => set(event.target.value) },
141
+ field.options.map((option) => {
142
+ const optionValue = typeof option === "string" ? option : option.value
143
+ const optionLabel = typeof option === "string" ? option || "Default" : option.label
144
+ return h("option", { value: optionValue, selected: optionValue === value }, optionLabel)
145
+ })
146
+ )
147
+
148
+ case "segmented":
149
+ return h(
150
+ "div",
151
+ { class: "flex rounded-md border border-gray-300 overflow-hidden" },
152
+ field.options.map((option) =>
153
+ h(
154
+ "button",
155
+ {
156
+ type: "button",
157
+ title: value === option.value ? "Click again to use the default" : "",
158
+ class: `flex-1 px-2 py-1 text-xs border-l first:border-l-0 border-gray-300 ${value === option.value ? "bg-blue-600 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`,
159
+ onclick: (event) => {
160
+ event.preventDefault()
161
+ set(value === option.value ? "" : option.value)
162
+ this.render()
163
+ }
164
+ },
165
+ option.label
166
+ )
167
+ )
168
+ )
169
+
170
+ case "toggle":
171
+ return h(
172
+ "span",
173
+ { class: "flex items-center gap-2" },
174
+ h("input", {
175
+ type: "checkbox",
176
+ class: "h-4 w-4 rounded border-gray-300 text-blue-600",
177
+ checked: value === field.on,
178
+ onchange: (event) => set(event.target.checked ? field.on : field.off)
179
+ }),
180
+ h("span", { class: "text-xs text-gray-600" }, value === field.on ? "On" : "Off")
181
+ )
182
+
183
+ case "spacing":
184
+ return this.spacing(value, set)
185
+
186
+ case "list":
187
+ return this.list(node, field, Array.isArray(value) ? value : [], set)
188
+
189
+ case "image":
190
+ return this.image(node, field, value, set)
191
+
192
+ default:
193
+ return h("input", { type: "text", class: INPUT, value, placeholder: field.placeholder || "", oninput: (event) => set(event.target.value) })
194
+ }
195
+ }
196
+
197
+ // An image URL, or with the host's images (editor.media, core/media.js) a
198
+ // choice between one of those, a new upload, and a URL
199
+ image(node, field, value, set) {
200
+ const url = h("input", { type: "text", class: INPUT, value, placeholder: field.placeholder || "https://…", oninput: (event) => set(event.target.value) })
201
+ if (!this.editor.media.enabled) return url
202
+
203
+ const source = imageSource(node, this.editor, field)
204
+ const sources = this.control(node, { type: "segmented", options: [{ value: "upload", label: "Uploaded" }, { value: "web", label: "Web" }] }, source, (next) =>
205
+ this.editor.setValue(node.id, field.source, next || source)
206
+ )
207
+ return h("div", { class: "space-y-1.5" }, sources, source === "upload" ? this.mediaPicker(node, field, getValue(node, field.image)) : url)
208
+ }
209
+
210
+ mediaPicker(node, field, value) {
211
+ const { images, uploading, error } = this.editor.media
212
+ const current = images.find((image) => image.id === value)
213
+ const file = h("input", {
214
+ type: "file",
215
+ accept: "image/*",
216
+ class: "hidden",
217
+ onchange: (event) => {
218
+ const chosen = event.target.files[0]
219
+ if (chosen) this.editor.uploadImage(node.id, chosen, field)
220
+ }
221
+ })
222
+ const select = h(
223
+ "select",
224
+ {
225
+ class: `${INPUT} min-w-0 flex-1`,
226
+ onchange: (event) => {
227
+ const image = images.find((option) => option.id === event.target.value)
228
+ if (image) this.editor.chooseImage(node.id, image, field)
229
+ }
230
+ },
231
+ h("option", { value: "" }, images.length ? "Choose an image" : "No images yet"),
232
+ images.map((image) => h("option", { value: image.id, selected: image.id === value }, image.name))
233
+ )
234
+ return h(
235
+ "div",
236
+ {},
237
+ h(
238
+ "div",
239
+ { class: "flex items-center gap-1.5" },
240
+ select,
241
+ h("button", { type: "button", class: "shrink-0 rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50", disabled: uploading, onclick: () => file.click() }, uploading ? "Uploading…" : "Upload"),
242
+ file
243
+ ),
244
+ error ? h("p", { class: "mt-1 text-[11px] text-red-600", role: "alert" }, error) : null,
245
+ current ? h("img", { src: current.url, alt: "", class: "mt-2 max-h-24 rounded border border-gray-200" }) : null
246
+ )
247
+ }
248
+
249
+ // A code field in a dialog over the editor, for more room. Changes apply as
250
+ // you type, like in the field itself.
251
+ expand(node, field) {
252
+ const close = () => {
253
+ dialog.remove()
254
+ this.render()
255
+ }
256
+ const input = codeInput({ value: getValue(node, field.key) ?? "", language: field.language, oninput: (next) => this.editor.setValue(node.id, field.key, next), class: "h-full min-h-0 resize-none" })
257
+ input.classList.add("flex-1", "min-h-0")
258
+ const dialog = h(
259
+ "div",
260
+ {
261
+ class: "absolute inset-0 z-40 flex items-center justify-center bg-gray-900/40 p-6",
262
+ onmousedown: (event) => event.target === dialog && close(),
263
+ // Escape closes the dialog, not anything behind it
264
+ onkeydown: (event) => {
265
+ if (event.key !== "Escape") return
266
+ event.stopPropagation()
267
+ close()
268
+ }
269
+ },
270
+ h(
271
+ "div",
272
+ { role: "dialog", "aria-label": field.label, class: "flex h-full max-h-[800px] w-full max-w-5xl flex-col gap-3 rounded-xl bg-white p-4 shadow-xl" },
273
+ h(
274
+ "div",
275
+ { class: "flex items-center gap-3" },
276
+ h("h3", { class: "text-sm font-semibold text-gray-900" }, label(node) === field.label ? field.label : `${label(node)} · ${field.label}`),
277
+ field.hint ? h("span", { class: "text-xs text-gray-500" }, field.hint) : null,
278
+ h("button", { type: "button", title: "Close", class: "ml-auto w-8 h-8 flex items-center justify-center rounded-md text-gray-600 hover:bg-gray-100", html: icon("close"), onclick: close })
279
+ ),
280
+ input
281
+ )
282
+ )
283
+ this.editor.root.append(dialog)
284
+ input.textarea.focus()
285
+ }
286
+
287
+ color(value, set) {
288
+ const text = h("input", {
289
+ type: "text", class: `${INPUT} font-mono text-xs`, value, placeholder: "Default", oninput: (event) => {
290
+ set(event.target.value)
291
+ if (/^#[0-9a-f]{6}$/i.test(event.target.value)) swatch.value = event.target.value
292
+ }
293
+ })
294
+ const swatch = h("input", {
295
+ type: "color",
296
+ class: "h-7 w-8 shrink-0 cursor-pointer rounded border border-gray-300 bg-white p-0.5",
297
+ value: /^#[0-9a-f]{6}$/i.test(value) ? value : "#ffffff",
298
+ oninput: (event) => {
299
+ text.value = event.target.value
300
+ set(event.target.value)
301
+ }
302
+ })
303
+ return h("span", { class: "flex items-center gap-1.5" }, swatch, text)
304
+ }
305
+
306
+ spacing(value, set) {
307
+ const values = parseSpacing(value)
308
+ const inputs = ["Top", "Right", "Bottom", "Left"].map((side, i) =>
309
+ h(
310
+ "span",
311
+ { class: "block" },
312
+ h("input", {
313
+ type: "text",
314
+ class: `${INPUT} text-center px-1`,
315
+ value: values[i],
316
+ title: side,
317
+ placeholder: side[0],
318
+ oninput: (event) => {
319
+ values[i] = event.target.value
320
+ set(formatSpacing(values))
321
+ }
322
+ }),
323
+ h("span", { class: "block text-center text-[10px] text-gray-400" }, side)
324
+ )
325
+ )
326
+ return h("span", { class: "grid grid-cols-4 gap-1" }, inputs)
327
+ }
328
+
329
+ list(node, field, items, set) {
330
+ const update = (next) => {
331
+ set(next, { coalesce: false })
332
+ this.render()
333
+ }
334
+ const rows = items.map((item, index) =>
335
+ h(
336
+ "div",
337
+ { class: "rounded-md border border-gray-200 bg-gray-50 p-2 space-y-1.5" },
338
+ field.item.map((part) =>
339
+ part.type === "select"
340
+ ? h(
341
+ "select",
342
+ { class: INPUT, onchange: (event) => update(items.map((it, i) => (i === index ? { ...it, [part.name]: event.target.value } : it))) },
343
+ part.options.map((option) => h("option", { value: option, selected: option === item[part.name] }, option))
344
+ )
345
+ : h("input", {
346
+ type: "text",
347
+ class: INPUT,
348
+ placeholder: part.label,
349
+ value: item[part.name] ?? "",
350
+ oninput: (event) => {
351
+ items = items.map((it, i) => (i === index ? { ...it, [part.name]: event.target.value } : it))
352
+ set(items)
353
+ }
354
+ })
355
+ ),
356
+ h(
357
+ "div",
358
+ { class: "flex justify-end gap-1" },
359
+ h("button", {
360
+ type: "button", title: "Move up", class: "w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:bg-gray-200 disabled:opacity-30", disabled: index === 0, html: icon("up", "w-3.5 h-3.5"), onclick: () => {
361
+ const next = [...items]
362
+ ;[next[index - 1], next[index]] = [next[index], next[index - 1]]
363
+ update(next)
364
+ }
365
+ }),
366
+ h("button", { type: "button", title: "Remove", class: "w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:bg-gray-200 hover:text-red-600", html: icon("trash", "w-3.5 h-3.5"), onclick: () => update(items.filter((_, i) => i !== index)) })
367
+ )
368
+ )
369
+ )
370
+ return h(
371
+ "div",
372
+ { class: "space-y-2" },
373
+ rows,
374
+ h("button", { type: "button", class: "w-full rounded-md border border-dashed border-gray-300 py-1.5 text-xs text-gray-600 hover:border-blue-400 hover:text-blue-600", onclick: () => update([...items, { ...field.newItem }]) }, "Add")
375
+ )
376
+ }
377
+ }