tess_edit 0.4.3 → 0.5.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c56f2e7b17a8fbd7a3ef64e0c12c14858918b5710ba8d0b50a76c26f4eb3f881
4
- data.tar.gz: f99aadcc71360ee330d3583d5e7caac11514945e2d6c01501070f8cc79eda6b4
3
+ metadata.gz: 1401c4a6e315c3ae6d07dc4ee7cf80ae9b384fb532a570d09f5e6a1593da12a0
4
+ data.tar.gz: f0045ef4c910b201fade5d8f10245d61636a69f4bafd4fd6200beaaf29db6243
5
5
  SHA512:
6
- metadata.gz: 674212f3ad1388f2c72e4a025c186be0cac74a599d7020d289b3f2e7d31f3fb9c8141ea7215efd9eeac21d3206646deae3b2bba05507d6b70f183821f32151dc
7
- data.tar.gz: de52504e92d98fa3070159c34253542a42a32a9b226920bef1bfde9cc8b2cbd8245deabd6a51f26a20448fd2bb2e910ada14282cc8a210f086ff935e80f9417d
6
+ metadata.gz: 4a35b96d1c0696e3b1152c06392efa789256443651aaac7705f2df5c58924dc281d25b23b17e23509e6b18fd88bc0c7a7a9d8d693d0a049cfe365887299bf265
7
+ data.tar.gz: c6fbba58d1b069991db8aef3111950958cbbf6011ab38027cc347a8e7dd67c807a3427b0bdcbcac9ffc23637de25fdab63cbd80db92692a07a15c5eb6f8aa5d3
data/README.md CHANGED
@@ -220,6 +220,8 @@ The endpoint answers `GET` with the variables at `path` (the roots without one),
220
220
 
221
221
  `children` says a variable has variables of its own, at the path with its name added; a `collection` is a list of them, and its path goes into its elements (`order.order_lines.product`). The editor offers a collection's `first`, `last` and `size`, and inside a Loop block (`{% for line in order.order_lines %}`) its variable, `line`, asking for `order.order_lines.…` for it. An unknown path answers 404. [Liquidum](https://github.com/entdec/liquidum)'s `/liquidum/variables` answers this way from your drops; the dummy app's `VariablesController` is a fixed example (`/?variables=1`).
222
222
 
223
+ The endpoint can change while the editor is open, say when the record a template is for changes: set `data-tess-edit-variables-url-value` again and the editor asks the new one.
224
+
223
225
  A field of your own gets the `{ }` button with `liquid: true`, or `liquid: "expression"` (a condition) or `liquid: "collection"` (what a loop goes over) for Liquid without braces.
224
226
 
225
227
  ### Translations
@@ -276,6 +278,32 @@ registerTessEdit(application)
276
278
 
277
279
  Roles say where a block may go: `body` (in the email or a wrapper, like sections and heroes), `wrapper`, `column` (in a section) and `content` (in a column or hero). A container lists the roles it takes in `accepts`; Loop and Condition use `"inherit"`. The full definition, and the helpers a render hook gets (`element`, `container`, `children`, `liquid`, `attrs`, `richText`, ...), are described at the top of `core/registry.js` and `export/mjml.js`. Blocks render only through their `render` hooks: `mjml` for emails, and a hook of its own for each kit. Stored documents don't change; a document with a block type that isn't registered fails `normalize`.
278
280
 
281
+ `extendBlock(type, { fields })` adds fields to a block that is registered: a group with the title of one of its groups adds to that one, any other goes last, or before the group its `before` names.
282
+
283
+ #### Your own form fields in Inspect
284
+
285
+ A `slot` field shows an element of your page in Inspect, for settings that aren't part of the document, such as a template's trigger in your own form fields. Render the element inside the editor, hidden, as a `slot` target named by `data-tess-edit-slot`:
286
+
287
+ ```erb
288
+ <div data-controller="tess-edit">
289
+ <div data-tess-edit-target="slot" data-tess-edit-slot="trigger" hidden>
290
+ <%= form.select :event, … %>
291
+ </div>
292
+ </div>
293
+ ```
294
+
295
+ ```js
296
+ extendBlock("email", {
297
+ fields: [{ title: "When", before: "Message", fields: [{
298
+ key: "trigger", label: "Trigger", type: "slot", slot: "trigger",
299
+ summary: (element) => element.querySelector("select").selectedOptions[0]?.text, // shown while collapsed
300
+ open: (element) => Boolean(element.querySelector("[role=alert]")) // open at first, e.g. with errors
301
+ }] }]
302
+ })
303
+ ```
304
+
305
+ With a `summary` the field shows collapsed to it, with a button to edit it; without one the element always shows. Its values are your form fields, submitted with the form; without the element the field doesn't show. The element moves into Inspect once and stays there, hidden while another block is selected, so your Stimulus controllers in it keep their state. Combining or splitting the panels moves it again: its controllers reconnect then, with the values typed and chosen written into the fields' attributes first. The demo has one at `/?slots=1` (`test/dummy/app/javascript/slots.js`).
306
+
279
307
  A select field's `options` can be a function of the editor, `options: (editor) => [...]`, for choices from your app. Give the editor those with `data-tess-edit-options-value` (JSON), which blocks read as `editor.options`.
280
308
 
281
309
  ## Kits
@@ -301,6 +329,8 @@ A block is part of the email kit unless it says otherwise. `extendBlock` adds ki
301
329
 
302
330
  Loading a document into the editor of another kit fails with a message saying which editor it is for.
303
331
 
332
+
333
+ A kit can leave out `blocks`: with `blocks: false` it is its root alone, such as a text message with its fields in Inspect, and the editor has no Blocks and Layers panels. Its `canvas` and `outputs` hooks get the editor's `options` and `element` too, for a canvas that previews from your app (`/?kit=memo` in the demo), where a link with `data-tess-open` opens in a new tab instead of selecting; the canvas renders again when `data-tess-edit-variables-url-value` changes. A field's `hint` can be a function of its value, `` hint: (value, node) => `${value.length} / 160` ``, kept up to date as it's typed. Code fields color `html`, `xml`, `css` and `yaml`.
304
334
  ## Development
305
335
 
306
336
  The dummy app in `test/dummy` serves a demo page:
@@ -22,7 +22,7 @@ export default {
22
22
  }),
23
23
  fields: [
24
24
  {
25
- title: "Email",
25
+ title: "Message",
26
26
  fields: [
27
27
  { key: "data.subject", label: "Subject", type: "text", liquid: true },
28
28
  { key: "data.preheader", label: "Preview text", type: "text", liquid: true, hint: "Shown after the subject in most inboxes." }
@@ -1,8 +1,10 @@
1
1
  // Field definitions shared by the built-in blocks, and usable by custom ones.
2
2
  // A field: { key: "attributes.padding" | "data.subject", label, type, ... }.
3
3
  // Types: text, textarea, code, size, color, select, segmented, toggle,
4
- // spacing and list; see ui/inspector.js. A select's options can be a
5
- // function of the editor, options(editor), for choices the host gives.
4
+ // spacing, list and slot (an element of the host's); see ui/inspector.js. A select's options can be a
5
+ // function of the editor, options(editor), for choices the host gives. A
6
+ // field's hint can be a function of its value, hint(value, node), shown as
7
+ // it's typed (a counter).
6
8
  // `liquid` gives a field a button to insert the host's variables (see
7
9
  // Inspector#variables).
8
10
 
@@ -11,14 +11,16 @@
11
11
  // as tabs on one side). The default; a choice made in the editor's
12
12
  // top bar is remembered per browser. Below NARROW_WIDTH the editor
13
13
  // puts all panels on the left, whatever the choice, to leave room
14
- // for the canvas.
14
+ // for the canvas. A kit without blocks (blocks: false) has Inspect
15
+ // on the right only.
15
16
  // compileUrl the endpoint that compiles the email kit's MJML to HTML (default
16
17
  // /tess_edit/compile, the engine mounted at /tess_edit)
17
18
  // mediaUrl the host's images, chosen and uploaded in Image blocks (see
18
19
  // core/media.js); without it images only have a URL
19
20
  // variablesUrl the variables a template can use, suggested while typing {{
20
21
  // and browsed with { } buttons (see core/variables.js), e.g.
21
- // Liquidum's /liquidum/variables?roots=…
22
+ // Liquidum's /liquidum/variables?roots=…; a host can change it,
23
+ // e.g. when the class a template is for changes
22
24
  // translationsUrl the host's translations for Liquid's t filter ({{ '.greeting' | t }}),
23
25
  // shown in a language chosen in the top bar, listed and edited
24
26
  // while pointed at, and made from selected text (see
@@ -34,6 +36,8 @@
34
36
  // Targets (all optional, usually hidden inputs in a form):
35
37
  // jsonInput, mjmlInput, htmlInput kept up to date with every change, with
36
38
  // the kit's outputs (an <output>Input target for each)
39
+ // slot an element of the host's, named by data-tess-edit-slot,
40
+ // that a "slot" field shows in Inspect (see ui/inspector.js)
37
41
  // Events:
38
42
  // tess-edit:change detail: { json, ...the kit's outputs, errors, document }:
39
43
  // for an email { json, mjml, html, errors, document }, with
@@ -77,7 +81,7 @@ function placed(node, at) {
77
81
 
78
82
  export default class extends Controller {
79
83
  static values = { kit: { type: String, default: DEFAULT_KIT }, document: String, sample: { type: Boolean, default: true }, frame: { type: Boolean, default: true }, panels: { type: String, default: "split" }, compileUrl: { type: String, default: COMPILE_URL }, mediaUrl: String, variablesUrl: String, translationsUrl: String, lock: String, blocks: String, options: String }
80
- static targets = ["jsonInput", "mjmlInput", "htmlInput"]
84
+ static targets = ["jsonInput", "mjmlInput", "htmlInput", "slot"]
81
85
 
82
86
  connect() {
83
87
  this.kit = getKit(this.kitValue)
@@ -123,6 +127,24 @@ export default class extends Controller {
123
127
  this.element.tessEdit = this
124
128
  }
125
129
 
130
+ // Points the editor at other variables (variablesUrl), once it's connected
131
+ variablesUrlValueChanged() {
132
+ if (!this.variables) return
133
+ this.variables = new Variables(this.variablesUrlValue)
134
+ if (!this.variableMenu && this.variables.enabled) {
135
+ this.variableMenu = new VariableMenu(this)
136
+ this.textEditing.menu = this.variableMenu
137
+ }
138
+ this.inspector.render()
139
+ // A kit's canvas may depend on them, like a preview with sample values
140
+ this.scheduleCanvas()
141
+ }
142
+
143
+ // The host's element for a slot field, if it gave one
144
+ slot(name) {
145
+ return this.slotTargets.find((element) => element.dataset.tessEditSlot === name) || null
146
+ }
147
+
126
148
  // --- translations -------------------------------------------------------------
127
149
 
128
150
  // The language translations show in: the one chosen in the top bar, else
@@ -407,6 +429,13 @@ export default class extends Controller {
407
429
  const panelClass = (border, width) => `${width} shrink-0 flex flex-col ${border} border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 min-h-0`
408
430
  // Only the sidebars move: moving <main> would reload the canvas iframe and blank it.
409
431
  if (!this.main.isConnected) this.body.append(this.main)
432
+ this.inspector?.freezeSlots()
433
+ if (!this.hasBlocks) {
434
+ this.inspectPanel.className = panelClass("border-l", "w-80")
435
+ if (this.inspectorElement.parentNode !== this.inspectPanel) this.inspectPanel.replaceChildren(this.inspectorElement)
436
+ if (this.main.nextSibling !== this.inspectPanel) this.main.after(this.inspectPanel)
437
+ return
438
+ }
410
439
  this.sidePanel.replaceChildren(this.leftTabs, this.blocksElement, this.treeElement)
411
440
  if (this.layout === "split") {
412
441
  this.sidePanel.className = panelClass("border-r", "w-64")
@@ -451,11 +480,17 @@ export default class extends Controller {
451
480
  this.resizeObserver.observe(this.root)
452
481
  }
453
482
 
454
- // The layout in use: the chosen one, unless the editor is too narrow to split.
483
+ // The layout in use: the chosen one, unless the editor is too narrow to
484
+ // split, or the kit has no blocks
455
485
  get layout() {
486
+ if (!this.hasBlocks) return "split"
456
487
  return this.narrow && this.panels === "split" ? "left" : this.panels
457
488
  }
458
489
 
490
+ get hasBlocks() {
491
+ return this.kit.blocks !== false
492
+ }
493
+
459
494
  storedPanels() {
460
495
  return this.stored(PANELS_KEY)
461
496
  }
@@ -505,7 +540,8 @@ export default class extends Controller {
505
540
  this.kit.devices?.length ? toggle(this.device, this.kit.devices.map((device) => [device.key, device.icon, device.title]), (device) => this.setDevice(device)) : null,
506
541
  this.translations.enabled ? this.translationLocaleMenu() : null,
507
542
  h("div", { class: "flex-1" }),
508
- toggle(
543
+ // Without blocks there are no panels to arrange
544
+ this.hasBlocks && toggle(
509
545
  this.layout,
510
546
  [
511
547
  ["left", "panels-left", "All panels on the left"],
@@ -514,7 +550,7 @@ export default class extends Controller {
514
550
  ],
515
551
  (panels) => this.setPanels(panels)
516
552
  ),
517
- h("span", { class: "mx-1 h-5 w-px bg-gray-200 dark:bg-gray-700" }),
553
+ this.hasBlocks && h("span", { class: "mx-1 h-5 w-px bg-gray-200 dark:bg-gray-700" }),
518
554
  h("button", { type: "button", title: "Undo (⌘Z)", class: TOOL, disabled: !this.history.canUndo, html: icon("undo"), onclick: () => this.undo() }),
519
555
  h("button", { type: "button", title: "Redo (⇧⌘Z)", class: TOOL, disabled: !this.history.canRedo, html: icon("redo"), onclick: () => this.redo() }),
520
556
  h(
@@ -590,7 +626,7 @@ export default class extends Controller {
590
626
  async run(channel, render) {
591
627
  this.requests[channel]?.abort()
592
628
  const request = (this.requests[channel] = new AbortController())
593
- const result = await render({ compileUrl: this.compileUrlValue, signal: request.signal })
629
+ const result = await render({ compileUrl: this.compileUrlValue, signal: request.signal, options: this.options, element: this.element })
594
630
  return request.signal.aborted ? null : result
595
631
  }
596
632
 
@@ -49,12 +49,17 @@
49
49
  // starter: () => doc, // the sample document
50
50
  // devices: [{ key, icon, title, width }], // optional: canvas widths in the top bar
51
51
  // previewTitle: "…", // the Preview button's title
52
- // canvas: async (doc, { editor, compileUrl, signal }) => ({ html, errors }),
52
+ // canvas: async (doc, { editor, compileUrl, signal, options, element }) => ({ html, errors }),
53
53
  // // the canvas's HTML, with the tess-<id> and tessph-<id>
54
- // // classes when editor is true; null when aborted
55
- // outputs: async (doc, { compileUrl, signal }) => ({ outputs: { html: "…" }, errors }),
54
+ // // classes when editor is true; null when aborted.
55
+ // // options are the editor's (data-tess-edit-options-value),
56
+ // // element its element, for a kit that reads its page;
57
+ // // a link with data-tess-open opens in a new tab
58
+ // outputs: async (doc, { compileUrl, signal, options, element }) => ({ outputs: { html: "…" }, errors }),
56
59
  // // what the editor stores besides the JSON (the inputs,
57
60
  // // the change event and the Code dialog); null when aborted
61
+ // blocks: false, // optional: a kit of its root alone, such as a text
62
+ // // message, has no Blocks and Layers panels
58
63
  // notes: { html: "…" }, // optional: what each output is, in the Code dialog
59
64
  // errors: "The email could not be compiled" // shown before the reasons
60
65
  // })
@@ -65,8 +70,9 @@
65
70
  //
66
71
  // extendBlock("text", { kits: ["note"], render: { note: (node, ctx) => "<div>…</div>" } })
67
72
  //
68
- // which adds the kits and render hooks (and any fields, in groups of their
69
- // own) to the block as it is registered, also when another gem replaced it.
73
+ // which adds the kits and render hooks (and any fields, to its groups or in
74
+ // groups of their own) to the block as it is registered, also when another
75
+ // gem replaced it.
70
76
 
71
77
  import { builtinBlocks, builtinCategories } from "tess_edit/blocks/builtin"
72
78
  import { builtinKits } from "tess_edit/kits/builtin"
@@ -154,7 +160,9 @@ export function registerBlock(definition) {
154
160
  return definitions.get(type)
155
161
  }
156
162
 
157
- // Adds kits, render hooks and field groups to a registered block
163
+ // Adds kits, render hooks and field groups to a registered block. A group
164
+ // with the title of one it has adds its fields to that one; a new group goes
165
+ // last, or before the group its `before` names.
158
166
  export function extendBlock(type, { kits = [], render = {}, fields = [] } = {}) {
159
167
  const definition = definitions.get(type)
160
168
  if (!definition) throw new Error(`No block ${type} to extend`)
@@ -162,11 +170,19 @@ export function extendBlock(type, { kits = [], render = {}, fields = [] } = {})
162
170
  ...definition,
163
171
  kits: [...new Set([...definition.kits, ...kits])],
164
172
  render: { ...definition.render, ...render },
165
- fields: [...(definition.fields || []), ...fields]
173
+ fields: fields.reduce(addGroup, definition.fields || [])
166
174
  })
167
175
  return definitions.get(type)
168
176
  }
169
177
 
178
+ function addGroup(groups, { before, ...group }) {
179
+ if (groups.some((existing) => existing.title === group.title)) {
180
+ return groups.map((existing) => (existing.title === group.title ? { ...existing, fields: [...existing.fields, ...group.fields] } : existing))
181
+ }
182
+ const index = groups.findIndex((existing) => existing.title === before)
183
+ return index < 0 ? [...groups, group] : [...groups.slice(0, index), group, ...groups.slice(index)]
184
+ }
185
+
170
186
  export function getBlock(type) {
171
187
  return definitions.get(type)
172
188
  }
@@ -90,7 +90,13 @@ export class Canvas {
90
90
  if (mode === "edit") {
91
91
  this.listen()
92
92
  this.annotate()
93
- } else doc.addEventListener("click", (event) => event.target.closest("a") && event.preventDefault(), true)
93
+ } else {
94
+ doc.addEventListener("click", (event) => {
95
+ if (!event.target.closest("a")) return
96
+ event.preventDefault()
97
+ this.openLink(event)
98
+ }, true)
99
+ }
94
100
  this.refresh()
95
101
  }
96
102
 
@@ -327,6 +333,7 @@ export class Canvas {
327
333
  if (this.editor.editingId && event.target.closest?.(".ProseMirror")) return
328
334
  if (event.target.closest?.("#tess-overlay")) return
329
335
  event.preventDefault()
336
+ if (this.openLink(event)) return
330
337
  if (this.free.swallowClick()) return
331
338
  const hit = this.hit(event.target)
332
339
  if (hit?.placeholder) {
@@ -342,6 +349,15 @@ export class Canvas {
342
349
  this.editor.select(hit ? hit.id : this.editor.doc.id, { reveal: true })
343
350
  }
344
351
 
352
+ // A link a kit marks with data-tess-open, part of its canvas rather than of
353
+ // the document (a link to a designer), opens in a new tab
354
+ openLink(event) {
355
+ const link = event.target.closest?.("a[data-tess-open][href]")
356
+ if (!link) return false
357
+ window.open(link.href, "_blank", "noopener")
358
+ return true
359
+ }
360
+
345
361
  onDoubleClick(event) {
346
362
  const hit = this.hit(event.target)
347
363
  if (hit && !hit.placeholder && getBlock(find(this.editor.doc, hit.id).node.type)?.richText) this.editor.startEditing(hit.id)
@@ -1,5 +1,6 @@
1
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.
2
+ // transparent, over a copy of the text with HTML (or XML), CSS, YAML and
3
+ // Liquid colored.
3
4
  // Both wrap the same way and scroll together, so the caret and selection of
4
5
  // the textarea line up with the colored text. Tab indents, Shift+Tab
5
6
  // outdents, and Enter keeps the line's indentation.
@@ -78,7 +79,17 @@ export function highlightCss(text) {
78
79
  ])
79
80
  }
80
81
 
81
- const HIGHLIGHT = { html: highlightHtml, css: highlightCss }
82
+ export function highlightYaml(text) {
83
+ return tokenize(text, [
84
+ ["liquid", LIQUID],
85
+ ["comment", /(?:^|(?<=\s))#[^\n]*/],
86
+ ["string", /"(?:[^"\\\n]|\\.)*"|'[^'\n]*'/, (string) => span("string", liquidIn(string))],
87
+ ["property", /[A-Za-z_$][\w $.-]*(?=:(?:\s|$))/],
88
+ ["number", /\b(?:-?\d+(?:\.\d+)?|true|false|null)\b/]
89
+ ])
90
+ }
91
+
92
+ const HIGHLIGHT = { html: highlightHtml, xml: highlightHtml, css: highlightCss, yaml: highlightYaml }
82
93
 
83
94
  // Replaces the selection, keeping the textarea's own undo where the browser allows
84
95
  function insert(textarea, text) {
@@ -107,7 +118,7 @@ function onKeydown(event) {
107
118
  }
108
119
  }
109
120
 
110
- // value, language ("html" or "css"), oninput(value); class sizes the field
121
+ // value, language ("html", "xml", "css" or "yaml"), oninput(value); class sizes the field
111
122
  export function codeInput({ value = "", language = "html", oninput, class: className = "min-h-32", readOnly = false }) {
112
123
  const highlight = HIGHLIGHT[language] || escape
113
124
  const colored = h("pre", { "aria-hidden": "true", class: `${BOX} pointer-events-none absolute inset-0 h-full overflow-hidden border-transparent bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200` })
@@ -33,3 +33,35 @@ export function debounce(fn, wait) {
33
33
  debounced.cancel = () => clearTimeout(timer)
34
34
  return debounced
35
35
  }
36
+
37
+ // Puts items into parent in this order. Kept elements (a Set) are never
38
+ // taken out: they are hidden when not among the items and stay where they
39
+ // are when they are, with the other items put around them. Moving an element
40
+ // reconnects the Stimulus controllers inside it, which is what keeping
41
+ // avoids. Any other child goes.
42
+ export function place(parent, items, kept = new Set()) {
43
+ const wanted = new Set(items)
44
+ for (const child of [...parent.children]) {
45
+ if (wanted.has(child)) continue
46
+ if (kept.has(child)) child.hidden = true
47
+ else child.remove()
48
+ }
49
+ let previous = null
50
+ for (const item of items) {
51
+ if (kept.has(item)) item.hidden = false
52
+ if (item.parentNode !== parent) previous ? previous.after(item) : parent.prepend(item)
53
+ previous = item
54
+ }
55
+ }
56
+
57
+ // Writes the values of the fields in element into their attributes, for
58
+ // when it has to move: controllers that copy markup (outerHTML) as they
59
+ // reconnect, like Satis's toggle, then keep what was typed and chosen.
60
+ export function freeze(element) {
61
+ element.querySelectorAll("input, textarea, select").forEach((field) => {
62
+ if (field.tagName === "SELECT") [...field.options].forEach((option) => option.toggleAttribute("selected", option.selected))
63
+ else if (field.tagName === "TEXTAREA") field.textContent = field.value
64
+ else if (["checkbox", "radio"].includes(field.type)) field.toggleAttribute("checked", field.checked)
65
+ else if (field.type !== "file") field.setAttribute("value", field.value)
66
+ })
67
+ }
@@ -1,5 +1,18 @@
1
1
  // The Inspect panel: renders the selected block's fields from its
2
2
  // definition (blocks/*.js, via core/registry.js) and writes changes back through the editor.
3
+ //
4
+ // A "slot" field shows an element of the host's, rendered with the page:
5
+ //
6
+ // <div data-tess-edit-target="slot" data-tess-edit-slot="trigger" hidden>…form fields…</div>
7
+ // { key: "trigger", label: "When", type: "slot", slot: "trigger",
8
+ // summary: (element) => "Account · created", open: (element) => false }
9
+ //
10
+ // Its values are the host's form fields, not part of the document. With a
11
+ // summary it shows collapsed to that, with a button to edit it, and
12
+ // open(element) opens it at first, e.g. when it has errors; without one it
13
+ // always shows. The element is never recreated, so the
14
+ // host's Stimulus controllers in it keep their state: it and its group stay
15
+ // in Inspect, hidden while another block is selected (see place in ui/dom.js).
3
16
 
4
17
  import { imageSource } from "tess_edit/core/media"
5
18
  import { getBlock, label } from "tess_edit/core/registry"
@@ -7,7 +20,7 @@ import { find, getValue } from "tess_edit/core/document"
7
20
  import { translationCalls } from "tess_edit/core/translations"
8
21
  import { loopScope } from "tess_edit/core/variables"
9
22
  import { codeInput } from "tess_edit/ui/code_input"
10
- import { h } from "tess_edit/ui/dom"
23
+ import { freeze, h, place } from "tess_edit/ui/dom"
11
24
  import { icon } from "tess_edit/ui/icons"
12
25
  import { inputAdapter, suggestIn } from "tess_edit/ui/variable_menu"
13
26
 
@@ -42,6 +55,17 @@ export class Inspector {
42
55
  this.adapters = new Map()
43
56
  this.inputs = new Map()
44
57
  this.hints = []
58
+ // Groups with a slot, and the slots, by key: kept, never rendered again
59
+ this.keptGroups = new Map()
60
+ this.slots = new Map()
61
+ this.header = h("div", { class: "flex items-center gap-2 px-4 h-12 border-b border-gray-200 dark:border-gray-700 shrink-0" })
62
+ this.body = h("div", { class: "flex-1 overflow-y-auto pb-6" })
63
+ this.element.append(this.header, this.body)
64
+ }
65
+
66
+ // Before Inspect moves (see the editor's arrangePanels)
67
+ freezeSlots() {
68
+ this.slots.forEach(({ element }) => freeze(element))
45
69
  }
46
70
 
47
71
  // Again, when the translations or the editor's language change
@@ -70,29 +94,45 @@ export class Inspector {
70
94
  h("button", { type: "button", title, class: "w-7 h-7 flex items-center justify-center rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-100", html: icon(name), onclick })
71
95
  )
72
96
 
73
- const header = h(
74
- "div",
75
- { class: "flex items-center gap-2 px-4 h-12 border-b border-gray-200 dark:border-gray-700 shrink-0" },
97
+ this.header.replaceChildren(
76
98
  h("span", { class: "text-gray-400 dark:text-gray-500", html: icon(definition.icon, "w-4 h-4") }),
77
99
  h("h2", { class: "font-semibold text-gray-900 dark:text-gray-100 truncate" }, label(node)),
78
100
  h("div", { class: "ml-auto flex items-center gap-0.5" }, actions)
79
101
  )
80
102
 
81
- // A group shows when one of its fields applies (see visible below)
82
- const groups = (definition.fields || []).filter((group) => group.fields.some((field) => !field.visible || field.visible(node, this.editor))).map((group) => this.group(node, group))
103
+ // A group shows when one of its fields applies (see shows below)
104
+ const groups = (definition.fields || []).filter((group) => group.fields.some((field) => this.shows(node, field))).map((group) => this.group(node, group))
83
105
  const locked = this.editor.lock && node.id !== doc.id && !this.editor.isFree(node.id)
84
106
  ? h("p", { class: "mx-4 mt-3 flex items-center gap-2 rounded-md bg-amber-50 dark:bg-amber-900/30 px-3 py-2 text-xs text-amber-800 dark:text-amber-200" }, h("span", { html: icon("lock", "w-3.5 h-3.5") }), "Part of the layout")
85
107
  : null
86
108
  // A block's hint explains how to edit it, which a locked block can't be.
87
109
  const hint = definition.hint && !locked ? h("p", { class: "mx-4 mt-3 rounded-md bg-gray-50 dark:bg-gray-700/50 px-3 py-2 text-xs text-gray-600 dark:text-gray-300" }, definition.hint) : null
88
110
 
89
- this.element.replaceChildren(header, h("div", { class: "flex-1 overflow-y-auto pb-6" }, locked, hint, groups))
111
+ place(this.body, [locked, hint, ...groups].filter(Boolean), new Set([...this.keptGroups.values()].map((kept) => kept.details)))
112
+ }
113
+
114
+ // A field's visible(node, editor) shows it only when it applies; a slot
115
+ // only when the host gave its element
116
+ shows(node, field) {
117
+ if (field.type === "slot" && !this.editor.slot(field.slot)) return false
118
+ return !field.visible || field.visible(node, this.editor)
90
119
  }
91
120
 
92
121
  group(node, group) {
93
122
  const key = `${node.type}:${group.title}`
94
123
  const open = this.openGroups.has(key) ? this.openGroups.get(key) : !group.collapsed
95
- const details = h(
124
+ const fields = group.fields.filter((field) => this.shows(node, field)).map((field) => this.field(node, field))
125
+ const kept = this.keptGroups.get(key) || (group.fields.some((field) => field.type === "slot") ? this.keepGroup(key, group) : null)
126
+ if (kept) {
127
+ kept.details.open = open
128
+ place(kept.list, fields, new Set([...this.slots.values()].map((slot) => slot.wrapper)))
129
+ return kept.details
130
+ }
131
+ return this.groupElement(key, group, open, h("div", { class: "px-4 pb-4 space-y-3" }, fields))
132
+ }
133
+
134
+ groupElement(key, group, open, list) {
135
+ return h(
96
136
  "details",
97
137
  { class: "group/field border-b border-gray-100 dark:border-gray-700", open, ontoggle: (event) => this.openGroups.set(key, event.currentTarget.open) },
98
138
  h(
@@ -101,13 +141,20 @@ export class Inspector {
101
141
  h("span", { html: icon("chevron", "w-3 h-3 transition-transform group-open/field:rotate-90") }),
102
142
  group.title
103
143
  ),
104
- // A field's visible(node, editor) shows it only when it applies
105
- 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)))
144
+ list
106
145
  )
107
- return details
146
+ }
147
+
148
+ keepGroup(key, group) {
149
+ const list = h("div", { class: "px-4 pb-4 space-y-3" })
150
+ const kept = { details: this.groupElement(key, group, !group.collapsed, list), list }
151
+ this.keptGroups.set(key, kept)
152
+ return kept
108
153
  }
109
154
 
110
155
  field(node, field) {
156
+ if (field.type === "slot") return this.slot(field)
157
+
111
158
  const value = getValue(node, field.key) ?? ""
112
159
  const set = (next, options = {}) => this.editor.setValue(node.id, field.key, next, options)
113
160
  // A fieldset disables every input and button of a locked field at once.
@@ -128,15 +175,22 @@ export class Inspector {
128
175
  ? h("button", { type: "button", title: "Make translatable: keep the selected text, or all of it, as a translation", class: "rounded px-1 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/40", html: icon("translate", "w-3.5 h-3.5"), onclick: () => this.makeTranslatable(input) })
129
176
  : null
130
177
  const tools = expand || variables || translate ? h("span", { class: "absolute right-0 top-0 flex items-center gap-0.5" }, expand, variables, translate) : null
178
+ // A hint can be a function of the value, kept up to date as it's typed: hint(value, node)
179
+ const hint = field.hint ? h("span", { class: "mt-1 block text-[11px] leading-snug text-gray-500 dark:text-gray-400" }) : null
180
+ const showHint = () => {
181
+ const current = find(this.editor.doc, node.id)?.node || node
182
+ hint.textContent = typeof field.hint === "function" ? field.hint(getValue(current, field.key) ?? "", current) : field.hint
183
+ }
184
+ if (hint) showHint()
131
185
  // A div, not a label: a label would forward clicks to the first button or color picker inside it.
132
186
  return h(
133
187
  "div",
134
- { class: "relative block" },
188
+ { class: "relative block", oninput: typeof field.hint === "function" ? showHint : null },
135
189
  h("span", { class: "mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300" }, field.label),
136
190
  tools,
137
191
  control,
138
192
  input ? this.translationHint(input) : null,
139
- field.hint ? h("span", { class: "mt-1 block text-[11px] leading-snug text-gray-500 dark:text-gray-400" }, field.hint) : null
193
+ hint
140
194
  )
141
195
  }
142
196
 
@@ -222,6 +276,48 @@ export class Inspector {
222
276
  }
223
277
  }
224
278
 
279
+ // The host's element, collapsed to its summary until edited
280
+ slot(field) {
281
+ let slot = this.slots.get(field.slot)
282
+ if (!slot) {
283
+ const element = this.editor.slot(field.slot)
284
+ const summary = h("span", { class: "min-w-0 flex-1 truncate text-[13px] text-gray-700 dark:text-gray-300" })
285
+ const button = h("button", { type: "button", class: "ml-auto shrink-0 rounded px-1.5 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/40", "aria-expanded": "false", onclick: () => this.toggleSlot(slot) })
286
+ const holder = h("div", { class: "pt-1" })
287
+ slot = {
288
+ field,
289
+ element,
290
+ summary,
291
+ button,
292
+ holder,
293
+ open: !field.summary || Boolean(field.open?.(element)),
294
+ wrapper: h("div", { class: "block" }, h("span", { class: "mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300" }, field.label), h("div", { class: "flex items-center gap-2" }, summary, button), holder)
295
+ }
296
+ // Its only move: it reconnects, with what was typed so far
297
+ freeze(element)
298
+ holder.append(element)
299
+ element.hidden = false
300
+ this.slots.set(field.slot, slot)
301
+ }
302
+ this.showSlot(slot)
303
+ return slot.wrapper
304
+ }
305
+
306
+ toggleSlot(slot) {
307
+ slot.open = !slot.open
308
+ this.showSlot(slot)
309
+ }
310
+
311
+ showSlot(slot) {
312
+ const { open, field, element, button, holder, summary } = slot
313
+ holder.hidden = !open
314
+ button.parentElement.hidden = !field.summary
315
+ button.textContent = open ? "Done" : "Edit"
316
+ button.setAttribute("aria-expanded", String(open))
317
+ summary.hidden = open
318
+ summary.textContent = open ? "" : field.summary?.(element) || ""
319
+ }
320
+
225
321
  // Suggests the host's Liquid variables (editor.variables) while typing {{ in
226
322
  // a text field. A field's `liquid` also gives it a { } button to browse
227
323
  // them: true for Liquid in text, "expression" for a condition, "collection"
@@ -1,3 +1,3 @@
1
1
  module TessEdit
2
- VERSION = "0.4.3"
2
+ VERSION = "0.5.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tess_edit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom de Grunt