@vectoriox/iox-builder 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Injectable, InjectionToken, inject, NgZone, EventEmitter, createComponent, Output, Input, Inject, Directive, ChangeDetectionStrategy, Component, ViewChild, effect, Optional, ContentChildren, NO_ERRORS_SCHEMA, NgModule } from '@angular/core';
2
+ import { Injectable, InjectionToken, Optional, Inject, inject, NgZone, EventEmitter, createComponent, Output, Input, Directive, ChangeDetectionStrategy, Component, ViewChild, effect, ContentChildren, NO_ERRORS_SCHEMA, NgModule } from '@angular/core';
3
3
  import * as i2 from '@angular/common';
4
4
  import { DOCUMENT, CommonModule } from '@angular/common';
5
5
  import * as i16 from '@angular/forms';
6
6
  import { FormsModule } from '@angular/forms';
7
- import * as i4 from '@angular/cdk/drag-drop';
7
+ import * as i5 from '@angular/cdk/drag-drop';
8
8
  import { DragDropModule } from '@angular/cdk/drag-drop';
9
9
  import { AccordionModule } from 'primeng/accordion';
10
10
  import * as i17 from 'primeng/button';
@@ -16,7 +16,7 @@ import { SelectModule } from 'primeng/select';
16
16
  import { TooltipModule } from 'primeng/tooltip';
17
17
  import { DialogModule } from 'primeng/dialog';
18
18
  import * as i1 from '@vectoriox/iox-ui';
19
- import { VALIDATION_STATES as VALIDATION_STATES$1, VALIDATION_STATE_CLASS as VALIDATION_STATE_CLASS$1, DEFAULT_WIDTH_BY_TYPE, COLOR_SCHEME_TRAIT, COLOR_SCHEMES, ColorScheme, Breakpoint, BREAKPOINTS, countOverrides, setOverride, NEW_NODE_STYLE_DEFAULTS, defaultElementStyleFor, applyElementStyle, stripSuppressedProps, DeclarationContext, buildPartSelector, planResponsiveRules, resolveStyleProps, buildTargetedStateSelector, statePseudoClass, compileTokenCssBlocks, catalogFontId, fontsForTokens, GOOGLE_FONTS_FILE_ORIGIN, googleFontHref, compileDeclarations, wrapInBreakpointQuery, BreakpointQueryKind, breakpointDef, breakpointForWidth, FormRenderState, safeSetInput, ContextStore, ComponentInstanceRegistryService, isActionEnabled, isContextAction, contextActionEffect, resolveContextValue, declaredInputNames, emptyImagePlaceholder, syncNodeDesignClasses, composeVirtualTraits, withDefaultHoverTransition, stateNamesIn, partClassName, resolveInputs, setInputOverride, collectContextSlotKeys, withContextSlots, compilePageStyle, nodeUsesContext, resolveBoundValue, IS_PREVIEW, planDataSource, dataSourcePlanToRequest, normalizeCollectionResult, resolveRepeaterItems, getByPath, computeRelative, IoxPageModule, PALETTE_PRESETS, elementStyleById, PART_CLASS_NAMES } from '@vectoriox/iox-ui';
19
+ import { VALIDATION_STATES as VALIDATION_STATES$1, VALIDATION_STATE_CLASS as VALIDATION_STATE_CLASS$1, DEFAULT_WIDTH_BY_TYPE, COLOR_SCHEME_TRAIT, COLOR_SCHEMES, ColorScheme, Breakpoint, BREAKPOINTS, countOverrides, setOverride, NEW_NODE_STYLE_DEFAULTS, defaultElementStyleFor, applyElementStyle, stripSuppressedProps, DeclarationContext, buildPartSelector, planResponsiveRules, resolveStyleProps, buildTargetedStateSelector, statePseudoClass, compileTokenCssBlocks, catalogFontId, fontsForTokens, GOOGLE_FONTS_FILE_ORIGIN, googleFontHref, compileDeclarations, wrapInBreakpointQuery, BreakpointQueryKind, breakpointDef, breakpointForWidth, FormRenderState, safeSetInput, ContextStore, ComponentInstanceRegistryService, isActionEnabled, isContextAction, contextActionEffect, resolveContextValue, declaredInputNames, emptyImagePlaceholder, syncNodeDesignClasses, composeVirtualTraits, withDefaultHoverTransition, stateNamesIn, partClassName, resolveInputs, setInputOverride, collectContextSlotKeys, withContextSlots, compilePageStyle, nodeUsesContext, resolveBoundValue, IS_PREVIEW, planDataSource, dataSourcePlanToRequest, normalizeCollectionResult, resolveRepeaterItems, getByPath, computeRelative, LinkType, buttonBlockHref, IoxPageModule, PALETTE_PRESETS, elementStyleById, PART_CLASS_NAMES } from '@vectoriox/iox-ui';
20
20
  export { ComponentInstanceRegistryService, VIRTUAL_TRAIT_KEYS, composeVirtualTraits } from '@vectoriox/iox-ui';
21
21
  import Lenis from 'lenis';
22
22
  import { Subject, BehaviorSubject, of, forkJoin } from 'rxjs';
@@ -784,6 +784,24 @@ function reissueIdentityTraits(node) {
784
784
  trait.default = `${prefix}-${freshId()}`;
785
785
  }
786
786
  }
787
+ /**
788
+ * A NEW node is born with its identity: an empty identity trait (a palette Form's `formId`) is issued,
789
+ * one already set (a preset's `contact-…`) is kept. A Form inserted from the palette used to keep
790
+ * `formId: ''` — its submissions posted to `forms//submit` and nothing was ever stored.
791
+ */
792
+ function issueIdentityTraits(root) {
793
+ for (const trait of (root.traits ?? [])) {
794
+ if (!trait?.name || !REGENERATED_TRAITS.has(trait.name))
795
+ continue;
796
+ if (trait.default === undefined || trait.default === '')
797
+ trait.default = `${IDENTITY_PREFIX[trait.name] ?? trait.name}-${freshId()}`;
798
+ }
799
+ for (const child of root.children ?? [])
800
+ if (child)
801
+ issueIdentityTraits(child);
802
+ }
803
+ /** The readable prefix a freshly issued identity carries (`form-a1b2c3`). */
804
+ const IDENTITY_PREFIX = { formId: 'form' };
787
805
  function regenerateNodeIds(root, opts = {}) {
788
806
  const idMap = new Map();
789
807
  const assign = (node) => {
@@ -1601,6 +1619,8 @@ const BUILDER_CHROME_EN = Object.freeze({
1601
1619
  'builder.chrome.save': 'Save',
1602
1620
  'builder.chrome.preview': 'Preview',
1603
1621
  'builder.chrome.addImage': 'Add image',
1622
+ 'builder.chrome.refuseNestedForm': 'A form can’t go inside another form. Place it outside the form.',
1623
+ 'builder.chrome.refuseStateBlock': 'Success and error messages belong to their form. They can’t be placed on their own.',
1604
1624
  });
1605
1625
  const CANVAS_EMPTY_IMAGE_LABEL_KEY = 'builder.chrome.addImage';
1606
1626
  const CANVAS_EMPTY_IMAGE_LABEL_EN = BUILDER_CHROME_EN[CANVAS_EMPTY_IMAGE_LABEL_KEY];
@@ -1651,6 +1671,7 @@ class ComponentRegistryService {
1651
1671
  if (!config)
1652
1672
  return null;
1653
1673
  wearDefaultStyles(config);
1674
+ issueIdentityTraits(config);
1654
1675
  return config;
1655
1676
  }
1656
1677
  /**
@@ -1921,6 +1942,93 @@ function resolveDragTarget(el, selectedElement, isDragHandle) {
1921
1942
  return (isDragHandle || isDescendantOfSelected) ? selectedElement : el;
1922
1943
  }
1923
1944
 
1945
+ /**
1946
+ * What may NOT be placed where — the ONE rule every insert path asks (drag, click-to-insert, quick-add,
1947
+ * the Form presets, paste, duplicate). Everything not refused here is accepted: any container-capable
1948
+ * node takes any child, a Form included.
1949
+ */
1950
+ var InsertRefusal;
1951
+ (function (InsertRefusal) {
1952
+ /** A form inside a form — fields belong to their NEAREST form (forms plan D1), and HTML forbids it. */
1953
+ InsertRefusal["NestedForm"] = "nestedForm";
1954
+ /** A Success / Error block is part of its form's state machine; it is never placed on its own. */
1955
+ InsertRefusal["StateBlockAlone"] = "stateBlockAlone";
1956
+ })(InsertRefusal || (InsertRefusal = {}));
1957
+ const FORM_STATE_BLOCK_TYPES = new Set(['FormSuccess', 'FormError']);
1958
+ /** The builder-chrome label that says each refusal on screen (English in `BUILDER_CHROME_EN`). */
1959
+ const INSERT_REFUSAL_LABEL_KEYS = Object.freeze({
1960
+ [InsertRefusal.NestedForm]: 'builder.chrome.refuseNestedForm',
1961
+ [InsertRefusal.StateBlockAlone]: 'builder.chrome.refuseStateBlock',
1962
+ });
1963
+ /** The refusal in the host's language (the library's English when no host resolver is provided). */
1964
+ function insertRefusalLabel(reason, labels = defaultBuilderLabels) {
1965
+ const key = INSERT_REFUSAL_LABEL_KEYS[reason];
1966
+ return labels(key, BUILDER_CHROME_EN[key]);
1967
+ }
1968
+ function containsType(node, type) {
1969
+ if (node.type === type)
1970
+ return true;
1971
+ return (node.children ?? []).some(child => containsType(child, type));
1972
+ }
1973
+ /**
1974
+ * Why `node` may not be inserted under `ancestors` (outermost first, the new parent last; empty = the page),
1975
+ * or null when it may. `node` is the whole subtree being placed — a Container holding a Form is refused
1976
+ * inside a Form just as a bare Form is. Reordering a node inside its CURRENT parent is not an insert and
1977
+ * never asks.
1978
+ */
1979
+ function insertRefusal(node, ancestors) {
1980
+ if (!node)
1981
+ return null;
1982
+ if (node.type && FORM_STATE_BLOCK_TYPES.has(node.type))
1983
+ return InsertRefusal.StateBlockAlone;
1984
+ if (ancestors.some(a => a?.type === FORM_TYPE) && containsType(node, FORM_TYPE))
1985
+ return InsertRefusal.NestedForm;
1986
+ return null;
1987
+ }
1988
+
1989
+ /**
1990
+ * ROOT ESCAPE — near the top or bottom edge of a ROOT-level container the drop goes to the PAGE, between
1991
+ * root containers, instead of inside the container. This file is the decision; DragEngineService only
1992
+ * measures and applies it.
1993
+ *
1994
+ * ⚠️ The bug this replaced (2026-10-19, "a Form cannot be inserted into a Section / Container / Linked
1995
+ * Container"): escape switched on when the pointer was in the edge band and the PREVIOUS target was the
1996
+ * same container — which is also true on the second sample after ENTERING through the band — and it only
1997
+ * switched off once the pointer LEFT the container. So approaching a root container from above or below
1998
+ * locked every drop inside it to the page, even at its centre; an empty (short) container was almost
1999
+ * always entered that way. Now escape needs a move OUTWARD — from the container's inner region to its
2000
+ * edge — and it ends as soon as the pointer is back in the inner region.
2001
+ */
2002
+ /** The edge band on a tall container, in screen px. */
2003
+ const ROOT_ESCAPE_PX = 14;
2004
+ var RootZone;
2005
+ (function (RootZone) {
2006
+ RootZone["Outside"] = "outside";
2007
+ RootZone["Edge"] = "edge";
2008
+ RootZone["Inner"] = "inner";
2009
+ })(RootZone || (RootZone = {}));
2010
+ /** The band shrinks on a short container (a quarter of its height), so its middle stays a drop target. */
2011
+ function rootEscapeBand(height) {
2012
+ return Math.min(ROOT_ESCAPE_PX, Math.max(0, height) / 4);
2013
+ }
2014
+ /** Where the pointer's y sits relative to a root container's vertical extent. */
2015
+ function rootZone(py, top, bottom) {
2016
+ if (py < top || py > bottom)
2017
+ return RootZone.Outside;
2018
+ const band = rootEscapeBand(bottom - top);
2019
+ return py <= top + band || py >= bottom - band ? RootZone.Edge : RootZone.Inner;
2020
+ }
2021
+ /**
2022
+ * Whether this sample escapes `rootId` to the page: the pointer is on its edge band AND it either was
2023
+ * already escaping it or has just moved out to the edge from its inner region. Entering from outside
2024
+ * through the band never escapes.
2025
+ */
2026
+ function escapesRoot(rootId, zone, memory) {
2027
+ if (zone !== RootZone.Edge)
2028
+ return false;
2029
+ return memory.escapingFor === rootId || memory.innerOf === rootId;
2030
+ }
2031
+
1924
2032
  /**
1925
2033
  * CSS container name of the builder canvas frame (and of the global-elements overlay). Breakpoint
1926
2034
  * rules are emitted as `@container iox-canvas (max-width: …)`: the frame's layout width IS the
@@ -2462,6 +2570,11 @@ const MOTION_EASE = 'cubic-bezier(0.22, 1, 0.36, 1)';
2462
2570
  // Lower = more lag/weight, higher = snappier. 0.35 catches up to the pointer
2463
2571
  // within ~5-6 frames (~80-100ms) — reads as "responsive but weighted".
2464
2572
  const GHOST_FOLLOW_SMOOTHING = 0.35;
2573
+ const TARGET_CHIP_BG = '#6366f1';
2574
+ // The admin chrome's danger fill (toolbar.component.scss's unpublish hover reads the same token).
2575
+ const REFUSED_CHIP_BG = 'var(--iox-danger-text, #8E211A)';
2576
+ /** On the drop-target overlay while the target refuses the dragged payload (styled by the host). */
2577
+ const DROP_REFUSED_CLASS = 'iox-drop-target-overlay--refused';
2465
2578
  function deepCloneNode(node) {
2466
2579
  const clone = JSON.parse(JSON.stringify(node));
2467
2580
  // Fresh ids + remap intra-subtree references (stateTriggers, interaction targets) so a cloned
@@ -2470,10 +2583,12 @@ function deepCloneNode(node) {
2470
2583
  return clone;
2471
2584
  }
2472
2585
  class DragEngineService {
2473
- constructor(registry, symbolRegistry, overlayService) {
2586
+ constructor(registry, symbolRegistry, overlayService, labels) {
2474
2587
  this.registry = registry;
2475
2588
  this.symbolRegistry = symbolRegistry;
2476
2589
  this.overlayService = overlayService;
2590
+ /** Every insert the builder refused, for the host to say on screen (a toast). */
2591
+ this.insertRefused$ = new Subject();
2477
2592
  // ── Drop-zone registry ────────────────────────────────────
2478
2593
  this.ids = [];
2479
2594
  this.elMap = new Map();
@@ -2524,9 +2639,13 @@ class DragEngineService {
2524
2639
  this._scale = 1;
2525
2640
  // ── Dropzone rect cache — snapshotted at drag start and on scroll ─────────
2526
2641
  this._dropzoneRectCache = new Map();
2527
- // ── Root-escape ───────────────────────────────────────────
2528
- this.ROOT_ESCAPE_PX = 14;
2642
+ // ── Root-escape (decision: utils/root-escape.util.ts) ─────
2529
2643
  this._rootEscapeActiveFor = null;
2644
+ /** The root container whose inner region held the previous sample. */
2645
+ this._innerRootId = null;
2646
+ // ── Drop refusal (utils/insert-rules.util.ts) ─────────────
2647
+ /** Why the current target would refuse the dragged payload; null = it accepts. */
2648
+ this._targetRefusal = null;
2530
2649
  // ─────────────────────────────────────────────────────────
2531
2650
  // Draggable registration
2532
2651
  // ─────────────────────────────────────────────────────────
@@ -2544,10 +2663,47 @@ class DragEngineService {
2544
2663
  this._overlay.style.top = this._ghostY + 'px';
2545
2664
  this._ghostRaf = requestAnimationFrame(this._ghostFollowStep);
2546
2665
  };
2666
+ this.labels = labels ?? defaultBuilderLabels;
2667
+ }
2668
+ /**
2669
+ * The ONE acceptance check for every insert path: null when `data` (a panel payload or a node) may go
2670
+ * under `ancestors`; otherwise the refusal, already announced on `insertRefused$`.
2671
+ */
2672
+ refuseInsert(data, ancestors) {
2673
+ const reason = insertRefusal(this.payloadNode(data), ancestors);
2674
+ if (reason)
2675
+ this.insertRefused$.next({ reason, message: insertRefusalLabel(reason, this.labels) });
2676
+ return reason;
2677
+ }
2678
+ /** The node a payload WOULD insert, without building it: a type string, a block/symbol, or a node. */
2679
+ payloadNode(data) {
2680
+ if (typeof data === 'string')
2681
+ return { type: data };
2682
+ if (!data || typeof data !== 'object')
2683
+ return null;
2684
+ const blk = data;
2685
+ const symbolNode = blk.symbolId ? this.symbolRegistry.getSymbol(blk.symbolId)?.node : null;
2686
+ return (symbolNode ?? blk.node ?? blk);
2687
+ }
2688
+ /** The nodes a dropzone sits in, outermost first, its own node last — read from the DOM, which is
2689
+ * where a dropzone id lives. Empty for the page itself. */
2690
+ dropzoneAncestors(id) {
2691
+ const out = [];
2692
+ for (let el = this._getEl(id); el && el.id !== 'canvas-preview'; el = el.parentElement) {
2693
+ const match = this.overlayService.findNodeByElement(el);
2694
+ if (match)
2695
+ out.unshift(match.node);
2696
+ }
2697
+ return out;
2547
2698
  }
2548
2699
  // ── Shared drop handler — used by all layout components ───────────────────
2549
2700
  handleDrop(children, event, dropListId, cdr, afterDrop) {
2550
2701
  const { payload, insertIndex } = event;
2702
+ const changesParent = payload.type === 'external' || payload.sourceId !== dropListId;
2703
+ if (changesParent && this.refuseInsert(payload.data, this.dropzoneAncestors(dropListId))) {
2704
+ afterDrop?.();
2705
+ return;
2706
+ }
2551
2707
  if (payload.type === 'external') {
2552
2708
  const node = this.materialize(payload.data);
2553
2709
  if (node) {
@@ -2621,7 +2777,9 @@ class DragEngineService {
2621
2777
  * `dropComplete$` exactly like a drop, so the builder selects the new node and records one undo
2622
2778
  * step through the same path. Returns the inserted node, or null if the payload was unusable.
2623
2779
  */
2624
- insertPayload(children, index, data) {
2780
+ insertPayload(children, index, data, ancestors = []) {
2781
+ if (this.refuseInsert(data, ancestors))
2782
+ return null;
2625
2783
  const node = this.materialize(data);
2626
2784
  if (!node)
2627
2785
  return null;
@@ -2667,7 +2825,7 @@ class DragEngineService {
2667
2825
  registerDropzone(id, el, data, cdr, dropCb, postDrop) {
2668
2826
  if (!id)
2669
2827
  return;
2670
- // Stamp id on element so DOM-walk helpers (_isRootContainer) find it.
2828
+ // Stamp id on element so DOM-walk helpers (_rootContainerOf, dropzoneAncestors) find it.
2671
2829
  el.id = id;
2672
2830
  if (!this.ids.includes(id)) {
2673
2831
  this.ids = [...this.ids, id];
@@ -2773,6 +2931,8 @@ class DragEngineService {
2773
2931
  this._sourceEl = el;
2774
2932
  this._isDragging = true;
2775
2933
  this._rootEscapeActiveFor = null;
2934
+ this._innerRootId = null;
2935
+ this._targetRefusal = null;
2776
2936
  // Suppress native text selection for the duration of the drag — without this,
2777
2937
  // dragging the pointer across any text (canvas or sidebar) selects/highlights it.
2778
2938
  document.body.classList.add('iox-dragging');
@@ -2887,6 +3047,8 @@ class DragEngineService {
2887
3047
  this._removeTargetOverlay();
2888
3048
  document.querySelectorAll('.iox-drag-over').forEach(el => el.classList.remove('iox-drag-over'));
2889
3049
  this._rootEscapeActiveFor = null;
3050
+ this._innerRootId = null;
3051
+ this._targetRefusal = null;
2890
3052
  this._isDragging = false;
2891
3053
  this._payload = null;
2892
3054
  this._sourceEl = null;
@@ -2974,55 +3136,14 @@ class DragEngineService {
2974
3136
  }
2975
3137
  }
2976
3138
  }
2977
- // Root-escape with hysteresis.
2978
- // Purpose: when the cursor is near the top/bottom edge of a root-level
2979
- // section, treat the target as canvas-preview so the user can insert
2980
- // between root sections.
2981
- //
2982
- // IMPORTANT: only activate if the cursor was ALREADY inside this section
2983
- // (i.e. deepTarget is the section itself or a nested child of it).
2984
- // Without this guard, crossing from Section A into the top 14px of
2985
- // Section C immediately locks the target to canvas-preview and prevents
2986
- // the user from ever reaching Section C's nested containers.
2987
- if (this._rootEscapeActiveFor) {
2988
- const escEl = this._getEl(this._rootEscapeActiveFor);
2989
- if (escEl) {
2990
- const r = this._dropzoneRectCache.get(this._rootEscapeActiveFor) ?? escEl.getBoundingClientRect();
2991
- const inside = px >= r.left && px <= r.right && py >= r.top && py <= r.bottom;
2992
- if (inside) {
2993
- bestId = 'canvas-preview';
2994
- }
2995
- else {
2996
- this._rootEscapeActiveFor = null;
2997
- }
2998
- }
2999
- else {
3000
- this._rootEscapeActiveFor = null;
3001
- }
3002
- }
3003
- if (!this._rootEscapeActiveFor &&
3004
- bestId && bestId !== 'canvas-preview' && this._isRootContainer(bestId)) {
3005
- const el = this._getEl(bestId);
3006
- if (el) {
3007
- const rect = this._dropzoneRectCache.get(bestId) ?? el.getBoundingClientRect();
3008
- const nearEdge = py <= rect.top + this.ROOT_ESCAPE_PX ||
3009
- py >= rect.bottom - this.ROOT_ESCAPE_PX;
3010
- // Only escape if we were already targeting this section or
3011
- // something nested inside it — not when crossing into it fresh.
3012
- const wasInsideThisSection = this._deepTargetId === bestId ||
3013
- this._isDescendantDropzone(this._deepTargetId, bestId);
3014
- if (nearEdge && wasInsideThisSection) {
3015
- this._rootEscapeActiveFor = bestId;
3016
- bestId = 'canvas-preview';
3017
- }
3018
- }
3019
- }
3139
+ bestId = this._applyRootEscape(bestId, py);
3020
3140
  if (bestId !== this._deepTargetId) {
3021
3141
  if (this._deepTargetId) {
3022
3142
  const old = this._getEl(this._deepTargetId);
3023
3143
  old?.classList.remove('iox-drag-over');
3024
3144
  }
3025
3145
  this._deepTargetId = bestId;
3146
+ this._targetRefusal = this._refusalAt(bestId);
3026
3147
  if (bestId) {
3027
3148
  this._getEl(bestId)?.classList.add('iox-drag-over');
3028
3149
  }
@@ -3034,6 +3155,50 @@ class DragEngineService {
3034
3155
  this._indicatorContainerId = null;
3035
3156
  }
3036
3157
  }
3158
+ /** Near a ROOT container's edge, after moving there from its inner region, the drop goes to the page. */
3159
+ _applyRootEscape(bestId, py) {
3160
+ const rootId = bestId && bestId !== 'canvas-preview' ? this._rootContainerOf(bestId) : null;
3161
+ const rootEl = rootId ? this._getEl(rootId) : null;
3162
+ const rect = rootId && rootEl ? (this._dropzoneRectCache.get(rootId) ?? rootEl.getBoundingClientRect()) : null;
3163
+ const zone = rect ? rootZone(py, rect.top, rect.bottom) : RootZone.Outside;
3164
+ const escape = !!rootId && rootId === bestId &&
3165
+ escapesRoot(rootId, zone, { escapingFor: this._rootEscapeActiveFor, innerOf: this._innerRootId });
3166
+ this._rootEscapeActiveFor = escape ? rootId : null;
3167
+ this._innerRootId = zone === RootZone.Inner ? rootId : null;
3168
+ return escape ? 'canvas-preview' : bestId;
3169
+ }
3170
+ /** The ROOT-level dropzone (a direct child zone of the page) that holds dropzone `id`, or null when it
3171
+ * is not on the page canvas (the global-elements layer). */
3172
+ _rootContainerOf(id) {
3173
+ let root = null;
3174
+ for (let el = this._getEl(id); el; el = el.parentElement) {
3175
+ if (el.id === 'canvas-preview')
3176
+ return root;
3177
+ if (el.id && this.elMap.has(el.id))
3178
+ root = el.id;
3179
+ }
3180
+ return null;
3181
+ }
3182
+ /** Why the dragged payload may not land in `targetId` (null = it may). Reordering inside the source
3183
+ * container is not an insert. */
3184
+ _refusalAt(targetId) {
3185
+ if (!targetId || !this._payload)
3186
+ return null;
3187
+ if (this._payload.type === 'internal' && this._payload.sourceId === targetId)
3188
+ return null;
3189
+ const ancestors = targetId === 'canvas-preview' ? [] : this.dropzoneAncestors(targetId);
3190
+ return insertRefusal(this.payloadNode(this._payload.data), ancestors);
3191
+ }
3192
+ /** The node a dropzone element belongs to — the element itself or the nearest registered ancestor
3193
+ * (a LinkedContainer, a Dialog, a Repeater or a slide registers an INNER element as its zone). */
3194
+ _ownerNode(el) {
3195
+ for (let cur = el; cur && cur.id !== 'canvas-preview'; cur = cur.parentElement) {
3196
+ const match = this.overlayService.findNodeByElement(cur);
3197
+ if (match)
3198
+ return match.node;
3199
+ }
3200
+ return undefined;
3201
+ }
3037
3202
  // ─────────────────────────────────────────────────────────
3038
3203
  // Target overlay — fixed to <body>, sized from getBoundingClientRect
3039
3204
  // so it is always above all canvas content regardless of stacking
@@ -3067,7 +3232,7 @@ class DragEngineService {
3067
3232
  this._targetChip.style.cssText = [
3068
3233
  'position:absolute', 'left:0', 'top:-18px', 'max-width:100%', 'overflow:hidden',
3069
3234
  'white-space:nowrap', 'text-overflow:ellipsis', 'padding:1px 6px',
3070
- 'border-radius:4px 4px 0 0', 'background:#6366f1', 'color:#fff',
3235
+ 'border-radius:4px 4px 0 0', `background:${TARGET_CHIP_BG}`, 'color:#fff',
3071
3236
  'font:500 10px/1.5 system-ui,sans-serif', 'pointer-events:none',
3072
3237
  ].join(';');
3073
3238
  this._targetOverlay.appendChild(this._targetChip);
@@ -3095,40 +3260,19 @@ class DragEngineService {
3095
3260
  _updateTargetChip(targetId, el) {
3096
3261
  if (!this._targetChip)
3097
3262
  return;
3098
- const name = this.overlayService.findNodeByElement(el)?.node;
3263
+ const refused = this._targetRefusal;
3264
+ this._targetOverlay?.classList.toggle(DROP_REFUSED_CLASS, !!refused);
3265
+ this._targetChip.style.background = refused ? REFUSED_CHIP_BG : TARGET_CHIP_BG;
3266
+ if (refused) {
3267
+ this._targetChip.textContent = insertRefusalLabel(refused, this.labels);
3268
+ return;
3269
+ }
3270
+ const name = this._ownerNode(el);
3099
3271
  const childCount = this.getData(targetId)?.length ?? 0;
3100
3272
  const index = this._indicatorContainerId === targetId && this._indicatorIndex >= 0
3101
3273
  ? this._indicatorIndex : childCount;
3102
3274
  this._targetChip.textContent = dropTargetLabel(name?.label || name?.type, index, childCount);
3103
3275
  }
3104
- _isRootContainer(id) {
3105
- const el = this._getEl(id);
3106
- if (!el)
3107
- return false;
3108
- let parent = el.parentElement;
3109
- while (parent) {
3110
- if (parent.id === 'canvas-preview')
3111
- return true;
3112
- if (parent.id && this.ids.includes(parent.id))
3113
- return false;
3114
- parent = parent.parentElement;
3115
- }
3116
- return false;
3117
- }
3118
- /**
3119
- * Returns true if the dropzone with `childId` is a DOM descendant of the
3120
- * dropzone with `parentId`. Used to decide whether the cursor was already
3121
- * "inside" a root section before triggering root-escape.
3122
- */
3123
- _isDescendantDropzone(childId, parentId) {
3124
- if (!childId)
3125
- return false;
3126
- const childEl = this._getEl(childId);
3127
- const parentEl = this._getEl(parentId);
3128
- if (!childEl || !parentEl)
3129
- return false;
3130
- return parentEl.contains(childEl);
3131
- }
3132
3276
  // ─────────────────────────────────────────────────────────
3133
3277
  // Insertion indicator (animates open so siblings shift smoothly)
3134
3278
  // ─────────────────────────────────────────────────────────
@@ -3281,12 +3425,17 @@ class DragEngineService {
3281
3425
  }
3282
3426
  return children.length;
3283
3427
  }
3284
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: DragEngineService, deps: [{ token: ComponentRegistryService }, { token: SymbolRegistryService }, { token: OverlayService }], target: i0.ɵɵFactoryTarget.Injectable }); }
3428
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: DragEngineService, deps: [{ token: ComponentRegistryService }, { token: SymbolRegistryService }, { token: OverlayService }, { token: IOX_BUILDER_LABELS, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
3285
3429
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: DragEngineService }); }
3286
3430
  }
3287
3431
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: DragEngineService, decorators: [{
3288
3432
  type: Injectable
3289
- }], ctorParameters: () => [{ type: ComponentRegistryService }, { type: SymbolRegistryService }, { type: OverlayService }] });
3433
+ }], ctorParameters: () => [{ type: ComponentRegistryService }, { type: SymbolRegistryService }, { type: OverlayService }, { type: undefined, decorators: [{
3434
+ type: Optional
3435
+ }, {
3436
+ type: Inject,
3437
+ args: [IOX_BUILDER_LABELS]
3438
+ }] }] });
3290
3439
 
3291
3440
  /**
3292
3441
  * Page-scoped registry that holds the current page's data sources.
@@ -5992,9 +6141,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5992
6141
  }] } });
5993
6142
 
5994
6143
  class LayerTreeComponent {
5995
- constructor(registry, overlayService) {
6144
+ constructor(registry, overlayService, dragEngine) {
5996
6145
  this.registry = registry;
5997
6146
  this.overlayService = overlayService;
6147
+ this.dragEngine = dragEngine;
5998
6148
  this.layout = [];
5999
6149
  this.globalElements = [];
6000
6150
  this.nodeSelect = new EventEmitter();
@@ -6102,6 +6252,13 @@ class LayerTreeComponent {
6102
6252
  const targetTreeNode = this.flatTree[event.currentIndex];
6103
6253
  if (!targetTreeNode)
6104
6254
  return;
6255
+ // Landing beside the target means landing in the target's parent — the same acceptance rule
6256
+ // every canvas insert asks (a form never goes inside a form).
6257
+ if (targetTreeNode.parentArray !== draggedTreeNode.parentArray) {
6258
+ const ancestors = (nodeAncestorPathInEither(this.layout, this.globalElements, targetTreeNode.node) ?? []).slice(0, -1);
6259
+ if (this.dragEngine.refuseInsert(draggedTreeNode.node, ancestors))
6260
+ return;
6261
+ }
6105
6262
  // Remove from source parent
6106
6263
  const srcArray = draggedTreeNode.parentArray;
6107
6264
  const srcIdx = draggedTreeNode.indexInParent;
@@ -6165,13 +6322,13 @@ class LayerTreeComponent {
6165
6322
  }
6166
6323
  }
6167
6324
  }
6168
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: LayerTreeComponent, deps: [{ token: ComponentRegistryService }, { token: OverlayService }], target: i0.ɵɵFactoryTarget.Component }); }
6169
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: LayerTreeComponent, isStandalone: false, selector: "app-layer-tree", inputs: { layout: "layout", globalElements: "globalElements" }, outputs: { nodeSelect: "nodeSelect", nodeAction: "nodeAction", nodeMove: "nodeMove" }, ngImport: i0, template: "<div class=\"layer-tree\">\n <div class=\"tree-header\">\n <button class=\"section-tab\" [class.section-tab--active]=\"activeSection === 'page'\" (click)=\"activeSection = 'page'\">\n <i class=\"ph-thin ph-stack\"></i> Page\n </button>\n <button class=\"section-tab\" [class.section-tab--active]=\"activeSection === 'global'\" (click)=\"activeSection = 'global'\">\n <i class=\"ph-thin ph-globe\"></i> Global\n <span *ngIf=\"flatGlobalTree.length\" class=\"section-tab-badge\">{{ flatGlobalTree.length }}</span>\n </button>\n </div>\n\n <!-- \u2500\u2500 Page elements tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div *ngIf=\"activeSection === 'page'\"\n class=\"tree-body\"\n cdkDropList\n [cdkDropListData]=\"flatTree\"\n (cdkDropListDropped)=\"onTreeDrop($event)\">\n <div *ngIf=\"!flatTree.length\" class=\"tree-empty\">\n <i class=\"ph-thin ph-tree-structure\"></i>\n <span>No layers yet</span>\n </div>\n\n <div *ngFor=\"let tn of flatTree\"\n class=\"tree-row\"\n cdkDrag\n [cdkDragData]=\"tn\"\n [class.is-selected]=\"tn.node.id === selectedNodeId\"\n [class.is-hovered]=\"tn.node.id === hoveredNodeId\"\n [style.padding-inline-start.px]=\"12 + tn.depth * 16\"\n (click)=\"onNodeClick(tn)\"\n (mouseenter)=\"onNodeMouseEnter(tn)\"\n (mouseleave)=\"onNodeMouseLeave()\">\n\n <i class=\"ph-thin ph-dots-six-vertical drag-handle\" cdkDragHandle></i>\n\n <button *ngIf=\"tn.hasChildren\" class=\"expand-btn\" (click)=\"toggleExpand(tn, $event)\">\n <i class=\"ph-thin\" [ngClass]=\"tn.expanded ? 'ph-caret-down' : 'ph-caret-right'\"></i>\n </button>\n <span *ngIf=\"!tn.hasChildren\" class=\"expand-spacer\"></span>\n\n <i [class]=\"tn.icon\" class=\"node-icon\"></i>\n <ng-container *ngIf=\"editingNodeId === tn.node.id; else staticLabel\">\n <input class=\"node-rename-input\"\n [value]=\"editingValue\"\n (input)=\"editingValue = $any($event.target).value\"\n (keydown.enter)=\"saveRename(tn)\"\n (keydown.escape)=\"cancelRename()\"\n (blur)=\"saveRename(tn)\"\n (click)=\"$event.stopPropagation()\"\n autofocus>\n </ng-container>\n <ng-template #staticLabel>\n <span class=\"node-label\" [class.node-label--named]=\"tn.node.label\">{{ tn.label }}</span>\n </ng-template>\n\n <span class=\"node-actions\">\n <button class=\"action-btn\" title=\"Rename\" (click)=\"startRename(tn, $event)\">\n <i class=\"ph-thin ph-pencil-simple\"></i>\n </button>\n <button class=\"action-btn\" title=\"Duplicate\" (click)=\"onDuplicateNode(tn, $event)\">\n <i class=\"ph-thin ph-copy\"></i>\n </button>\n <button class=\"action-btn action-btn--delete\" title=\"Delete\" (click)=\"onDeleteNode(tn, $event)\">\n <i class=\"ph-thin ph-trash\"></i>\n </button>\n </span>\n </div>\n </div>\n\n <!-- \u2500\u2500 Global elements tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div *ngIf=\"activeSection === 'global'\" class=\"tree-body\">\n <div *ngIf=\"!flatGlobalTree.length\" class=\"tree-empty\">\n <i class=\"ph-thin ph-globe\"></i>\n <span>No global elements</span>\n </div>\n\n <div *ngFor=\"let tn of flatGlobalTree\"\n class=\"tree-row tree-row--global\"\n [class.is-selected]=\"tn.node.id === selectedNodeId\"\n [class.is-hovered]=\"tn.node.id === hoveredNodeId\"\n [style.padding-inline-start.px]=\"12 + tn.depth * 16\"\n (click)=\"onNodeClick(tn)\"\n (mouseenter)=\"onNodeMouseEnter(tn)\"\n (mouseleave)=\"onNodeMouseLeave()\">\n\n <button *ngIf=\"tn.hasChildren\" class=\"expand-btn\" (click)=\"toggleExpand(tn, $event)\">\n <i class=\"ph-thin\" [ngClass]=\"tn.expanded ? 'ph-caret-down' : 'ph-caret-right'\"></i>\n </button>\n <span *ngIf=\"!tn.hasChildren\" class=\"expand-spacer\"></span>\n\n <i [class]=\"tn.icon\" class=\"node-icon\"></i>\n <span class=\"node-label\">{{ tn.label }}</span>\n <span class=\"node-position-badge\">{{ tn.node.globalPosition }}</span>\n\n <span class=\"node-actions\">\n <button class=\"action-btn action-btn--delete\" title=\"Delete\" (click)=\"onDeleteNode(tn, $event)\">\n <i class=\"ph-thin ph-trash\"></i>\n </button>\n </span>\n </div>\n </div>\n</div>\n", styles: [".layer-tree{display:flex;flex-direction:column;height:100%;overflow:hidden}.layer-tree .tree-header{display:flex;gap:2px;padding:6px 8px;border-bottom:1px solid var(--p-surface-200);flex-shrink:0}.layer-tree .section-tab{display:inline-flex;align-items:center;gap:4px;flex:1;justify-content:center;padding:4px 8px;font-size:11px;font-weight:500;border:1px solid var(--p-surface-200);border-radius:4px;background:transparent;color:var(--p-text-muted-color);cursor:pointer;transition:background .12s,color .12s,border-color .12s;white-space:nowrap}.layer-tree .section-tab i{font-size:11px}.layer-tree .section-tab:hover{background:var(--p-surface-100);color:var(--p-text-color)}.layer-tree .section-tab--active{background:#cb909026;border-color:#cb9090;color:#cb9090}.layer-tree .section-tab-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;font-size:10px;font-weight:600;background:#cb9090;color:#fff;border-radius:8px}.layer-tree .tree-body{flex:1 1 0;overflow-y:auto;overflow-x:hidden;padding-block:4px}.layer-tree .tree-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;padding:2rem 1rem;color:var(--p-text-muted-color);font-size:12px}.layer-tree .tree-empty i{font-size:22px}.layer-tree .tree-row{display:flex;align-items:center;gap:6px;height:28px;padding-inline-end:8px;cursor:pointer;font-size:12px;color:var(--p-text-color);border-inline-start:2px solid transparent;transition:background .12s,border-color .12s;-webkit-user-select:none;user-select:none}.layer-tree .tree-row:hover,.layer-tree .tree-row.is-hovered{background:var(--p-surface-100)}.layer-tree .tree-row.is-selected{background:#cb90901a;border-inline-start-color:#cb9090;font-weight:500}.layer-tree .drag-handle{font-size:10px;color:var(--p-text-muted-color);opacity:0;cursor:grab;flex-shrink:0;transition:opacity .12s}.layer-tree .tree-row:hover .drag-handle{opacity:.5}.layer-tree .drag-handle:hover{opacity:1!important}.layer-tree .cdk-drag-preview{box-sizing:border-box;display:flex;align-items:center;gap:6px;height:28px;padding:0 8px 0 12px;border-radius:6px;background:var(--p-surface-0);border:1px solid #cb9090;box-shadow:0 4px 12px #0000001f;font-size:12px}.layer-tree .cdk-drag-placeholder{height:2px;background:#cb9090;border-radius:1px;opacity:.6}.layer-tree .cdk-drag-placeholder>*{visibility:hidden}.layer-tree .cdk-drag-animating{transition:transform .2s cubic-bezier(0,0,.2,1)}.layer-tree .expand-btn{display:flex;align-items:center;justify-content:center;width:16px;height:16px;padding:0;border:none;background:transparent;color:var(--p-text-muted-color);cursor:pointer;flex-shrink:0;border-radius:2px}.layer-tree .expand-btn:hover{background:var(--p-surface-200);color:var(--p-text-color)}.layer-tree .expand-btn i{font-size:10px}.layer-tree .expand-spacer{width:16px;flex-shrink:0}.layer-tree .node-icon{font-size:14px;color:var(--p-text-muted-color);flex-shrink:0}.layer-tree .node-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}.layer-tree .node-label.node-label--named{color:var(--p-primary-color, #cb9090);font-weight:500}.layer-tree .node-rename-input{flex:1;min-width:0;border:1px solid var(--p-primary-color, #cb9090);border-radius:4px;background:var(--p-surface-0, #fff);color:var(--p-text-color);font-family:inherit;font-size:.72rem;padding:1px 4px;outline:none}.layer-tree .node-actions{display:flex;gap:2px;margin-inline-start:auto;opacity:0;transition:opacity .12s;flex-shrink:0}.layer-tree .tree-row:hover .node-actions,.layer-tree .tree-row.is-selected .node-actions{opacity:1}.layer-tree .tree-section-header{display:flex;align-items:center;gap:6px;padding:6px 12px 4px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--p-text-muted-color);border-top:1px solid var(--p-surface-200);margin-top:4px}.layer-tree .tree-section-header i{font-size:12px}.layer-tree .tree-body--global{flex:0 0 auto}.layer-tree .tree-row--global{border-inline-start-color:transparent}.layer-tree .tree-row--global.is-selected{background:#638fcb1a;border-inline-start-color:#638fcb}.layer-tree .node-position-badge{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#638fcb;background:#638fcb1f;padding:1px 5px;border-radius:3px;flex-shrink:0}.layer-tree .action-btn{display:flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:none;background:transparent;color:var(--p-text-muted-color);cursor:pointer;border-radius:4px;font-size:12px;transition:background .12s,color .12s}.layer-tree .action-btn:hover{background:var(--p-surface-200);color:var(--p-text-color)}.layer-tree .action-btn--delete:hover{background:#dc26261a;color:#dc2626}\n"], dependencies: [{ kind: "directive", type: i2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i4.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer", "cdkDropListHasAnchor"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: i4.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "directive", type: i4.CdkDragHandle, selector: "[cdkDragHandle]", inputs: ["cdkDragHandleDisabled"] }] }); }
6325
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: LayerTreeComponent, deps: [{ token: ComponentRegistryService }, { token: OverlayService }, { token: DragEngineService }], target: i0.ɵɵFactoryTarget.Component }); }
6326
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: LayerTreeComponent, isStandalone: false, selector: "app-layer-tree", inputs: { layout: "layout", globalElements: "globalElements" }, outputs: { nodeSelect: "nodeSelect", nodeAction: "nodeAction", nodeMove: "nodeMove" }, ngImport: i0, template: "<div class=\"layer-tree\">\n <div class=\"tree-header\">\n <button class=\"section-tab\" [class.section-tab--active]=\"activeSection === 'page'\" (click)=\"activeSection = 'page'\">\n <i class=\"ph-thin ph-stack\"></i> Page\n </button>\n <button class=\"section-tab\" [class.section-tab--active]=\"activeSection === 'global'\" (click)=\"activeSection = 'global'\">\n <i class=\"ph-thin ph-globe\"></i> Global\n <span *ngIf=\"flatGlobalTree.length\" class=\"section-tab-badge\">{{ flatGlobalTree.length }}</span>\n </button>\n </div>\n\n <!-- \u2500\u2500 Page elements tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div *ngIf=\"activeSection === 'page'\"\n class=\"tree-body\"\n cdkDropList\n [cdkDropListData]=\"flatTree\"\n (cdkDropListDropped)=\"onTreeDrop($event)\">\n <div *ngIf=\"!flatTree.length\" class=\"tree-empty\">\n <i class=\"ph-thin ph-tree-structure\"></i>\n <span>No layers yet</span>\n </div>\n\n <div *ngFor=\"let tn of flatTree\"\n class=\"tree-row\"\n cdkDrag\n [cdkDragData]=\"tn\"\n [class.is-selected]=\"tn.node.id === selectedNodeId\"\n [class.is-hovered]=\"tn.node.id === hoveredNodeId\"\n [style.padding-inline-start.px]=\"12 + tn.depth * 16\"\n (click)=\"onNodeClick(tn)\"\n (mouseenter)=\"onNodeMouseEnter(tn)\"\n (mouseleave)=\"onNodeMouseLeave()\">\n\n <i class=\"ph-thin ph-dots-six-vertical drag-handle\" cdkDragHandle></i>\n\n <button *ngIf=\"tn.hasChildren\" class=\"expand-btn\" (click)=\"toggleExpand(tn, $event)\">\n <i class=\"ph-thin\" [ngClass]=\"tn.expanded ? 'ph-caret-down' : 'ph-caret-right'\"></i>\n </button>\n <span *ngIf=\"!tn.hasChildren\" class=\"expand-spacer\"></span>\n\n <i [class]=\"tn.icon\" class=\"node-icon\"></i>\n <ng-container *ngIf=\"editingNodeId === tn.node.id; else staticLabel\">\n <input class=\"node-rename-input\"\n [value]=\"editingValue\"\n (input)=\"editingValue = $any($event.target).value\"\n (keydown.enter)=\"saveRename(tn)\"\n (keydown.escape)=\"cancelRename()\"\n (blur)=\"saveRename(tn)\"\n (click)=\"$event.stopPropagation()\"\n autofocus>\n </ng-container>\n <ng-template #staticLabel>\n <span class=\"node-label\" [class.node-label--named]=\"tn.node.label\">{{ tn.label }}</span>\n </ng-template>\n\n <span class=\"node-actions\">\n <button class=\"action-btn\" title=\"Rename\" (click)=\"startRename(tn, $event)\">\n <i class=\"ph-thin ph-pencil-simple\"></i>\n </button>\n <button class=\"action-btn\" title=\"Duplicate\" (click)=\"onDuplicateNode(tn, $event)\">\n <i class=\"ph-thin ph-copy\"></i>\n </button>\n <button class=\"action-btn action-btn--delete\" title=\"Delete\" (click)=\"onDeleteNode(tn, $event)\">\n <i class=\"ph-thin ph-trash\"></i>\n </button>\n </span>\n </div>\n </div>\n\n <!-- \u2500\u2500 Global elements tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div *ngIf=\"activeSection === 'global'\" class=\"tree-body\">\n <div *ngIf=\"!flatGlobalTree.length\" class=\"tree-empty\">\n <i class=\"ph-thin ph-globe\"></i>\n <span>No global elements</span>\n </div>\n\n <div *ngFor=\"let tn of flatGlobalTree\"\n class=\"tree-row tree-row--global\"\n [class.is-selected]=\"tn.node.id === selectedNodeId\"\n [class.is-hovered]=\"tn.node.id === hoveredNodeId\"\n [style.padding-inline-start.px]=\"12 + tn.depth * 16\"\n (click)=\"onNodeClick(tn)\"\n (mouseenter)=\"onNodeMouseEnter(tn)\"\n (mouseleave)=\"onNodeMouseLeave()\">\n\n <button *ngIf=\"tn.hasChildren\" class=\"expand-btn\" (click)=\"toggleExpand(tn, $event)\">\n <i class=\"ph-thin\" [ngClass]=\"tn.expanded ? 'ph-caret-down' : 'ph-caret-right'\"></i>\n </button>\n <span *ngIf=\"!tn.hasChildren\" class=\"expand-spacer\"></span>\n\n <i [class]=\"tn.icon\" class=\"node-icon\"></i>\n <span class=\"node-label\">{{ tn.label }}</span>\n <span class=\"node-position-badge\">{{ tn.node.globalPosition }}</span>\n\n <span class=\"node-actions\">\n <button class=\"action-btn action-btn--delete\" title=\"Delete\" (click)=\"onDeleteNode(tn, $event)\">\n <i class=\"ph-thin ph-trash\"></i>\n </button>\n </span>\n </div>\n </div>\n</div>\n", styles: [".layer-tree{display:flex;flex-direction:column;height:100%;overflow:hidden}.layer-tree .tree-header{display:flex;gap:2px;padding:6px 8px;border-bottom:1px solid var(--p-surface-200);flex-shrink:0}.layer-tree .section-tab{display:inline-flex;align-items:center;gap:4px;flex:1;justify-content:center;padding:4px 8px;font-size:11px;font-weight:500;border:1px solid var(--p-surface-200);border-radius:4px;background:transparent;color:var(--p-text-muted-color);cursor:pointer;transition:background .12s,color .12s,border-color .12s;white-space:nowrap}.layer-tree .section-tab i{font-size:11px}.layer-tree .section-tab:hover{background:var(--p-surface-100);color:var(--p-text-color)}.layer-tree .section-tab--active{background:#cb909026;border-color:#cb9090;color:#cb9090}.layer-tree .section-tab-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;font-size:10px;font-weight:600;background:#cb9090;color:#fff;border-radius:8px}.layer-tree .tree-body{flex:1 1 0;overflow-y:auto;overflow-x:hidden;padding-block:4px}.layer-tree .tree-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;padding:2rem 1rem;color:var(--p-text-muted-color);font-size:12px}.layer-tree .tree-empty i{font-size:22px}.layer-tree .tree-row{display:flex;align-items:center;gap:6px;height:28px;padding-inline-end:8px;cursor:pointer;font-size:12px;color:var(--p-text-color);border-inline-start:2px solid transparent;transition:background .12s,border-color .12s;-webkit-user-select:none;user-select:none}.layer-tree .tree-row:hover,.layer-tree .tree-row.is-hovered{background:var(--p-surface-100)}.layer-tree .tree-row.is-selected{background:#cb90901a;border-inline-start-color:#cb9090;font-weight:500}.layer-tree .drag-handle{font-size:10px;color:var(--p-text-muted-color);opacity:0;cursor:grab;flex-shrink:0;transition:opacity .12s}.layer-tree .tree-row:hover .drag-handle{opacity:.5}.layer-tree .drag-handle:hover{opacity:1!important}.layer-tree .cdk-drag-preview{box-sizing:border-box;display:flex;align-items:center;gap:6px;height:28px;padding:0 8px 0 12px;border-radius:6px;background:var(--p-surface-0);border:1px solid #cb9090;box-shadow:0 4px 12px #0000001f;font-size:12px}.layer-tree .cdk-drag-placeholder{height:2px;background:#cb9090;border-radius:1px;opacity:.6}.layer-tree .cdk-drag-placeholder>*{visibility:hidden}.layer-tree .cdk-drag-animating{transition:transform .2s cubic-bezier(0,0,.2,1)}.layer-tree .expand-btn{display:flex;align-items:center;justify-content:center;width:16px;height:16px;padding:0;border:none;background:transparent;color:var(--p-text-muted-color);cursor:pointer;flex-shrink:0;border-radius:2px}.layer-tree .expand-btn:hover{background:var(--p-surface-200);color:var(--p-text-color)}.layer-tree .expand-btn i{font-size:10px}.layer-tree .expand-spacer{width:16px;flex-shrink:0}.layer-tree .node-icon{font-size:14px;color:var(--p-text-muted-color);flex-shrink:0}.layer-tree .node-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}.layer-tree .node-label.node-label--named{color:var(--p-primary-color, #cb9090);font-weight:500}.layer-tree .node-rename-input{flex:1;min-width:0;border:1px solid var(--p-primary-color, #cb9090);border-radius:4px;background:var(--p-surface-0, #fff);color:var(--p-text-color);font-family:inherit;font-size:.72rem;padding:1px 4px;outline:none}.layer-tree .node-actions{display:flex;gap:2px;margin-inline-start:auto;opacity:0;transition:opacity .12s;flex-shrink:0}.layer-tree .tree-row:hover .node-actions,.layer-tree .tree-row.is-selected .node-actions{opacity:1}.layer-tree .tree-section-header{display:flex;align-items:center;gap:6px;padding:6px 12px 4px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--p-text-muted-color);border-top:1px solid var(--p-surface-200);margin-top:4px}.layer-tree .tree-section-header i{font-size:12px}.layer-tree .tree-body--global{flex:0 0 auto}.layer-tree .tree-row--global{border-inline-start-color:transparent}.layer-tree .tree-row--global.is-selected{background:#638fcb1a;border-inline-start-color:#638fcb}.layer-tree .node-position-badge{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#638fcb;background:#638fcb1f;padding:1px 5px;border-radius:3px;flex-shrink:0}.layer-tree .action-btn{display:flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:none;background:transparent;color:var(--p-text-muted-color);cursor:pointer;border-radius:4px;font-size:12px;transition:background .12s,color .12s}.layer-tree .action-btn:hover{background:var(--p-surface-200);color:var(--p-text-color)}.layer-tree .action-btn--delete:hover{background:#dc26261a;color:#dc2626}\n"], dependencies: [{ kind: "directive", type: i2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i5.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer", "cdkDropListHasAnchor"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: i5.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "directive", type: i5.CdkDragHandle, selector: "[cdkDragHandle]", inputs: ["cdkDragHandleDisabled"] }] }); }
6170
6327
  }
6171
6328
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: LayerTreeComponent, decorators: [{
6172
6329
  type: Component,
6173
6330
  args: [{ selector: 'app-layer-tree', standalone: false, template: "<div class=\"layer-tree\">\n <div class=\"tree-header\">\n <button class=\"section-tab\" [class.section-tab--active]=\"activeSection === 'page'\" (click)=\"activeSection = 'page'\">\n <i class=\"ph-thin ph-stack\"></i> Page\n </button>\n <button class=\"section-tab\" [class.section-tab--active]=\"activeSection === 'global'\" (click)=\"activeSection = 'global'\">\n <i class=\"ph-thin ph-globe\"></i> Global\n <span *ngIf=\"flatGlobalTree.length\" class=\"section-tab-badge\">{{ flatGlobalTree.length }}</span>\n </button>\n </div>\n\n <!-- \u2500\u2500 Page elements tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div *ngIf=\"activeSection === 'page'\"\n class=\"tree-body\"\n cdkDropList\n [cdkDropListData]=\"flatTree\"\n (cdkDropListDropped)=\"onTreeDrop($event)\">\n <div *ngIf=\"!flatTree.length\" class=\"tree-empty\">\n <i class=\"ph-thin ph-tree-structure\"></i>\n <span>No layers yet</span>\n </div>\n\n <div *ngFor=\"let tn of flatTree\"\n class=\"tree-row\"\n cdkDrag\n [cdkDragData]=\"tn\"\n [class.is-selected]=\"tn.node.id === selectedNodeId\"\n [class.is-hovered]=\"tn.node.id === hoveredNodeId\"\n [style.padding-inline-start.px]=\"12 + tn.depth * 16\"\n (click)=\"onNodeClick(tn)\"\n (mouseenter)=\"onNodeMouseEnter(tn)\"\n (mouseleave)=\"onNodeMouseLeave()\">\n\n <i class=\"ph-thin ph-dots-six-vertical drag-handle\" cdkDragHandle></i>\n\n <button *ngIf=\"tn.hasChildren\" class=\"expand-btn\" (click)=\"toggleExpand(tn, $event)\">\n <i class=\"ph-thin\" [ngClass]=\"tn.expanded ? 'ph-caret-down' : 'ph-caret-right'\"></i>\n </button>\n <span *ngIf=\"!tn.hasChildren\" class=\"expand-spacer\"></span>\n\n <i [class]=\"tn.icon\" class=\"node-icon\"></i>\n <ng-container *ngIf=\"editingNodeId === tn.node.id; else staticLabel\">\n <input class=\"node-rename-input\"\n [value]=\"editingValue\"\n (input)=\"editingValue = $any($event.target).value\"\n (keydown.enter)=\"saveRename(tn)\"\n (keydown.escape)=\"cancelRename()\"\n (blur)=\"saveRename(tn)\"\n (click)=\"$event.stopPropagation()\"\n autofocus>\n </ng-container>\n <ng-template #staticLabel>\n <span class=\"node-label\" [class.node-label--named]=\"tn.node.label\">{{ tn.label }}</span>\n </ng-template>\n\n <span class=\"node-actions\">\n <button class=\"action-btn\" title=\"Rename\" (click)=\"startRename(tn, $event)\">\n <i class=\"ph-thin ph-pencil-simple\"></i>\n </button>\n <button class=\"action-btn\" title=\"Duplicate\" (click)=\"onDuplicateNode(tn, $event)\">\n <i class=\"ph-thin ph-copy\"></i>\n </button>\n <button class=\"action-btn action-btn--delete\" title=\"Delete\" (click)=\"onDeleteNode(tn, $event)\">\n <i class=\"ph-thin ph-trash\"></i>\n </button>\n </span>\n </div>\n </div>\n\n <!-- \u2500\u2500 Global elements tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div *ngIf=\"activeSection === 'global'\" class=\"tree-body\">\n <div *ngIf=\"!flatGlobalTree.length\" class=\"tree-empty\">\n <i class=\"ph-thin ph-globe\"></i>\n <span>No global elements</span>\n </div>\n\n <div *ngFor=\"let tn of flatGlobalTree\"\n class=\"tree-row tree-row--global\"\n [class.is-selected]=\"tn.node.id === selectedNodeId\"\n [class.is-hovered]=\"tn.node.id === hoveredNodeId\"\n [style.padding-inline-start.px]=\"12 + tn.depth * 16\"\n (click)=\"onNodeClick(tn)\"\n (mouseenter)=\"onNodeMouseEnter(tn)\"\n (mouseleave)=\"onNodeMouseLeave()\">\n\n <button *ngIf=\"tn.hasChildren\" class=\"expand-btn\" (click)=\"toggleExpand(tn, $event)\">\n <i class=\"ph-thin\" [ngClass]=\"tn.expanded ? 'ph-caret-down' : 'ph-caret-right'\"></i>\n </button>\n <span *ngIf=\"!tn.hasChildren\" class=\"expand-spacer\"></span>\n\n <i [class]=\"tn.icon\" class=\"node-icon\"></i>\n <span class=\"node-label\">{{ tn.label }}</span>\n <span class=\"node-position-badge\">{{ tn.node.globalPosition }}</span>\n\n <span class=\"node-actions\">\n <button class=\"action-btn action-btn--delete\" title=\"Delete\" (click)=\"onDeleteNode(tn, $event)\">\n <i class=\"ph-thin ph-trash\"></i>\n </button>\n </span>\n </div>\n </div>\n</div>\n", styles: [".layer-tree{display:flex;flex-direction:column;height:100%;overflow:hidden}.layer-tree .tree-header{display:flex;gap:2px;padding:6px 8px;border-bottom:1px solid var(--p-surface-200);flex-shrink:0}.layer-tree .section-tab{display:inline-flex;align-items:center;gap:4px;flex:1;justify-content:center;padding:4px 8px;font-size:11px;font-weight:500;border:1px solid var(--p-surface-200);border-radius:4px;background:transparent;color:var(--p-text-muted-color);cursor:pointer;transition:background .12s,color .12s,border-color .12s;white-space:nowrap}.layer-tree .section-tab i{font-size:11px}.layer-tree .section-tab:hover{background:var(--p-surface-100);color:var(--p-text-color)}.layer-tree .section-tab--active{background:#cb909026;border-color:#cb9090;color:#cb9090}.layer-tree .section-tab-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;font-size:10px;font-weight:600;background:#cb9090;color:#fff;border-radius:8px}.layer-tree .tree-body{flex:1 1 0;overflow-y:auto;overflow-x:hidden;padding-block:4px}.layer-tree .tree-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;padding:2rem 1rem;color:var(--p-text-muted-color);font-size:12px}.layer-tree .tree-empty i{font-size:22px}.layer-tree .tree-row{display:flex;align-items:center;gap:6px;height:28px;padding-inline-end:8px;cursor:pointer;font-size:12px;color:var(--p-text-color);border-inline-start:2px solid transparent;transition:background .12s,border-color .12s;-webkit-user-select:none;user-select:none}.layer-tree .tree-row:hover,.layer-tree .tree-row.is-hovered{background:var(--p-surface-100)}.layer-tree .tree-row.is-selected{background:#cb90901a;border-inline-start-color:#cb9090;font-weight:500}.layer-tree .drag-handle{font-size:10px;color:var(--p-text-muted-color);opacity:0;cursor:grab;flex-shrink:0;transition:opacity .12s}.layer-tree .tree-row:hover .drag-handle{opacity:.5}.layer-tree .drag-handle:hover{opacity:1!important}.layer-tree .cdk-drag-preview{box-sizing:border-box;display:flex;align-items:center;gap:6px;height:28px;padding:0 8px 0 12px;border-radius:6px;background:var(--p-surface-0);border:1px solid #cb9090;box-shadow:0 4px 12px #0000001f;font-size:12px}.layer-tree .cdk-drag-placeholder{height:2px;background:#cb9090;border-radius:1px;opacity:.6}.layer-tree .cdk-drag-placeholder>*{visibility:hidden}.layer-tree .cdk-drag-animating{transition:transform .2s cubic-bezier(0,0,.2,1)}.layer-tree .expand-btn{display:flex;align-items:center;justify-content:center;width:16px;height:16px;padding:0;border:none;background:transparent;color:var(--p-text-muted-color);cursor:pointer;flex-shrink:0;border-radius:2px}.layer-tree .expand-btn:hover{background:var(--p-surface-200);color:var(--p-text-color)}.layer-tree .expand-btn i{font-size:10px}.layer-tree .expand-spacer{width:16px;flex-shrink:0}.layer-tree .node-icon{font-size:14px;color:var(--p-text-muted-color);flex-shrink:0}.layer-tree .node-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}.layer-tree .node-label.node-label--named{color:var(--p-primary-color, #cb9090);font-weight:500}.layer-tree .node-rename-input{flex:1;min-width:0;border:1px solid var(--p-primary-color, #cb9090);border-radius:4px;background:var(--p-surface-0, #fff);color:var(--p-text-color);font-family:inherit;font-size:.72rem;padding:1px 4px;outline:none}.layer-tree .node-actions{display:flex;gap:2px;margin-inline-start:auto;opacity:0;transition:opacity .12s;flex-shrink:0}.layer-tree .tree-row:hover .node-actions,.layer-tree .tree-row.is-selected .node-actions{opacity:1}.layer-tree .tree-section-header{display:flex;align-items:center;gap:6px;padding:6px 12px 4px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--p-text-muted-color);border-top:1px solid var(--p-surface-200);margin-top:4px}.layer-tree .tree-section-header i{font-size:12px}.layer-tree .tree-body--global{flex:0 0 auto}.layer-tree .tree-row--global{border-inline-start-color:transparent}.layer-tree .tree-row--global.is-selected{background:#638fcb1a;border-inline-start-color:#638fcb}.layer-tree .node-position-badge{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#638fcb;background:#638fcb1f;padding:1px 5px;border-radius:3px;flex-shrink:0}.layer-tree .action-btn{display:flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:none;background:transparent;color:var(--p-text-muted-color);cursor:pointer;border-radius:4px;font-size:12px;transition:background .12s,color .12s}.layer-tree .action-btn:hover{background:var(--p-surface-200);color:var(--p-text-color)}.layer-tree .action-btn--delete:hover{background:#dc26261a;color:#dc2626}\n"] }]
6174
- }], ctorParameters: () => [{ type: ComponentRegistryService }, { type: OverlayService }], propDecorators: { layout: [{
6331
+ }], ctorParameters: () => [{ type: ComponentRegistryService }, { type: OverlayService }, { type: DragEngineService }], propDecorators: { layout: [{
6175
6332
  type: Input
6176
6333
  }], globalElements: [{
6177
6334
  type: Input
@@ -7012,6 +7169,9 @@ class BuilderComponent {
7012
7169
  }
7013
7170
  onDrop(event) {
7014
7171
  const { payload, insertIndex } = event;
7172
+ const changesParent = payload.type === 'external' || payload.sourceId !== 'canvas-preview';
7173
+ if (changesParent && this.dragEngine.refuseInsert(payload.data, []))
7174
+ return;
7015
7175
  if (payload.type === 'external') {
7016
7176
  if (typeof payload.data === 'string') {
7017
7177
  const config = this.registry.createConfig(payload.data);
@@ -7302,7 +7462,7 @@ class BuilderComponent {
7302
7462
  }
7303
7463
  case ToolbarAction.Duplicate: {
7304
7464
  const result = this.findNodeInTree(node, this.layout) ?? this.findNodeInTree(node, this.globalElements);
7305
- if (result) {
7465
+ if (result && !this.dragEngine.refuseInsert(node, this.siblingAncestors(node))) {
7306
7466
  const clone = this.deepCloneWithNewIds(node);
7307
7467
  result.parent.splice(result.index + 1, 0, clone);
7308
7468
  this.cdr.detectChanges();
@@ -7462,6 +7622,8 @@ class BuilderComponent {
7462
7622
  const pastedNode = this.clipboard.getNode();
7463
7623
  if (!pastedNode)
7464
7624
  break;
7625
+ if (this.dragEngine.refuseInsert(pastedNode, this.siblingAncestors(node)))
7626
+ break;
7465
7627
  const clone = this.deepCloneWithNewIds(pastedNode);
7466
7628
  const result = this.findNodeInTree(node, this.layout) ?? this.findNodeInTree(node, this.globalElements);
7467
7629
  if (result) {
@@ -7544,6 +7706,10 @@ class BuilderComponent {
7544
7706
  this.cdr.markForCheck();
7545
7707
  }, 1200);
7546
7708
  }
7709
+ /** The nodes a sibling of `node` sits under — `node`'s ancestors, outermost first (empty at a root). */
7710
+ siblingAncestors(node) {
7711
+ return (nodeAncestorPathInEither(this.layout, this.globalElements, node) ?? [node]).slice(0, -1);
7712
+ }
7547
7713
  findNodeInTree(target, children) {
7548
7714
  for (let i = 0; i < children.length; i++) {
7549
7715
  if (children[i] === target) {
@@ -7637,7 +7803,8 @@ class BuilderComponent {
7637
7803
  if (inGlobals && !target.parent) {
7638
7804
  target = resolveInsertTarget(this.layout, null, n => this.dragEngine.isDropContainer(n.children));
7639
7805
  }
7640
- if (!this.dragEngine.insertPayload(target.children, target.index, data))
7806
+ const ancestors = target.parent ? (nodeAncestorPathInEither(this.layout, this.globalElements, target.parent) ?? [target.parent]) : [];
7807
+ if (!this.dragEngine.insertPayload(target.children, target.index, data, ancestors))
7641
7808
  return;
7642
7809
  this.cdr.detectChanges();
7643
7810
  }
@@ -8697,7 +8864,10 @@ class BuilderButtonBlockComponent {
8697
8864
  this.children = [];
8698
8865
  this.style = {};
8699
8866
  this.tag = 'button';
8867
+ this.linkType = LinkType.External;
8700
8868
  this.href = '';
8869
+ /** A page link shows its route as authored, as the canvas Link does (the engine resolves it). */
8870
+ this.pageRoute = '';
8701
8871
  this.target = '_self';
8702
8872
  this.type = 'button';
8703
8873
  this.nodeId = '';
@@ -8706,6 +8876,9 @@ class BuilderButtonBlockComponent {
8706
8876
  this.childMouseEnter = new EventEmitter();
8707
8877
  this.childMouseLeave = new EventEmitter();
8708
8878
  }
8879
+ get resolvedHref() {
8880
+ return buttonBlockHref(this);
8881
+ }
8709
8882
  ngOnInit() {
8710
8883
  // Register the HOST as the dropzone (was the ioxDropzone directive on the inner button).
8711
8884
  this.dragEngine.registerDropzone(this.dropListId, this.elRef.nativeElement, this.children, this.cdr, (evt) => this.onDrop(evt));
@@ -8717,7 +8890,7 @@ class BuilderButtonBlockComponent {
8717
8890
  this.dragEngine.handleDrop(this.children, event, this.dropListId, this.cdr);
8718
8891
  }
8719
8892
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: BuilderButtonBlockComponent, deps: [{ token: i0.ElementRef }, { token: DragEngineService }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component }); }
8720
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: BuilderButtonBlockComponent, isStandalone: false, selector: "button[iox-button-block],a[iox-button-block]", inputs: { children: "children", style: "style", tag: "tag", href: "href", target: "target", type: "type", nodeId: "nodeId", dropListId: "dropListId" }, outputs: { childClick: "childClick", childMouseEnter: "childMouseEnter", childMouseLeave: "childMouseLeave" }, host: { properties: { "class": "'button-block-root iox-node-' + nodeId", "attr.href": "tag === 'a' ? (href || null) : null", "attr.target": "tag === 'a' ? (target || null) : null", "attr.type": "tag === 'a' ? null : type" } }, ngImport: i0, template: `
8893
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: BuilderButtonBlockComponent, isStandalone: false, selector: "button[iox-button-block],a[iox-button-block]", inputs: { children: "children", style: "style", tag: "tag", linkType: "linkType", href: "href", pageRoute: "pageRoute", target: "target", type: "type", nodeId: "nodeId", dropListId: "dropListId" }, outputs: { childClick: "childClick", childMouseEnter: "childMouseEnter", childMouseLeave: "childMouseLeave" }, host: { properties: { "class": "'button-block-root iox-node-' + nodeId", "attr.href": "resolvedHref", "attr.target": "tag === 'a' ? (target || null) : null", "attr.type": "tag === 'a' ? null : type" } }, ngImport: i0, template: `
8721
8894
  <span *ngIf="!children.length" class="button-block-placeholder" data-iox-chrome>Button</span>
8722
8895
 
8723
8896
  <!-- WRAPPER COLLAPSE (task 9c): render children directly into the host; iox-outer + drag land
@@ -8747,7 +8920,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
8747
8920
  </ng-container>
8748
8921
  `, host: {
8749
8922
  '[class]': "'button-block-root iox-node-' + nodeId",
8750
- '[attr.href]': "tag === 'a' ? (href || null) : null",
8923
+ '[attr.href]': 'resolvedHref',
8751
8924
  '[attr.target]': "tag === 'a' ? (target || null) : null",
8752
8925
  '[attr.type]': "tag === 'a' ? null : type",
8753
8926
  }, standalone: false, styles: [":host{box-sizing:border-box}.button-block-placeholder{flex:1;display:flex;align-items:center;justify-content:center;color:#64748b66;font-size:12px;pointer-events:none;-webkit-user-select:none;user-select:none}:host ::ng-deep>[class*=iox-outer-]{min-width:0}\n"] }]
@@ -8757,8 +8930,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
8757
8930
  type: Input
8758
8931
  }], tag: [{
8759
8932
  type: Input
8933
+ }], linkType: [{
8934
+ type: Input
8760
8935
  }], href: [{
8761
8936
  type: Input
8937
+ }], pageRoute: [{
8938
+ type: Input
8762
8939
  }], target: [{
8763
8940
  type: Input
8764
8941
  }], type: [{
@@ -10207,66 +10384,6 @@ function matchesControlSearch(name, label, term) {
10207
10384
  return name.toLowerCase().includes(needle) || (label ?? '').toLowerCase().includes(needle);
10208
10385
  }
10209
10386
 
10210
- const FORM_PRESETS = Object.freeze([
10211
- {
10212
- id: 'contact', name: 'Contact', icon: 'ph-thin ph-envelope-simple',
10213
- description: 'Name, email and a message. Stored, and emailed to you.',
10214
- formName: 'Contact form',
10215
- fields: [
10216
- { label: 'Full name', control: 'text', required: true },
10217
- { label: 'Email', control: 'email', required: true },
10218
- { label: 'Message', control: 'textarea', required: true, rows: 5 },
10219
- ],
10220
- submitLabel: 'Send message',
10221
- successText: 'Thanks — we’ll be in touch shortly.',
10222
- errorText: 'We couldn’t send your message. Please try again — your answers are still here.',
10223
- },
10224
- {
10225
- id: 'newsletter', name: 'Newsletter', icon: 'ph-thin ph-paper-plane-tilt',
10226
- description: 'One email field plus consent. Stored.',
10227
- formName: 'Newsletter signup',
10228
- fields: [
10229
- { label: 'Email', control: 'email', required: true, placeholder: 'you@company.com' },
10230
- // Consent is a checkbox that must be TICKED — `required` on a checkbox becomes the
10231
- // `accepted` rule, with its own message, rather than a generic "this is required".
10232
- { label: 'Email me occasional updates', control: 'checkbox', required: true },
10233
- ],
10234
- submitLabel: 'Subscribe',
10235
- successText: 'You’re on the list.',
10236
- errorText: 'We couldn’t sign you up. Please try again.',
10237
- },
10238
- {
10239
- id: 'application', name: 'Application', icon: 'ph-thin ph-read-cv-logo',
10240
- description: 'Details plus a CV upload. Stored, with an auto-reply.',
10241
- formName: 'Application',
10242
- fields: [
10243
- { label: 'Full name', control: 'text', required: true },
10244
- { label: 'Email', control: 'email', required: true },
10245
- { label: 'Phone', control: 'tel' },
10246
- { label: 'Role', control: 'select', required: true, optionsText: 'Design\nEngineering\nOther' },
10247
- { label: 'CV', control: 'file', required: true, help: 'PDF or Word, up to 5MB' },
10248
- { label: 'Anything else', control: 'textarea', rows: 4 },
10249
- ],
10250
- submitLabel: 'Apply',
10251
- successText: 'Application received — we’ll read it properly and reply.',
10252
- errorText: 'We couldn’t submit your application. Please try again.',
10253
- },
10254
- {
10255
- id: 'booking', name: 'Booking request', icon: 'ph-thin ph-calendar-check',
10256
- description: 'Date and party size. Stored, and it notifies you.',
10257
- formName: 'Booking request',
10258
- fields: [
10259
- { label: 'Name', control: 'text', required: true },
10260
- { label: 'Email', control: 'email', required: true },
10261
- { label: 'Date', control: 'date', required: true },
10262
- { label: 'People', control: 'number', required: true },
10263
- { label: 'Notes', control: 'textarea', rows: 3, help: 'Allergies, access needs, anything useful' },
10264
- ],
10265
- submitLabel: 'Request a booking',
10266
- successText: 'Request received — we’ll confirm by email.',
10267
- errorText: 'We couldn’t send your request. Please try again.',
10268
- },
10269
- ]);
10270
10387
  /**
10271
10388
  * Build a preset's node tree.
10272
10389
  *
@@ -10659,11 +10776,9 @@ function text(content, createConfig, styleId, styles = {}) {
10659
10776
  wearing(node, styleId);
10660
10777
  return styled(node, { padding: '0', margin: '0', ...styles });
10661
10778
  }
10662
- /** A button's label takes the BUTTON's type (the style's size, weight, case, tracking) — not body's. */
10663
- const BUTTON_LABEL = {
10664
- color: 'inherit', fontFamily: 'inherit', fontSize: 'inherit', fontWeight: 'inherit', lineHeight: 'inherit',
10665
- letterSpacing: 'inherit', textTransform: 'inherit',
10666
- };
10779
+ /** A button's label takes the BUTTON's type (size, weight, case, tracking) and ink — the contract's
10780
+ * `button-label` element style, the one the site composer gives a composed button's label too (W1.1). */
10781
+ const BUTTON_LABEL_STYLE = 'button-label';
10667
10782
  function withChildren(node, children) {
10668
10783
  if (!node || children.some(c => !c))
10669
10784
  return null;
@@ -10689,7 +10804,7 @@ function button(styleId, label, createConfig, styles = {}) {
10689
10804
  return null;
10690
10805
  setTrait(btn, 'tag', 'button');
10691
10806
  styled(btn, styles);
10692
- return withChildren(btn, [text(label, createConfig, undefined, BUTTON_LABEL)]);
10807
+ return withChildren(btn, [text(label, createConfig, BUTTON_LABEL_STYLE)]);
10693
10808
  }
10694
10809
  /** A Container sized by its content: the palette Container's 200px drop-zone min-height is canvas
10695
10810
  * furniture, and a specimen has no drop zone. */
@@ -10863,6 +10978,10 @@ class BuilderButtonComponentConfig extends ComponentConfig {
10863
10978
  }
10864
10979
  }
10865
10980
 
10981
+ const BUTTON_LINK_TYPES = [
10982
+ { label: 'External URL', value: LinkType.External },
10983
+ { label: 'Website Page', value: LinkType.Page },
10984
+ ];
10866
10985
  class ButtonBlockComponentConfig extends ComponentConfig {
10867
10986
  constructor() {
10868
10987
  super('ButtonBlock', 'app-builder-button-block', 'ph-thin ph-cursor-click', 'Basic');
@@ -10871,13 +10990,21 @@ class ButtonBlockComponentConfig extends ComponentConfig {
10871
10990
  this.children = [];
10872
10991
  this.inputs = {
10873
10992
  tag: 'select',
10993
+ linkType: 'select',
10874
10994
  href: 'text',
10995
+ pageRoute: 'text',
10875
10996
  target: 'select',
10876
10997
  type: 'select',
10877
10998
  };
10999
+ // A link button points at a URL or at a website PAGE by route — the Link's two link types
11000
+ // (W1.1): a page route is shown as the route here and resolved base-relative by the engine.
11001
+ // `href` / `pageRoute` depend on `linkType`, which depends on `tag`: a trait whose controlling
11002
+ // trait is hidden is hidden too, so a <button> shows neither.
10878
11003
  this.traits = [
10879
11004
  new TraitConfig('tag', 'Tag', TraitInputType.Select, ['button', 'a'], 'button'),
10880
- new TraitConfig('href', 'URL', TraitInputType.Text, undefined, '', false, { trait: 'tag', values: 'a' }),
11005
+ new TraitConfig('linkType', 'Link Type', TraitInputType.Select, BUTTON_LINK_TYPES, LinkType.External, false, { trait: 'tag', values: 'a' }),
11006
+ new TraitConfig('href', 'URL', TraitInputType.Text, undefined, '', false, { trait: 'linkType', values: LinkType.External }),
11007
+ new TraitConfig('pageRoute', 'Page Route', TraitInputType.Text, undefined, '', false, { trait: 'linkType', values: LinkType.Page }),
10881
11008
  new TraitConfig('target', 'Target', TraitInputType.Select, ['_self', '_blank'], '_self', false, { trait: 'tag', values: 'a' }),
10882
11009
  new TraitConfig('type', 'Type', TraitInputType.Select, ['button', 'submit', 'reset'], 'button', false, { trait: 'tag', values: 'button' }),
10883
11010
  ];
@@ -11676,5 +11803,5 @@ class BuilderSpacerComponentConfig extends ComponentConfig {
11676
11803
  * Generated bundle index. Do not edit.
11677
11804
  */
11678
11805
 
11679
- export { ACTION_TYPE_OPTIONS, AuditCode, AuditPanelComponent, AuditSeverity, BODY_CRUMB_LABEL, BREAKPOINT_PRESENTATION, BUILDER_CANVAS_CONTAINER, BUILDER_CHROME_EN, BUILDER_FONT_LINK_ATTR, BUILDER_TOKEN_SCOPE_CLASS, BackgroundGroupStyleConfig, BorderGroupStyleConfig, BreadcrumbBarComponent, BuilderButtonBlockComponent, BuilderButtonComponentConfig, BuilderClipboardService, BuilderComponent, BuilderContainerComponent, BuilderContainerComponentConfig, BuilderDataSourceService, BuilderDialogComponent, BuilderDividerComponentConfig, BuilderHeadingComponentConfig, BuilderHistoryService, BuilderIconComponentConfig, BuilderImageComponentConfig, BuilderLinkComponentConfig, BuilderLinkedContainerComponent, BuilderLinkedContainerConfig, BuilderListComponentConfig, BuilderListItemComponentConfig, BuilderMode, BuilderRepeaterComponent, BuilderSpacerComponentConfig, ButtonBlockComponentConfig, CANVAS_EMPTY_IMAGE_LABEL_EN, CANVAS_EMPTY_IMAGE_LABEL_KEY, CANVAS_ROOT_RESET_CSS, CardComponentConfig, ChildLayoutGroupStyleConfig, ComponentConfig, ComponentRegistryService, DEFAULT_ESSENTIAL_TRAITS, DRAG_HANDLE_ATTR, DataSourceRegistryService, DataSourceType, DialogComponentConfig, DimensionsGroupStyleConfig, DragEngineService, EASING_OPTIONS$1 as EASING_OPTIONS, EDITOR_CHROME_ATTRIBUTE, ESSENTIAL_TRAITS_BY_TYPE, EffectsGroupStyleConfig, FIELD_KIND_PRESETS, FIELD_PARTS, FORM_PRESETS, FORM_TYPE, FieldComponentConfig, FieldGroupComponentConfig, FormComponentConfig, FormErrorComponentConfig, FormStateService, FormSuccessComponentConfig, GalleryConfig, GroupStyleConfig, INTERACTION_STATES, INVERSE_ACTION, IOX_BUILDER_CANVAS, IOX_BUILDER_LABELS, IOX_DATA_SOURCE_ENDPOINTS, InteractionEngineService, InteractionPresetRegistryService, IoxBuilderModule, IoxDraggableDirective, IoxDropzoneDirective, LAYOUT_PRESETS, LayerTreeComponent, LayoutGroupStyleConfig, ListGroupStyleConfig, NodeAction, OverlayComponent, OverlayService, PALETTE_PRESET_DEFS, PanelChildComponent, PanelComponent, PanelEventService, PanelEventTypes, PanelTypes, PositionGroupStyleConfig, PresetRegistryService, RELATIONAL_STATES, RELATIONAL_STATE_BASE, ROUTE_ANIMATION_OPTIONS, RenderDirective, RepeaterComponentConfig, SCREEN_WIDTH_OPTIONS, STRUCTURAL_STATES, SUPPORTED_STATES, SectionComponent, SectionComponentConfig, SizeGroupStyleConfig, SliderContainerComponent, SliderContainerConfig, SliderSlideComponent, SliderSlideConfig, SpacingGroupStyleConfig, StyleCategory, StyleRegistryService, TraitConfig as StyleTraitConfig, SubmitButtonComponentConfig, SymbolRegistryService, TARGETED_STATES, TARGETED_STATE_BASE, TRIGGER_OPTIONS, TextBlockComponentConfig, ToolbarAction, ToolbarComponent, TraitConfig, TraitInputType, TypographyGroupStyleConfig, UNITS_ALL, UNITS_DEG, UNITS_FIXED, UNITS_NO_VW, VALIDATION_STATES, VALIDATION_STATE_CLASS, ViewportService, ZOOM_OPTIONS, arrayItemSchemaFields, arraySchemaFields, auditNodes, buildConsentPreset, buildElementStyleSpecimen, buildFieldKindPreset, buildFlavorSpecimen, buildFormPreset, buildFullStyleTraits, buildLayoutPreset, buildPalettePreset, buildPresetStyleTraits, canFluidise, canvasEmptyImageLabel, collapseColumnsProps, columnCountOf, defaultBuilderLabels, defaultPageSettings, dropTargetLabel, essentialTraitsFor, findNodeLocation, findParentOf, flattenSchemaFields, fluidiseProps, generateNodeId, isHiddenAt, isSpecimen, markSpecimen, matchesControlSearch, nearestFormNode, nodeAncestorPath, nodeAncestorPathInEither, reorderProps, resolveInsertTarget, resolveTraitControllerType, resolveTraitOptions, setStyleTrait, setTrait, stackChildrenProps, summarise, syncEmptyImagePlaceholder, syncNodeDesign, textNode, visibilityProps, wrapNode };
11806
+ export { ACTION_TYPE_OPTIONS, AuditCode, AuditPanelComponent, AuditSeverity, BODY_CRUMB_LABEL, BREAKPOINT_PRESENTATION, BUILDER_CANVAS_CONTAINER, BUILDER_CHROME_EN, BUILDER_FONT_LINK_ATTR, BUILDER_TOKEN_SCOPE_CLASS, BackgroundGroupStyleConfig, BorderGroupStyleConfig, BreadcrumbBarComponent, BuilderButtonBlockComponent, BuilderButtonComponentConfig, BuilderClipboardService, BuilderComponent, BuilderContainerComponent, BuilderContainerComponentConfig, BuilderDataSourceService, BuilderDialogComponent, BuilderDividerComponentConfig, BuilderHeadingComponentConfig, BuilderHistoryService, BuilderIconComponentConfig, BuilderImageComponentConfig, BuilderLinkComponentConfig, BuilderLinkedContainerComponent, BuilderLinkedContainerConfig, BuilderListComponentConfig, BuilderListItemComponentConfig, BuilderMode, BuilderRepeaterComponent, BuilderSpacerComponentConfig, ButtonBlockComponentConfig, CANVAS_EMPTY_IMAGE_LABEL_EN, CANVAS_EMPTY_IMAGE_LABEL_KEY, CANVAS_ROOT_RESET_CSS, CardComponentConfig, ChildLayoutGroupStyleConfig, ComponentConfig, ComponentRegistryService, DEFAULT_ESSENTIAL_TRAITS, DRAG_HANDLE_ATTR, DROP_REFUSED_CLASS, DataSourceRegistryService, DataSourceType, DialogComponentConfig, DimensionsGroupStyleConfig, DragEngineService, EASING_OPTIONS$1 as EASING_OPTIONS, EDITOR_CHROME_ATTRIBUTE, ESSENTIAL_TRAITS_BY_TYPE, EffectsGroupStyleConfig, FIELD_KIND_PRESETS, FIELD_PARTS, FORM_STATE_BLOCK_TYPES, FORM_TYPE, FieldComponentConfig, FieldGroupComponentConfig, FormComponentConfig, FormErrorComponentConfig, FormStateService, FormSuccessComponentConfig, GalleryConfig, GroupStyleConfig, INSERT_REFUSAL_LABEL_KEYS, INTERACTION_STATES, INVERSE_ACTION, IOX_BUILDER_CANVAS, IOX_BUILDER_LABELS, IOX_DATA_SOURCE_ENDPOINTS, InsertRefusal, InteractionEngineService, InteractionPresetRegistryService, IoxBuilderModule, IoxDraggableDirective, IoxDropzoneDirective, LAYOUT_PRESETS, LayerTreeComponent, LayoutGroupStyleConfig, ListGroupStyleConfig, NodeAction, OverlayComponent, OverlayService, PALETTE_PRESET_DEFS, PanelChildComponent, PanelComponent, PanelEventService, PanelEventTypes, PanelTypes, PositionGroupStyleConfig, PresetRegistryService, RELATIONAL_STATES, RELATIONAL_STATE_BASE, ROOT_ESCAPE_PX, ROUTE_ANIMATION_OPTIONS, RenderDirective, RepeaterComponentConfig, RootZone, SCREEN_WIDTH_OPTIONS, STRUCTURAL_STATES, SUPPORTED_STATES, SectionComponent, SectionComponentConfig, SizeGroupStyleConfig, SliderContainerComponent, SliderContainerConfig, SliderSlideComponent, SliderSlideConfig, SpacingGroupStyleConfig, StyleCategory, StyleRegistryService, TraitConfig as StyleTraitConfig, SubmitButtonComponentConfig, SymbolRegistryService, TARGETED_STATES, TARGETED_STATE_BASE, TRIGGER_OPTIONS, TextBlockComponentConfig, ToolbarAction, ToolbarComponent, TraitConfig, TraitInputType, TypographyGroupStyleConfig, UNITS_ALL, UNITS_DEG, UNITS_FIXED, UNITS_NO_VW, VALIDATION_STATES, VALIDATION_STATE_CLASS, ViewportService, ZOOM_OPTIONS, arrayItemSchemaFields, arraySchemaFields, auditNodes, buildConsentPreset, buildElementStyleSpecimen, buildFieldKindPreset, buildFlavorSpecimen, buildFormPreset, buildFullStyleTraits, buildLayoutPreset, buildPalettePreset, buildPresetStyleTraits, canFluidise, canvasEmptyImageLabel, collapseColumnsProps, columnCountOf, defaultBuilderLabels, defaultPageSettings, dropTargetLabel, escapesRoot, essentialTraitsFor, findNodeLocation, findParentOf, flattenSchemaFields, fluidiseProps, generateNodeId, insertRefusal, insertRefusalLabel, isHiddenAt, isSpecimen, markSpecimen, matchesControlSearch, nearestFormNode, nodeAncestorPath, nodeAncestorPathInEither, reorderProps, resolveInsertTarget, resolveTraitControllerType, resolveTraitOptions, rootEscapeBand, rootZone, setStyleTrait, setTrait, stackChildrenProps, summarise, syncEmptyImagePlaceholder, syncNodeDesign, textNode, visibilityProps, wrapNode };
11680
11807
  //# sourceMappingURL=vectoriox-iox-builder.mjs.map