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 +4 -4
- data/README.md +30 -0
- data/app/javascript/tess_edit/blocks/email.js +1 -1
- data/app/javascript/tess_edit/blocks/fields.js +4 -2
- data/app/javascript/tess_edit/controllers/editor_controller.js +43 -7
- data/app/javascript/tess_edit/core/registry.js +23 -7
- data/app/javascript/tess_edit/ui/canvas.js +17 -1
- data/app/javascript/tess_edit/ui/code_input.js +14 -3
- data/app/javascript/tess_edit/ui/dom.js +32 -0
- data/app/javascript/tess_edit/ui/inspector.js +109 -13
- data/lib/tess_edit/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1401c4a6e315c3ae6d07dc4ee7cf80ae9b384fb532a570d09f5e6a1593da12a0
|
|
4
|
+
data.tar.gz: f0045ef4c910b201fade5d8f10245d61636a69f4bafd4fd6200beaaf29db6243
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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: "
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
69
|
-
// own) to the block as it is registered, also when another
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|
82
|
-
const groups = (definition.fields || []).filter((group) => group.fields.some((field) =>
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
data/lib/tess_edit/version.rb
CHANGED