@innovastudio/contentbox 1.6.200 → 1.6.202

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.
@@ -11844,7 +11844,9 @@ class PanelText {
11844
11844
  <button title="${out('Subscript')}" data-format="subscript"><span style="font-family:serif;font-size:13px;">x</span><sub style="font-size:10px;margin-bottom:-6px;">2</sub></button>
11845
11845
  </div>
11846
11846
 
11847
- <div class="group flex">
11847
+ <!-- 4 to 6 buttons, depending on the symbols and searchReplace
11848
+ options. Named so the scss can wrap it at six. -->
11849
+ <div class="group flex inserts">
11848
11850
  <button title="${out('Link')}" class="btn-link"><svg style="transform:rotate(45deg)"><use xlink:href="#icon-link"></use></svg></button>
11849
11851
  <button title="${out('Image')}" class="btn-image"><svg><use xlink:href="#icon-image"></use></svg></button>
11850
11852
  <button title="${out('Icon')}" class="btn-icon"><svg><use xlink:href="#icon-mood-smile"></use></svg></button>
@@ -12449,7 +12451,7 @@ class PanelText {
12449
12451
  label: `<h6 style="margin:0;line-height:1.6;font-size:13px;">${out('Heading')} 6</h6>`
12450
12452
  }, {
12451
12453
  value: 'p',
12452
- label: `<p style="margin:2px 0 0;line-height:1.6;font-size:12px;">${out('Paragraph')}</p>`
12454
+ label: `<p style="margin:0;line-height:1.6;font-size:12px;">${out('Paragraph')}</p>`
12453
12455
  }, {
12454
12456
  value: 'div',
12455
12457
  label: '<div style="margin:0;line-height:1.6;font-size:12px;">div</div>'
@@ -15505,10 +15507,12 @@ class PanelButton {
15505
15507
  const inpHref = panel.querySelector('.inp-href');
15506
15508
  inpHref.addEventListener('input', () => {
15507
15509
  this.edit(btn => setHref$1(btn, inpHref.value), false);
15510
+ this.syncLinkDialog();
15508
15511
  });
15509
15512
  const chkNewTab = panel.querySelector('.chk-newtab');
15510
15513
  chkNewTab.addEventListener('change', () => {
15511
15514
  this.edit(btn => setNewTab$1(btn, chkNewTab.checked), false);
15515
+ this.syncLinkDialog();
15512
15516
  }); // --- Colours ---
15513
15517
 
15514
15518
  panel.querySelectorAll('[data-colorstate]').forEach(btn => {
@@ -15578,6 +15582,20 @@ class PanelButton {
15578
15582
  this.getState();
15579
15583
  this.repositionTools();
15580
15584
  }
15585
+ } // ContentBuilder's link dialog edits the same href/target and can be open
15586
+ // alongside this panel. It reads its fields when it opens, so without this
15587
+ // its Ok would write back the link as it was before the panel touched it.
15588
+ // (The other direction is the editor's own: see elementhyperlink.js.)
15589
+
15590
+
15591
+ syncLinkDialog() {
15592
+ const btn = this.target();
15593
+ const modal = this.builder.editor.builderStuff.querySelector('.is-modal.createlink');
15594
+ if (!btn || !modal || !modal.classList.contains('active')) return;
15595
+ const url = modal.querySelector('.input-url');
15596
+ if (url && document.activeElement !== url) url.value = btn.getAttribute('href') || '';
15597
+ const newwindow = modal.querySelector('.input-newwindow');
15598
+ if (newwindow) newwindow.checked = btn.getAttribute('target') === '_blank';
15581
15599
  } // Size and padding change the button's box, which leaves the floating
15582
15600
  // tools pointing at where it used to be.
15583
15601
 
@@ -17802,6 +17820,926 @@ class PanelPlugin {
17802
17820
 
17803
17821
  }
17804
17822
 
17823
+ /*
17824
+ codesource.js
17825
+ ------------------------------------------------------------------
17826
+ The two pure helpers ContentBox needs for [data-cb-code] blocks. Derived
17827
+ from ContentBuilder's codeelement.js, which also defines a CodeElement
17828
+ editor class — ContentBox never instantiates that, so it is not carried
17829
+ here. Named for what this file is, not for where it came from.
17830
+
17831
+ wrapCodeSource() is a string pre-pass. Call it on any HTML string about to
17832
+ be inserted with createContextualFragment (which executes scripts). It
17833
+ parses with DOMParser -- where scripts can never run -- and moves each
17834
+ [data-cb-code] block's source into an inert <template data-source>, so
17835
+ nothing has run by the time the content is in the DOM.
17836
+ */
17837
+ const ATTR$1 = 'data-cb-code';
17838
+ const SEL$1 = '[' + ATTR$1 + ']';
17839
+ const TPL_SEL$1 = ':scope > template[data-source]';
17840
+ function escapeCodeSource$1(src) {
17841
+ return src.replace(/<\/template>/gi, '<\\/template>');
17842
+ }
17843
+ /*
17844
+ STRING PRE-PASS (inert).
17845
+ Call this on any HTML *string* that is about to be inserted with
17846
+ createContextualFragment (which executes scripts). It parses the string with
17847
+ DOMParser -- where scripts can never run -- and moves each [data-cb-code]
17848
+ block's source into an inert <template data-source>. After insertion nothing
17849
+ has run; init()/render() brings them to life.
17850
+
17851
+ Exported standalone as well, so it can be used where no builder instance
17852
+ exists yet (ContentBox injects content before creating the editor).
17853
+ */
17854
+
17855
+ function wrapCodeSource$1(html) {
17856
+ if (!html) return html;
17857
+ if (html.indexOf(ATTR$1) === -1) return html; // fast path
17858
+
17859
+ const parsed = new DOMParser().parseFromString(html, 'text/html');
17860
+ parsed.querySelectorAll(SEL$1).forEach(block => {
17861
+ // Already wrapped (round-trip within the editor) => skip
17862
+ if (block.querySelector(TPL_SEL$1)) return; // Snippet form (data-src) is already inert => handled by init()
17863
+
17864
+ if (block.hasAttribute('data-src')) return; // The block's innerHTML IS the pristine source (what flatten() wrote)
17865
+
17866
+ const src = block.innerHTML;
17867
+ block.innerHTML = '';
17868
+ const tpl = parsed.createElement('template');
17869
+ tpl.setAttribute('data-source', '');
17870
+ tpl.innerHTML = escapeCodeSource$1(src);
17871
+ block.appendChild(tpl);
17872
+ });
17873
+ return parsed.body.innerHTML;
17874
+ }
17875
+ /*
17876
+ SOURCE MAPPING (live render -> template source).
17877
+ ------------------------------------------------------------------
17878
+ A code element keeps two copies of itself: the <template data-source>,
17879
+ which is what gets saved, and the live render beside it, which is thrown
17880
+ away on every save. An edit written to the render alone therefore
17881
+ disappears the moment the page is saved.
17882
+
17883
+ These helpers find the node in the SOURCE that a live node was built from,
17884
+ so an edit can be written to both.
17885
+
17886
+ The mapping is positional: the render IS the source, parsed. A node's path
17887
+ of child indexes is the same in both -- except for <script> elements, which
17888
+ render() deletes once they have run, and the block's own <template>, which
17889
+ is never part of the render. Both are skipped on each side, so the two
17890
+ walks stay in step.
17891
+
17892
+ A node that the running code created or rewrote has no counterpart, or one
17893
+ holding something else. Those are NOT editable: the next render would
17894
+ overwrite the edit anyway. codeSourceEditable() is that test, and the
17895
+ panels use it to decide what they offer.
17896
+ */
17897
+ // The code element `el` belongs to, or null.
17898
+
17899
+ function codeElementOf(el) {
17900
+ if (!el || !el.closest) return null;
17901
+ return el.closest(SEL$1);
17902
+ } // Element children as the RENDER sees them.
17903
+
17904
+ function renderedChildren(parent) {
17905
+ return Array.prototype.filter.call(parent.children, child => {
17906
+ const tag = child.tagName.toLowerCase();
17907
+ if (tag === 'script') return false; // deleted after running
17908
+
17909
+ if (tag === 'template' && child.hasAttribute('data-source')) return false; // the source itself
17910
+
17911
+ return true;
17912
+ });
17913
+ } // The node in the block's source that this live node came from, or null.
17914
+
17915
+
17916
+ function codeSourceNodeFor(el) {
17917
+ const block = codeElementOf(el);
17918
+ if (!block || el === block) return null;
17919
+ const tpl = block.querySelector(TPL_SEL$1);
17920
+ if (!tpl || !tpl.content) return null; // Path from the block down to `el`.
17921
+
17922
+ const path = [];
17923
+
17924
+ for (let node = el; node && node !== block; node = node.parentElement) {
17925
+ const parent = node.parentElement;
17926
+ if (!parent) return null; // detached
17927
+
17928
+ const index = renderedChildren(parent).indexOf(node);
17929
+ if (index === -1) return null;
17930
+ path.unshift({
17931
+ index: index,
17932
+ tag: node.tagName
17933
+ });
17934
+ }
17935
+
17936
+ if (!path.length) return null; // The same walk through the source.
17937
+
17938
+ let node = tpl.content;
17939
+
17940
+ for (let i = 0; i < path.length; i++) {
17941
+ node = renderedChildren(node)[path[i].index];
17942
+ if (!node) return null;
17943
+ if (node.tagName !== path[i].tag) return null; // the code rebuilt this part
17944
+ }
17945
+
17946
+ return node;
17947
+ } // May an edit to this live node be written back to the source?
17948
+
17949
+ function codeSourceEditable(el) {
17950
+ const block = codeElementOf(el);
17951
+ if (!block) return true; // not a code element's business
17952
+
17953
+ if (el === block) return true; // the block itself is ordinary markup, saved as it is
17954
+
17955
+ const src = codeSourceNodeFor(el);
17956
+ if (!src) return false; // It must still hold what the source says it holds. Where the code rewrote
17957
+ // it (a clock's own text, a lazily swapped image) an edit here would be
17958
+ // undone by the next render, so it is not offered at all.
17959
+
17960
+ if (src.innerHTML !== el.innerHTML) return false;
17961
+ if (el.tagName === 'IMG' && src.getAttribute('src') !== el.getAttribute('src')) return false;
17962
+ return true;
17963
+ }
17964
+ /*
17965
+ Write an edit made to the live node into its source counterpart, so it
17966
+ survives the save. A no-op (returns false) for anything outside a code
17967
+ element, or for a node the code built itself.
17968
+
17969
+ `spec` names what to carry over, and only that:
17970
+ { html: true } the node's content
17971
+ { attrs: ['src','alt'] } those attributes
17972
+ { styles: ['background-image'] } those inline style properties
17973
+ { fx: true } every data-fx-* attribute
17974
+
17975
+ Deliberately never the whole element: a running script and the motion
17976
+ runtime both write to the live node (ids, aria state, inline transforms),
17977
+ and none of that belongs in the saved source.
17978
+ */
17979
+
17980
+ function syncToCodeSource(el, spec) {
17981
+ if (!el || !spec) return false;
17982
+ const src = codeSourceNodeFor(el);
17983
+ if (!src) return false;
17984
+ if (spec.html) src.innerHTML = el.innerHTML;
17985
+ if (spec.attrs) spec.attrs.forEach(name => {
17986
+ const value = el.getAttribute(name);
17987
+ if (value === null) src.removeAttribute(name);else src.setAttribute(name, value);
17988
+ });
17989
+ if (spec.styles) spec.styles.forEach(prop => {
17990
+ src.style.setProperty(prop, el.style.getPropertyValue(prop), el.style.getPropertyPriority(prop));
17991
+ });
17992
+
17993
+ if (spec.fx) {
17994
+ Array.prototype.slice.call(src.attributes).forEach(attr => {
17995
+ if (attr.name.indexOf('data-fx-') === 0) src.removeAttribute(attr.name);
17996
+ });
17997
+ Array.prototype.slice.call(el.attributes).forEach(attr => {
17998
+ if (attr.name.indexOf('data-fx-') === 0) src.setAttribute(attr.name, attr.value);
17999
+ });
18000
+ }
18001
+
18002
+ return true;
18003
+ }
18004
+
18005
+ /*
18006
+ * motion-presets.js — the declarative vocabulary of the Motion panel.
18007
+ *
18008
+ * Everything the Motion UI can apply lives here as DATA, so adding a new
18009
+ * effect is a table edit, never a code edit. Nothing in this file touches
18010
+ * the DOM or the builder.
18011
+ *
18012
+ * Three kinds of thing, because Kinetra genuinely has three:
18013
+ *
18014
+ * 1. RECIPES — canned percent keyframes (data-fx-0 … data-fx-100).
18015
+ * "Fade up", "Zoom in", "Wipe in". These are just a
18016
+ * starting set of keyframes; the Motion dock (phase 2)
18017
+ * edits them freely afterwards.
18018
+ * 2. PRESETS — single-attribute effects that are NOT keyframes:
18019
+ * parallax, horizontal, split, typewriter, draw, marquee.
18020
+ * Each is identified by one "key" attribute.
18021
+ * 3. POINTER — micro-interactions (tilt, magnetic). Not scene-bound
18022
+ * at all; they are per-element toggles.
18023
+ *
18024
+ * Option types:
18025
+ * 'text' — free text input, written verbatim to the attribute
18026
+ * 'select' — dropdown; value written to the attribute
18027
+ * 'toggle' — VALUELESS boolean attribute (present / absent)
18028
+ */
18029
+
18030
+ /* Ease names are NOT listed here. motion-ease.js derives them from kinetra's
18031
+ * exported `easings` table, so there is exactly one source of truth and a copy
18032
+ * can't drift when someone adds or renames a curve. */
18033
+
18034
+ /* ------------------------------------------------------------------
18035
+ * 1. KEYFRAME RECIPES
18036
+ *
18037
+ * `frames` maps percent -> declaration string. Applying a recipe REPLACES
18038
+ * every existing data-fx-N on the element (a recipe is a fresh start, not
18039
+ * an overlay), and sets data-fx-ease.
18040
+ *
18041
+ * Compound-string rule: both ends of a clip-path/box-shadow/etc. must share
18042
+ * the same token count, or Kinetra snaps instead of tweening. Every recipe
18043
+ * below already obeys that — keep it that way when adding new ones.
18044
+ * ------------------------------------------------------------------ */
18045
+ const RECIPES = [{
18046
+ id: 'fade',
18047
+ duration: 0.6,
18048
+ label: 'Fade in',
18049
+ ease: 'easeOut',
18050
+ frames: {
18051
+ 0: 'opacity:0',
18052
+ 100: 'opacity:1'
18053
+ }
18054
+ }, {
18055
+ id: 'fade-up',
18056
+ duration: 0.6,
18057
+ label: 'Fade up',
18058
+ ease: 'smooth',
18059
+ // kinetra's closest match to CSS ease-in-out
18060
+ frames: {
18061
+ 0: 'opacity:0; transform:translateY(70px)',
18062
+ // .is-fadeInUp was 70px
18063
+ 100: 'opacity:1; transform:translateY(0px)'
18064
+ }
18065
+ }, {
18066
+ id: 'fade-down',
18067
+ duration: 0.6,
18068
+ label: 'Fade down',
18069
+ ease: 'smooth',
18070
+ frames: {
18071
+ 0: 'opacity:0; transform:translateY(-70px)',
18072
+ 100: 'opacity:1; transform:translateY(0px)'
18073
+ }
18074
+ }, {
18075
+ id: 'fade-left',
18076
+ duration: 0.6,
18077
+ label: 'From left',
18078
+ ease: 'smooth',
18079
+ frames: {
18080
+ 0: 'opacity:0; transform:translateX(-70px)',
18081
+ 100: 'opacity:1; transform:translateX(0px)'
18082
+ }
18083
+ }, {
18084
+ id: 'fade-right',
18085
+ duration: 0.6,
18086
+ label: 'From right',
18087
+ ease: 'smooth',
18088
+ frames: {
18089
+ 0: 'opacity:0; transform:translateX(70px)',
18090
+ 100: 'opacity:1; transform:translateX(0px)'
18091
+ }
18092
+ }, {
18093
+ id: 'zoom-in',
18094
+ duration: 0.8,
18095
+ label: 'Zoom in',
18096
+ ease: 'smooth',
18097
+ frames: {
18098
+ 0: 'opacity:0; transform:scale(0.8)',
18099
+ // .is-zoomIn was scale(.8)
18100
+ 100: 'opacity:1; transform:scale(1)'
18101
+ }
18102
+ }, {
18103
+ id: 'zoom-out',
18104
+ duration: 0.8,
18105
+ label: 'Zoom out',
18106
+ ease: 'smooth',
18107
+ frames: {
18108
+ 0: 'opacity:0; transform:scale(1.2)',
18109
+ // .is-zoomOut was scale(1.2)
18110
+ 100: 'opacity:1; transform:scale(1)'
18111
+ }
18112
+ }, {
18113
+ id: 'slide-up',
18114
+ duration: 0.6,
18115
+ label: 'Slide up',
18116
+ ease: 'smooth',
18117
+ frames: {
18118
+ 0: 'transform:translateY(70px)',
18119
+ 100: 'transform:translateY(0px)'
18120
+ }
18121
+ }, {
18122
+ id: 'slide-down',
18123
+ duration: 0.6,
18124
+ label: 'Slide down',
18125
+ ease: 'smooth',
18126
+ frames: {
18127
+ 0: 'transform:translateY(-70px)',
18128
+ 100: 'transform:translateY(0px)'
18129
+ }
18130
+ }, {
18131
+ id: 'slide-left',
18132
+ duration: 0.6,
18133
+ label: 'Slide from left',
18134
+ ease: 'smooth',
18135
+ frames: {
18136
+ 0: 'transform:translateX(-70px)',
18137
+ 100: 'transform:translateX(0px)'
18138
+ }
18139
+ }, {
18140
+ id: 'slide-right',
18141
+ duration: 0.6,
18142
+ label: 'Slide from right',
18143
+ ease: 'smooth',
18144
+ frames: {
18145
+ 0: 'transform:translateX(70px)',
18146
+ 100: 'transform:translateX(0px)'
18147
+ }
18148
+ }, {
18149
+ id: 'blur-in',
18150
+ duration: 0.6,
18151
+ label: 'Blur in',
18152
+ ease: 'easeOut',
18153
+ frames: {
18154
+ 0: 'opacity:0; filter:blur(12px)',
18155
+ 100: 'opacity:1; filter:blur(0px)'
18156
+ }
18157
+ }, {
18158
+ id: 'flip-x',
18159
+ label: 'Flip X',
18160
+ duration: 0.8,
18161
+ ease: 'smooth',
18162
+ frames: {
18163
+ 0: 'opacity:0; transform:perspective(2500px) rotateX(100deg)',
18164
+ 100: 'opacity:1; transform:perspective(2500px) rotateX(0deg)'
18165
+ }
18166
+ }, {
18167
+ id: 'flip-y',
18168
+ label: 'Flip Y',
18169
+ duration: 0.8,
18170
+ ease: 'smooth',
18171
+ frames: {
18172
+ 0: 'opacity:0; transform:perspective(2500px) rotateY(100deg)',
18173
+ 100: 'opacity:1; transform:perspective(2500px) rotateY(0deg)'
18174
+ }
18175
+ }, {
18176
+ id: 'pulse',
18177
+ label: 'Pulse',
18178
+ duration: 0.6,
18179
+ ease: 'linear',
18180
+ frames: {
18181
+ 0: 'opacity:0; transform:scale(0.9)',
18182
+ 50: 'opacity:1; transform:scale(1.05)',
18183
+ 100: 'opacity:1; transform:scale(1)'
18184
+ }
18185
+ }, {
18186
+ id: 'bounce-in',
18187
+ label: 'Bounce in',
18188
+ duration: 0.6,
18189
+ ease: 'linear',
18190
+ frames: {
18191
+ 0: 'opacity:0; transform:translateX(0px)',
18192
+ 40: 'opacity:1; transform:translateX(-20px)',
18193
+ 60: 'opacity:1; transform:translateX(0px)',
18194
+ 80: 'opacity:1; transform:translateX(-15px)',
18195
+ 100: 'opacity:1; transform:translateX(0px)'
18196
+ }
18197
+ }, {
18198
+ id: 'wipe-right',
18199
+ duration: 0.6,
18200
+ label: 'Wipe in',
18201
+ ease: 'expoOut',
18202
+ frames: {
18203
+ 0: 'opacity:1; clip-path:inset(0% 100% 0% 0%)',
18204
+ 100: 'opacity:1; clip-path:inset(0% 0% 0% 0%)'
18205
+ }
18206
+ }, {
18207
+ id: 'rise-mask',
18208
+ duration: 0.7,
18209
+ label: 'Rise + mask',
18210
+ ease: 'expoOut',
18211
+ frames: {
18212
+ 0: 'opacity:0; transform:translateY(70px); clip-path:inset(100% 0% 0% 0%)',
18213
+ 100: 'opacity:1; transform:translateY(0px); clip-path:inset(0% 0% 0% 0%)'
18214
+ }
18215
+ }, {
18216
+ id: 'rotate-in',
18217
+ duration: 0.6,
18218
+ label: 'Rotate in',
18219
+ ease: 'backOut',
18220
+ frames: {
18221
+ 0: 'opacity:0; transform:rotate(-8deg) scale(0.94)',
18222
+ 100: 'opacity:1; transform:rotate(0deg) scale(1)'
18223
+ }
18224
+ }, {
18225
+ id: 'drift',
18226
+ duration: 0.6,
18227
+ label: 'Drift through',
18228
+ ease: 'linear',
18229
+ noCascade: true,
18230
+ // a through-motion, not an entrance — pointless in a cascade
18231
+ frames: {
18232
+ 0: 'transform:translateY(60px)',
18233
+ 100: 'transform:translateY(-60px)'
18234
+ }
18235
+ }, {
18236
+ id: 'hold-scale',
18237
+ duration: 0.6,
18238
+ label: 'Scale back + hold',
18239
+ ease: 'linear',
18240
+ note: 'Card-stack layer: shrinks as the next layer covers it.',
18241
+ frames: {
18242
+ 0: 'transform:scale(1)',
18243
+ 50: 'transform:scale(0.9)',
18244
+ 100: 'transform:scale(0.9)'
18245
+ }
18246
+ }];
18247
+ /* ------------------------------------------------------------------
18248
+ * 2. SCROLL / TEXT PRESETS (single-attribute effects)
18249
+ * ------------------------------------------------------------------ */
18250
+
18251
+ const PRESETS = [{
18252
+ id: 'parallax',
18253
+ label: 'Parallax',
18254
+ group: 'scroll',
18255
+ key: 'data-fx-parallax',
18256
+ defaults: {
18257
+ 'data-fx-parallax': '120'
18258
+ },
18259
+ hint: 'Drifts as it scrolls. Works in any section.',
18260
+ options: [{
18261
+ attr: 'data-fx-parallax',
18262
+ label: 'Travel',
18263
+ type: 'text',
18264
+ placeholder: '120 (px) or 20%'
18265
+ }]
18266
+ }, {
18267
+ id: 'horizontal',
18268
+ label: 'Horizontal track',
18269
+ group: 'scroll',
18270
+ key: 'data-fx-horizontal',
18271
+ pinnedOnly: true,
18272
+ defaults: {
18273
+ 'data-fx-horizontal': ''
18274
+ },
18275
+ hint: 'Vertical scroll drives a sideways track. Needs a pinned scene, and children with flex:0 0 auto.',
18276
+ options: []
18277
+ }, {
18278
+ id: 'split',
18279
+ label: 'Split text',
18280
+ group: 'text',
18281
+ key: 'data-fx-split',
18282
+ defaults: {
18283
+ 'data-fx-split': 'words'
18284
+ },
18285
+ hint: 'Staggered word/char entrance. Formatting inside the element is preserved.',
18286
+ options: [{
18287
+ attr: 'data-fx-split',
18288
+ label: 'Unit',
18289
+ type: 'select',
18290
+ choices: [['words', 'Words'], ['chars', 'Characters']]
18291
+ }, {
18292
+ attr: 'data-fx-split-mask',
18293
+ label: 'Mask reveal',
18294
+ type: 'toggle'
18295
+ }, {
18296
+ attr: 'data-fx-split-stagger',
18297
+ label: 'Stagger',
18298
+ type: 'text',
18299
+ placeholder: '0.08 (sec)'
18300
+ }, {
18301
+ attr: 'data-fx-split-y',
18302
+ label: 'Travel',
18303
+ type: 'text',
18304
+ placeholder: '115% or 18px'
18305
+ }, {
18306
+ attr: 'data-fx-split-scrub',
18307
+ label: 'Scrub through scene',
18308
+ type: 'toggle',
18309
+ hint: 'Best in a pinned scene — units brighten in reading order as you scroll.'
18310
+ }, {
18311
+ attr: 'data-fx-split-from',
18312
+ label: 'Dim opacity',
18313
+ type: 'text',
18314
+ placeholder: '0.12',
18315
+ requires: 'data-fx-split-scrub'
18316
+ }]
18317
+ }, {
18318
+ id: 'typewriter',
18319
+ label: 'Typewriter',
18320
+ group: 'text',
18321
+ key: 'data-fx-typewriter',
18322
+ defaults: {
18323
+ 'data-fx-typewriter': ''
18324
+ },
18325
+ hint: 'The scrollbar types the text. Plain text only.',
18326
+ options: [{
18327
+ attr: 'data-fx-typewriter-until',
18328
+ label: 'Done at',
18329
+ type: 'text',
18330
+ placeholder: '0.85 (progress)'
18331
+ }]
18332
+ }, {
18333
+ id: 'marquee',
18334
+ label: 'Marquee',
18335
+ group: 'text',
18336
+ key: 'data-fx-marquee',
18337
+ defaults: {
18338
+ 'data-fx-marquee': ''
18339
+ },
18340
+ hint: 'Drifting band. Content should be a repeating strip.',
18341
+ options: [{
18342
+ attr: 'data-fx-marquee-speed',
18343
+ label: 'Speed',
18344
+ type: 'text',
18345
+ placeholder: '90 (px/sec)'
18346
+ }, {
18347
+ attr: 'data-fx-marquee-lean',
18348
+ label: 'Lean with velocity',
18349
+ type: 'toggle'
18350
+ }]
18351
+ }, {
18352
+ id: 'path',
18353
+ label: 'Motion path',
18354
+ group: 'scroll',
18355
+ key: 'data-fx-path',
18356
+ defaults: {
18357
+ 'data-fx-path': '-200,60; 0,-80; 200,60'
18358
+ },
18359
+ hint: 'Travels a smooth curve through waypoints. Edit the stops in the Motion dock; px offsets from the element\'s resting position.',
18360
+ options: [{
18361
+ attr: 'data-fx-path',
18362
+ label: 'Waypoints',
18363
+ type: 'text',
18364
+ placeholder: 'x,y; x,y @tx,ty; …'
18365
+ }, {
18366
+ attr: 'data-fx-path-dwell',
18367
+ label: 'Dwell',
18368
+ type: 'text',
18369
+ placeholder: '0.78 (0 = constant, 1 = stops)'
18370
+ }, {
18371
+ attr: 'data-fx-path-curve',
18372
+ label: 'Curve',
18373
+ type: 'text',
18374
+ placeholder: '1 (0 = straight lines)'
18375
+ }, {
18376
+ attr: 'data-fx-path-rotate',
18377
+ label: 'Auto-rotate along path',
18378
+ type: 'toggle'
18379
+ }, {
18380
+ attr: 'data-fx-path-rotate',
18381
+ label: 'Rotate offset',
18382
+ type: 'text',
18383
+ placeholder: '0 (deg; art faces right 0, up 90)',
18384
+ requires: 'data-fx-path-rotate'
18385
+ }]
18386
+ }, {
18387
+ id: 'draw',
18388
+ label: 'Draw SVG',
18389
+ group: 'scroll',
18390
+ key: 'data-fx-draw',
18391
+ defaults: {
18392
+ 'data-fx-draw': ''
18393
+ },
18394
+ tags: ['svg', 'path'],
18395
+ hint: 'Paths draw themselves as the scene scrubs. Put on an <svg> or a <path>.',
18396
+ options: []
18397
+ }];
18398
+ /* ------------------------------------------------------------------
18399
+ * 3. POINTER MICRO-INTERACTIONS
18400
+ * ------------------------------------------------------------------ */
18401
+
18402
+ const POINTER = [{
18403
+ id: 'tilt',
18404
+ label: 'Tilt',
18405
+ key: 'data-fx-tilt',
18406
+ defaults: {
18407
+ 'data-fx-tilt': ''
18408
+ },
18409
+ options: [{
18410
+ attr: 'data-fx-tilt-max',
18411
+ label: 'Max angle',
18412
+ type: 'text',
18413
+ placeholder: '14 (deg)'
18414
+ }]
18415
+ }, {
18416
+ id: 'magnetic',
18417
+ label: 'Magnetic',
18418
+ key: 'data-fx-magnetic',
18419
+ defaults: {
18420
+ 'data-fx-magnetic': ''
18421
+ },
18422
+ options: [{
18423
+ attr: 'data-fx-magnetic-strength',
18424
+ label: 'Strength',
18425
+ type: 'text',
18426
+ placeholder: '0.35'
18427
+ }]
18428
+ }];
18429
+ /* ------------------------------------------------------------------
18430
+ * Helpers — every consumer of this table should go through these, so the
18431
+ * "one system per element" and "attributes only" rules stay in one place.
18432
+ * ------------------------------------------------------------------ */
18433
+
18434
+ /* High-traffic legacy attributes. Mirrors LEGACY_PROBE in contentbox-effects.js:
18435
+ * an element carrying any of these is owned by the old timeline and must not
18436
+ * receive data-fx-*. */
18437
+
18438
+ const LEGACY_PROBE = ['data-bottom-top', 'data-center', 'data-center-top', 'data-center-bottom', 'data-top', 'data-top-bottom', 'data-bottom', 'data-in', 'data-cen', 'data-out', 'data-t', 'data-t-100', 'data-t-200', 'data-t-300', 'data-t-400', 'data-t-500', 'data-t-600', 'data-t-700', 'data-t-800', 'data-t-900', 'data-t-1000', 'data-hidden-onstart', 'data-smooth-scrolling'];
18439
+ /* THE SECOND LEGACY SYSTEM.
18440
+ *
18441
+ * animatescroll.js's "On View" wizard never wrote attributes at all — it wrote
18442
+ * CSS CLASSES, toggled at runtime by inViewSetup()'s IntersectionObserver. So
18443
+ * an element can be legacy-animated while carrying no legacy attribute
18444
+ * whatsoever, which means contentbox-effects' hasLegacyAttrs() guard sails
18445
+ * straight past it. An element with both class="is-animated is-fadeInUp" and
18446
+ * data-fx-0 would get animated TWICE, by two systems both writing transform
18447
+ * and opacity.
18448
+ *
18449
+ * This list is lifted verbatim from animatescroll.js's cleanupBasic(). */
18450
+
18451
+ const LEGACY_ONVIEW_CLASSES = ['is-animated', 'is-scale-animated', 'is-inview', 'once', 'is-fadeIn', 'is-fadeInUp', 'is-fadeInDown', 'is-fadeInLeft', 'is-fadeInRight', 'is-zoomIn', 'is-zoomOut', 'is-slideInUp', 'is-slideInDown', 'is-slideInLeft', 'is-slideInRight', 'is-flipInX', 'is-flipInY', 'is-pulse', 'is-bounceIn'];
18452
+ function hasLegacyOnView(el) {
18453
+ if (!el || !el.classList) return false;
18454
+ return el.classList.contains('is-animated') || el.classList.contains('is-scale-animated');
18455
+ }
18456
+ /** Strip every trace of the class-based system from one element. */
18457
+
18458
+ function stripLegacyOnView(el) {
18459
+ if (!el || !el.classList) return false;
18460
+ let touched = false;
18461
+ LEGACY_ONVIEW_CLASSES.forEach(c => {
18462
+ if (el.classList.contains(c)) {
18463
+ el.classList.remove(c);
18464
+ touched = true;
18465
+ }
18466
+ }); // delay-0ms … delay-2000ms
18467
+
18468
+ Array.from(el.classList).forEach(c => {
18469
+ if (/^delay-\d+ms$/.test(c)) {
18470
+ el.classList.remove(c);
18471
+ touched = true;
18472
+ }
18473
+ });
18474
+ if (el.getAttribute('class') === '') el.removeAttribute('class');
18475
+ return touched;
18476
+ }
18477
+ /** Any element the OLD systems own — attributes (skrollr) OR classes (on-view). */
18478
+
18479
+ function hasLegacyMotion(el) {
18480
+ if (!el || !el.hasAttribute) return false;
18481
+ if (hasLegacyOnView(el)) return true;
18482
+ if (LEGACY_PROBE.some(a => el.hasAttribute(a))) return true; // catch the whole data-t-N / data-xs-t-N / data-sm-t-N family
18483
+
18484
+ for (const attr of el.attributes) {
18485
+ if (/^data-(xs-|sm-)?t(-\d+)?$/.test(attr.name)) return true;
18486
+ }
18487
+
18488
+ return false;
18489
+ }
18490
+ /** Every data-fx-* attribute name currently on the element. */
18491
+
18492
+ function fxAttrs(el) {
18493
+ if (!el || !el.attributes) return [];
18494
+ return Array.from(el.attributes).map(a => a.name).filter(n => n.startsWith('data-fx-'));
18495
+ }
18496
+ function hasMotion(el) {
18497
+ return fxAttrs(el).some(n => n !== MUTE_ATTR);
18498
+ }
18499
+ /* ---- mute ----
18500
+ *
18501
+ * "Off" is an ATTRIBUTE, not editor memory. The tempting alternative — stash
18502
+ * the data-fx-* attributes in JS, strip them from the DOM, restore on unmute —
18503
+ * loses the user's animation the first time the editor autosaves while muted,
18504
+ * because builder.html() serializes the DOM and the attributes aren't in it.
18505
+ *
18506
+ * As an attribute it survives save, undo, reload and hand-off; it's visible in
18507
+ * the HTML so nobody is ever confused about why an animation isn't running;
18508
+ * and it stops being a debug toggle and becomes a real feature ("turn this off
18509
+ * but keep the setup"). It costs a few dead bytes in the published page.
18510
+ *
18511
+ * contentbox-effects._eligible() is the single gate every feature passes
18512
+ * through, so one check there disables keyframes, split, typewriter, parallax,
18513
+ * horizontal, draw, marquee, tilt and magnetic at once. On a .is-section it
18514
+ * mutes the whole scene. */
18515
+
18516
+ const MUTE_ATTR = 'data-fx-off';
18517
+ /* ---- scene attributes ----
18518
+ *
18519
+ * These live on the .is-section and describe the SCENE, not any element's own
18520
+ * motion — so clearing an element must never touch them (a section is both).
18521
+ *
18522
+ * This list used to be copy-pasted into motion.js, panel-motion.js and
18523
+ * motion-cascade.js. Then data-fx-duration and data-fx-once were added and only
18524
+ * one copy learned about them, so "remove track" on a section quietly deleted
18525
+ * the cascade's timing. One list, one place.
18526
+ *
18527
+ * ADDING A NEW SCENE ATTRIBUTE? It goes here, and in animation-framwork-ai.md.
18528
+ */
18529
+
18530
+ const SCENE_ATTRS = ['data-fx-length', // pinned: how much scroll the pin eats (vh)
18531
+ 'data-fx-scrub', // seconds of catch-up lag, or "off" for time-driven
18532
+ 'data-fx-start', // where progress 0 is
18533
+ 'data-fx-end', // where progress 100 is
18534
+ 'data-fx-duration', // seconds — the scene's own clock, when not scrubbing
18535
+ 'data-fx-once', // don't replay on re-entry
18536
+ 'data-fx-emit'];
18537
+ /* ---- what is NOT the user's content ----
18538
+ *
18539
+ * ContentBox injects its toolbars INTO the row, as siblings of the column:
18540
+ *
18541
+ * div.row
18542
+ * ├ div.column ← real content
18543
+ * ├ div.is-tool.is-row-tool ← editor UI
18544
+ * ├ div.is-tool.is-col-tool ← editor UI
18545
+ * └ div.is-rowadd-tool ← editor UI
18546
+ *
18547
+ * So anything walking row.children sees toolbars as columns. They aren't empty
18548
+ * — they hold buttons — so they pass every "does this cell have content?" test,
18549
+ * and the cascade cheerfully animates the editor's own move/delete buttons.
18550
+ *
18551
+ * Kinetra also generates nodes (split-text spans, pinned-section clones). Those
18552
+ * aren't content either.
18553
+ *
18554
+ * NOTE the deliberate absence of `.column` / `.row` here: those class names are
18555
+ * user-configurable, so structure is read positionally and only the chrome is
18556
+ * named. */
18557
+
18558
+ const EDITOR_CHROME_SELECTOR = ['.is-tool', // row + column toolbars (contentbuilder's controlSelector)
18559
+ '.is-rowadd-tool', '.is-section-tool', '.is-box-tool', '.is-section-info'].join(',');
18560
+ /* NOT `.is-overlay-bg`. It is authored markup saved with the page, and it is
18561
+ * where the parallax of most section templates lives — and where converting a
18562
+ * legacy section writes its keyframes. Excluding it hid a section's most
18563
+ * common animation from the timeline dock, which lists a scene's animated
18564
+ * elements. It stays out of the ancestor picker and out of the cascade, but
18565
+ * that is each of their own rule, not this one. */
18566
+
18567
+ const NOT_CONTENT_SELECTOR = [EDITOR_CHROME_SELECTOR, '.kinetra-word', '.kinetra-char', '.kinetra-mask', '[data-section-clone]', '[data-fx-mirrors]'].join(',');
18568
+ /** Editor chrome or Kinetra-generated — never animate it, never list it. */
18569
+
18570
+ function isNotContent(el) {
18571
+ if (!el || el.nodeType !== 1 || !el.closest) return true;
18572
+ return !!el.closest(NOT_CONTENT_SELECTOR); // closest → catches buttons *inside* a toolbar
18573
+ }
18574
+ function isMuted(el) {
18575
+ return !!(el && el.hasAttribute && el.hasAttribute(MUTE_ATTR));
18576
+ }
18577
+ /** Percent keyframes on the element, sorted: [{ pct, value }] */
18578
+
18579
+ function keyframesOf(el) {
18580
+ if (!el || !el.attributes) return [];
18581
+ const out = [];
18582
+
18583
+ for (const a of el.attributes) {
18584
+ const m = /^data-fx-(\d{1,3})$/.exec(a.name);
18585
+ if (!m) continue;
18586
+ const pct = parseInt(m[1], 10);
18587
+ if (pct < 0 || pct > 100) continue;
18588
+ out.push({
18589
+ pct,
18590
+ value: a.value
18591
+ });
18592
+ }
18593
+
18594
+ return out.sort((x, y) => x.pct - y.pct);
18595
+ }
18596
+ /** Which preset (if any) is applied. Returns the preset object or null. */
18597
+
18598
+ function activePreset(el, table) {
18599
+ if (!el || !el.hasAttribute) return null;
18600
+ return (table || PRESETS).find(p => el.hasAttribute(p.key)) || null;
18601
+ }
18602
+ /** Remove every data-fx-N keyframe (leaves presets and ease alone). */
18603
+
18604
+ function clearKeyframes(el) {
18605
+ for (const name of fxAttrs(el)) {
18606
+ if (/^data-fx-\d{1,3}$/.test(name)) el.removeAttribute(name);
18607
+ }
18608
+ }
18609
+ /** Remove one preset and all of its option attributes. */
18610
+
18611
+ function clearPreset(el, preset) {
18612
+ el.removeAttribute(preset.key);
18613
+ (preset.options || []).forEach(o => {
18614
+ if (o.attr !== preset.key) el.removeAttribute(o.attr);
18615
+ });
18616
+ }
18617
+ /** Apply a keyframe recipe: replaces all existing keyframes. */
18618
+
18619
+ function applyRecipe(el, recipe) {
18620
+ clearKeyframes(el);
18621
+ Object.keys(recipe.frames).forEach(pct => {
18622
+ el.setAttribute('data-fx-' + pct, recipe.frames[pct]);
18623
+ });
18624
+ if (recipe.ease) el.setAttribute('data-fx-ease', recipe.ease);else el.removeAttribute('data-fx-ease');
18625
+ }
18626
+ /** Best-effort: which recipe do the current keyframes look like? */
18627
+
18628
+ function matchRecipe(el) {
18629
+ const frames = keyframesOf(el);
18630
+ if (!frames.length) return null;
18631
+
18632
+ const norm = s => s.replace(/\s+/g, '').replace(/;$/, '').toLowerCase();
18633
+
18634
+ return RECIPES.find(r => {
18635
+ const keys = Object.keys(r.frames);
18636
+ if (keys.length !== frames.length) return false;
18637
+ return keys.every(pct => {
18638
+ const f = frames.find(x => x.pct === parseInt(pct, 10));
18639
+ return f && norm(f.value) === norm(r.frames[pct]);
18640
+ });
18641
+ }) || null;
18642
+ }
18643
+ /* ---- Scene (section) helpers ---- */
18644
+
18645
+ function isPinned(section) {
18646
+ return !!(section && section.classList.contains('section-pin'));
18647
+ }
18648
+ const SCENE_DEFAULTS = {
18649
+ length: '320',
18650
+ // vh, pinned only
18651
+ scrub: '0.25',
18652
+ // seconds of catch-up lag
18653
+ duration: '1',
18654
+ // seconds — only read when NOT scrubbing
18655
+ start: 'top bottom',
18656
+ // end: 'bottom top',
18657
+ end: 'bottom bottom'
18658
+ };
18659
+ /* The scene window, as a choice rather than a string.
18660
+ *
18661
+ * There are only two answers that matter:
18662
+ * - fully in view — the entrance is the scene. Right for ~everything.
18663
+ * - leaves the screen — keep moving the whole time the section is on screen.
18664
+ * Right for drifting, parallax-like motion.
18665
+ * Anything else is a fine-tune, and stays available as raw edge strings.
18666
+ *
18667
+ * NOTE: the window is IGNORED when the scene is time-driven (scrub="off") —
18668
+ * an IntersectionObserver does the triggering there. */
18669
+
18670
+ const SCENE_WINDOWS = [{
18671
+ id: 'inview',
18672
+ label: 'the section is fully in view',
18673
+ start: 'top bottom',
18674
+ end: 'bottom bottom'
18675
+ }, {
18676
+ id: 'offscreen',
18677
+ label: 'the section leaves the screen',
18678
+ start: 'top bottom',
18679
+ end: 'bottom top'
18680
+ }];
18681
+ function windowOf(section) {
18682
+ if (!section) return 'inview';
18683
+ const s = (section.getAttribute('data-fx-start') || '').trim();
18684
+ const e = (section.getAttribute('data-fx-end') || '').trim();
18685
+ if (!s && !e) return 'inview'; // no attributes = defaults
18686
+
18687
+ const m = SCENE_WINDOWS.find(w => (s === '' || s === w.start) && e === w.end);
18688
+ return m ? m.id : 'custom';
18689
+ }
18690
+ function setWindow(section, id) {
18691
+ if (!section) return;
18692
+
18693
+ if (id === 'inview') {
18694
+ // the default — write nothing
18695
+ section.removeAttribute('data-fx-start');
18696
+ section.removeAttribute('data-fx-end');
18697
+ return;
18698
+ }
18699
+
18700
+ const w = SCENE_WINDOWS.find(x => x.id === id);
18701
+ if (!w) return; // 'custom' — leave the strings alone
18702
+
18703
+ section.removeAttribute('data-fx-start'); // start default is already right
18704
+
18705
+ section.setAttribute('data-fx-end', w.end);
18706
+ }
18707
+ /* Which clock drives the scene. `data-fx-scrub="off"` is the switch:
18708
+ * the scrollbar stops being the clock and the scene plays itself. */
18709
+
18710
+ function triggerOf(section) {
18711
+ if (!section) return 'scroll';
18712
+ const v = (section.getAttribute('data-fx-scrub') || '').trim().toLowerCase();
18713
+ return v === 'off' || v === 'none' || v === 'false' ? 'enter' : 'scroll';
18714
+ }
18715
+ /* The trigger a scene gets when nobody has chosen one.
18716
+ *
18717
+ * Note what this is NOT: it does not reinterpret a missing data-fx-scrub.
18718
+ * The runtime's fallback for that attribute is scrub 0.25 (contentbox-effects
18719
+ * sceneOptsFor), so a saved page with no trigger on it is scroll-driven and
18720
+ * will stay that way. This is the value the editor WRITES the first time it
18721
+ * gives a section motion — content made from now on plays on enter, content
18722
+ * already out there keeps whatever it has. */
18723
+
18724
+ const DEFAULT_TRIGGER = 'enter';
18725
+ /* Is anything in this section actually animated?
18726
+ *
18727
+ * Scene attributes don't count — data-fx-duration on a bare section describes
18728
+ * a clock nothing is running on. What counts is an element's own motion:
18729
+ * keyframes or a preset, on the section itself or anywhere inside it. */
18730
+
18731
+ function sceneHasMotion(section) {
18732
+ if (!section) return false;
18733
+ const ownMotion = fxAttrs(section).some(n => n !== MUTE_ATTR && SCENE_ATTRS.indexOf(n) === -1);
18734
+ if (ownMotion) return true;
18735
+ return Array.from(section.querySelectorAll('*')).some(el => hasMotion(el));
18736
+ }
18737
+
18738
+ // block's source for an anchor.
18739
+
18740
+ const LINK_ATTRS = {
18741
+ attrs: ['href', 'title', 'target']
18742
+ }; // Panel for elements that live in a BOX OVERLAY but outside div.is-container:
17805
18743
  //
17806
18744
  // .is-section.is-box > .is-overlay > .is-overlay-content > ... > <img>
17807
18745
  //
@@ -17848,7 +18786,28 @@ class PanelOverlay {
17848
18786
 
17849
18787
  <div class="inp-text" role="textbox"></div>
17850
18788
 
17851
- <div class="label-note mt-4">${out('This text is part of an animated layer. Change the wording here; its movement lives on the Motion tab above.')}</div>
18789
+ <div class="label-note mt-4 note-motion">${out('This text is part of an animated layer. Change the wording here; its movement lives on the Motion tab above.')}</div>
18790
+
18791
+ <div class="label-note mt-4 note-plain" style="display:none">${out('Change the wording here. Its placement and styling come from the section design.')}</div>
18792
+
18793
+ </div>
18794
+
18795
+ <div class="div-overlay-link" style="display:none">
18796
+
18797
+ <label class="label">
18798
+ <div>${out('Link')}:</div>
18799
+ <input type="text" class="inp-href" id="_inp_ovl_href_${this.random()}">
18800
+ </label>
18801
+
18802
+ <label class="label mt-2">
18803
+ <div>${out('Title')}:</div>
18804
+ <input type="text" class="inp-linktitle" id="_inp_ovl_linktitle_${this.random()}">
18805
+ </label>
18806
+
18807
+ <label class="label checkbox mt-3">
18808
+ <input type="checkbox" class="chk-newwindow" id="_chk_ovl_newwindow_${this.random()}">
18809
+ <span>${out('Open in new tab')}</span>
18810
+ </label>
17852
18811
 
17853
18812
  </div>
17854
18813
 
@@ -17873,7 +18832,7 @@ class PanelOverlay {
17873
18832
  </div>
17874
18833
 
17875
18834
  <label class="label mt-2 label-title">
17876
- <div>${out('Title')}:</div>
18835
+ <div>${out('Alt')}:</div>
17877
18836
  <input type="text" class="inp-title" id="_inp_ovl_title_${this.random()}">
17878
18837
  </label>
17879
18838
 
@@ -17881,6 +18840,15 @@ class PanelOverlay {
17881
18840
 
17882
18841
  </div>
17883
18842
 
18843
+ <div class="div-overlay-code mt-4" style="display:none">
18844
+ <div class="group single flex" style="width:100%">
18845
+ <button title="${out('Edit Code')}" class="btn-editcode">
18846
+ <svg><use xlink:href="#icon-viewcode"></use></svg>
18847
+ <span>${out('Edit Code')}</span>
18848
+ </button>
18849
+ </div>
18850
+ </div>
18851
+
17884
18852
  <div class="div-overlay-list" style="display:none">
17885
18853
  <div class="label-note list-caption">${out('Everything else in this layer')}:</div>
17886
18854
  <div class="overlay-list"></div>
@@ -17926,13 +18894,70 @@ class PanelOverlay {
17926
18894
  }, btnOpenAsset);
17927
18895
  }); // Show/hide button
17928
18896
 
17929
- if (!(this.builder.onImageSelectClick || this.builder.imageSelect)) btnOpenAsset.style.display = 'none';
18897
+ if (!(this.builder.onImageSelectClick || this.builder.imageSelect)) btnOpenAsset.style.display = 'none'; // --- Link ---
18898
+ //
18899
+ // These fields only ever EDIT an anchor that is already in the layer;
18900
+ // nothing here creates one. Wrapping a layer's text or image in a link
18901
+ // restructures the design, which is the same reason the floating image
18902
+ // tool is reduced for overlay images (see controlpanel.js getActive).
18903
+
18904
+ const inpHref = panel.querySelector('.inp-href');
18905
+ inpHref.addEventListener('input', () => {
18906
+ const link = this.link();
18907
+ if (!link) return;
18908
+ this.builder.editor.saveForUndo();
18909
+ const url = inpHref.value.trim();
18910
+ if (url) link.setAttribute('href', url);else link.removeAttribute('href'); // emptied: no link, but the element stays as it is
18911
+
18912
+ syncToCodeSource(link, LINK_ATTRS);
18913
+ this.builder.editor.onChange();
18914
+ });
18915
+ const inpLinkTitle = panel.querySelector('.inp-linktitle');
18916
+ inpLinkTitle.addEventListener('input', () => {
18917
+ const link = this.link();
18918
+ if (!link) return;
18919
+ this.builder.editor.saveForUndo();
18920
+ const title = inpLinkTitle.value;
18921
+ if (title) link.setAttribute('title', title);else link.removeAttribute('title');
18922
+ syncToCodeSource(link, LINK_ATTRS);
18923
+ this.builder.editor.onChange();
18924
+ });
18925
+ const chkNewWindow = panel.querySelector('.chk-newwindow');
18926
+ chkNewWindow.addEventListener('change', () => {
18927
+ const link = this.link();
18928
+ if (!link) return;
18929
+ this.builder.editor.saveForUndo(); // target alone, as the editor's own link dialog writes it
18930
+ // (elementhyperlink.js).
18931
+
18932
+ if (chkNewWindow.checked) link.setAttribute('target', '_blank');else link.removeAttribute('target');
18933
+ syncToCodeSource(link, LINK_ATTRS);
18934
+ this.builder.editor.onChange();
18935
+ }); // The layer is a code block: its markup is edited here, its code there.
18936
+ // Without this the code is out of reach while one of its elements is
18937
+ // selected, since the panel showing is this one, not the Code panel.
18938
+
18939
+ const btnEditCode = panel.querySelector('.btn-editcode');
18940
+ btnEditCode.addEventListener('click', () => {
18941
+ const el = this.target();
18942
+ const block = el ? el.closest('[data-cb-code]') : null;
18943
+ const codeElement = this.builder.editor.codeElement;
18944
+ if (!block || !codeElement) return; // Applying the dialog rebuilds the block from its source, replacing
18945
+ // the element this panel is on. Select the block first, so what the
18946
+ // panel shows is still on the page when the dialog closes.
18947
+
18948
+ this.builder.controlpanel.selectOverlayElement(block);
18949
+ codeElement.activeBlock = block;
18950
+ codeElement.edit();
18951
+ });
17930
18952
  const inpTitle = panel.querySelector('.inp-title');
17931
18953
  inpTitle.addEventListener('input', () => {
17932
18954
  this.builder.editor.saveForUndo();
17933
18955
  let img = this.target();
17934
18956
  if (!img) return;
17935
18957
  img.setAttribute('alt', inpTitle.value);
18958
+ syncToCodeSource(img, {
18959
+ attrs: ['alt']
18960
+ });
17936
18961
  this.builder.editor.onChange();
17937
18962
  }); // --- Text ---
17938
18963
 
@@ -18012,7 +19037,14 @@ class PanelOverlay {
18012
19037
  const html = this.unwrapBlock(this.ed.getHTML());
18013
19038
  if (html === el.innerHTML) return;
18014
19039
  this.builder.editor.saveForUndo();
18015
- el.innerHTML = html;
19040
+ el.innerHTML = html; // An element inside a code block lives in the block's RENDER, which is
19041
+ // thrown away on save; the wording has to reach the block's source too.
19042
+ // No-op everywhere else. (Elements the code built itself never get
19043
+ // here: isOverlayTextElement refuses them.)
19044
+
19045
+ syncToCodeSource(el, {
19046
+ html: true
19047
+ });
18016
19048
  this.refreshRow(el);
18017
19049
  this.builder.editor.onChange();
18018
19050
  }
@@ -18026,6 +19058,41 @@ class PanelOverlay {
18026
19058
  }
18027
19059
 
18028
19060
  return html;
19061
+ } // The anchor the current selection belongs to, or the single one inside it.
19062
+ //
19063
+ // Null where there is none, and null where the region holds SEVERAL: which
19064
+ // of them the fields would then be editing is a guess, and a wrong guess
19065
+ // rewrites a link the user was not looking at.
19066
+
19067
+
19068
+ linkFor(el) {
19069
+ if (!el || !el.closest) return null;
19070
+ const scope = el.closest(this.builder.overlayEditableSelector);
19071
+ if (!scope) return null;
19072
+ let link = el.closest('a');
19073
+ if (link && !scope.contains(link)) link = null;
19074
+
19075
+ if (!link) {
19076
+ const inside = el.querySelectorAll('a');
19077
+ if (inside.length === 1) link = inside[0];
19078
+ }
19079
+
19080
+ if (!link) return null; // Inside a code block, only an anchor still verbatim in the block's
19081
+ // source can be edited — the same rule as the wording.
19082
+
19083
+ if (!codeSourceEditable(link)) return null;
19084
+ return link;
19085
+ }
19086
+
19087
+ link() {
19088
+ return this.linkFor(this.target());
19089
+ }
19090
+
19091
+ getStateLink(link) {
19092
+ const panel = this.panel;
19093
+ panel.querySelector('.inp-href').value = link.getAttribute('href') || '';
19094
+ panel.querySelector('.inp-linktitle').value = link.getAttribute('title') || '';
19095
+ panel.querySelector('.chk-newwindow').checked = link.getAttribute('target') === '_blank';
18029
19096
  } // The element this panel is currently editing. ControlPanel keeps it in
18030
19097
  // activeElement, the same as every other panel.
18031
19098
 
@@ -18057,8 +19124,13 @@ class PanelOverlay {
18057
19124
  // selection lost (eg. the image got wrapped in a link)
18058
19125
  this.builder.controlpanel.select('');
18059
19126
  return;
18060
- }
19127
+ } // The floating tool writes to the live element and knows nothing
19128
+ // about code blocks; carry the change into the source as well.
19129
+
18061
19130
 
19131
+ syncToCodeSource(img, {
19132
+ attrs: ['src', 'alt']
19133
+ });
18062
19134
  this.getState();
18063
19135
  };
18064
19136
 
@@ -18070,6 +19142,10 @@ class PanelOverlay {
18070
19142
  return;
18071
19143
  }
18072
19144
 
19145
+ const el = this.target();
19146
+ if (el) syncToCodeSource(el, {
19147
+ attrs: ['src', 'alt']
19148
+ });
18073
19149
  this.getState();
18074
19150
  };
18075
19151
  } // `target` is the {type, element} descriptor from ControlPanel.overlayTarget().
@@ -18091,19 +19167,30 @@ class PanelOverlay {
18091
19167
  const hasText = target ? !!target.hasText : type === 'text';
18092
19168
  const hasSource = target ? !!target.hasSource : type !== 'text';
18093
19169
  const isImg = el.tagName.toLowerCase() === 'img';
18094
- this.sourceKind = isImg ? 'image' : 'background';
19170
+ this.sourceKind = isImg ? 'image' : 'background'; // A link is an ASPECT too, and an independent one: a heading can be a
19171
+ // link, so can an image, and so can an icon with neither text nor
19172
+ // picture in it (which is why the decoration note steps aside for it).
19173
+
19174
+ const link = this.linkFor(el);
18095
19175
  const divText = this.panel.querySelector('.div-overlay-text');
19176
+ const divLink = this.panel.querySelector('.div-overlay-link');
18096
19177
  const divImage = this.panel.querySelector('.div-overlay-image');
18097
19178
  const divDeco = this.panel.querySelector('.div-overlay-decoration');
18098
19179
  divText.style.display = hasText ? '' : 'none';
19180
+ divLink.style.display = link ? '' : 'none';
18099
19181
  divImage.style.display = hasSource ? '' : 'none';
18100
- if (divDeco) divDeco.style.display = !hasText && !hasSource ? '' : 'none';
19182
+ if (divDeco) divDeco.style.display = !hasText && !hasSource && !link ? '' : 'none';
18101
19183
  /* [FX-PATH] */
18102
- // Both on screen at once (an element painting its image inside its own
18103
- // letterforms): the second section needs separating from the first.
18104
19184
 
18105
- divImage.classList.toggle('stacked', hasText && hasSource);
19185
+ if (link) this.getStateLink(link); // More than one section on screen at once (an element painting its image
19186
+ // inside its own letterforms; a heading that is also a link): each one
19187
+ // after the first needs separating from what precedes it.
19188
+
19189
+ divLink.classList.toggle('stacked', !!link && hasText);
19190
+ divImage.classList.toggle('stacked', hasSource && (hasText || !!link));
18106
19191
  this.panel.querySelector('.label-title').style.display = isImg ? '' : 'none';
19192
+ const divCode = this.panel.querySelector('.div-overlay-code');
19193
+ divCode.style.display = this.builder.editor.codeElement && el.closest('[data-cb-code]') ? '' : 'none';
18107
19194
  if (hasText) this.getStateText(el);
18108
19195
  if (hasSource) this.getStateSource(el);
18109
19196
  this.renderList(el);
@@ -18219,11 +19306,28 @@ class PanelOverlay {
18219
19306
  if (!value || value === 'none') return '';
18220
19307
  const found = /url\((['"]?)(.*?)\1\)/.exec(value);
18221
19308
  return found ? found[2] : '';
19309
+ } // Does this layer move on the Motion tab? Only then does the note that
19310
+ // points at it say anything true: a layer can be a static caption, or a
19311
+ // code block, or animate from CSS keyframes the tab has no part in.
19312
+ // Resolved up the layer, since the keyframes are often on a wrapper.
19313
+
19314
+
19315
+ layerHasMotion(el) {
19316
+ const stop = el.closest(this.builder.overlayEditableSelector);
19317
+
19318
+ for (let node = el; node && node !== stop; node = node.parentElement) {
19319
+ if (hasMotion(node)) return true;
19320
+ }
19321
+
19322
+ return false;
18222
19323
  }
18223
19324
 
18224
19325
  getStateText(el) {
18225
19326
  const panel = this.panel;
18226
- if (!el) return; // Load the element's content into the field. Guarded so the write-back
19327
+ if (!el) return;
19328
+ const animated = this.layerHasMotion(el);
19329
+ panel.querySelector('.note-motion').style.display = animated ? '' : 'none';
19330
+ panel.querySelector('.note-plain').style.display = animated ? 'none' : ''; // Load the element's content into the field. Guarded so the write-back
18227
19331
  // does not fire on our own load, and skipped while the user is typing in
18228
19332
  // it (which would move their caret to the end on every keystroke).
18229
19333
 
@@ -18314,6 +19418,9 @@ class PanelOverlay {
18314
19418
  el.style.backgroundImage = `url("${src}")`;
18315
19419
  }
18316
19420
 
19421
+ syncToCodeSource(el, {
19422
+ styles: ['background-image']
19423
+ });
18317
19424
  this.refreshRow(el);
18318
19425
  return;
18319
19426
  }
@@ -18322,6 +19429,9 @@ class PanelOverlay {
18322
19429
  this.builder.editor.element.image.repositionImageTool();
18323
19430
  });
18324
19431
  el.setAttribute('src', src);
19432
+ syncToCodeSource(el, {
19433
+ attrs: ['src']
19434
+ });
18325
19435
  this.refreshRow(el);
18326
19436
  }
18327
19437
 
@@ -25309,739 +26419,6 @@ js$1.exports;
25309
26419
  var jsExports = js$1.exports;
25310
26420
  var JsBeautify$1 = /*@__PURE__*/getDefaultExportFromCjs(jsExports);
25311
26421
 
25312
- /*
25313
- * motion-presets.js — the declarative vocabulary of the Motion panel.
25314
- *
25315
- * Everything the Motion UI can apply lives here as DATA, so adding a new
25316
- * effect is a table edit, never a code edit. Nothing in this file touches
25317
- * the DOM or the builder.
25318
- *
25319
- * Three kinds of thing, because Kinetra genuinely has three:
25320
- *
25321
- * 1. RECIPES — canned percent keyframes (data-fx-0 … data-fx-100).
25322
- * "Fade up", "Zoom in", "Wipe in". These are just a
25323
- * starting set of keyframes; the Motion dock (phase 2)
25324
- * edits them freely afterwards.
25325
- * 2. PRESETS — single-attribute effects that are NOT keyframes:
25326
- * parallax, horizontal, split, typewriter, draw, marquee.
25327
- * Each is identified by one "key" attribute.
25328
- * 3. POINTER — micro-interactions (tilt, magnetic). Not scene-bound
25329
- * at all; they are per-element toggles.
25330
- *
25331
- * Option types:
25332
- * 'text' — free text input, written verbatim to the attribute
25333
- * 'select' — dropdown; value written to the attribute
25334
- * 'toggle' — VALUELESS boolean attribute (present / absent)
25335
- */
25336
-
25337
- /* Ease names are NOT listed here. motion-ease.js derives them from kinetra's
25338
- * exported `easings` table, so there is exactly one source of truth and a copy
25339
- * can't drift when someone adds or renames a curve. */
25340
-
25341
- /* ------------------------------------------------------------------
25342
- * 1. KEYFRAME RECIPES
25343
- *
25344
- * `frames` maps percent -> declaration string. Applying a recipe REPLACES
25345
- * every existing data-fx-N on the element (a recipe is a fresh start, not
25346
- * an overlay), and sets data-fx-ease.
25347
- *
25348
- * Compound-string rule: both ends of a clip-path/box-shadow/etc. must share
25349
- * the same token count, or Kinetra snaps instead of tweening. Every recipe
25350
- * below already obeys that — keep it that way when adding new ones.
25351
- * ------------------------------------------------------------------ */
25352
- const RECIPES = [{
25353
- id: 'fade',
25354
- duration: 0.6,
25355
- label: 'Fade in',
25356
- ease: 'easeOut',
25357
- frames: {
25358
- 0: 'opacity:0',
25359
- 100: 'opacity:1'
25360
- }
25361
- }, {
25362
- id: 'fade-up',
25363
- duration: 0.6,
25364
- label: 'Fade up',
25365
- ease: 'smooth',
25366
- // kinetra's closest match to CSS ease-in-out
25367
- frames: {
25368
- 0: 'opacity:0; transform:translateY(70px)',
25369
- // .is-fadeInUp was 70px
25370
- 100: 'opacity:1; transform:translateY(0px)'
25371
- }
25372
- }, {
25373
- id: 'fade-down',
25374
- duration: 0.6,
25375
- label: 'Fade down',
25376
- ease: 'smooth',
25377
- frames: {
25378
- 0: 'opacity:0; transform:translateY(-70px)',
25379
- 100: 'opacity:1; transform:translateY(0px)'
25380
- }
25381
- }, {
25382
- id: 'fade-left',
25383
- duration: 0.6,
25384
- label: 'From left',
25385
- ease: 'smooth',
25386
- frames: {
25387
- 0: 'opacity:0; transform:translateX(-70px)',
25388
- 100: 'opacity:1; transform:translateX(0px)'
25389
- }
25390
- }, {
25391
- id: 'fade-right',
25392
- duration: 0.6,
25393
- label: 'From right',
25394
- ease: 'smooth',
25395
- frames: {
25396
- 0: 'opacity:0; transform:translateX(70px)',
25397
- 100: 'opacity:1; transform:translateX(0px)'
25398
- }
25399
- }, {
25400
- id: 'zoom-in',
25401
- duration: 0.8,
25402
- label: 'Zoom in',
25403
- ease: 'smooth',
25404
- frames: {
25405
- 0: 'opacity:0; transform:scale(0.8)',
25406
- // .is-zoomIn was scale(.8)
25407
- 100: 'opacity:1; transform:scale(1)'
25408
- }
25409
- }, {
25410
- id: 'zoom-out',
25411
- duration: 0.8,
25412
- label: 'Zoom out',
25413
- ease: 'smooth',
25414
- frames: {
25415
- 0: 'opacity:0; transform:scale(1.2)',
25416
- // .is-zoomOut was scale(1.2)
25417
- 100: 'opacity:1; transform:scale(1)'
25418
- }
25419
- }, {
25420
- id: 'slide-up',
25421
- duration: 0.6,
25422
- label: 'Slide up',
25423
- ease: 'smooth',
25424
- frames: {
25425
- 0: 'transform:translateY(70px)',
25426
- 100: 'transform:translateY(0px)'
25427
- }
25428
- }, {
25429
- id: 'slide-down',
25430
- duration: 0.6,
25431
- label: 'Slide down',
25432
- ease: 'smooth',
25433
- frames: {
25434
- 0: 'transform:translateY(-70px)',
25435
- 100: 'transform:translateY(0px)'
25436
- }
25437
- }, {
25438
- id: 'slide-left',
25439
- duration: 0.6,
25440
- label: 'Slide from left',
25441
- ease: 'smooth',
25442
- frames: {
25443
- 0: 'transform:translateX(-70px)',
25444
- 100: 'transform:translateX(0px)'
25445
- }
25446
- }, {
25447
- id: 'slide-right',
25448
- duration: 0.6,
25449
- label: 'Slide from right',
25450
- ease: 'smooth',
25451
- frames: {
25452
- 0: 'transform:translateX(70px)',
25453
- 100: 'transform:translateX(0px)'
25454
- }
25455
- }, {
25456
- id: 'blur-in',
25457
- duration: 0.6,
25458
- label: 'Blur in',
25459
- ease: 'easeOut',
25460
- frames: {
25461
- 0: 'opacity:0; filter:blur(12px)',
25462
- 100: 'opacity:1; filter:blur(0px)'
25463
- }
25464
- }, {
25465
- id: 'flip-x',
25466
- label: 'Flip X',
25467
- duration: 0.8,
25468
- ease: 'smooth',
25469
- frames: {
25470
- 0: 'opacity:0; transform:perspective(2500px) rotateX(100deg)',
25471
- 100: 'opacity:1; transform:perspective(2500px) rotateX(0deg)'
25472
- }
25473
- }, {
25474
- id: 'flip-y',
25475
- label: 'Flip Y',
25476
- duration: 0.8,
25477
- ease: 'smooth',
25478
- frames: {
25479
- 0: 'opacity:0; transform:perspective(2500px) rotateY(100deg)',
25480
- 100: 'opacity:1; transform:perspective(2500px) rotateY(0deg)'
25481
- }
25482
- }, {
25483
- id: 'pulse',
25484
- label: 'Pulse',
25485
- duration: 0.6,
25486
- ease: 'linear',
25487
- frames: {
25488
- 0: 'opacity:0; transform:scale(0.9)',
25489
- 50: 'opacity:1; transform:scale(1.05)',
25490
- 100: 'opacity:1; transform:scale(1)'
25491
- }
25492
- }, {
25493
- id: 'bounce-in',
25494
- label: 'Bounce in',
25495
- duration: 0.6,
25496
- ease: 'linear',
25497
- frames: {
25498
- 0: 'opacity:0; transform:translateX(0px)',
25499
- 40: 'opacity:1; transform:translateX(-20px)',
25500
- 60: 'opacity:1; transform:translateX(0px)',
25501
- 80: 'opacity:1; transform:translateX(-15px)',
25502
- 100: 'opacity:1; transform:translateX(0px)'
25503
- }
25504
- }, {
25505
- id: 'wipe-right',
25506
- duration: 0.6,
25507
- label: 'Wipe in',
25508
- ease: 'expoOut',
25509
- frames: {
25510
- 0: 'opacity:1; clip-path:inset(0% 100% 0% 0%)',
25511
- 100: 'opacity:1; clip-path:inset(0% 0% 0% 0%)'
25512
- }
25513
- }, {
25514
- id: 'rise-mask',
25515
- duration: 0.7,
25516
- label: 'Rise + mask',
25517
- ease: 'expoOut',
25518
- frames: {
25519
- 0: 'opacity:0; transform:translateY(70px); clip-path:inset(100% 0% 0% 0%)',
25520
- 100: 'opacity:1; transform:translateY(0px); clip-path:inset(0% 0% 0% 0%)'
25521
- }
25522
- }, {
25523
- id: 'rotate-in',
25524
- duration: 0.6,
25525
- label: 'Rotate in',
25526
- ease: 'backOut',
25527
- frames: {
25528
- 0: 'opacity:0; transform:rotate(-8deg) scale(0.94)',
25529
- 100: 'opacity:1; transform:rotate(0deg) scale(1)'
25530
- }
25531
- }, {
25532
- id: 'drift',
25533
- duration: 0.6,
25534
- label: 'Drift through',
25535
- ease: 'linear',
25536
- noCascade: true,
25537
- // a through-motion, not an entrance — pointless in a cascade
25538
- frames: {
25539
- 0: 'transform:translateY(60px)',
25540
- 100: 'transform:translateY(-60px)'
25541
- }
25542
- }, {
25543
- id: 'hold-scale',
25544
- duration: 0.6,
25545
- label: 'Scale back + hold',
25546
- ease: 'linear',
25547
- note: 'Card-stack layer: shrinks as the next layer covers it.',
25548
- frames: {
25549
- 0: 'transform:scale(1)',
25550
- 50: 'transform:scale(0.9)',
25551
- 100: 'transform:scale(0.9)'
25552
- }
25553
- }];
25554
- /* ------------------------------------------------------------------
25555
- * 2. SCROLL / TEXT PRESETS (single-attribute effects)
25556
- * ------------------------------------------------------------------ */
25557
-
25558
- const PRESETS = [{
25559
- id: 'parallax',
25560
- label: 'Parallax',
25561
- group: 'scroll',
25562
- key: 'data-fx-parallax',
25563
- defaults: {
25564
- 'data-fx-parallax': '120'
25565
- },
25566
- hint: 'Drifts as it scrolls. Works in any section.',
25567
- options: [{
25568
- attr: 'data-fx-parallax',
25569
- label: 'Travel',
25570
- type: 'text',
25571
- placeholder: '120 (px) or 20%'
25572
- }]
25573
- }, {
25574
- id: 'horizontal',
25575
- label: 'Horizontal track',
25576
- group: 'scroll',
25577
- key: 'data-fx-horizontal',
25578
- pinnedOnly: true,
25579
- defaults: {
25580
- 'data-fx-horizontal': ''
25581
- },
25582
- hint: 'Vertical scroll drives a sideways track. Needs a pinned scene, and children with flex:0 0 auto.',
25583
- options: []
25584
- }, {
25585
- id: 'split',
25586
- label: 'Split text',
25587
- group: 'text',
25588
- key: 'data-fx-split',
25589
- defaults: {
25590
- 'data-fx-split': 'words'
25591
- },
25592
- hint: 'Staggered word/char entrance. Formatting inside the element is preserved.',
25593
- options: [{
25594
- attr: 'data-fx-split',
25595
- label: 'Unit',
25596
- type: 'select',
25597
- choices: [['words', 'Words'], ['chars', 'Characters']]
25598
- }, {
25599
- attr: 'data-fx-split-mask',
25600
- label: 'Mask reveal',
25601
- type: 'toggle'
25602
- }, {
25603
- attr: 'data-fx-split-stagger',
25604
- label: 'Stagger',
25605
- type: 'text',
25606
- placeholder: '0.08 (sec)'
25607
- }, {
25608
- attr: 'data-fx-split-y',
25609
- label: 'Travel',
25610
- type: 'text',
25611
- placeholder: '115% or 18px'
25612
- }, {
25613
- attr: 'data-fx-split-scrub',
25614
- label: 'Scrub through scene',
25615
- type: 'toggle',
25616
- hint: 'Best in a pinned scene — units brighten in reading order as you scroll.'
25617
- }, {
25618
- attr: 'data-fx-split-from',
25619
- label: 'Dim opacity',
25620
- type: 'text',
25621
- placeholder: '0.12',
25622
- requires: 'data-fx-split-scrub'
25623
- }]
25624
- }, {
25625
- id: 'typewriter',
25626
- label: 'Typewriter',
25627
- group: 'text',
25628
- key: 'data-fx-typewriter',
25629
- defaults: {
25630
- 'data-fx-typewriter': ''
25631
- },
25632
- hint: 'The scrollbar types the text. Plain text only.',
25633
- options: [{
25634
- attr: 'data-fx-typewriter-until',
25635
- label: 'Done at',
25636
- type: 'text',
25637
- placeholder: '0.85 (progress)'
25638
- }]
25639
- }, {
25640
- id: 'marquee',
25641
- label: 'Marquee',
25642
- group: 'text',
25643
- key: 'data-fx-marquee',
25644
- defaults: {
25645
- 'data-fx-marquee': ''
25646
- },
25647
- hint: 'Drifting band. Content should be a repeating strip.',
25648
- options: [{
25649
- attr: 'data-fx-marquee-speed',
25650
- label: 'Speed',
25651
- type: 'text',
25652
- placeholder: '90 (px/sec)'
25653
- }, {
25654
- attr: 'data-fx-marquee-lean',
25655
- label: 'Lean with velocity',
25656
- type: 'toggle'
25657
- }]
25658
- }, {
25659
- id: 'path',
25660
- label: 'Motion path',
25661
- group: 'scroll',
25662
- key: 'data-fx-path',
25663
- defaults: {
25664
- 'data-fx-path': '-200,60; 0,-80; 200,60'
25665
- },
25666
- hint: 'Travels a smooth curve through waypoints. Edit the stops in the Motion dock; px offsets from the element\'s resting position.',
25667
- options: [{
25668
- attr: 'data-fx-path',
25669
- label: 'Waypoints',
25670
- type: 'text',
25671
- placeholder: 'x,y; x,y @tx,ty; …'
25672
- }, {
25673
- attr: 'data-fx-path-dwell',
25674
- label: 'Dwell',
25675
- type: 'text',
25676
- placeholder: '0.78 (0 = constant, 1 = stops)'
25677
- }, {
25678
- attr: 'data-fx-path-curve',
25679
- label: 'Curve',
25680
- type: 'text',
25681
- placeholder: '1 (0 = straight lines)'
25682
- }, {
25683
- attr: 'data-fx-path-rotate',
25684
- label: 'Auto-rotate along path',
25685
- type: 'toggle'
25686
- }, {
25687
- attr: 'data-fx-path-rotate',
25688
- label: 'Rotate offset',
25689
- type: 'text',
25690
- placeholder: '0 (deg; art faces right 0, up 90)',
25691
- requires: 'data-fx-path-rotate'
25692
- }]
25693
- }, {
25694
- id: 'draw',
25695
- label: 'Draw SVG',
25696
- group: 'scroll',
25697
- key: 'data-fx-draw',
25698
- defaults: {
25699
- 'data-fx-draw': ''
25700
- },
25701
- tags: ['svg', 'path'],
25702
- hint: 'Paths draw themselves as the scene scrubs. Put on an <svg> or a <path>.',
25703
- options: []
25704
- }];
25705
- /* ------------------------------------------------------------------
25706
- * 3. POINTER MICRO-INTERACTIONS
25707
- * ------------------------------------------------------------------ */
25708
-
25709
- const POINTER = [{
25710
- id: 'tilt',
25711
- label: 'Tilt',
25712
- key: 'data-fx-tilt',
25713
- defaults: {
25714
- 'data-fx-tilt': ''
25715
- },
25716
- options: [{
25717
- attr: 'data-fx-tilt-max',
25718
- label: 'Max angle',
25719
- type: 'text',
25720
- placeholder: '14 (deg)'
25721
- }]
25722
- }, {
25723
- id: 'magnetic',
25724
- label: 'Magnetic',
25725
- key: 'data-fx-magnetic',
25726
- defaults: {
25727
- 'data-fx-magnetic': ''
25728
- },
25729
- options: [{
25730
- attr: 'data-fx-magnetic-strength',
25731
- label: 'Strength',
25732
- type: 'text',
25733
- placeholder: '0.35'
25734
- }]
25735
- }];
25736
- /* ------------------------------------------------------------------
25737
- * Helpers — every consumer of this table should go through these, so the
25738
- * "one system per element" and "attributes only" rules stay in one place.
25739
- * ------------------------------------------------------------------ */
25740
-
25741
- /* High-traffic legacy attributes. Mirrors LEGACY_PROBE in contentbox-effects.js:
25742
- * an element carrying any of these is owned by the old timeline and must not
25743
- * receive data-fx-*. */
25744
-
25745
- const LEGACY_PROBE = ['data-bottom-top', 'data-center', 'data-center-top', 'data-center-bottom', 'data-top', 'data-top-bottom', 'data-bottom', 'data-in', 'data-cen', 'data-out', 'data-t', 'data-t-100', 'data-t-200', 'data-t-300', 'data-t-400', 'data-t-500', 'data-t-600', 'data-t-700', 'data-t-800', 'data-t-900', 'data-t-1000', 'data-hidden-onstart', 'data-smooth-scrolling'];
25746
- /* THE SECOND LEGACY SYSTEM.
25747
- *
25748
- * animatescroll.js's "On View" wizard never wrote attributes at all — it wrote
25749
- * CSS CLASSES, toggled at runtime by inViewSetup()'s IntersectionObserver. So
25750
- * an element can be legacy-animated while carrying no legacy attribute
25751
- * whatsoever, which means contentbox-effects' hasLegacyAttrs() guard sails
25752
- * straight past it. An element with both class="is-animated is-fadeInUp" and
25753
- * data-fx-0 would get animated TWICE, by two systems both writing transform
25754
- * and opacity.
25755
- *
25756
- * This list is lifted verbatim from animatescroll.js's cleanupBasic(). */
25757
-
25758
- const LEGACY_ONVIEW_CLASSES = ['is-animated', 'is-scale-animated', 'is-inview', 'once', 'is-fadeIn', 'is-fadeInUp', 'is-fadeInDown', 'is-fadeInLeft', 'is-fadeInRight', 'is-zoomIn', 'is-zoomOut', 'is-slideInUp', 'is-slideInDown', 'is-slideInLeft', 'is-slideInRight', 'is-flipInX', 'is-flipInY', 'is-pulse', 'is-bounceIn'];
25759
- function hasLegacyOnView(el) {
25760
- if (!el || !el.classList) return false;
25761
- return el.classList.contains('is-animated') || el.classList.contains('is-scale-animated');
25762
- }
25763
- /** Strip every trace of the class-based system from one element. */
25764
-
25765
- function stripLegacyOnView(el) {
25766
- if (!el || !el.classList) return false;
25767
- let touched = false;
25768
- LEGACY_ONVIEW_CLASSES.forEach(c => {
25769
- if (el.classList.contains(c)) {
25770
- el.classList.remove(c);
25771
- touched = true;
25772
- }
25773
- }); // delay-0ms … delay-2000ms
25774
-
25775
- Array.from(el.classList).forEach(c => {
25776
- if (/^delay-\d+ms$/.test(c)) {
25777
- el.classList.remove(c);
25778
- touched = true;
25779
- }
25780
- });
25781
- if (el.getAttribute('class') === '') el.removeAttribute('class');
25782
- return touched;
25783
- }
25784
- /** Any element the OLD systems own — attributes (skrollr) OR classes (on-view). */
25785
-
25786
- function hasLegacyMotion(el) {
25787
- if (!el || !el.hasAttribute) return false;
25788
- if (hasLegacyOnView(el)) return true;
25789
- if (LEGACY_PROBE.some(a => el.hasAttribute(a))) return true; // catch the whole data-t-N / data-xs-t-N / data-sm-t-N family
25790
-
25791
- for (const attr of el.attributes) {
25792
- if (/^data-(xs-|sm-)?t(-\d+)?$/.test(attr.name)) return true;
25793
- }
25794
-
25795
- return false;
25796
- }
25797
- /** Every data-fx-* attribute name currently on the element. */
25798
-
25799
- function fxAttrs(el) {
25800
- if (!el || !el.attributes) return [];
25801
- return Array.from(el.attributes).map(a => a.name).filter(n => n.startsWith('data-fx-'));
25802
- }
25803
- function hasMotion(el) {
25804
- return fxAttrs(el).some(n => n !== MUTE_ATTR);
25805
- }
25806
- /* ---- mute ----
25807
- *
25808
- * "Off" is an ATTRIBUTE, not editor memory. The tempting alternative — stash
25809
- * the data-fx-* attributes in JS, strip them from the DOM, restore on unmute —
25810
- * loses the user's animation the first time the editor autosaves while muted,
25811
- * because builder.html() serializes the DOM and the attributes aren't in it.
25812
- *
25813
- * As an attribute it survives save, undo, reload and hand-off; it's visible in
25814
- * the HTML so nobody is ever confused about why an animation isn't running;
25815
- * and it stops being a debug toggle and becomes a real feature ("turn this off
25816
- * but keep the setup"). It costs a few dead bytes in the published page.
25817
- *
25818
- * contentbox-effects._eligible() is the single gate every feature passes
25819
- * through, so one check there disables keyframes, split, typewriter, parallax,
25820
- * horizontal, draw, marquee, tilt and magnetic at once. On a .is-section it
25821
- * mutes the whole scene. */
25822
-
25823
- const MUTE_ATTR = 'data-fx-off';
25824
- /* ---- scene attributes ----
25825
- *
25826
- * These live on the .is-section and describe the SCENE, not any element's own
25827
- * motion — so clearing an element must never touch them (a section is both).
25828
- *
25829
- * This list used to be copy-pasted into motion.js, panel-motion.js and
25830
- * motion-cascade.js. Then data-fx-duration and data-fx-once were added and only
25831
- * one copy learned about them, so "remove track" on a section quietly deleted
25832
- * the cascade's timing. One list, one place.
25833
- *
25834
- * ADDING A NEW SCENE ATTRIBUTE? It goes here, and in animation-framwork-ai.md.
25835
- */
25836
-
25837
- const SCENE_ATTRS = ['data-fx-length', // pinned: how much scroll the pin eats (vh)
25838
- 'data-fx-scrub', // seconds of catch-up lag, or "off" for time-driven
25839
- 'data-fx-start', // where progress 0 is
25840
- 'data-fx-end', // where progress 100 is
25841
- 'data-fx-duration', // seconds — the scene's own clock, when not scrubbing
25842
- 'data-fx-once', // don't replay on re-entry
25843
- 'data-fx-emit'];
25844
- /* ---- what is NOT the user's content ----
25845
- *
25846
- * ContentBox injects its toolbars INTO the row, as siblings of the column:
25847
- *
25848
- * div.row
25849
- * ├ div.column ← real content
25850
- * ├ div.is-tool.is-row-tool ← editor UI
25851
- * ├ div.is-tool.is-col-tool ← editor UI
25852
- * └ div.is-rowadd-tool ← editor UI
25853
- *
25854
- * So anything walking row.children sees toolbars as columns. They aren't empty
25855
- * — they hold buttons — so they pass every "does this cell have content?" test,
25856
- * and the cascade cheerfully animates the editor's own move/delete buttons.
25857
- *
25858
- * Kinetra also generates nodes (split-text spans, pinned-section clones). Those
25859
- * aren't content either.
25860
- *
25861
- * NOTE the deliberate absence of `.column` / `.row` here: those class names are
25862
- * user-configurable, so structure is read positionally and only the chrome is
25863
- * named. */
25864
-
25865
- const EDITOR_CHROME_SELECTOR = ['.is-tool', // row + column toolbars (contentbuilder's controlSelector)
25866
- '.is-rowadd-tool', '.is-section-tool', '.is-box-tool', '.is-section-info'].join(',');
25867
- /* NOT `.is-overlay-bg`. It is authored markup saved with the page, and it is
25868
- * where the parallax of most section templates lives — and where converting a
25869
- * legacy section writes its keyframes. Excluding it hid a section's most
25870
- * common animation from the timeline dock, which lists a scene's animated
25871
- * elements. It stays out of the ancestor picker and out of the cascade, but
25872
- * that is each of their own rule, not this one. */
25873
-
25874
- const NOT_CONTENT_SELECTOR = [EDITOR_CHROME_SELECTOR, '.kinetra-word', '.kinetra-char', '.kinetra-mask', '[data-section-clone]', '[data-fx-mirrors]'].join(',');
25875
- /** Editor chrome or Kinetra-generated — never animate it, never list it. */
25876
-
25877
- function isNotContent(el) {
25878
- if (!el || el.nodeType !== 1 || !el.closest) return true;
25879
- return !!el.closest(NOT_CONTENT_SELECTOR); // closest → catches buttons *inside* a toolbar
25880
- }
25881
- function isMuted(el) {
25882
- return !!(el && el.hasAttribute && el.hasAttribute(MUTE_ATTR));
25883
- }
25884
- /** Percent keyframes on the element, sorted: [{ pct, value }] */
25885
-
25886
- function keyframesOf(el) {
25887
- if (!el || !el.attributes) return [];
25888
- const out = [];
25889
-
25890
- for (const a of el.attributes) {
25891
- const m = /^data-fx-(\d{1,3})$/.exec(a.name);
25892
- if (!m) continue;
25893
- const pct = parseInt(m[1], 10);
25894
- if (pct < 0 || pct > 100) continue;
25895
- out.push({
25896
- pct,
25897
- value: a.value
25898
- });
25899
- }
25900
-
25901
- return out.sort((x, y) => x.pct - y.pct);
25902
- }
25903
- /** Which preset (if any) is applied. Returns the preset object or null. */
25904
-
25905
- function activePreset(el, table) {
25906
- if (!el || !el.hasAttribute) return null;
25907
- return (table || PRESETS).find(p => el.hasAttribute(p.key)) || null;
25908
- }
25909
- /** Remove every data-fx-N keyframe (leaves presets and ease alone). */
25910
-
25911
- function clearKeyframes(el) {
25912
- for (const name of fxAttrs(el)) {
25913
- if (/^data-fx-\d{1,3}$/.test(name)) el.removeAttribute(name);
25914
- }
25915
- }
25916
- /** Remove one preset and all of its option attributes. */
25917
-
25918
- function clearPreset(el, preset) {
25919
- el.removeAttribute(preset.key);
25920
- (preset.options || []).forEach(o => {
25921
- if (o.attr !== preset.key) el.removeAttribute(o.attr);
25922
- });
25923
- }
25924
- /** Apply a keyframe recipe: replaces all existing keyframes. */
25925
-
25926
- function applyRecipe(el, recipe) {
25927
- clearKeyframes(el);
25928
- Object.keys(recipe.frames).forEach(pct => {
25929
- el.setAttribute('data-fx-' + pct, recipe.frames[pct]);
25930
- });
25931
- if (recipe.ease) el.setAttribute('data-fx-ease', recipe.ease);else el.removeAttribute('data-fx-ease');
25932
- }
25933
- /** Best-effort: which recipe do the current keyframes look like? */
25934
-
25935
- function matchRecipe(el) {
25936
- const frames = keyframesOf(el);
25937
- if (!frames.length) return null;
25938
-
25939
- const norm = s => s.replace(/\s+/g, '').replace(/;$/, '').toLowerCase();
25940
-
25941
- return RECIPES.find(r => {
25942
- const keys = Object.keys(r.frames);
25943
- if (keys.length !== frames.length) return false;
25944
- return keys.every(pct => {
25945
- const f = frames.find(x => x.pct === parseInt(pct, 10));
25946
- return f && norm(f.value) === norm(r.frames[pct]);
25947
- });
25948
- }) || null;
25949
- }
25950
- /* ---- Scene (section) helpers ---- */
25951
-
25952
- function isPinned(section) {
25953
- return !!(section && section.classList.contains('section-pin'));
25954
- }
25955
- const SCENE_DEFAULTS = {
25956
- length: '320',
25957
- // vh, pinned only
25958
- scrub: '0.25',
25959
- // seconds of catch-up lag
25960
- duration: '1',
25961
- // seconds — only read when NOT scrubbing
25962
- start: 'top bottom',
25963
- // end: 'bottom top',
25964
- end: 'bottom bottom'
25965
- };
25966
- /* The scene window, as a choice rather than a string.
25967
- *
25968
- * There are only two answers that matter:
25969
- * - fully in view — the entrance is the scene. Right for ~everything.
25970
- * - leaves the screen — keep moving the whole time the section is on screen.
25971
- * Right for drifting, parallax-like motion.
25972
- * Anything else is a fine-tune, and stays available as raw edge strings.
25973
- *
25974
- * NOTE: the window is IGNORED when the scene is time-driven (scrub="off") —
25975
- * an IntersectionObserver does the triggering there. */
25976
-
25977
- const SCENE_WINDOWS = [{
25978
- id: 'inview',
25979
- label: 'the section is fully in view',
25980
- start: 'top bottom',
25981
- end: 'bottom bottom'
25982
- }, {
25983
- id: 'offscreen',
25984
- label: 'the section leaves the screen',
25985
- start: 'top bottom',
25986
- end: 'bottom top'
25987
- }];
25988
- function windowOf(section) {
25989
- if (!section) return 'inview';
25990
- const s = (section.getAttribute('data-fx-start') || '').trim();
25991
- const e = (section.getAttribute('data-fx-end') || '').trim();
25992
- if (!s && !e) return 'inview'; // no attributes = defaults
25993
-
25994
- const m = SCENE_WINDOWS.find(w => (s === '' || s === w.start) && e === w.end);
25995
- return m ? m.id : 'custom';
25996
- }
25997
- function setWindow(section, id) {
25998
- if (!section) return;
25999
-
26000
- if (id === 'inview') {
26001
- // the default — write nothing
26002
- section.removeAttribute('data-fx-start');
26003
- section.removeAttribute('data-fx-end');
26004
- return;
26005
- }
26006
-
26007
- const w = SCENE_WINDOWS.find(x => x.id === id);
26008
- if (!w) return; // 'custom' — leave the strings alone
26009
-
26010
- section.removeAttribute('data-fx-start'); // start default is already right
26011
-
26012
- section.setAttribute('data-fx-end', w.end);
26013
- }
26014
- /* Which clock drives the scene. `data-fx-scrub="off"` is the switch:
26015
- * the scrollbar stops being the clock and the scene plays itself. */
26016
-
26017
- function triggerOf(section) {
26018
- if (!section) return 'scroll';
26019
- const v = (section.getAttribute('data-fx-scrub') || '').trim().toLowerCase();
26020
- return v === 'off' || v === 'none' || v === 'false' ? 'enter' : 'scroll';
26021
- }
26022
- /* The trigger a scene gets when nobody has chosen one.
26023
- *
26024
- * Note what this is NOT: it does not reinterpret a missing data-fx-scrub.
26025
- * The runtime's fallback for that attribute is scrub 0.25 (contentbox-effects
26026
- * sceneOptsFor), so a saved page with no trigger on it is scroll-driven and
26027
- * will stay that way. This is the value the editor WRITES the first time it
26028
- * gives a section motion — content made from now on plays on enter, content
26029
- * already out there keeps whatever it has. */
26030
-
26031
- const DEFAULT_TRIGGER = 'enter';
26032
- /* Is anything in this section actually animated?
26033
- *
26034
- * Scene attributes don't count — data-fx-duration on a bare section describes
26035
- * a clock nothing is running on. What counts is an element's own motion:
26036
- * keyframes or a preset, on the section itself or anywhere inside it. */
26037
-
26038
- function sceneHasMotion(section) {
26039
- if (!section) return false;
26040
- const ownMotion = fxAttrs(section).some(n => n !== MUTE_ATTR && SCENE_ATTRS.indexOf(n) === -1);
26041
- if (ownMotion) return true;
26042
- return Array.from(section.querySelectorAll('*')).some(el => hasMotion(el));
26043
- }
26044
-
26045
26422
  /*
26046
26423
  * section-anim.js — the Section panel's ready-made "Animation" presets,
26047
26424
  * expressed as Motion (data-fx-*) instead of the legacy skrollr attributes.
@@ -33184,6 +33561,11 @@ class PanelSection {
33184
33561
  <button title="${out('Duplicate')}" class="btn-duplicate">
33185
33562
  <svg><use xlink:href="#icon-copy"></use></svg>
33186
33563
  </button>
33564
+ ${builder.sectionHtmlButton ? `
33565
+ <button title="${out('Section HTML')}" class="btn-html">
33566
+ <svg><use xlink:href="#icon-code"></use></svg>
33567
+ </button>
33568
+ ` : ''}
33187
33569
  <button title="${out('Delete')}" class="btn-delete">
33188
33570
  <svg><use xlink:href="#icon-trash"></use></svg>
33189
33571
  </button>
@@ -33385,6 +33767,10 @@ class PanelSection {
33385
33767
  btnDuplicate.addEventListener('click', () => {
33386
33768
  this.section.sectionDuplicate();
33387
33769
  });
33770
+ const btnHtml = panel.querySelector('.btn-html');
33771
+ if (btnHtml) btnHtml.addEventListener('click', () => {
33772
+ this.builder.openSectionHtml();
33773
+ });
33388
33774
  const btnDelete = panel.querySelector('.btn-delete');
33389
33775
  btnDelete.addEventListener('click', () => {
33390
33776
  this.section.sectionDelete();
@@ -35920,6 +36306,14 @@ class PanelMotion {
35920
36306
  const builder = this.builder;
35921
36307
  builder.editor.saveForUndo();
35922
36308
  mutate();
36309
+ /* An element inside a code block lives in the block's RENDER, which is
36310
+ * discarded on save. Carry its motion into the block's source so the
36311
+ * animation survives. No-op for every other element. */
36312
+
36313
+ const inCode = this.target();
36314
+ if (inCode) syncToCodeSource(inCode, {
36315
+ fx: true
36316
+ });
35923
36317
  this.refreshRuntime(rerender);
35924
36318
  builder.onChange();
35925
36319
  const dock = builder.motion;
@@ -42340,47 +42734,100 @@ function effectiveFullviewWidth(preferred, availableWidth) {
42340
42734
  }
42341
42735
 
42342
42736
  class Settings {
42343
- constructor(dialog, builder) {
42737
+ constructor(builder) {
42344
42738
  this.builder = builder;
42345
- this.dialog = dialog;
42346
- let html = `
42347
- <button class="btn-back" tabindex="-1" title="${out('Close')}"><svg class="is-icon-flex"><use xlink:href="#icon-arrow-back"></use></svg></button>
42348
-
42349
- <div class="submain">
42739
+ } // Built on first open, not with the control panel: the dialog lives in the
42740
+ // editor's UI root, and the editor is created after the panel.
42350
42741
 
42351
- <div class="label" style="font-size:18px">${out('Settings')}</div>
42352
-
42353
- <div class="label">${out('Paste')}:</div>
42354
- <select class="inp-paste">
42355
- <option value="auto">${out('Auto')}</option>
42356
- <option value="html-without-styles">${out('HTML (without styles)')}</option>
42357
- <option value="html">${out('HTML (with styles)')}</option>
42358
- <option value="text">${out('Text only')}</option>
42359
- </select>
42360
42742
 
42361
- <div class="label">${out('HTML View')}:</div>
42362
- <select class="inp-htmlview">
42363
- <option value="shorten">${out('Shorten HTML')}</option>
42364
- <option value="actual">${out('Actual')}</option>
42365
- </select>
42743
+ render() {
42744
+ const builder = this.builder; // A settings dialog in the editor's own dialog world: the shell, the
42745
+ // title bar and every control below are the components the Preferences
42746
+ // and Column/Row dialogs use, so this reads as one of them rather than
42747
+ // a side panel that happens to float. See the editor's
42748
+ // docs/settings-ui-guide.md.
42366
42749
 
42367
- <div class="label editing-width-label">${out('Editing Width')}:</div>
42368
- <div class="group desktopwidth">
42369
- <button type="button" data-width="auto">${out('Auto')}</button>
42370
- ${FULLVIEW_WIDTHS.map(w => `<button type="button" data-width="${w}">${w}</button>`).join('')}
42750
+ const html = `
42751
+ <div class="is-modal cbxsettings" tabindex="-1" role="dialog" aria-modal="true" aria-hidden="true">
42752
+ <div class="is-modal-content">
42753
+ <div class="is-modal-bar is-draggable">
42754
+ ${out('Settings')}
42755
+ <button type="button" class="is-modal-close" title="${out('Close')}" tabindex="-1">&#10005;</button>
42371
42756
  </div>
42372
42757
 
42373
- ${this.builder.themes && this.builder.themes.length > 0 ? `
42374
- <div class="label">${out('Theme')}:</div>
42375
- <div class="group">
42376
- <button type="button" title="${out('Light')}" data-theme="light" style="background:#fff;border:rgb(132 132 132 / 16%) 1px solid;"></button>
42377
- <button type="button" title="${out('Dark')}" data-theme="dark" style="background:#000;border:rgba(0,0,0,0.15) 1px solid;"></button>
42758
+ <div style="padding:0;margin-top:35px;">
42759
+ <div class="cbx-set-body">
42760
+
42761
+ <div class="cbx-set-row2">
42762
+ <div class="cb-field">
42763
+ <span class="cb-grouplabel">${out('Paste')}</span>
42764
+ <div class="cb-sel">
42765
+ <select class="inp-paste">
42766
+ <option value="auto">${out('Auto')}</option>
42767
+ <option value="html-without-styles">${out('HTML (without styles)')}</option>
42768
+ <option value="html">${out('HTML (with styles)')}</option>
42769
+ <option value="text">${out('Text only')}</option>
42770
+ </select>
42771
+ </div>
42772
+ </div>
42773
+
42774
+ <div class="cb-field">
42775
+ <span class="cb-grouplabel">${out('HTML View')}</span>
42776
+ <div class="cb-sel">
42777
+ <select class="inp-htmlview">
42778
+ <option value="shorten">${out('Shorten HTML')}</option>
42779
+ <option value="actual">${out('Actual')}</option>
42780
+ </select>
42781
+ </div>
42782
+ </div>
42783
+ </div>
42784
+
42785
+ <div class="cb-field editing-width-field">
42786
+ <span class="cb-grouplabel editing-width-label">${out('Editing Width')}</span>
42787
+ <div class="cb-seg desktopwidth" role="group" aria-label="${out('Editing Width')}">
42788
+ <button type="button" data-width="auto">${out('Auto')}</button>
42789
+ ${FULLVIEW_WIDTHS.map(w => `<button type="button" data-width="${w}">${w}</button>`).join('')}
42790
+ </div>
42791
+ </div>
42792
+
42793
+ <div class="cbx-set-sep"></div>
42794
+
42795
+ <div class="cb-switchrow">
42796
+ <span class="lbl">${out('Quick Section Controls')}</span>
42797
+ <label class="switch"><input type="checkbox" class="inp-sectionquicktool"><span class="slider"></span></label>
42798
+ </div>
42799
+ <div class="cb-switchrow">
42800
+ <span class="lbl">${out('Section Tools')}</span>
42801
+ <label class="switch"><input type="checkbox" class="inp-sectiontool"><span class="slider"></span></label>
42802
+ </div>
42803
+ <div class="cb-switchrow">
42804
+ <span class="lbl">${out('Box Tools')}</span>
42805
+ <label class="switch"><input type="checkbox" class="inp-boxtool"><span class="slider"></span></label>
42806
+ </div>
42807
+
42808
+ ${this.builder.themes && this.builder.themes.length > 0 ? `
42809
+ <div class="cbx-set-sep"></div>
42810
+
42811
+ <div class="cb-field">
42812
+ <span class="cb-grouplabel">${out('Theme')}</span>
42813
+ <div class="cbx-themes">
42814
+ <button type="button" title="${out('Light')}" data-theme="light" style="background:#fff;border:rgb(132 132 132 / 16%) 1px solid;"></button>
42815
+ <button type="button" title="${out('Dark')}" data-theme="dark" style="background:#000;border:rgba(0,0,0,0.15) 1px solid;"></button>
42816
+ </div>
42817
+ </div>
42818
+ ` : ''}
42819
+
42820
+ </div>
42378
42821
  </div>
42379
- ` : ''}
42380
-
42381
42822
  </div>
42382
- `;
42383
- dialog.insertAdjacentHTML('beforeend', html);
42823
+ </div>
42824
+ `; // In the editor's UI root, not ContentBox's: that is where the dialog
42825
+ // components live ([data-cb-ui]).
42826
+
42827
+ const stuff = builder.editor.builderStuff;
42828
+ stuff.insertAdjacentHTML('beforeend', html);
42829
+ const dialog = stuff.querySelector('.is-modal.cbxsettings');
42830
+ this.modal = dialog;
42384
42831
  this.dialog = dialog;
42385
42832
  const inpPaste = dialog.querySelector('.inp-paste');
42386
42833
  inpPaste.addEventListener('change', () => {
@@ -42399,6 +42846,27 @@ class Settings {
42399
42846
  }
42400
42847
 
42401
42848
  localStorage.setItem('_htmlview', htmlView);
42849
+ }); // The tools on the page. Everything they do is on the control panel too,
42850
+ // so this is a preference, not a capability: it is remembered per user
42851
+ // and the page is re-rendered at once so the choice is visible.
42852
+
42853
+ const inpSectionQuickTool = dialog.querySelector('.inp-sectionquicktool');
42854
+ inpSectionQuickTool.addEventListener('change', () => {
42855
+ this.builder.sectionQuickTool = inpSectionQuickTool.checked;
42856
+ localStorage.setItem('_sectionquicktool', inpSectionQuickTool.checked ? '1' : '0');
42857
+ this.builder.controlpanel.refreshSectionQuickTool();
42858
+ });
42859
+ const inpSectionTool = dialog.querySelector('.inp-sectiontool');
42860
+ inpSectionTool.addEventListener('change', () => {
42861
+ this.builder.sectionTool = inpSectionTool.checked;
42862
+ localStorage.setItem('_sectiontool', inpSectionTool.checked ? '1' : '0');
42863
+ this.builder.refreshPageTools();
42864
+ });
42865
+ const inpBoxTool = dialog.querySelector('.inp-boxtool');
42866
+ inpBoxTool.addEventListener('change', () => {
42867
+ this.builder.boxTool = inpBoxTool.checked;
42868
+ localStorage.setItem('_boxtool', inpBoxTool.checked ? '1' : '0');
42869
+ this.builder.refreshPageTools();
42402
42870
  });
42403
42871
  const btnTheme = dialog.querySelectorAll('[data-theme]');
42404
42872
  btnTheme.forEach(btn => {
@@ -42441,11 +42909,7 @@ class Settings {
42441
42909
  // it is actually on screen.
42442
42910
 
42443
42911
  window.addEventListener('resize', () => {
42444
- if (this.dialog.style.display) this.renderDesktopWidth();
42445
- });
42446
- const btnBack = dialog.querySelector('.btn-back');
42447
- btnBack.addEventListener('click', () => {
42448
- this.close();
42912
+ if (this.modal.classList.contains('active')) this.renderDesktopWidth();
42449
42913
  });
42450
42914
  }
42451
42915
 
@@ -42477,6 +42941,9 @@ class Settings {
42477
42941
  inpHtmlView.value = 'actual';
42478
42942
  }
42479
42943
 
42944
+ dialog.querySelector('.inp-sectionquicktool').checked = this.builder.sectionQuickTool !== false;
42945
+ dialog.querySelector('.inp-sectiontool').checked = this.builder.sectionTool !== false;
42946
+ dialog.querySelector('.inp-boxtool').checked = this.builder.boxTool !== false;
42480
42947
  this.renderDesktopWidth();
42481
42948
  }
42482
42949
  /**
@@ -42499,8 +42966,8 @@ class Settings {
42499
42966
 
42500
42967
  renderDesktopWidth() {
42501
42968
  const dialog = this.dialog;
42502
- const label = dialog.querySelector('.editing-width-label');
42503
- const group = dialog.querySelector('.group.desktopwidth');
42969
+ const label = dialog.querySelector('.editing-width-field');
42970
+ const group = dialog.querySelector('.desktopwidth');
42504
42971
  if (!group) return; // The workspace, not the window. In fullview #editPanel is inset by the left rail
42505
42972
  // and by the control panel this dialog sits in, so a 1290px window leaves about
42506
42973
  // 939px here, and the difference decides whether any of this is worth showing.
@@ -42514,8 +42981,8 @@ class Settings {
42514
42981
 
42515
42982
  const selectable = FULLVIEW_WIDTHS.some(width => width > available && fullviewWidthFits(width, available));
42516
42983
  const show = this.builder.scaleFullview && selectable;
42517
- label.style.display = show ? '' : 'none';
42518
- group.style.display = show ? '' : 'none';
42984
+ label.style.display = show ? '' : 'none'; // the whole field: label + segments
42985
+
42519
42986
  if (!show) return; // Auto is the state with nothing stored, so storage is what decides it, not the
42520
42987
  // resolved width: under Auto on a wide workspace the width resolves to the
42521
42988
  // fullviewLogicalWidth default, and marking that too would light up two buttons
@@ -42558,12 +43025,20 @@ class Settings {
42558
43025
  }
42559
43026
 
42560
43027
  open() {
42561
- this.dialog.style.display = 'flex';
42562
- this.getState();
43028
+ if (!this.modal) this.render();
43029
+ this.getState(); // Dragging is wired on first open: the shell is rendered with the panel,
43030
+ // long before the editor is ready to take a selector.
43031
+
43032
+ if (!this.draggable) {
43033
+ this.draggable = true;
43034
+ this.builder.editor.draggable('.is-modal.cbxsettings .is-draggable');
43035
+ }
43036
+
43037
+ this.builder.editor.util.showModal(this.modal);
42563
43038
  }
42564
43039
 
42565
43040
  close() {
42566
- this.dialog.style.display = '';
43041
+ if (this.modal) this.builder.editor.util.hideModal(this.modal);
42567
43042
  }
42568
43043
 
42569
43044
  }
@@ -42679,6 +43154,28 @@ class ControlPanel {
42679
43154
 
42680
43155
  <div class="panelnav">
42681
43156
  <div class="part-breadcrumb"></div>
43157
+
43158
+ <!-- Quick access to the section's own actions, right under the
43159
+ Section crumb. A click in the page selects the Box, so these
43160
+ would otherwise cost a trip to the Section panel first.
43161
+ Deliberately smaller than the panel's own controls: it reads
43162
+ as a shortcut belonging to the crumb above it, not as part of
43163
+ the panel below. Shown only when Section is the only crumb (a
43164
+ Box is selected); deeper selections have their own arrows and
43165
+ these would read as "move this element". -->
43166
+ <div class="part-sectionmove group" role="group" aria-label="${out('Section')}" style="display:none">
43167
+ <button title="${out('Top')}" data-sectionmove="top"><svg><use xlink:href="#icon-arrow-top"></use></svg></button>
43168
+ <button title="${out('Up')}" data-sectionmove="up"><svg><use xlink:href="#icon-arrow-up"></use></svg></button>
43169
+ <button title="${out('Down')}" data-sectionmove="down"><svg><use xlink:href="#icon-arrow-down"></use></svg></button>
43170
+ <button title="${out('Bottom')}" data-sectionmove="bottom"><svg><use xlink:href="#icon-arrow-bottom"></use></svg></button>
43171
+ <button class="btn-sectionmore" title="${out('Height')}" aria-haspopup="true" aria-expanded="false"><svg><use xlink:href="#icon-dots"></use></svg></button>
43172
+ <button class="btn-sectiondup" title="${out('Duplicate')}"><svg><use xlink:href="#icon-copy"></use></svg></button>
43173
+ ${this.builder.sectionHtmlButton ? `
43174
+ <button class="btn-sectionhtml" title="${out('Section HTML')}"><svg><use xlink:href="#icon-code"></use></svg></button>
43175
+ ` : ''}
43176
+ <button class="btn-sectiondel" title="${out('Delete')}"><svg><use xlink:href="#icon-trash"></use></svg></button>
43177
+ </div>
43178
+
42682
43179
  <h3 class="part-title"></h3>
42683
43180
  <div class="part-empty">${out('No Selection.')}</div>
42684
43181
  <div class="part-tabs" style="display:none">
@@ -42710,12 +43207,23 @@ class ControlPanel {
42710
43207
  <div class="panel-dialog icons"></div>
42711
43208
  <div class="panel-dialog blocks"></div>
42712
43209
  <div class="panel-dialog imageadjust"></div>
42713
- <div class="panel-dialog settings"></div>
42714
43210
  <div class="panel-dialog group"></div>
42715
43211
  <div class="panel-dialog blockoptions"></div>
42716
43212
  <div class="panel-dialog shadow"></div>
42717
43213
  <div class="panel-dialog symbols"></div>
42718
43214
 
43215
+ <!-- Section height, the one remaining section control used often
43216
+ enough to be worth reaching from the Box selection. A pop
43217
+ rather than four more icons: the presets are a grid, and the
43218
+ strip has to stay small to keep reading as a shortcut. -->
43219
+ <div class="panel-pop heightpop" tabIndex="-1" aria-hidden="true">
43220
+ <div class="label">${out('Height')}:</div>
43221
+ <div class="group sectionheight">
43222
+ ${[10, 15, 20, 25, 30, 40, 50, 60, 70, 75, 80, 85, 90, 100].map(h => `<button title="${h}%" data-sectionheight="${h}">${h}%</button>`).join('')}
43223
+ <button title="${out('Clear')}" class="btn-clear" data-sectionheight="0"><svg><use xlink:href="#icon-eraser"></use></svg></button>
43224
+ </div>
43225
+ </div>
43226
+
42719
43227
  <div class="panel-pop more" tabIndex="-1" aria-hidden="true">
42720
43228
  <!--<button class="btn-preferences" title="${out('Preferences')}">${out('Preferences')}</button>-->
42721
43229
  <button class="btn-settings" title="${out('Settings')}">${out('Settings')}</button>
@@ -42725,14 +43233,6 @@ class ControlPanel {
42725
43233
  <div class="plugins"></div>
42726
43234
  </div>
42727
43235
 
42728
- <div class="is-modal modal-settings" aria-hidden="true">
42729
- <div class="modal-content">
42730
- <div style="padding:30px">
42731
- <p>Modal content goes here.</p>
42732
- </div>
42733
- </div>
42734
- </div>
42735
-
42736
43236
  </div>
42737
43237
 
42738
43238
  `;
@@ -42770,14 +43270,64 @@ class ControlPanel {
42770
43270
  this.showPanel(this.what);
42771
43271
  });
42772
43272
  });
42773
- this.breadcrumb = controlPanel.querySelector('.part-breadcrumb');
43273
+ this.breadcrumb = controlPanel.querySelector('.part-breadcrumb'); // builder.activeSection is kept current whenever a box is activated, and
43274
+ // every action below reads it, so these need no selection of their own
43275
+ // and leave the open panel untouched.
43276
+
43277
+ this.sectionMoveBar = controlPanel.querySelector('.part-sectionmove');
43278
+ this.sectionMoveBar.querySelectorAll('[data-sectionmove]').forEach(btn => {
43279
+ btn.addEventListener('click', () => {
43280
+ this.builder.section.sectionMove(btn.getAttribute('data-sectionmove'));
43281
+ });
43282
+ });
43283
+ this.sectionMoveBar.querySelector('.btn-sectiondup').addEventListener('click', () => {
43284
+ this.builder.section.sectionDuplicate();
43285
+ });
43286
+ const btnSectionHtml = this.sectionMoveBar.querySelector('.btn-sectionhtml');
43287
+ if (btnSectionHtml) btnSectionHtml.addEventListener('click', () => {
43288
+ this.builder.openSectionHtml();
43289
+ });
43290
+ this.heightPop = controlPanel.querySelector('.panel-pop.heightpop');
43291
+ this.btnSectionMore = this.sectionMoveBar.querySelector('.btn-sectionmore');
43292
+ this.btnSectionMore.addEventListener('click', () => {
43293
+ this.markSectionHeight();
43294
+ this.showPop(this.heightPop, () => {
43295
+ this.btnSectionMore.setAttribute('aria-expanded', false);
43296
+ this.btnSectionMore.focus();
43297
+ }, this.btnSectionMore); // showPop right-aligns the pop to its button, which is how the
43298
+ // toolbar's own pops sit. This one belongs to the strip, so it hangs
43299
+ // under the whole strip and lines up with its left edge, pulled back
43300
+ // in only if the window is narrow enough to push it off screen.
43301
+
43302
+ const r = this.sectionMoveBar.getBoundingClientRect();
43303
+ this.heightPop.style.top = `${r.bottom + 5}px`;
43304
+ this.heightPop.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - this.heightPop.offsetWidth - 8))}px`;
43305
+ this.btnSectionMore.setAttribute('aria-expanded', true);
43306
+ });
43307
+ this.heightPop.querySelectorAll('[data-sectionheight]').forEach(btn => {
43308
+ btn.addEventListener('click', () => {
43309
+ this.builder.section.setSectionHeight(btn.getAttribute('data-sectionheight'));
43310
+ this.markSectionHeight(btn.getAttribute('data-sectionheight')); // Left open: picking a height is a try-and-look action, and the
43311
+ // next try should not cost another trip to the button.
43312
+ });
43313
+ }); // Same confirm as the section tool's trash: this is the same action at
43314
+ // the same scale, and the whole section goes with one small click.
43315
+
43316
+ this.sectionMoveBar.querySelector('.btn-sectiondel').addEventListener('click', () => {
43317
+ if (this.builder.settings.deleteConfirm) {
43318
+ this.builder.editor.util.showModal(this.builder.builderStuff.querySelector('.is-modal.delsectionconfirm'));
43319
+ return;
43320
+ }
43321
+
43322
+ this.builder.section.sectionDelete();
43323
+ if (this.builder.onSelectChange) this.builder.onSelectChange();
43324
+ });
42774
43325
  this.title = controlPanel.querySelector('.part-title');
42775
43326
  this.empty = controlPanel.querySelector('.part-empty');
42776
43327
  this.dialogIcons = controlPanel.querySelector('.panel-dialog.icons'); // this.dialogBlocks = controlPanel.querySelector('.panel-dialog.blocks');
42777
43328
 
42778
43329
  this.dialogShadow = controlPanel.querySelector('.panel-dialog.shadow');
42779
43330
  this.dialogImageAdjust = controlPanel.querySelector('.panel-dialog.imageadjust');
42780
- this.dialogSettings = controlPanel.querySelector('.panel-dialog.settings');
42781
43331
  this.dialogGroup = controlPanel.querySelector('.panel-dialog.group');
42782
43332
  this.dialogBlockOptions = controlPanel.querySelector('.panel-dialog.blockoptions');
42783
43333
  this.dialogSymbols = controlPanel.querySelector('.panel-dialog.symbols');
@@ -42785,8 +43335,11 @@ class ControlPanel {
42785
43335
  this.objDialogIcons = new Icons(this.dialogIcons, this.builder); // this.objDialogBlocks = new Blocks(this.dialogBlocks, this.builder);
42786
43336
 
42787
43337
  this.objDialogShadow = new Shadow(this.dialogShadow, this.builder);
42788
- this.objDialogImageAdjust = new ImageAdjust(this.dialogImageAdjust, this.builder);
42789
- this.objDialogSettings = new Settings(this.dialogSettings, this.builder);
43338
+ this.objDialogImageAdjust = new ImageAdjust(this.dialogImageAdjust, this.builder); // Settings is a facility, not a selection level: it has nothing to do with
43339
+ // what is selected, so it builds its own dialog in the editor's UI root
43340
+ // and opens over everything.
43341
+
43342
+ this.objDialogSettings = new Settings(this.builder);
42790
43343
  this.objDialogGroup = new Group(this.dialogGroup, this.builder);
42791
43344
  this.objDialogBlockOptions = new BlockOptions(this.dialogBlockOptions, this.builder);
42792
43345
  this.objPanelText = new PanelText(this.panelText, this.builder); // comment/disable if using ContentBuilder's controlpanel
@@ -42945,9 +43498,7 @@ class ControlPanel {
42945
43498
 
42946
43499
  const btnSettings = controlPanel.querySelector('.btn-settings');
42947
43500
  btnSettings.addEventListener('click', () => {
42948
- this.objDialogSettings.open(); // const modal = document.querySelector('.modal-settings');
42949
- // this.modal.showModal(modal);
42950
-
43501
+ this.objDialogSettings.open();
42951
43502
  this.hidePop(this.more);
42952
43503
  }); // Accordion // Move to editorReady if using ContentBuilder's controlpanel
42953
43504
 
@@ -43178,7 +43729,9 @@ class ControlPanel {
43178
43729
  return;
43179
43730
  }
43180
43731
 
43181
- if (isOpen(this.dialogSettings)) {
43732
+ const settingsModal = this.objDialogSettings.modal;
43733
+
43734
+ if (settingsModal && settingsModal.classList.contains('active')) {
43182
43735
  this.objDialogSettings.close();
43183
43736
  return;
43184
43737
  } // The element dock last: the pickers above are transient, opened for one
@@ -43394,7 +43947,11 @@ class ControlPanel {
43394
43947
 
43395
43948
  const target = textElement || el;
43396
43949
  const hasText = !!textElement;
43397
- const hasSource = this.builder.isOverlayImageElement(target);
43950
+ const hasSource = this.builder.isOverlayImageElement(target); // A link is an aspect of its own: a heading can be one, so can an
43951
+ // image, and so can an icon that is neither. panel-overlay.js owns the
43952
+ // rule (which anchor, and whether it can be saved); this only names it.
43953
+
43954
+ const linkOf = node => !!(this.objPanelOverlay && this.objPanelOverlay.linkFor(node));
43398
43955
 
43399
43956
  if (!hasText && !hasSource) {
43400
43957
  // [FX-PATH] Decoration aspect. Ornament layers — a cloud, a
@@ -43409,7 +43966,8 @@ class ControlPanel {
43409
43966
  type: 'decoration',
43410
43967
  element: deco,
43411
43968
  hasText: false,
43412
- hasSource: false
43969
+ hasSource: false,
43970
+ hasLink: linkOf(deco)
43413
43971
  };
43414
43972
  }
43415
43973
 
@@ -43419,7 +43977,8 @@ class ControlPanel {
43419
43977
  type: type,
43420
43978
  element: target,
43421
43979
  hasText: hasText,
43422
- hasSource: hasSource
43980
+ hasSource: hasSource,
43981
+ hasLink: linkOf(target)
43423
43982
  };
43424
43983
  } // [FX-PATH] Which element a decoration click selects. Path-first, matching
43425
43984
  // the Motion picker: a compound object (3D die tumbling inside a flying
@@ -43765,6 +44324,7 @@ class ControlPanel {
43765
44324
  }
43766
44325
 
43767
44326
  this.breadcrumb.innerHTML = breadcrumbHtml;
44327
+ this.showSectionQuickTool(selection);
43768
44328
  const links = this.breadcrumb.querySelectorAll('a');
43769
44329
  links.forEach(link => {
43770
44330
  link.addEventListener('click', e => {
@@ -43775,6 +44335,51 @@ class ControlPanel {
43775
44335
  });
43776
44336
  });
43777
44337
  }
44338
+ /**
44339
+ * The quick section controls, shown only when Section is the only crumb (a
44340
+ * Box is selected). Deeper selections have their own arrows and these would
44341
+ * read as "move this element".
44342
+ */
44343
+
44344
+
44345
+ showSectionQuickTool(selection) {
44346
+ this.quickToolSelection = selection;
44347
+ const show = selection === 'box' && this.builder.sectionQuickTool !== false;
44348
+ this.sectionMoveBar.style.display = show ? '' : 'none';
44349
+ this.btnSectionMore.style.display = show && this.sectionHeightAllowed() ? '' : 'none';
44350
+ this.hidePop(this.heightPop);
44351
+ }
44352
+ /** Re-apply the switch in Settings to the selection already on screen. */
44353
+
44354
+
44355
+ refreshSectionQuickTool() {
44356
+ this.showSectionQuickTool(this.quickToolSelection);
44357
+ } // Light up the height the section is on. `value` is passed straight after a
44358
+ // click so the state is right even though the class lands on a timeout.
44359
+
44360
+
44361
+ markSectionHeight(value) {
44362
+ const section = this.builder.activeSection;
44363
+ if (!section) return;
44364
+ this.heightPop.querySelectorAll('[data-sectionheight]').forEach(btn => {
44365
+ const h = btn.getAttribute('data-sectionheight');
44366
+ const on = value !== undefined ? h === value && h !== '0' : section.classList.contains('is-section-' + h);
44367
+ btn.classList.toggle('on', on);
44368
+ });
44369
+ } // A plugin that owns its whole section can refuse the height presets
44370
+ // (contentbox.sectionHeight === false), exactly as the Section panel reads
44371
+ // it. Then the pop has nothing to show and the button goes with it.
44372
+
44373
+
44374
+ sectionHeightAllowed() {
44375
+ const section = this.builder.activeSection;
44376
+ if (!section || !section.classList.contains('is-box')) return true;
44377
+ const el = section.querySelector('.is-overlay-content [data-cb-type]');
44378
+ if (!el) return true;
44379
+ const runtime = this.builder.win.builderRuntime;
44380
+ const plugin = runtime && runtime.getPlugin(el.getAttribute('data-cb-type'));
44381
+ return !(plugin && plugin.contentbox && plugin.contentbox.sectionHeight === false);
44382
+ }
43778
44383
 
43779
44384
  animSelection(elm) {
43780
44385
  elm.classList.add('selection-active');
@@ -43796,6 +44401,7 @@ class ControlPanel {
43796
44401
  if (this.builder.contentSize) this.builder.contentSize.hide();
43797
44402
  this.markOverlaySelection(null);
43798
44403
  this.breadcrumb.innerHTML = '';
44404
+ this.showSectionQuickTool('');
43799
44405
  this.title.innerHTML = '';
43800
44406
  this.title.style.display = 'none';
43801
44407
  this.empty.style.display = '';
@@ -44055,9 +44661,15 @@ class ControlPanel {
44055
44661
  const overlayTarget = this.overlayTarget(element); // Resolved once, here, not inside the branch below: it reads
44056
44662
  // computed style, and the dispatch would otherwise call it twice.
44057
44663
 
44058
- const buttonNow = this.buttonTarget(element);
44664
+ const buttonNow = this.buttonTarget(element); // A code element sitting in an overlay is not a decorative layer:
44665
+ // it has an editor of its own. Without this it would land on the
44666
+ // Decoration note, which is the one place its code cannot be
44667
+ // reached from. Only the block ITSELF — a layer that merely
44668
+ // contains one still belongs to the overlay panel.
44669
+
44670
+ const overlayCodeBlock = overlayTarget && overlayTarget.element.matches('[data-cb-code]') ? overlayTarget.element : null;
44059
44671
 
44060
- if (overlayTarget) {
44672
+ if (overlayTarget && !overlayCodeBlock) {
44061
44673
  // Overlay layer content (outside .is-container) — reduced panel
44062
44674
  // Name every aspect: titling an element 'Text' while the panel
44063
44675
  // leads with an image source reads as the wrong panel.
@@ -44069,6 +44681,7 @@ class ControlPanel {
44069
44681
  // backdrop setting that is not what they clicked.
44070
44682
  const parts = [];
44071
44683
  if (overlayTarget.hasText) parts.push(out('Text'));
44684
+ if (overlayTarget.hasLink) parts.push(out('Link'));
44072
44685
  if (overlayTarget.hasSource) parts.push(out('Image'));
44073
44686
  if (!parts.length) parts.push(out('Decoration')); // [FX-PATH]
44074
44687
 
@@ -61382,9 +61995,9 @@ class UndoRedo {
61382
61995
  [data-html] and [data-html] logic never touches [data-cb-code].
61383
61996
  */
61384
61997
 
61385
- const ATTR$1 = 'data-cb-code';
61386
- const SEL$1 = '[' + ATTR$1 + ']';
61387
- const TPL_SEL$1 = ':scope > template[data-source]';
61998
+ const ATTR = 'data-cb-code';
61999
+ const SEL = '[' + ATTR + ']';
62000
+ const TPL_SEL = ':scope > template[data-source]';
61388
62001
 
61389
62002
  /*
61390
62003
  Prevent a literal </template> inside user code from prematurely closing the
@@ -61392,7 +62005,7 @@ const TPL_SEL$1 = ':scope > template[data-source]';
61392
62005
  (save -> load). Inside a JS string, <\/template> is identical to </template>
61393
62006
  at runtime. (Same convention as the classic <\/script>.)
61394
62007
  */
61395
- function escapeCodeSource$1(src) {
62008
+ function escapeCodeSource(src) {
61396
62009
  return src.replace(/<\/template>/gi, '<\\/template>');
61397
62010
  }
61398
62011
 
@@ -61407,14 +62020,14 @@ function escapeCodeSource$1(src) {
61407
62020
  Exported standalone as well, so it can be used where no builder instance
61408
62021
  exists yet (ContentBox injects content before creating the editor).
61409
62022
  */
61410
- function wrapCodeSource$1(html) {
62023
+ function wrapCodeSource(html) {
61411
62024
  if (!html) return html;
61412
- if (html.indexOf(ATTR$1) === -1) return html; // fast path
62025
+ if (html.indexOf(ATTR) === -1) return html; // fast path
61413
62026
 
61414
62027
  const parsed = new DOMParser().parseFromString(html, 'text/html');
61415
- parsed.querySelectorAll(SEL$1).forEach(block => {
62028
+ parsed.querySelectorAll(SEL).forEach(block => {
61416
62029
  // Already wrapped (round-trip within the editor) => skip
61417
- if (block.querySelector(TPL_SEL$1)) return;
62030
+ if (block.querySelector(TPL_SEL)) return;
61418
62031
 
61419
62032
  // Snippet form (data-src) is already inert => handled by init()
61420
62033
  if (block.hasAttribute('data-src')) return;
@@ -61424,7 +62037,7 @@ function wrapCodeSource$1(html) {
61424
62037
  block.innerHTML = '';
61425
62038
  const tpl = parsed.createElement('template');
61426
62039
  tpl.setAttribute('data-source', '');
61427
- tpl.innerHTML = escapeCodeSource$1(src);
62040
+ tpl.innerHTML = escapeCodeSource(src);
61428
62041
  block.appendChild(tpl);
61429
62042
  });
61430
62043
  return parsed.body.innerHTML;
@@ -61443,7 +62056,7 @@ class CodeElement {
61443
62056
  // hideControls), and so it also works in non-editable columns.
61444
62057
  this.doDocumentClick = e => {
61445
62058
  if (!e.target || !e.target.closest) return;
61446
- const block = e.target.closest(SEL$1);
62059
+ const block = e.target.closest(SEL);
61447
62060
  if (block && this.inContentArea(block)) {
61448
62061
  this.activeBlock = block;
61449
62062
  // ContentBox drives editing from its side panel (same as
@@ -61485,7 +62098,7 @@ class CodeElement {
61485
62098
  return this.builder.util.makeId();
61486
62099
  }
61487
62100
  getTemplate(block) {
61488
- return block.querySelector(TPL_SEL$1);
62101
+ return block.querySelector(TPL_SEL);
61489
62102
  }
61490
62103
 
61491
62104
  /*
@@ -61512,7 +62125,7 @@ class CodeElement {
61512
62125
  return this.contentRoots().some(root => root.contains(block));
61513
62126
  }
61514
62127
  escapeSource(src) {
61515
- return escapeCodeSource$1(src);
62128
+ return escapeCodeSource(src);
61516
62129
  }
61517
62130
 
61518
62131
  /*
@@ -61528,7 +62141,7 @@ class CodeElement {
61528
62141
  return ids;
61529
62142
  }
61530
62143
  wrapSource(html) {
61531
- return wrapCodeSource$1(html);
62144
+ return wrapCodeSource(html);
61532
62145
  }
61533
62146
 
61534
62147
  /*
@@ -61584,7 +62197,7 @@ class CodeElement {
61584
62197
  rerenderAll(root) {
61585
62198
  const scope = root || this.builder.doc;
61586
62199
  if (!scope || !scope.querySelectorAll) return;
61587
- scope.querySelectorAll(SEL$1).forEach(block => this.render(block));
62200
+ scope.querySelectorAll(SEL).forEach(block => this.render(block));
61588
62201
  }
61589
62202
 
61590
62203
  /*
@@ -61617,7 +62230,7 @@ class CodeElement {
61617
62230
  }
61618
62231
 
61619
62232
  // 2) Earlier code element templates (not part of live DOM queries)
61620
- const otherTpls = roots[r].querySelectorAll(SEL$1 + ' > template[data-source]');
62233
+ const otherTpls = roots[r].querySelectorAll(SEL + ' > template[data-source]');
61621
62234
  for (let i = 0; i < otherTpls.length; i++) {
61622
62235
  const otherTpl = otherTpls[i];
61623
62236
  if (otherTpl === tpl) continue;
@@ -61659,7 +62272,7 @@ class CodeElement {
61659
62272
  */
61660
62273
  init(container) {
61661
62274
  if (!container || !container.querySelectorAll) return;
61662
- const blocks = container.querySelectorAll(SEL$1);
62275
+ const blocks = container.querySelectorAll(SEL);
61663
62276
  if (blocks.length === 0) return;
61664
62277
  this.ensureCss();
61665
62278
  blocks.forEach(block => {
@@ -61840,7 +62453,7 @@ class CodeElement {
61840
62453
  markup filled the section on the page.
61841
62454
  */
61842
62455
  style.textContent = `
61843
- [${ATTR$1}] { min-height: 30px; -webkit-user-select: none; user-select: none; }
62456
+ [${ATTR}] { min-height: 30px; -webkit-user-select: none; user-select: none; }
61844
62457
  `;
61845
62458
  doc.head.appendChild(style);
61846
62459
  }
@@ -73964,10 +74577,10 @@ class Image$1 {
73964
74577
 
73965
74578
  <div class="cb-field">
73966
74579
  <label class="label-checkbox">
73967
- <input class="input-newwindow" id="__input_newwindow2" type="checkbox" /> ${util.out('Open new window')}
74580
+ <input class="input-newwindow" id="__input_newwindow2" type="checkbox" /> ${util.out('Open in new tab')}
73968
74581
  </label>
73969
- <label class="label-checkbox" id="lblImageLinkOpenLightbox" style="${this.builder.useLightbox ? '' : 'display:none'}">
73970
- <input class="input-openlightbox" id="__input_openlightbox2" type="checkbox" /> ${util.out('Open in a lightbox (for image, video or Youtube)')}
74582
+ <label class="label-checkbox mt-1" id="lblImageLinkOpenLightbox" style="${this.builder.useLightbox ? '' : 'display:none'}">
74583
+ <input class="input-openlightbox" id="__input_openlightbox2" type="checkbox" /> ${util.out('Open in a lightbox')}
73971
74584
  </label>
73972
74585
  </div>
73973
74586
 
@@ -75047,9 +75660,22 @@ class ButtonEditor {
75047
75660
  if (cmd === 'href') setHref(el, value);else if (cmd === 'newtab') setNewTab(el, value);else if (cmd === 'fontsize') setFontSize(el, value === '' ? '' : parseInt(value, 10));else if (cmd === 'tracking') {
75048
75661
  if (value !== '') setTracking(el, parseInt(value, 10));
75049
75662
  } else if (cmd === 'margin') setMargin(el, side, value === '' ? 0 : parseInt(value, 10));
75663
+ if (cmd === 'href' || cmd === 'newtab') this.syncLinkDialog(el);
75050
75664
  this.builder.opts.onChange();
75051
75665
  this.realtime();
75052
75666
  }
75667
+
75668
+ // The link dialog edits the same href/target and can be open alongside this
75669
+ // panel. Its fields are read when it opens, so without this its Ok would
75670
+ // write back the link as it was before the panel touched it.
75671
+ syncLinkDialog(el) {
75672
+ const modal = this.builderStuff.querySelector('.is-modal.createlink');
75673
+ if (!modal || !this.dom.hasClass(modal, 'active')) return;
75674
+ const url = modal.querySelector('.input-url');
75675
+ if (url && document.activeElement !== url) url.value = el.getAttribute('href') || '';
75676
+ const newwindow = modal.querySelector('.input-newwindow');
75677
+ if (newwindow) newwindow.checked = el.getAttribute('target') === '_blank';
75678
+ }
75053
75679
  openColor(cmd, btn) {
75054
75680
  const el = this.getButton();
75055
75681
  if (!el) return;
@@ -75368,6 +75994,23 @@ class Hyperlink {
75368
75994
  this.buttonEditor = new ButtonEditor(this.builder);
75369
75995
  }
75370
75996
  }
75997
+
75998
+ // The link dialog and a button UI can be open at the same time and both
75999
+ // show the link's href and target. After the dialog writes to the link,
76000
+ // the button UI has to re-read it, or it keeps showing (and writing back)
76001
+ // the old values. The host can own that UI instead (onButtonEdit).
76002
+ syncButtonUI(link) {
76003
+ if (!link || !link.isConnected) return;
76004
+ if (this.builder.opts.onButtonEdit) {
76005
+ if (link === this.builder.activeLinkButton) this.builder.opts.onButtonEdit(link);
76006
+ return;
76007
+ }
76008
+ const dom = this.builder.dom;
76009
+ const buttonModal = this.builder.builderStuff.querySelector('.is-modal.buttoneditor');
76010
+ if (!buttonModal || !dom.hasClass(buttonModal, 'active')) return;
76011
+ if (!this.buttonEditor) this.buttonEditor = new ButtonEditor(this.builder);
76012
+ this.buttonEditor.realtime();
76013
+ }
75371
76014
  renderPanel() {
75372
76015
  const builderStuff = this.builder.builderStuff;
75373
76016
  const util = this.builder.util;
@@ -75399,10 +76042,10 @@ class Hyperlink {
75399
76042
  </div>
75400
76043
  <div class="cb-field">
75401
76044
  <label class="label-checkbox">
75402
- <input class="input-newwindow" id="__input_newwindow" type="checkbox" /> ${util.out('Open new window')}
76045
+ <input class="input-newwindow" id="__input_newwindow" type="checkbox" /> ${util.out('Open in new tab')}
75403
76046
  </label>
75404
- <label class="label-checkbox" id="lblOpenLightbox" style="${this.builder.useLightbox ? '' : 'display:none'}">
75405
- <input class="input-openlightbox" id="__input_openlightbox" type="checkbox" /> ${util.out('Open in a lightbox (for image, video or Youtube)')}
76047
+ <label class="label-checkbox mt-1" id="lblOpenLightbox" style="${this.builder.useLightbox ? '' : 'display:none'}">
76048
+ <input class="input-openlightbox" id="__input_openlightbox" type="checkbox" /> ${util.out('Open in a lightbox')}
75406
76049
  </label>
75407
76050
  </div>
75408
76051
  <div class="cb-field">
@@ -75445,6 +76088,7 @@ class Hyperlink {
75445
76088
  let inputOk = modal.querySelector('.input-ok');
75446
76089
  dom.addEventListener(inputOk, 'click', () => {
75447
76090
  this.builder.uo.saveForUndo();
76091
+ let editedLink = null;
75448
76092
  let link = this.builder.activeLink;
75449
76093
  if (link) {
75450
76094
  // Edit Existing Link
@@ -75454,35 +76098,19 @@ class Hyperlink {
75454
76098
  let linktext = modal.querySelector('.input-text').value;
75455
76099
  if (linktext === '') linktext = url;
75456
76100
 
75457
- // Engine seam: edit / unlink an existing link via Inscribe.
75458
- // A cleared URL means UNLINK. Icon links stay legacy.
75459
- if (this.builder.opts.engine === 'inscribe' && !this.builder.activeIcon) {
76101
+ // Engine seam: only UNLINK (a cleared URL) goes through
76102
+ // Inscribe. An edit is applied in place below: region.link()
76103
+ // re-wraps the selection in a NEW plain <a>, which dropped
76104
+ // the link's classes and inline styles, so changing a
76105
+ // button's URL stripped the button. Icon links stay legacy.
76106
+ if (url === '' && this.builder.opts.engine === 'inscribe' && !this.builder.activeIcon) {
75460
76107
  const region = this.builder.editingEngine.active;
75461
76108
  if (region && link.isConnected) {
75462
76109
  dom.selectElementContents(link); // target the link
75463
- if (url !== '') {
75464
- const attrs = {
75465
- target: modal.querySelector('.input-newwindow').checked ? '_blank' : '',
75466
- title: title || '',
75467
- class: modal.querySelector('.input-openlightbox').checked ? 'is-lightbox' : ''
75468
- };
75469
- const created = region.link(url, attrs); // re-wraps, returns <a>
75470
- if (created && linktext && created.textContent !== linktext) {
75471
- created.textContent = linktext;
75472
- }
75473
- this.builder.opts.onChange();
75474
- util.hideModal(modal);
75475
- if (created && created.isConnected) {
75476
- region.focus();
75477
- dom.selectElementContents(created);
75478
- util.saveSelection();
75479
- }
75480
- } else {
75481
- region.unlink();
75482
- this.hideTool();
75483
- this.builder.opts.onChange();
75484
- util.hideModal(modal);
75485
- }
76110
+ region.unlink();
76111
+ this.hideTool();
76112
+ this.builder.opts.onChange();
76113
+ util.hideModal(modal);
75486
76114
  return;
75487
76115
  }
75488
76116
  }
@@ -75490,6 +76118,7 @@ class Hyperlink {
75490
76118
  link.setAttribute('href', url);
75491
76119
  if (modal.querySelector('.input-newwindow').checked) {
75492
76120
  link.setAttribute('target', '_blank');
76121
+ if (!link.getAttribute('rel')) link.setAttribute('rel', 'noopener noreferrer');
75493
76122
  } else {
75494
76123
  link.removeAttribute('target');
75495
76124
  }
@@ -75501,7 +76130,14 @@ class Hyperlink {
75501
76130
  if (this.builder.activeIcon) ; else {
75502
76131
  link.innerHTML = linktext;
75503
76132
  }
75504
- link.setAttribute('title', title);
76133
+ if (title) link.setAttribute('title', title);else link.removeAttribute('title');
76134
+
76135
+ // A button UI (this editor's Button panel, or the
76136
+ // host's own) shows the same link and can be open at
76137
+ // the same time as this dialog, so it has to re-read
76138
+ // what was just written.
76139
+ this.syncButtonUI(link);
76140
+ editedLink = link;
75505
76141
  } else {
75506
76142
  // link.outerHTML = link.innerHTML;
75507
76143
  this.hideTool();
@@ -75659,6 +76295,17 @@ class Hyperlink {
75659
76295
  this.builder.opts.onRender();
75660
76296
  }
75661
76297
  util.hideModal(modal);
76298
+
76299
+ // hideModal() blurs, so the caret goes back on the edited link
76300
+ // afterwards: the dialog can be reopened straight away.
76301
+ if (editedLink && editedLink.isConnected && !this.builder.activeIcon) {
76302
+ const region = this.builder.editingEngine.active;
76303
+ if (region) {
76304
+ region.focus();
76305
+ dom.selectElementContents(editedLink);
76306
+ util.saveSelection();
76307
+ }
76308
+ }
75662
76309
  });
75663
76310
  let inputCancel = modal.querySelector('.input-cancel');
75664
76311
  dom.addEventListener(inputCancel, 'click', () => {
@@ -75804,7 +76451,10 @@ class Hyperlink {
75804
76451
  }
75805
76452
 
75806
76453
  // Button Stuff
75807
- if (this.builder.useButtonPlugin) {
76454
+ // A host with its own button UI (onButtonEdit) already opens it by
76455
+ // selecting the button, so the pencil is a second route to the same
76456
+ // panel and is left out.
76457
+ if (this.builder.useButtonPlugin || this.builder.opts.onButtonEdit) {
75808
76458
  linkTool.querySelector('.link-button-edit').style.display = 'none';
75809
76459
  } else {
75810
76460
  if (link && this.builder.activeLinkButton) linkTool.querySelector('.link-button-edit').style.display = 'block';else linkTool.querySelector('.link-button-edit').style.display = 'none';
@@ -76108,6 +76758,11 @@ class Button {
76108
76758
  this.buttonEditor.realtime();
76109
76759
  }
76110
76760
  const buttonTool = this.buttonTool;
76761
+
76762
+ // A host with its own button UI (onButtonEdit) already opens it by
76763
+ // selecting the button, so the pencil is a second route to the same
76764
+ // panel and is left out.
76765
+ buttonTool.querySelector('.button-edit').style.display = this.builder.opts.onButtonEdit ? 'none' : '';
76111
76766
  let top = activeButton.getBoundingClientRect().top + this.builder.win.pageYOffset;
76112
76767
  let left = activeButton.getBoundingClientRect().left;
76113
76768
  buttonTool.style.display = 'flex';
@@ -90676,7 +91331,16 @@ class ColorPickerKelir {
90676
91331
  }
90677
91332
 
90678
91333
  // Close the picker on scroll — it's positioned once, so scrolling detaches it.
90679
- const hideOnScroll = () => {
91334
+ //
91335
+ // The page moving under the picker is the only scroll that counts. A text
91336
+ // input scrolls its own content whenever the caret passes the edge — on
91337
+ // paste, on Delete, on Cmd+Left/Right — and that is a scroll event too,
91338
+ // which a capture listener on window receives even though it does not
91339
+ // bubble. Without this guard the picker closed while its own hex field
91340
+ // was being edited.
91341
+ const hideOnScroll = e => {
91342
+ const target = e && e.target;
91343
+ if (target && target.nodeType === 1 && popPicker.contains(target)) return;
90680
91344
  this.builder.util.hidePop(popPicker);
90681
91345
  window.removeEventListener('scroll', hideOnScroll, true);
90682
91346
  if (this.builder.win && this.builder.win !== window) this.builder.win.removeEventListener('scroll', hideOnScroll, true);
@@ -137250,6 +137914,90 @@ class ContentBuilder {
137250
137914
  openFilePicker(type, callback) {
137251
137915
  this.openAssetSelect(type, callback);
137252
137916
  }
137917
+
137918
+ /**
137919
+ * Is an asset manager reachable for this type of file? Mirrors the branches
137920
+ * openAsset() takes: either a picker page (filePicker / imageSelect / ...)
137921
+ * or the host's own onXSelectClick handler.
137922
+ */
137923
+ hasFilePicker(type) {
137924
+ if (type === 'media') return !!(this.onMediaSelectClick || this.onImageSelectClick || this.mediaSelect || this.imageSelect);
137925
+ if (type === 'video') return !!(this.onVideoSelectClick || this.videoSelect);
137926
+ if (type === 'audio') return !!(this.onAudioSelectClick || this.audioSelect);
137927
+ if (type === 'all') return !!(this.onFileSelectClick || this.fileSelect);
137928
+ return !!(this.onImageSelectClick || this.imageSelect);
137929
+ }
137930
+
137931
+ /**
137932
+ * Upload a file from the user's computer and hand its URL to `callback`.
137933
+ *
137934
+ * The per-type upload handler always exists — a builder configured without
137935
+ * one gets the data-URL fallback — so this is available whatever the host
137936
+ * set up. The URL comes back through returnUrl(), the same path the image
137937
+ * tool's upload button uses.
137938
+ */
137939
+ openFileUpload(type, callback) {
137940
+ const accept = type === 'video' ? 'video/*' : type === 'audio' ? 'audio/*' : type === 'media' ? 'image/*,video/*' : type === 'all' ? '*' : 'image/*';
137941
+ const handler = type === 'video' ? this.onVideoUpload : type === 'audio' ? this.onAudioUpload : type === 'media' ? this.onMediaUpload : type === 'all' ? this.onFileUpload : this.onImageUpload;
137942
+ if (!handler) return;
137943
+ const inpFile = document.createElement('input');
137944
+ inpFile.type = 'file';
137945
+ inpFile.accept = accept;
137946
+ inpFile.style.display = 'none';
137947
+ document.body.appendChild(inpFile);
137948
+ inpFile.addEventListener('change', async e => {
137949
+ if (!e.target.files || !e.target.files.length) {
137950
+ inpFile.remove();
137951
+ return;
137952
+ }
137953
+ this.onAssetUpload = url => {
137954
+ if (callback) callback(url);
137955
+ };
137956
+ await handler(e);
137957
+ inpFile.remove();
137958
+ });
137959
+ inpFile.click();
137960
+ }
137961
+
137962
+ /**
137963
+ * The file controls for a URL field in a plugin's settings: select from the
137964
+ * asset manager, and upload from the computer.
137965
+ *
137966
+ * Returns the buttons to append next to the input, in the order and with the
137967
+ * icons the editor's own image dialog uses — select first, upload last, and
137968
+ * select only where an asset manager is configured. A plugin appends what it
137969
+ * gets rather than deciding, so a builder without an asset manager never
137970
+ * shows a button that cannot open one.
137971
+ *
137972
+ * const [ ...buttons ] = builder.createFileButtons('media', (url) => {...});
137973
+ * row.append(input, ...buttons);
137974
+ *
137975
+ * `type` may be a function for a field whose kind follows another control
137976
+ * (a Media Type select, say) — it is then read on each click.
137977
+ */
137978
+ createFileButtons(type, callback) {
137979
+ const out = s => this.util.out(s);
137980
+ const fileType = () => typeof type === 'function' ? type() : type;
137981
+ const button = (label, icon, onClick) => {
137982
+ const btn = document.createElement('button');
137983
+ btn.type = 'button';
137984
+ btn.className = 'cbx-iconbtn';
137985
+ btn.title = label;
137986
+ btn.setAttribute('aria-label', label);
137987
+ btn.innerHTML = `<svg aria-hidden="true"><use xlink:href="#${icon}"></use></svg>`;
137988
+ btn.addEventListener('click', e => {
137989
+ e.preventDefault();
137990
+ onClick(btn);
137991
+ });
137992
+ return btn;
137993
+ };
137994
+ const buttons = [];
137995
+ if (this.hasFilePicker(fileType())) {
137996
+ buttons.push(button(out('Select'), 'icon-folder', btn => this.openFilePicker(fileType(), callback, btn)));
137997
+ }
137998
+ buttons.push(button(out('Upload'), 'icon-upload', () => this.openFileUpload(fileType(), callback)));
137999
+ return buttons;
138000
+ }
137253
138001
  openAssetSelect(targetAssetType, callback, defaultValue) {
137254
138002
  const inpUrl = document.createElement('input');
137255
138003
 
@@ -141372,59 +142120,6 @@ Please obtain a license at: https://innovastudio.com/contentbox`);
141372
142120
  }
141373
142121
  }
141374
142122
 
141375
- /*
141376
- codesource.js
141377
- ------------------------------------------------------------------
141378
- The two pure helpers ContentBox needs for [data-cb-code] blocks. Derived
141379
- from ContentBuilder's codeelement.js, which also defines a CodeElement
141380
- editor class — ContentBox never instantiates that, so it is not carried
141381
- here. Named for what this file is, not for where it came from.
141382
-
141383
- wrapCodeSource() is a string pre-pass. Call it on any HTML string about to
141384
- be inserted with createContextualFragment (which executes scripts). It
141385
- parses with DOMParser -- where scripts can never run -- and moves each
141386
- [data-cb-code] block's source into an inert <template data-source>, so
141387
- nothing has run by the time the content is in the DOM.
141388
- */
141389
- const ATTR = 'data-cb-code';
141390
- const SEL = '[' + ATTR + ']';
141391
- const TPL_SEL = ':scope > template[data-source]';
141392
- function escapeCodeSource(src) {
141393
- return src.replace(/<\/template>/gi, '<\\/template>');
141394
- }
141395
- /*
141396
- STRING PRE-PASS (inert).
141397
- Call this on any HTML *string* that is about to be inserted with
141398
- createContextualFragment (which executes scripts). It parses the string with
141399
- DOMParser -- where scripts can never run -- and moves each [data-cb-code]
141400
- block's source into an inert <template data-source>. After insertion nothing
141401
- has run; init()/render() brings them to life.
141402
-
141403
- Exported standalone as well, so it can be used where no builder instance
141404
- exists yet (ContentBox injects content before creating the editor).
141405
- */
141406
-
141407
- function wrapCodeSource(html) {
141408
- if (!html) return html;
141409
- if (html.indexOf(ATTR) === -1) return html; // fast path
141410
-
141411
- const parsed = new DOMParser().parseFromString(html, 'text/html');
141412
- parsed.querySelectorAll(SEL).forEach(block => {
141413
- // Already wrapped (round-trip within the editor) => skip
141414
- if (block.querySelector(TPL_SEL)) return; // Snippet form (data-src) is already inert => handled by init()
141415
-
141416
- if (block.hasAttribute('data-src')) return; // The block's innerHTML IS the pristine source (what flatten() wrote)
141417
-
141418
- const src = block.innerHTML;
141419
- block.innerHTML = '';
141420
- const tpl = parsed.createElement('template');
141421
- tpl.setAttribute('data-source', '');
141422
- tpl.innerHTML = escapeCodeSource(src);
141423
- block.appendChild(tpl);
141424
- });
141425
- return parsed.body.innerHTML;
141426
- }
141427
-
141428
142123
  class Draggable {
141429
142124
  constructor(opts = {}) {
141430
142125
  this.opts = opts;
@@ -164393,6 +165088,12 @@ class ContentBox {
164393
165088
  // HTML button on left sidebar
164394
165089
  sectionHtmlButton: true,
164395
165090
  // HTML button on section tool (edits the selected section only)
165091
+ sectionQuickTool: false,
165092
+ // Quick section controls under the Section breadcrumb of the panel. Off by default so nothing changes for people who know the panel; users can switch them on in Settings.
165093
+ sectionTool: true,
165094
+ // Section tools on the page (move, HTML, settings, remove). Control panel only; users can switch these off in Settings.
165095
+ boxTool: true,
165096
+ // Box tools on the page (background upload & select, box & module settings). Control panel only; users can switch these off in Settings.
164396
165097
  undoRedoButtons: true,
164397
165098
  // Undo & redo buttons on control panel
164398
165099
  contentSizeSlider: true,
@@ -167804,7 +168505,12 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
167804
168505
  localStorage.removeItem('_zoom'); // Reset zoom
167805
168506
 
167806
168507
  localStorage.removeItem('_livepreview');
167807
- localStorage.removeItem('_disableanimation');
168508
+ localStorage.removeItem('_disableanimation'); // The user's own pick in Settings stands in front of the shipped
168509
+ // default, like _fullviewwidth does.
168510
+
168511
+ if (localStorage.getItem('_sectionquicktool') !== null) this.sectionQuickTool = localStorage.getItem('_sectionquicktool') === '1';
168512
+ if (localStorage.getItem('_sectiontool') !== null) this.sectionTool = localStorage.getItem('_sectiontool') === '1';
168513
+ if (localStorage.getItem('_boxtool') !== null) this.boxTool = localStorage.getItem('_boxtool') === '1';
167808
168514
  }
167809
168515
 
167810
168516
  if (this.uploadFile) {
@@ -168060,7 +168766,7 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
168060
168766
  // scripts can run ([data-cb-code]); codeElement.init() then
168061
168767
  // renders them (called from applyBehaviorOn).
168062
168768
 
168063
- html = wrapCodeSource(html); // Render html content
168769
+ html = wrapCodeSource$1(html); // Render html content
168064
168770
 
168065
168771
  let range = document.createRange();
168066
168772
  this.wrapperEl.innerHTML = '';
@@ -170845,6 +171551,18 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
170845
171551
  this.editor.openAssetSelect(type, callback);
170846
171552
  }
170847
171553
 
171554
+ hasFilePicker(type) {
171555
+ return this.editor.hasFilePicker(type);
171556
+ }
171557
+
171558
+ openFileUpload(type, callback) {
171559
+ this.editor.openFileUpload(type, callback);
171560
+ }
171561
+
171562
+ createFileButtons(type, callback) {
171563
+ return this.editor.createFileButtons(type, callback);
171564
+ }
171565
+
170848
171566
  openColorPicker(currentColor, callback, btn) {
170849
171567
  this.editor.openColorPicker(currentColor, callback, btn);
170850
171568
  }
@@ -171003,7 +171721,7 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
171003
171721
  sectionSetup(section) {
171004
171722
  let tool = section.querySelector('.is-section-tool');
171005
171723
  if (tool) tool.parentNode.removeChild(tool);
171006
- dom.appendHtml(section, `<div class="is-section-tool">
171724
+ if (this.sectionToolEnabled) dom.appendHtml(section, `<div class="is-section-tool">
171007
171725
  <button type="button" class="btn-move-section-up" tabindex="-1" data-title="${out('Move Up')}" title="${out('Move Up')}">
171008
171726
  <svg class="is-icon-flex" style="transform:rotate(180deg)"><use xlink:href="#icon-arrow-down2"></use></svg>
171009
171727
  </button>
@@ -171029,7 +171747,8 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
171029
171747
  dom.appendHtml(section, '<div class="is-section-info">' + (sectionId ? `<div>#${sectionId}</div>` : '') + '</div>'); // this.editor.setTooltip(section);
171030
171748
  // Prepare for tooltip
171031
171749
 
171032
- let elms = section.querySelector('.is-section-tool').querySelectorAll('[title]');
171750
+ const sectionToolEl = section.querySelector('.is-section-tool');
171751
+ let elms = sectionToolEl ? sectionToolEl.querySelectorAll('[title]') : [];
171033
171752
  Array.prototype.forEach.call(elms, elm => {
171034
171753
  elm.setAttribute('data-title', elm.getAttribute('title'));
171035
171754
 
@@ -171040,15 +171759,15 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
171040
171759
  }
171041
171760
  });
171042
171761
  const btnSectionUp = section.querySelector('.btn-move-section-up');
171043
- btnSectionUp.addEventListener('click', () => {
171762
+ if (btnSectionUp) btnSectionUp.addEventListener('click', () => {
171044
171763
  this.section.sectionMove('up');
171045
171764
  });
171046
171765
  const btnSectionDown = section.querySelector('.btn-move-section-down');
171047
- btnSectionDown.addEventListener('click', () => {
171766
+ if (btnSectionDown) btnSectionDown.addEventListener('click', () => {
171048
171767
  this.section.sectionMove('down');
171049
171768
  });
171050
171769
  const btnSectionEdit = section.querySelector('.btn-section-edit');
171051
- btnSectionEdit.addEventListener('click', () => {
171770
+ if (btnSectionEdit) btnSectionEdit.addEventListener('click', () => {
171052
171771
  if (this.controlPanel) {
171053
171772
  this.controlpanel.open();
171054
171773
  this.controlpanel.select('section', true);
@@ -171062,7 +171781,7 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
171062
171781
  this.openSectionHtml(section);
171063
171782
  });
171064
171783
  const btnSectionRemove = section.querySelector('.btn-section-remove');
171065
- btnSectionRemove.addEventListener('click', () => {
171784
+ if (btnSectionRemove) btnSectionRemove.addEventListener('click', () => {
171066
171785
  const modal = document.querySelector('.is-modal.delsectionconfirm');
171067
171786
 
171068
171787
  if (this.settings.deleteConfirm) {
@@ -171221,6 +171940,41 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
171221
171940
  set activePlugin(el) {
171222
171941
  if (this.editor) this.editor.activePlugin = el;
171223
171942
  }
171943
+ /**
171944
+ * The page's own section and box tools, which the user can switch off in
171945
+ * Settings (sectionTool / boxTool).
171946
+ *
171947
+ * Only with the control panel: everything those tools do is on the panel
171948
+ * (the strip under the Section crumb, and the Box panel), so switching them
171949
+ * off costs nothing there. In legacy modal mode they are the only way in, so
171950
+ * the option is ignored and the tools always render.
171951
+ */
171952
+
171953
+
171954
+ get sectionToolEnabled() {
171955
+ return this.controlPanel ? this.sectionTool !== false : true;
171956
+ }
171957
+
171958
+ get boxToolEnabled() {
171959
+ return this.controlPanel ? this.boxTool !== false : true;
171960
+ }
171961
+ /** Re-run the section/box setup so the tools appear or disappear right away. */
171962
+
171963
+
171964
+ refreshPageTools() {
171965
+ if (!this.wrapperEl) return;
171966
+ this.wrapperEl.querySelectorAll('.is-section').forEach(section => {
171967
+ this.sectionSetup(section);
171968
+
171969
+ if (dom.hasClass(section, 'is-box')) {
171970
+ this.boxSetup(section);
171971
+ } else {
171972
+ section.querySelectorAll('.is-box').forEach(box => {
171973
+ this.boxSetup(box);
171974
+ });
171975
+ }
171976
+ });
171977
+ }
171224
171978
 
171225
171979
  boxSetup(box) {
171226
171980
  let tool = box.querySelector('.is-box-tool');
@@ -171240,7 +171994,7 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
171240
171994
  '</div>');
171241
171995
  */
171242
171996
 
171243
- dom.appendHtml(box, '<div class="is-box-tool" data-tool="1">' + '<button type="button" class="is-cover-upload" tabindex="-1" data-tooltip-top data-title="' + out('Upload Background') + '" title="' + out('Upload Background') + '" style="background:rgb(78 170 57)"><svg class="is-icon-flex" style="position: absolute;top: 8px;left: 8px;fill:rgb(255,255,255);"><use xlink:href="#ion-image"></use></svg></button>' + '<button type="button" class="is-select-image" tabindex="-1" data-tooltip-top data-title="' + out('Select Background') + '" title="' + out('Select Background') + '" style="background:rgb(251 173 27)"><svg class="is-icon-flex" style="color:#fff;width:15px;height:15px;"><use style="color: #fff;" xlink:href="#icon-folder2"></use></svg></button>' + '<button type="button" class="is-box-edit" tabindex="-1" data-tooltip-top data-title="' + out('Box Settings') + '" title="' + out('Box Settings') + '"><svg class="is-icon-flex"><use xlink:href="#ion-wrench"></use></svg></button>' + '<button type="button" class="is-module-edit" tabindex="-1" data-tooltip-top data-title="' + out('Module Settings') + '" title="' + out('Module Settings') + '" style="background:rgb(135 116 215)"><svg class="is-icon-flex"><use xlink:href="#ion-ios-gear"></use></svg></button>' + '</div>'); // Prepare for tooltip
171997
+ if (this.boxToolEnabled) dom.appendHtml(box, '<div class="is-box-tool" data-tool="1">' + '<button type="button" class="is-cover-upload" tabindex="-1" data-tooltip-top data-title="' + out('Upload Background') + '" title="' + out('Upload Background') + '" style="background:rgb(78 170 57)"><svg class="is-icon-flex" style="position: absolute;top: 8px;left: 8px;fill:rgb(255,255,255);"><use xlink:href="#ion-image"></use></svg></button>' + '<button type="button" class="is-select-image" tabindex="-1" data-tooltip-top data-title="' + out('Select Background') + '" title="' + out('Select Background') + '" style="background:rgb(251 173 27)"><svg class="is-icon-flex" style="color:#fff;width:15px;height:15px;"><use style="color: #fff;" xlink:href="#icon-folder2"></use></svg></button>' + '<button type="button" class="is-box-edit" tabindex="-1" data-tooltip-top data-title="' + out('Box Settings') + '" title="' + out('Box Settings') + '"><svg class="is-icon-flex"><use xlink:href="#ion-wrench"></use></svg></button>' + '<button type="button" class="is-module-edit" tabindex="-1" data-tooltip-top data-title="' + out('Module Settings') + '" title="' + out('Module Settings') + '" style="background:rgb(135 116 215)"><svg class="is-icon-flex"><use xlink:href="#ion-ios-gear"></use></svg></button>' + '</div>'); // Prepare for tooltip
171244
171998
 
171245
171999
  tool = box.querySelector('.is-box-tool');
171246
172000
 
@@ -171499,7 +172253,14 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
171499
172253
 
171500
172254
  if (el.closest('[data-cb-type]')) return false; // plugin — its own editable regions
171501
172255
 
171502
- if (el.closest('script, style, svg, noscript')) return false;
172256
+ if (el.closest('script, style, svg, noscript')) return false; // Inside a code element, only markup that is still verbatim in the
172257
+ // block's source can be edited: the render is discarded on save, so an
172258
+ // edit to anything the code built itself would not survive it. The
172259
+ // block itself is never text — its content is edited as code, and
172260
+ // writing to it here would overwrite the source template.
172261
+
172262
+ if (el.matches('[data-cb-code]')) return false;
172263
+ if (!codeSourceEditable(el)) return false;
171503
172264
  if (!this.hasOwnText(el)) return false;
171504
172265
 
171505
172266
  for (let parent = el.parentElement; parent && parent !== overlay; parent = parent.parentElement) {
@@ -171546,6 +172307,9 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
171546
172307
  if (el.closest('.is-container')) return false;
171547
172308
  if (el.closest('[data-html]')) return false;
171548
172309
  if (el.closest('[data-cb-type]')) return false;
172310
+ if (el.matches('[data-cb-code]')) return false; // code element — see isOverlayTextElement
172311
+
172312
+ if (!codeSourceEditable(el)) return false;
171549
172313
  if (el.tagName.toLowerCase() === 'img') return !el.hasAttribute('data-fixed');
171550
172314
  return this.hasBackgroundImage(el);
171551
172315
  }
@@ -172528,19 +173292,21 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
172528
173292
  sectionTool.style.transformOrigin = 'top right';
172529
173293
  }
172530
173294
 
172531
- const btnSectionUp = sectionTool.querySelector('.btn-move-section-up');
172532
- const btnSectionDown = sectionTool.querySelector('.btn-move-section-down');
172533
- const btnSectionRemove = sectionTool.querySelector('.btn-section-remove');
172534
-
172535
- if (box.offsetHeight < 160) {
172536
- btnSectionUp.style.display = 'none';
172537
- btnSectionDown.style.display = 'none';
172538
- btnSectionRemove.style.display = 'none';
172539
- sectionTool.style.top = '17px'; //'calc(50% - 17px)';
172540
- } else {
172541
- btnSectionUp.style.display = '';
172542
- btnSectionDown.style.display = '';
172543
- btnSectionRemove.style.display = '';
173295
+ if (sectionTool) {
173296
+ const btnSectionUp = sectionTool.querySelector('.btn-move-section-up');
173297
+ const btnSectionDown = sectionTool.querySelector('.btn-move-section-down');
173298
+ const btnSectionRemove = sectionTool.querySelector('.btn-section-remove');
173299
+
173300
+ if (box.offsetHeight < 160) {
173301
+ btnSectionUp.style.display = 'none';
173302
+ btnSectionDown.style.display = 'none';
173303
+ btnSectionRemove.style.display = 'none';
173304
+ sectionTool.style.top = '17px'; //'calc(50% - 17px)';
173305
+ } else {
173306
+ btnSectionUp.style.display = '';
173307
+ btnSectionDown.style.display = '';
173308
+ btnSectionRemove.style.display = '';
173309
+ }
172544
173310
  } //new box tool
172545
173311
 
172546
173312
 
@@ -173274,7 +174040,7 @@ Add an image for each feature.`, 'Create a new block showcasing a photo gallery
173274
174040
  // run ([data-cb-code]). Legacy [data-html] blocks are untouched.
173275
174041
 
173276
174042
 
173277
- html = wrapCodeSource(html); // Render html content
174043
+ html = wrapCodeSource$1(html); // Render html content
173278
174044
 
173279
174045
  let range = document.createRange();
173280
174046
  wrapper.innerHTML = '';