@mosaicoo/form-angular 0.5.0 → 0.7.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,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
- import { input as input$1, output, signal, ViewEncapsulation, ChangeDetectionStrategy, Component, computed, effect } from '@angular/core';
3
- import { JsonPipe } from '@angular/common';
2
+ import { input as input$1, output, signal, ViewEncapsulation, ChangeDetectionStrategy, Component, computed, effect, untracked } from '@angular/core';
3
+ import { NgTemplateOutlet, JsonPipe } from '@angular/common';
4
4
  import { collectInputs, generateKey, validateSchema, createFormEngine, getNodeAt, insertNode, parentOf, removeNode, duplicateNode, moveNode, updateNode } from '@mosaicoo/form-core';
5
5
  import { importForm, createLegacyImporter } from '@mosaicoo/form-core/interop';
6
6
  import { FormNodeComponent, FormRendererComponent } from '@mosaicoo/form-angular';
@@ -403,6 +403,99 @@ class InspectorComponent {
403
403
  const mask = this.inputValue(event).trim();
404
404
  this.emitPatch({ format: mask === '' ? undefined : { mask } });
405
405
  }
406
+ patchMultiple(event) {
407
+ const checked = event.target.checked;
408
+ this.emitPatch({
409
+ multiple: checked ? true : undefined,
410
+ dataType: checked ? 'array' : 'string',
411
+ });
412
+ }
413
+ // -- options source -------------------------------------------------------
414
+ sourceKind() {
415
+ return this.field().optionsSource?.type ?? '';
416
+ }
417
+ providerName() {
418
+ const source = this.field().optionsSource;
419
+ return source?.type === 'provider' ? source.name : '';
420
+ }
421
+ remoteUrl() {
422
+ const source = this.field().optionsSource;
423
+ return source?.type === 'remote' ? source.url : '';
424
+ }
425
+ refreshOn() {
426
+ return this.field().optionsSource?.refreshOn ?? '';
427
+ }
428
+ patchSourceKind(event) {
429
+ const kind = this.inputValue(event);
430
+ if (kind === 'provider') {
431
+ this.emitPatch({
432
+ optionsSource: { type: 'provider', name: this.providerName() || this.field().key },
433
+ });
434
+ }
435
+ else if (kind === 'remote') {
436
+ this.emitPatch({
437
+ optionsSource: { type: 'remote', url: this.remoteUrl() },
438
+ });
439
+ }
440
+ else {
441
+ this.emitPatch({ optionsSource: undefined });
442
+ }
443
+ }
444
+ patchProviderName(event) {
445
+ const name = this.inputValue(event).trim();
446
+ if (name === '')
447
+ return;
448
+ this.emitPatch({
449
+ optionsSource: { ...this.field().optionsSource, type: 'provider', name },
450
+ });
451
+ }
452
+ patchRemoteUrl(event) {
453
+ const url = this.inputValue(event).trim();
454
+ this.emitPatch({
455
+ optionsSource: { ...this.field().optionsSource, type: 'remote', url },
456
+ });
457
+ }
458
+ patchRefreshOn(event) {
459
+ const source = this.field().optionsSource;
460
+ if (!source)
461
+ return;
462
+ const refreshOn = this.inputValue(event).trim();
463
+ const next = { ...source };
464
+ if (refreshOn === '')
465
+ delete next.refreshOn;
466
+ else
467
+ next.refreshOn = refreshOn;
468
+ this.emitPatch({ optionsSource: next });
469
+ }
470
+ // -- props (type-specific settings for custom components) -----------------
471
+ propsError = signal('', ...(ngDevMode ? [{ debugName: "propsError" }] : /* istanbul ignore next */ []));
472
+ hasProps() {
473
+ return Object.keys(this.node().props ?? {}).length > 0;
474
+ }
475
+ propsText() {
476
+ const props = this.node().props;
477
+ return props && Object.keys(props).length > 0 ? JSON.stringify(props, null, 2) : '';
478
+ }
479
+ patchProps(event) {
480
+ const raw = this.inputValue(event).trim();
481
+ if (raw === '') {
482
+ this.propsError.set('');
483
+ this.emitPatch({ props: undefined });
484
+ return;
485
+ }
486
+ try {
487
+ const parsed = JSON.parse(raw);
488
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
489
+ this.propsError.set('Props must be a JSON object.');
490
+ return;
491
+ }
492
+ this.propsError.set('');
493
+ this.emitPatch({ props: parsed });
494
+ }
495
+ catch (error) {
496
+ this.propsError.set('Invalid JSON: ' + String(error instanceof Error ? error.message : error));
497
+ }
498
+ }
406
499
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: InspectorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
407
500
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.19", type: InspectorComponent, isStandalone: true, selector: "mform-inspector", inputs: { node: { classPropertyName: "node", publicName: "node", isSignal: true, isRequired: true, transformFunction: null }, schema: { classPropertyName: "schema", publicName: "schema", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { nodePatch: "nodePatch" }, ngImport: i0, template: `
408
501
  @let n = node();
@@ -538,8 +631,60 @@ class InspectorComponent {
538
631
  (change)="patchMask($event)"
539
632
  />
540
633
  </label>
634
+ <label class="dz-check">
635
+ <input type="checkbox" [checked]="field().multiple === true" (change)="patchMultiple($event)" />
636
+ <span>Multiple values</span>
637
+ </label>
638
+ </details>
639
+
640
+ <details class="dz-section">
641
+ <summary>Options source</summary>
642
+ <label class="dz-prop">
643
+ <span>Source</span>
644
+ <select [value]="sourceKind()" (change)="patchSourceKind($event)">
645
+ <option value="">Static list</option>
646
+ <option value="provider" [selected]="sourceKind() === 'provider'">Host provider</option>
647
+ <option value="remote" [selected]="sourceKind() === 'remote'">Remote URL</option>
648
+ </select>
649
+ </label>
650
+ @if (sourceKind() === 'provider') {
651
+ <label class="dz-prop">
652
+ <span>Provider name</span>
653
+ <input type="text" [value]="providerName()" (change)="patchProviderName($event)" />
654
+ </label>
655
+ }
656
+ @if (sourceKind() === 'remote') {
657
+ <label class="dz-prop">
658
+ <span>URL</span>
659
+ <input type="text" [value]="remoteUrl()" (change)="patchRemoteUrl($event)" />
660
+ </label>
661
+ }
662
+ @if (sourceKind()) {
663
+ <label class="dz-prop">
664
+ <span>Refresh on (field path or "data")</span>
665
+ <input type="text" [value]="refreshOn()" (change)="patchRefreshOn($event)" />
666
+ </label>
667
+ }
541
668
  </details>
542
669
  }
670
+
671
+ <!-- Type-specific configuration for CUSTOM components: the host reads
672
+ these from field.props (e.g. a board's columns, a chart's series). -->
673
+ <details class="dz-section" [open]="hasProps()">
674
+ <summary>Type settings (props)</summary>
675
+ <label class="dz-prop">
676
+ <span>JSON passed to the field component</span>
677
+ <textarea
678
+ rows="5"
679
+ spellcheck="false"
680
+ [value]="propsText()"
681
+ (change)="patchProps($event)"
682
+ ></textarea>
683
+ </label>
684
+ @if (propsError()) {
685
+ <p class="dz-import-error" role="alert">{{ propsError() }}</p>
686
+ }
687
+ </details>
543
688
  </div>
544
689
  `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
545
690
  }
@@ -683,8 +828,60 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
683
828
  (change)="patchMask($event)"
684
829
  />
685
830
  </label>
831
+ <label class="dz-check">
832
+ <input type="checkbox" [checked]="field().multiple === true" (change)="patchMultiple($event)" />
833
+ <span>Multiple values</span>
834
+ </label>
835
+ </details>
836
+
837
+ <details class="dz-section">
838
+ <summary>Options source</summary>
839
+ <label class="dz-prop">
840
+ <span>Source</span>
841
+ <select [value]="sourceKind()" (change)="patchSourceKind($event)">
842
+ <option value="">Static list</option>
843
+ <option value="provider" [selected]="sourceKind() === 'provider'">Host provider</option>
844
+ <option value="remote" [selected]="sourceKind() === 'remote'">Remote URL</option>
845
+ </select>
846
+ </label>
847
+ @if (sourceKind() === 'provider') {
848
+ <label class="dz-prop">
849
+ <span>Provider name</span>
850
+ <input type="text" [value]="providerName()" (change)="patchProviderName($event)" />
851
+ </label>
852
+ }
853
+ @if (sourceKind() === 'remote') {
854
+ <label class="dz-prop">
855
+ <span>URL</span>
856
+ <input type="text" [value]="remoteUrl()" (change)="patchRemoteUrl($event)" />
857
+ </label>
858
+ }
859
+ @if (sourceKind()) {
860
+ <label class="dz-prop">
861
+ <span>Refresh on (field path or "data")</span>
862
+ <input type="text" [value]="refreshOn()" (change)="patchRefreshOn($event)" />
863
+ </label>
864
+ }
686
865
  </details>
687
866
  }
867
+
868
+ <!-- Type-specific configuration for CUSTOM components: the host reads
869
+ these from field.props (e.g. a board's columns, a chart's series). -->
870
+ <details class="dz-section" [open]="hasProps()">
871
+ <summary>Type settings (props)</summary>
872
+ <label class="dz-prop">
873
+ <span>JSON passed to the field component</span>
874
+ <textarea
875
+ rows="5"
876
+ spellcheck="false"
877
+ [value]="propsText()"
878
+ (change)="patchProps($event)"
879
+ ></textarea>
880
+ </label>
881
+ @if (propsError()) {
882
+ <p class="dz-import-error" role="alert">{{ propsError() }}</p>
883
+ }
884
+ </details>
688
885
  </div>
689
886
  `,
690
887
  }]
@@ -779,6 +976,16 @@ const DEFAULT_CATALOG = [
779
976
  ] };
780
977
  },
781
978
  },
979
+ {
980
+ type: 'accordion', label: 'Accordion', icon: '☰',
981
+ create: (s) => {
982
+ const key = generateKey(s, 'accordion');
983
+ return { kind: 'container', key, type: 'accordion', dataScope: 'inherit', children: [
984
+ { kind: 'container', key: `${key}-section-1`, type: 'panel', dataScope: 'inherit', label: 'Section 1', children: [] },
985
+ { kind: 'container', key: `${key}-section-2`, type: 'panel', dataScope: 'inherit', label: 'Section 2', children: [] },
986
+ ] };
987
+ },
988
+ },
782
989
  {
783
990
  type: 'group', label: 'Data group', icon: '⧈',
784
991
  create: (s) => ({ kind: 'container', key: generateKey(s, 'group'), type: 'group',
@@ -808,8 +1015,124 @@ const DEFAULT_CATALOG = [
808
1015
  },
809
1016
  ];
810
1017
 
1018
+ const STORAGE_KEY$1 = 'mform-designer-blocks';
1019
+ function loadBlocks(storage) {
1020
+ if (!storage)
1021
+ return [];
1022
+ try {
1023
+ const raw = storage.getItem(STORAGE_KEY$1);
1024
+ const parsed = raw ? JSON.parse(raw) : [];
1025
+ return Array.isArray(parsed) ? parsed : [];
1026
+ }
1027
+ catch {
1028
+ return [];
1029
+ }
1030
+ }
1031
+ function saveBlocks(storage, blocks) {
1032
+ try {
1033
+ storage?.setItem(STORAGE_KEY$1, JSON.stringify(blocks));
1034
+ }
1035
+ catch {
1036
+ /* storage unavailable — blocks stay in memory for this session */
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * Designer workspace layout.
1042
+ *
1043
+ * Panels are dockable like in professional tools: each one can sit on the
1044
+ * left, on the right, or be hidden — the canvas always keeps the center. The
1045
+ * arrangement is a plain serializable object so hosts can persist it per user
1046
+ * (the designer also remembers it locally by default).
1047
+ */
1048
+ const DEFAULT_LAYOUT = {
1049
+ palette: 'left',
1050
+ structure: 'left',
1051
+ properties: 'right',
1052
+ leftWidth: 240,
1053
+ rightWidth: 280,
1054
+ };
1055
+ /** Ready-made arrangements offered in the workspace menu. */
1056
+ const WORKSPACE_PRESETS = [
1057
+ { id: 'default', label: 'Default (fields left, properties right)', layout: DEFAULT_LAYOUT },
1058
+ {
1059
+ id: 'authoring',
1060
+ label: 'Authoring (everything left)',
1061
+ layout: { ...DEFAULT_LAYOUT, properties: 'left', leftWidth: 300 },
1062
+ },
1063
+ {
1064
+ id: 'inspect',
1065
+ label: 'Inspect (structure + properties right)',
1066
+ layout: { ...DEFAULT_LAYOUT, structure: 'right', properties: 'right', rightWidth: 320 },
1067
+ },
1068
+ {
1069
+ id: 'canvas',
1070
+ label: 'Canvas only (panels hidden)',
1071
+ layout: { ...DEFAULT_LAYOUT, palette: 'hidden', structure: 'hidden', properties: 'hidden' },
1072
+ },
1073
+ ];
1074
+ const PANEL_LABELS = {
1075
+ palette: 'Fields',
1076
+ structure: 'Structure',
1077
+ properties: 'Properties',
1078
+ };
1079
+ const STORAGE_KEY = 'mform-designer-layout';
1080
+ function loadLayout(storage) {
1081
+ if (!storage)
1082
+ return DEFAULT_LAYOUT;
1083
+ try {
1084
+ const raw = storage.getItem(STORAGE_KEY);
1085
+ if (!raw)
1086
+ return DEFAULT_LAYOUT;
1087
+ const parsed = JSON.parse(raw);
1088
+ return { ...DEFAULT_LAYOUT, ...parsed };
1089
+ }
1090
+ catch {
1091
+ return DEFAULT_LAYOUT;
1092
+ }
1093
+ }
1094
+ function saveLayout(storage, layout) {
1095
+ try {
1096
+ storage?.setItem(STORAGE_KEY, JSON.stringify(layout));
1097
+ }
1098
+ catch {
1099
+ /* storage unavailable (private mode, SSR) — layout stays in memory */
1100
+ }
1101
+ }
1102
+
811
1103
  const EMPTY_SCHEMA = { version: 1, fields: [] };
812
1104
  const HISTORY_LIMIT = 50;
1105
+ /** True when `target` is `from` itself or somewhere inside it. */
1106
+ function isSameOrInside(from, target) {
1107
+ return from.every((segment, index) => target[index] === segment);
1108
+ }
1109
+ /**
1110
+ * A move removes before it inserts: when the source sits before the target
1111
+ * under the same parent, every index after it shifts down by one.
1112
+ */
1113
+ function adjustAfterRemoval(from, target) {
1114
+ const parentDepth = from.length - 1;
1115
+ const sameParent = from.slice(0, parentDepth).every((s, i) => target[i] === s);
1116
+ if (!sameParent || target.length < from.length)
1117
+ return target;
1118
+ const adjusted = [...target];
1119
+ const fromIndex = from[parentDepth];
1120
+ const targetIndex = adjusted[parentDepth];
1121
+ if (targetIndex > fromIndex)
1122
+ adjusted[parentDepth] = targetIndex - 1;
1123
+ return adjusted;
1124
+ }
1125
+ /** Deep copy of a block with fresh keys, so it can be inserted repeatedly. */
1126
+ function rekeyBlock(node, schema) {
1127
+ const copy = JSON.parse(JSON.stringify(node));
1128
+ const assign = (target) => {
1129
+ target.key = generateKey(schema, target.key);
1130
+ if (target.kind === 'container')
1131
+ target.children.forEach(assign);
1132
+ };
1133
+ assign(copy);
1134
+ return copy;
1135
+ }
813
1136
  /**
814
1137
  * `<mform-designer>` — visual authoring of form schemas.
815
1138
  *
@@ -835,7 +1158,93 @@ class FormDesignerComponent {
835
1158
  dialog = signal(null, ...(ngDevMode ? [{ debugName: "dialog" }] : /* istanbul ignore next */ []));
836
1159
  importError = signal('', ...(ngDevMode ? [{ debugName: "importError" }] : /* istanbul ignore next */ []));
837
1160
  showDiagnostics = signal(false, ...(ngDevMode ? [{ debugName: "showDiagnostics" }] : /* istanbul ignore next */ []));
838
- catalog = computed(() => [...DEFAULT_CATALOG, ...this.extraCatalog()], ...(ngDevMode ? [{ debugName: "catalog" }] : /* istanbul ignore next */ []));
1161
+ // -- workspace ------------------------------------------------------------
1162
+ /** Panel arrangement; persisted locally unless the host controls it. */
1163
+ layout = signal(DEFAULT_LAYOUT, ...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
1164
+ workspaceMenu = signal(false, ...(ngDevMode ? [{ debugName: "workspaceMenu" }] : /* istanbul ignore next */ []));
1165
+ panelIds = ['palette', 'structure', 'properties'];
1166
+ dockSides = ['left', 'right', 'hidden'];
1167
+ panelLabels = PANEL_LABELS;
1168
+ sideLabels = { left: '⇤', right: '⇥', hidden: '✕' };
1169
+ presets = WORKSPACE_PRESETS;
1170
+ /** Collapsed palette groups (only the first stays open by default). */
1171
+ openGroups = signal(new Set(), ...(ngDevMode ? [{ debugName: "openGroups" }] : /* istanbul ignore next */ []));
1172
+ storage = (() => {
1173
+ try {
1174
+ return typeof localStorage === 'undefined' ? null : localStorage;
1175
+ }
1176
+ catch {
1177
+ return null;
1178
+ }
1179
+ })();
1180
+ panelLabel(panel) {
1181
+ return PANEL_LABELS[panel];
1182
+ }
1183
+ panelSide(panel) {
1184
+ return this.layout()[panel];
1185
+ }
1186
+ dockedPanels(side) {
1187
+ const layout = this.layout();
1188
+ return this.panelIds.filter((panel) => layout[panel] === side);
1189
+ }
1190
+ bodyColumns() {
1191
+ const layout = this.layout();
1192
+ const left = this.dockedPanels('left').length > 0 ? `${layout.leftWidth}px ` : '';
1193
+ const right = this.dockedPanels('right').length > 0 ? ` ${layout.rightWidth}px` : '';
1194
+ return `${left}1fr${right}`;
1195
+ }
1196
+ dock(panel, side) {
1197
+ this.layout.update((current) => ({ ...current, [panel]: side }));
1198
+ saveLayout(this.storage, this.layout());
1199
+ }
1200
+ applyPreset(id) {
1201
+ const preset = WORKSPACE_PRESETS.find((p) => p.id === id);
1202
+ if (!preset)
1203
+ return;
1204
+ this.layout.set({ ...preset.layout });
1205
+ saveLayout(this.storage, this.layout());
1206
+ this.workspaceMenu.set(false);
1207
+ }
1208
+ resetLayout() {
1209
+ this.layout.set({ ...DEFAULT_LAYOUT });
1210
+ saveLayout(this.storage, this.layout());
1211
+ this.workspaceMenu.set(false);
1212
+ }
1213
+ isGroupOpen(label) {
1214
+ return this.openGroups().has(label);
1215
+ }
1216
+ toggleGroup(label) {
1217
+ this.openGroups.update((open) => {
1218
+ const next = new Set(open);
1219
+ if (next.has(label))
1220
+ next.delete(label);
1221
+ else
1222
+ next.add(label);
1223
+ return next;
1224
+ });
1225
+ }
1226
+ /** Saved blocks (host-controlled when `[blocks]` is bound). */
1227
+ blocks = input$1(null, ...(ngDevMode ? [{ debugName: "blocks" }] : /* istanbul ignore next */ []));
1228
+ blocksChanged = output();
1229
+ localBlocks = signal([], ...(ngDevMode ? [{ debugName: "localBlocks" }] : /* istanbul ignore next */ []));
1230
+ effectiveBlocks = computed(() => this.blocks() ?? this.localBlocks(), ...(ngDevMode ? [{ debugName: "effectiveBlocks" }] : /* istanbul ignore next */ []));
1231
+ /** Palette = built-ins + host extras + saved blocks as a group. */
1232
+ catalog = computed(() => {
1233
+ const groups = [...DEFAULT_CATALOG, ...this.extraCatalog()];
1234
+ const blocks = this.effectiveBlocks();
1235
+ if (blocks.length > 0) {
1236
+ groups.push({
1237
+ label: 'Blocks',
1238
+ entries: blocks.map((block) => ({
1239
+ type: `block:${block.id}`,
1240
+ label: block.label,
1241
+ icon: '⊞',
1242
+ create: (schema) => rekeyBlock(block.node, schema),
1243
+ })),
1244
+ });
1245
+ }
1246
+ return groups;
1247
+ }, ...(ngDevMode ? [{ debugName: "catalog" }] : /* istanbul ignore next */ []));
839
1248
  diagnostics = computed(() => validateSchema(this.draft()), ...(ngDevMode ? [{ debugName: "diagnostics" }] : /* istanbul ignore next */ []));
840
1249
  exportedJson = computed(() => JSON.stringify(this.draft(), null, 2), ...(ngDevMode ? [{ debugName: "exportedJson" }] : /* istanbul ignore next */ []));
841
1250
  /** Disposable engine driving the canvas leaves (defaults visible). */
@@ -864,13 +1273,21 @@ class FormDesignerComponent {
864
1273
  return items;
865
1274
  }, ...(ngDevMode ? [{ debugName: "tree" }] : /* istanbul ignore next */ []));
866
1275
  constructor() {
1276
+ this.layout.set(loadLayout(this.storage));
1277
+ this.localBlocks.set(loadBlocks(this.storage));
1278
+ // First palette group starts expanded; the rest opens on demand.
1279
+ const first = this.catalog()[0]?.label;
1280
+ if (first)
1281
+ this.openGroups.set(new Set([first]));
867
1282
  // (Re)load the document whenever the host hands a new definition in.
868
1283
  effect(() => {
869
1284
  const native = this.schema();
870
1285
  const source = this.source();
871
1286
  // Hosts often feed `schemaChanged` back into [schema] (controlled
872
1287
  // usage) — the echo of our own draft must not reset history/selection.
873
- if (native && native === this.draft())
1288
+ // `untracked` is essential: reading the draft as a dependency would make
1289
+ // every edit re-run this effect and revert the document.
1290
+ if (native && native === untracked(() => this.draft()))
874
1291
  return;
875
1292
  let initial = EMPTY_SCHEMA;
876
1293
  if (native) {
@@ -968,6 +1385,95 @@ class FormDesignerComponent {
968
1385
  return;
969
1386
  this.commit(updateNode(this.draft(), selected, event.patch));
970
1387
  }
1388
+ // -- reusable blocks ------------------------------------------------------
1389
+ /** Saves the selected node (with its subtree) as a reusable block. */
1390
+ saveAsBlock() {
1391
+ const selected = this.selected();
1392
+ const node = selected ? getNodeAt(this.draft(), selected) : null;
1393
+ if (!node)
1394
+ return;
1395
+ const label = node.label ?? node.key;
1396
+ const block = {
1397
+ id: `${node.key}-${this.effectiveBlocks().length + 1}`,
1398
+ label,
1399
+ node: JSON.parse(JSON.stringify(node)),
1400
+ };
1401
+ const next = [...this.effectiveBlocks(), block];
1402
+ if (this.blocks() === null) {
1403
+ this.localBlocks.set(next);
1404
+ saveBlocks(this.storage, next);
1405
+ }
1406
+ this.blocksChanged.emit(next);
1407
+ }
1408
+ removeBlock(id) {
1409
+ const next = this.effectiveBlocks().filter((block) => block.id !== id);
1410
+ if (this.blocks() === null) {
1411
+ this.localBlocks.set(next);
1412
+ saveBlocks(this.storage, next);
1413
+ }
1414
+ this.blocksChanged.emit(next);
1415
+ }
1416
+ isBlockEntry(entry) {
1417
+ return entry.type.startsWith('block:');
1418
+ }
1419
+ blockIdOf(entry) {
1420
+ return entry.type.slice('block:'.length);
1421
+ }
1422
+ // -- drag and drop --------------------------------------------------------
1423
+ /** What is being dragged: a palette entry or an existing node. */
1424
+ dragging = signal(null, ...(ngDevMode ? [{ debugName: "dragging" }] : /* istanbul ignore next */ []));
1425
+ /** Drop target highlighted while hovering. */
1426
+ dropTarget = signal(null, ...(ngDevMode ? [{ debugName: "dropTarget" }] : /* istanbul ignore next */ []));
1427
+ startEntryDrag(entry, event) {
1428
+ this.dragging.set({ kind: 'entry', entry });
1429
+ event.dataTransfer?.setData('text/plain', entry.type);
1430
+ if (event.dataTransfer)
1431
+ event.dataTransfer.effectAllowed = 'copy';
1432
+ }
1433
+ startNodeDrag(path, event) {
1434
+ this.dragging.set({ kind: 'node', path });
1435
+ event.dataTransfer?.setData('text/plain', path.join('.'));
1436
+ if (event.dataTransfer)
1437
+ event.dataTransfer.effectAllowed = 'move';
1438
+ }
1439
+ endDrag() {
1440
+ this.dragging.set(null);
1441
+ this.dropTarget.set(null);
1442
+ }
1443
+ allowDrop(target, event) {
1444
+ if (!this.dragging())
1445
+ return;
1446
+ event.preventDefault();
1447
+ event.stopPropagation();
1448
+ this.dropTarget.set(target.join('.'));
1449
+ }
1450
+ /** Drops onto `target`, the position the node should occupy. */
1451
+ drop(target, event) {
1452
+ event.preventDefault();
1453
+ event.stopPropagation();
1454
+ const dragging = this.dragging();
1455
+ this.endDrag();
1456
+ if (!dragging)
1457
+ return;
1458
+ if (dragging.kind === 'entry') {
1459
+ const node = dragging.entry.create(this.draft());
1460
+ this.commit(insertNode(this.draft(), target, node));
1461
+ this.select(target);
1462
+ return;
1463
+ }
1464
+ const from = dragging.path;
1465
+ if (isSameOrInside(from, target))
1466
+ return; // never drop a node into itself
1467
+ const adjusted = adjustAfterRemoval(from, target);
1468
+ this.commit(moveNode(this.draft(), from, adjusted));
1469
+ this.select(adjusted);
1470
+ }
1471
+ isDropTarget(path) {
1472
+ return this.dropTarget() === path.join('.');
1473
+ }
1474
+ isDragging() {
1475
+ return this.dragging() !== null;
1476
+ }
971
1477
  // -- selection ------------------------------------------------------------
972
1478
  select(path) {
973
1479
  this.selected.set(path);
@@ -1016,7 +1522,7 @@ class FormDesignerComponent {
1016
1522
  }
1017
1523
  }
1018
1524
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: FormDesignerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1019
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.19", type: FormDesignerComponent, isStandalone: true, selector: "mform-designer", inputs: { schema: { classPropertyName: "schema", publicName: "schema", isSignal: true, isRequired: false, transformFunction: null }, source: { classPropertyName: "source", publicName: "source", isSignal: true, isRequired: false, transformFunction: null }, extraCatalog: { classPropertyName: "extraCatalog", publicName: "extraCatalog", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { schemaChanged: "schemaChanged" }, ngImport: i0, template: `
1525
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.19", type: FormDesignerComponent, isStandalone: true, selector: "mform-designer", inputs: { schema: { classPropertyName: "schema", publicName: "schema", isSignal: true, isRequired: false, transformFunction: null }, source: { classPropertyName: "source", publicName: "source", isSignal: true, isRequired: false, transformFunction: null }, extraCatalog: { classPropertyName: "extraCatalog", publicName: "extraCatalog", isSignal: true, isRequired: false, transformFunction: null }, blocks: { classPropertyName: "blocks", publicName: "blocks", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { schemaChanged: "schemaChanged", blocksChanged: "blocksChanged" }, ngImport: i0, template: `
1020
1526
  <div class="dz-shell" (keydown)="onKeydown($event)">
1021
1527
  <header class="dz-toolbar">
1022
1528
  <div class="dz-tools">
@@ -1027,6 +1533,7 @@ class FormDesignerComponent {
1027
1533
  <button type="button" (click)="moveSelected(1)" [disabled]="!selected()" title="Move down">↓</button>
1028
1534
  <button type="button" (click)="duplicateSelected()" [disabled]="!selected()" title="Duplicate (Ctrl+D)">⧉</button>
1029
1535
  <button type="button" (click)="removeSelected()" [disabled]="!selected()" title="Delete (Del)">🗑</button>
1536
+ <button type="button" (click)="saveAsBlock()" [disabled]="!selected()" title="Save as reusable block">⊞</button>
1030
1537
  </div>
1031
1538
  <div class="dz-tools">
1032
1539
  @if (diagnostics().length > 0) {
@@ -1034,6 +1541,37 @@ class FormDesignerComponent {
1034
1541
  ⚠ {{ diagnostics().length }}
1035
1542
  </button>
1036
1543
  }
1544
+ <div class="dz-menu">
1545
+ <button type="button" (click)="workspaceMenu.set(!workspaceMenu())" title="Workspace layout">
1546
+ ▦ Workspace
1547
+ </button>
1548
+ @if (workspaceMenu()) {
1549
+ <div class="dz-menu-panel" role="menu">
1550
+ <span class="dz-menu-title">Panels</span>
1551
+ @for (panel of panelIds; track panel) {
1552
+ <div class="dz-menu-row">
1553
+ <span>{{ panelLabel(panel) }}</span>
1554
+ <span class="dz-seg">
1555
+ @for (side of dockSides; track side) {
1556
+ <button
1557
+ type="button"
1558
+ [class.is-on]="layout()[panel] === side"
1559
+ (click)="dock(panel, side)"
1560
+ >{{ sideLabels[side] }}</button>
1561
+ }
1562
+ </span>
1563
+ </div>
1564
+ }
1565
+ <span class="dz-menu-title">Presets</span>
1566
+ @for (preset of presets; track preset.id) {
1567
+ <button type="button" class="dz-menu-item" (click)="applyPreset(preset.id)">
1568
+ {{ preset.label }}
1569
+ </button>
1570
+ }
1571
+ <button type="button" class="dz-menu-item" (click)="resetLayout()">Reset layout</button>
1572
+ </div>
1573
+ }
1574
+ </div>
1037
1575
  <button type="button" (click)="dialog.set(dialog() === 'import' ? null : 'import')">Import</button>
1038
1576
  <button type="button" (click)="openExport()">Export</button>
1039
1577
  <button type="button" class="dz-primary" (click)="preview.set(!preview())">
@@ -1084,76 +1622,161 @@ class FormDesignerComponent {
1084
1622
  </details>
1085
1623
  </div>
1086
1624
  } @else {
1087
- <div class="dz-body">
1088
- <aside class="dz-palette">
1089
- <h4>Fields</h4>
1090
- @for (group of catalog(); track group.label) {
1091
- <div class="dz-group">
1092
- <span class="dz-group-label">{{ group.label }}</span>
1093
- <div class="dz-group-entries">
1094
- @for (entry of group.entries; track entry.type) {
1095
- <button type="button" class="dz-palette-item" (click)="add(entry)"
1096
- [title]="'Add ' + entry.label">
1097
- <span class="dz-icon">{{ entry.icon }}</span>{{ entry.label }}
1098
- </button>
1099
- }
1100
- </div>
1101
- </div>
1102
- }
1103
- <h4>Structure</h4>
1104
- <div class="dz-tree" role="tree">
1105
- @for (item of tree(); track item.id) {
1106
- <button
1107
- type="button"
1108
- role="treeitem"
1109
- class="dz-tree-item"
1110
- [class.is-selected]="isSelectedPath(item.path)"
1111
- [style.padding-left.px]="8 + item.depth * 14"
1112
- (click)="select(item.path)"
1113
- >
1114
- {{ item.label }}
1115
- </button>
1116
- } @empty {
1117
- <span class="dz-empty">no fields yet</span>
1625
+ <div class="dz-body" [style.grid-template-columns]="bodyColumns()">
1626
+ @if (dockedPanels('left').length > 0) {
1627
+ <aside class="dz-dock dz-dock-left">
1628
+ @for (panel of dockedPanels('left'); track panel) {
1629
+ <ng-container *ngTemplateOutlet="panelTemplate; context: { $implicit: panel }" />
1118
1630
  }
1119
- </div>
1120
- </aside>
1631
+ </aside>
1632
+ }
1121
1633
 
1122
- <main class="dz-canvas" (click)="select(null)">
1634
+ <main class="dz-canvas" [class.is-dragging]="isDragging()" (click)="select(null)">
1123
1635
  @for (node of draft().fields; track node.key; let i = $index) {
1636
+ <div
1637
+ class="dz-dropzone"
1638
+ [class.is-over]="isDropTarget([i])"
1639
+ (dragover)="allowDrop([i], $event)"
1640
+ (drop)="drop([i], $event)"
1641
+ ></div>
1124
1642
  <mform-designer-node
1125
1643
  [node]="node"
1126
1644
  [path]="[i]"
1127
1645
  [engine]="canvasEngine()"
1128
1646
  [selected]="selected()"
1129
1647
  (selectNode)="select($event)"
1648
+ draggable="true"
1649
+ (dragstart)="startNodeDrag([i], $event)"
1650
+ (dragend)="endDrag()"
1130
1651
  />
1131
1652
  } @empty {
1132
1653
  <div class="dz-canvas-empty">
1133
- Click a field in the palette to start building.
1654
+ Click a field in the palette — or drag it here — to start building.
1134
1655
  </div>
1135
1656
  }
1657
+ <div
1658
+ class="dz-dropzone is-last"
1659
+ [class.is-over]="isDropTarget([draft().fields.length])"
1660
+ (dragover)="allowDrop([draft().fields.length], $event)"
1661
+ (drop)="drop([draft().fields.length], $event)"
1662
+ ></div>
1136
1663
  </main>
1137
1664
 
1138
- <aside class="dz-props">
1139
- @if (selectedNode(); as node) {
1140
- <mform-inspector
1141
- [node]="node"
1142
- [schema]="draft()"
1143
- (nodePatch)="applyPatch($event)"
1144
- />
1145
- } @else {
1146
- <div class="dz-empty dz-props-empty">Select a field to edit its properties.</div>
1147
- }
1148
- </aside>
1665
+ @if (dockedPanels('right').length > 0) {
1666
+ <aside class="dz-dock dz-dock-right">
1667
+ @for (panel of dockedPanels('right'); track panel) {
1668
+ <ng-container *ngTemplateOutlet="panelTemplate; context: { $implicit: panel }" />
1669
+ }
1670
+ </aside>
1671
+ }
1149
1672
  </div>
1150
1673
  }
1151
1674
  </div>
1152
- `, isInline: true, styles: [".dz-shell{display:flex;flex-direction:column;height:100%;min-height:480px;font-family:var(--mform-font, system-ui, \"Segoe UI\", sans-serif);color:var(--mform-text, #1f1b2e);background:var(--mform-bg, #f6f5fa);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);overflow:hidden}.dz-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:8px 12px;background:var(--mform-surface, #fff);border-bottom:1px solid var(--mform-border, #d9d5e6)}.dz-tools{display:flex;align-items:center;gap:6px}.dz-toolbar button{font:inherit;font-size:13px;padding:6px 10px;border:1px solid transparent;border-radius:8px;background:none;color:inherit;cursor:pointer}.dz-toolbar button:hover:not(:disabled){background:var(--mform-bg, #f6f5fa)}.dz-toolbar button:disabled{opacity:.35;cursor:default}.dz-toolbar .dz-primary{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.dz-sep{width:1px;height:18px;background:var(--mform-border, #d9d5e6)}.dz-diag-badge{color:#b45309;font-weight:600}.dz-diagnostics{max-height:140px;overflow-y:auto;padding:8px 14px;background:#fdf5ef;border-bottom:1px solid var(--mform-border, #d9d5e6);font-size:12px}.dz-diag{padding:2px 0;color:#9a3412}.dz-diag.is-warning{color:#92400e}.dz-diag code{font-weight:600;margin-right:6px}.dz-diag em{color:var(--mform-muted, #6b6580);margin-left:6px}.dz-dialog{padding:14px;background:var(--mform-surface, #fff);border-bottom:1px solid var(--mform-border, #d9d5e6)}.dz-dialog h4{margin:0 0 8px;font-size:13px}.dz-dialog textarea{width:100%;font-family:ui-monospace,Consolas,monospace;font-size:12px;border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;padding:10px;box-sizing:border-box;resize:vertical}.dz-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}.dz-dialog-actions button{font:inherit;font-size:13px;padding:7px 14px;border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;background:none;cursor:pointer}.dz-dialog-actions .dz-primary{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border-color:transparent}.dz-import-error{color:var(--mform-danger, #c62842);font-size:12px}.dz-body{display:grid;grid-template-columns:240px 1fr 280px;flex:1;min-height:0}.dz-palette{border-right:1px solid var(--mform-border, #d9d5e6);background:var(--mform-surface, #fff);padding:12px;overflow-y:auto}.dz-palette h4{margin:4px 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mform-muted, #6b6580)}.dz-group{margin-bottom:10px}.dz-group-label{font-size:11px;color:var(--mform-muted, #6b6580)}.dz-group-entries{display:flex;flex-direction:column;gap:2px;margin-top:4px}.dz-palette-item{display:flex;align-items:center;gap:8px;font:inherit;font-size:13px;text-align:left;padding:6px 8px;border:none;border-radius:8px;background:none;color:inherit;cursor:pointer}.dz-palette-item:hover{background:var(--mform-bg, #f6f5fa)}.dz-icon{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--mform-border, #d9d5e6);border-radius:6px;font-size:11px}.dz-tree{display:flex;flex-direction:column;gap:1px}.dz-tree-item{font:inherit;font-size:12px;text-align:left;padding:4px 8px;border:none;border-radius:6px;background:none;color:inherit;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dz-tree-item:hover{background:var(--mform-bg, #f6f5fa)}.dz-tree-item.is-selected{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.dz-canvas{padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:10px}.dz-canvas-empty,.dz-empty{color:var(--mform-muted, #6b6580);font-size:13px;border:1px dashed var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);padding:18px;text-align:center}.dz-node{position:relative;border:1px solid transparent;border-radius:var(--mform-radius, 10px);padding:20px 10px 10px;cursor:pointer;background:var(--mform-surface, #fff)}.dz-node:hover{border-color:var(--mform-border, #d9d5e6)}.dz-node.is-selected{border-color:var(--mform-primary, #6d4bd0);box-shadow:0 0 0 1px var(--mform-primary, #6d4bd0)}.dz-chip{position:absolute;top:2px;left:10px;font-size:10px;color:var(--mform-muted, #6b6580);pointer-events:none}.dz-node.is-selected>.dz-chip{color:var(--mform-primary, #6d4bd0);font-weight:600}.dz-children{display:flex;flex-direction:column;gap:8px}.dz-columns{display:grid;gap:10px}.dz-column{display:flex;flex-direction:column;gap:8px;border:1px dashed var(--mform-border, #d9d5e6);border-radius:8px;padding:8px;min-height:40px}.dz-tabbar{display:flex;gap:4px;margin-bottom:8px;border-bottom:2px solid var(--mform-border, #d9d5e6)}.dz-tab{font:inherit;font-size:12px;padding:5px 12px;border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer}.dz-tab.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600;border-bottom:2px solid var(--mform-primary, #6d4bd0);margin-bottom:-2px}.dz-leaf{pointer-events:none}.dz-props{border-left:1px solid var(--mform-border, #d9d5e6);background:var(--mform-surface, #fff);overflow-y:auto}.dz-props-empty{margin:12px}.dz-inspector{padding:12px;display:flex;flex-direction:column;gap:10px}.dz-inspector h3{margin:0;font-size:13px}.dz-inspector h3 code{color:var(--mform-muted, #6b6580);font-weight:400}.dz-prop{display:flex;flex-direction:column;gap:4px;font-size:12px}.dz-prop span{color:var(--mform-muted, #6b6580)}.dz-prop input,.dz-prop select,.dz-prop textarea{font:inherit;font-size:13px;padding:7px 9px;border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;background:#fff;width:100%;box-sizing:border-box}.dz-check{display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer}.dz-check input{accent-color:var(--mform-primary, #6d4bd0)}.dz-section{border-top:1px solid var(--mform-border, #d9d5e6);padding-top:8px}.dz-section summary{font-size:12px;font-weight:600;cursor:pointer;margin-bottom:8px}.dz-section .dz-prop{margin-bottom:8px}.dz-option-row{display:flex;gap:6px;margin-bottom:6px}.dz-option-row input{flex:1;min-width:0;font-size:12px;padding:6px 8px;border:1px solid var(--mform-border, #d9d5e6);border-radius:6px}.dz-option-row button{border:none;background:none;cursor:pointer;color:var(--mform-muted, #6b6580)}.dz-add{font:inherit;font-size:12px;border:1px dashed var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0);background:none;border-radius:8px;padding:6px 10px;cursor:pointer}.dz-preview{padding:20px;overflow-y:auto}.dz-preview-data{margin-top:16px;font-size:12px}.dz-preview-data pre{background:var(--mform-surface, #fff);border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;padding:10px;overflow-x:auto}\n"], dependencies: [{ kind: "component", type: DesignerNodeComponent, selector: "mform-designer-node", inputs: ["node", "path", "engine", "selected"], outputs: ["selectNode"] }, { kind: "component", type: InspectorComponent, selector: "mform-inspector", inputs: ["node", "schema"], outputs: ["nodePatch"] }, { kind: "component", type: FormRendererComponent, selector: "mform-renderer", inputs: ["schema", "source", "initialData", "optionsProviders", "messages", "validators", "mode", "submitHandler", "labels"], outputs: ["submitted", "valueChanged"] }, { kind: "pipe", type: JsonPipe, name: "json" }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1675
+
1676
+ <!-- One template per panel, rendered into whichever dock holds it. -->
1677
+ <ng-template #panelTemplate let-panel>
1678
+ <section class="dz-panel" [attr.data-panel]="panel">
1679
+ <header class="dz-panel-head">
1680
+ <span>{{ panelLabel(panel) }}</span>
1681
+ <span class="dz-panel-actions">
1682
+ <button
1683
+ type="button"
1684
+ [title]="panelSide(panel) === 'left' ? 'Move right' : 'Move left'"
1685
+ (click)="dock(panel, panelSide(panel) === 'left' ? 'right' : 'left')"
1686
+ >{{ panelSide(panel) === 'left' ? '⇥' : '⇤' }}</button>
1687
+ <button type="button" title="Hide panel" (click)="dock(panel, 'hidden')">✕</button>
1688
+ </span>
1689
+ </header>
1690
+ <div class="dz-panel-body">
1691
+ @switch (panel) {
1692
+ @case ('palette') {
1693
+ @for (group of catalog(); track group.label) {
1694
+ <div class="dz-group">
1695
+ <button
1696
+ type="button"
1697
+ class="dz-group-toggle"
1698
+ [class.is-open]="isGroupOpen(group.label)"
1699
+ [attr.aria-expanded]="isGroupOpen(group.label)"
1700
+ (click)="toggleGroup(group.label)"
1701
+ >
1702
+ <span class="dz-caret" aria-hidden="true">{{ isGroupOpen(group.label) ? '▾' : '▸' }}</span>
1703
+ {{ group.label }}
1704
+ <span class="dz-group-count">{{ group.entries.length }}</span>
1705
+ </button>
1706
+ @if (isGroupOpen(group.label)) {
1707
+ <div class="dz-group-entries">
1708
+ @for (entry of group.entries; track entry.type) {
1709
+ <div class="dz-palette-row">
1710
+ <button
1711
+ type="button"
1712
+ class="dz-palette-item"
1713
+ draggable="true"
1714
+ (dragstart)="startEntryDrag(entry, $event)"
1715
+ (dragend)="endDrag()"
1716
+ (click)="add(entry)"
1717
+ [title]="'Add ' + entry.label + ' (or drag onto the canvas)'"
1718
+ >
1719
+ <span class="dz-icon">{{ entry.icon }}</span>{{ entry.label }}
1720
+ </button>
1721
+ @if (isBlockEntry(entry)) {
1722
+ <button
1723
+ type="button"
1724
+ class="dz-block-remove"
1725
+ (click)="removeBlock(blockIdOf(entry))"
1726
+ [attr.aria-label]="'Remove block ' + entry.label"
1727
+ >✕</button>
1728
+ }
1729
+ </div>
1730
+ }
1731
+ </div>
1732
+ }
1733
+ </div>
1734
+ }
1735
+ }
1736
+ @case ('structure') {
1737
+ <div class="dz-tree" role="tree">
1738
+ @for (item of tree(); track item.id) {
1739
+ <button
1740
+ type="button"
1741
+ role="treeitem"
1742
+ class="dz-tree-item"
1743
+ [class.is-selected]="isSelectedPath(item.path)"
1744
+ [style.padding-left.px]="8 + item.depth * 14"
1745
+ (click)="select(item.path)"
1746
+ >
1747
+ {{ item.label }}
1748
+ </button>
1749
+ } @empty {
1750
+ <span class="dz-empty">no fields yet</span>
1751
+ }
1752
+ </div>
1753
+ }
1754
+ @case ('properties') {
1755
+ @if (selectedNode(); as node) {
1756
+ <mform-inspector
1757
+ [node]="node"
1758
+ [schema]="draft()"
1759
+ (nodePatch)="applyPatch($event)"
1760
+ />
1761
+ } @else {
1762
+ <div class="dz-empty dz-props-empty">Select a field to edit its properties.</div>
1763
+ }
1764
+ }
1765
+ }
1766
+ </div>
1767
+ </section>
1768
+ </ng-template>
1769
+ `, isInline: true, styles: [".dz-shell{display:flex;flex-direction:column;height:100%;min-height:480px;font-family:var(--mform-font, system-ui, \"Segoe UI\", sans-serif);color:var(--mform-text, #1f1b2e);background:var(--mform-bg, #f6f5fa);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);overflow:hidden}.dz-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:8px 12px;background:var(--mform-surface, #fff);border-bottom:1px solid var(--mform-border, #d9d5e6)}.dz-tools{display:flex;align-items:center;gap:6px}.dz-toolbar button{font:inherit;font-size:13px;padding:6px 10px;border:1px solid transparent;border-radius:8px;background:none;color:inherit;cursor:pointer}.dz-toolbar button:hover:not(:disabled){background:var(--mform-bg, #f6f5fa)}.dz-toolbar button:disabled{opacity:.35;cursor:default}.dz-toolbar .dz-primary{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.dz-sep{width:1px;height:18px;background:var(--mform-border, #d9d5e6)}.dz-diag-badge{color:#b45309;font-weight:600}.dz-diagnostics{max-height:140px;overflow-y:auto;padding:8px 14px;background:#fdf5ef;border-bottom:1px solid var(--mform-border, #d9d5e6);font-size:12px}.dz-diag{padding:2px 0;color:#9a3412}.dz-diag.is-warning{color:#92400e}.dz-diag code{font-weight:600;margin-right:6px}.dz-diag em{color:var(--mform-muted, #6b6580);margin-left:6px}.dz-dialog{padding:14px;background:var(--mform-surface, #fff);border-bottom:1px solid var(--mform-border, #d9d5e6)}.dz-dialog h4{margin:0 0 8px;font-size:13px}.dz-dialog textarea{width:100%;font-family:ui-monospace,Consolas,monospace;font-size:12px;border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;padding:10px;box-sizing:border-box;resize:vertical}.dz-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}.dz-dialog-actions button{font:inherit;font-size:13px;padding:7px 14px;border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;background:none;cursor:pointer}.dz-dialog-actions .dz-primary{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border-color:transparent}.dz-import-error{color:var(--mform-danger, #c62842);font-size:12px}.dz-body{display:grid;flex:1;min-height:0}.dz-dock{display:flex;flex-direction:column;min-height:0;overflow-y:auto;background:var(--mform-surface, #fff)}.dz-dock-left{border-right:1px solid var(--mform-border, #d9d5e6)}.dz-dock-right{border-left:1px solid var(--mform-border, #d9d5e6)}.dz-panel{display:flex;flex-direction:column;border-bottom:1px solid var(--mform-border, #d9d5e6)}.dz-panel:last-child{border-bottom:none;flex:1}.dz-panel-head{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mform-muted, #6b6580);background:var(--mform-bg, #f6f5fa);position:sticky;top:0;z-index:1}.dz-panel-actions{display:flex;gap:2px;opacity:0;transition:opacity .12s}.dz-panel:hover .dz-panel-actions,.dz-panel:focus-within .dz-panel-actions{opacity:1}.dz-panel-actions button{border:none;background:none;color:inherit;cursor:pointer;font-size:11px;padding:2px 4px;border-radius:4px}.dz-panel-actions button:hover{background:var(--mform-surface, #fff)}.dz-panel-body{padding:10px}.dz-menu{position:relative}.dz-menu-panel{position:absolute;top:calc(100% + 6px);right:0;z-index:20;min-width:260px;padding:8px;background:var(--mform-surface, #fff);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);box-shadow:0 10px 30px #1f1b2e26}.dz-menu-title{display:block;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--mform-muted, #6b6580);padding:6px 6px 4px}.dz-menu-row{display:flex;align-items:center;justify-content:space-between;padding:4px 6px;font-size:13px}.dz-seg{display:flex;gap:2px}.dz-seg button{border:1px solid var(--mform-border, #d9d5e6);background:none;color:var(--mform-muted, #6b6580);border-radius:6px;font-size:11px;padding:3px 8px;cursor:pointer}.dz-seg button.is-on{background:var(--mform-primary, #6d4bd0);border-color:transparent;color:var(--mform-primary-contrast, #fff)}.dz-menu-item{display:block;width:100%;text-align:left;font:inherit;font-size:13px;padding:6px;border:none;border-radius:6px;background:none;color:inherit;cursor:pointer}.dz-menu-item:hover{background:var(--mform-bg, #f6f5fa)}.dz-group{margin-bottom:4px}.dz-group-toggle{display:flex;align-items:center;gap:6px;width:100%;font:inherit;font-size:12px;font-weight:600;text-align:left;padding:6px;border:none;border-radius:6px;background:none;color:inherit;cursor:pointer}.dz-group-toggle:hover{background:var(--mform-bg, #f6f5fa)}.dz-caret{font-size:10px;color:var(--mform-muted, #6b6580);width:10px}.dz-group-count{margin-left:auto;font-size:10px;font-weight:400;color:var(--mform-muted, #6b6580);background:var(--mform-bg, #f6f5fa);border-radius:999px;padding:1px 7px}.dz-group-entries{display:flex;flex-direction:column;gap:2px;margin:2px 0 6px 10px}.dz-palette-item{display:flex;align-items:center;gap:8px;font:inherit;font-size:13px;text-align:left;padding:6px 8px;border:none;border-radius:8px;background:none;color:inherit;cursor:pointer}.dz-palette-item:hover{background:var(--mform-bg, #f6f5fa)}.dz-icon{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--mform-border, #d9d5e6);border-radius:6px;font-size:11px}.dz-tree{display:flex;flex-direction:column;gap:1px}.dz-tree-item{font:inherit;font-size:12px;text-align:left;padding:4px 8px;border:none;border-radius:6px;background:none;color:inherit;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dz-tree-item:hover{background:var(--mform-bg, #f6f5fa)}.dz-tree-item.is-selected{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.dz-canvas{padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:10px}.dz-canvas-empty,.dz-empty{color:var(--mform-muted, #6b6580);font-size:13px;border:1px dashed var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);padding:18px;text-align:center}.dz-dropzone{height:4px;border-radius:999px;transition:height .1s,background .1s}.dz-canvas.is-dragging .dz-dropzone{height:14px;background:repeating-linear-gradient(90deg,var(--mform-border, #d9d5e6) 0 6px,transparent 6px 12px)}.dz-canvas.is-dragging .dz-dropzone.is-over{height:22px;background:var(--mform-primary, #6d4bd0)}.dz-palette-row{display:flex;align-items:center;gap:2px}.dz-palette-row .dz-palette-item{flex:1;cursor:grab}.dz-block-remove{border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer;font-size:11px;padding:2px 4px;opacity:0}.dz-palette-row:hover .dz-block-remove{opacity:1}.dz-node{position:relative;border:1px solid transparent;border-radius:var(--mform-radius, 10px);padding:20px 10px 10px;cursor:pointer;background:var(--mform-surface, #fff)}.dz-node:hover{border-color:var(--mform-border, #d9d5e6)}.dz-node.is-selected{border-color:var(--mform-primary, #6d4bd0);box-shadow:0 0 0 1px var(--mform-primary, #6d4bd0)}.dz-chip{position:absolute;top:2px;left:10px;font-size:10px;color:var(--mform-muted, #6b6580);pointer-events:none}.dz-node.is-selected>.dz-chip{color:var(--mform-primary, #6d4bd0);font-weight:600}.dz-children{display:flex;flex-direction:column;gap:8px}.dz-columns{display:grid;gap:10px}.dz-column{display:flex;flex-direction:column;gap:8px;border:1px dashed var(--mform-border, #d9d5e6);border-radius:8px;padding:8px;min-height:40px}.dz-tabbar{display:flex;gap:4px;margin-bottom:8px;border-bottom:2px solid var(--mform-border, #d9d5e6)}.dz-tab{font:inherit;font-size:12px;padding:5px 12px;border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer}.dz-tab.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600;border-bottom:2px solid var(--mform-primary, #6d4bd0);margin-bottom:-2px}.dz-leaf{pointer-events:none}.dz-props-empty{margin:4px 0}.dz-inspector{display:flex;flex-direction:column;gap:10px}.dz-inspector h3{margin:0;font-size:13px}.dz-inspector h3 code{color:var(--mform-muted, #6b6580);font-weight:400}.dz-prop{display:flex;flex-direction:column;gap:4px;font-size:12px}.dz-prop span{color:var(--mform-muted, #6b6580)}.dz-prop input,.dz-prop select,.dz-prop textarea{font:inherit;font-size:13px;padding:7px 9px;border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;background:#fff;width:100%;box-sizing:border-box}.dz-check{display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer}.dz-check input{accent-color:var(--mform-primary, #6d4bd0)}.dz-section{border-top:1px solid var(--mform-border, #d9d5e6);padding-top:8px}.dz-section summary{font-size:12px;font-weight:600;cursor:pointer;margin-bottom:8px}.dz-section .dz-prop{margin-bottom:8px}.dz-option-row{display:flex;gap:6px;margin-bottom:6px}.dz-option-row input{flex:1;min-width:0;font-size:12px;padding:6px 8px;border:1px solid var(--mform-border, #d9d5e6);border-radius:6px}.dz-option-row button{border:none;background:none;cursor:pointer;color:var(--mform-muted, #6b6580)}.dz-add{font:inherit;font-size:12px;border:1px dashed var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0);background:none;border-radius:8px;padding:6px 10px;cursor:pointer}.dz-preview{padding:20px;overflow-y:auto}.dz-preview-data{margin-top:16px;font-size:12px}.dz-preview-data pre{background:var(--mform-surface, #fff);border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;padding:10px;overflow-x:auto}\n"], dependencies: [{ kind: "component", type: DesignerNodeComponent, selector: "mform-designer-node", inputs: ["node", "path", "engine", "selected"], outputs: ["selectNode"] }, { kind: "component", type: InspectorComponent, selector: "mform-inspector", inputs: ["node", "schema"], outputs: ["nodePatch"] }, { kind: "component", type: FormRendererComponent, selector: "mform-renderer", inputs: ["schema", "source", "initialData", "optionsProviders", "messages", "validators", "mode", "submitHandler", "labels"], outputs: ["submitted", "valueChanged"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "pipe", type: JsonPipe, name: "json" }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1153
1770
  }
1154
1771
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: FormDesignerComponent, decorators: [{
1155
1772
  type: Component,
1156
- args: [{ selector: 'mform-designer', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [DesignerNodeComponent, InspectorComponent, FormRendererComponent, JsonPipe], template: `
1773
+ args: [{ selector: 'mform-designer', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [
1774
+ DesignerNodeComponent,
1775
+ InspectorComponent,
1776
+ FormRendererComponent,
1777
+ JsonPipe,
1778
+ NgTemplateOutlet,
1779
+ ], template: `
1157
1780
  <div class="dz-shell" (keydown)="onKeydown($event)">
1158
1781
  <header class="dz-toolbar">
1159
1782
  <div class="dz-tools">
@@ -1164,6 +1787,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
1164
1787
  <button type="button" (click)="moveSelected(1)" [disabled]="!selected()" title="Move down">↓</button>
1165
1788
  <button type="button" (click)="duplicateSelected()" [disabled]="!selected()" title="Duplicate (Ctrl+D)">⧉</button>
1166
1789
  <button type="button" (click)="removeSelected()" [disabled]="!selected()" title="Delete (Del)">🗑</button>
1790
+ <button type="button" (click)="saveAsBlock()" [disabled]="!selected()" title="Save as reusable block">⊞</button>
1167
1791
  </div>
1168
1792
  <div class="dz-tools">
1169
1793
  @if (diagnostics().length > 0) {
@@ -1171,6 +1795,37 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
1171
1795
  ⚠ {{ diagnostics().length }}
1172
1796
  </button>
1173
1797
  }
1798
+ <div class="dz-menu">
1799
+ <button type="button" (click)="workspaceMenu.set(!workspaceMenu())" title="Workspace layout">
1800
+ ▦ Workspace
1801
+ </button>
1802
+ @if (workspaceMenu()) {
1803
+ <div class="dz-menu-panel" role="menu">
1804
+ <span class="dz-menu-title">Panels</span>
1805
+ @for (panel of panelIds; track panel) {
1806
+ <div class="dz-menu-row">
1807
+ <span>{{ panelLabel(panel) }}</span>
1808
+ <span class="dz-seg">
1809
+ @for (side of dockSides; track side) {
1810
+ <button
1811
+ type="button"
1812
+ [class.is-on]="layout()[panel] === side"
1813
+ (click)="dock(panel, side)"
1814
+ >{{ sideLabels[side] }}</button>
1815
+ }
1816
+ </span>
1817
+ </div>
1818
+ }
1819
+ <span class="dz-menu-title">Presets</span>
1820
+ @for (preset of presets; track preset.id) {
1821
+ <button type="button" class="dz-menu-item" (click)="applyPreset(preset.id)">
1822
+ {{ preset.label }}
1823
+ </button>
1824
+ }
1825
+ <button type="button" class="dz-menu-item" (click)="resetLayout()">Reset layout</button>
1826
+ </div>
1827
+ }
1828
+ </div>
1174
1829
  <button type="button" (click)="dialog.set(dialog() === 'import' ? null : 'import')">Import</button>
1175
1830
  <button type="button" (click)="openExport()">Export</button>
1176
1831
  <button type="button" class="dz-primary" (click)="preview.set(!preview())">
@@ -1221,73 +1876,152 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
1221
1876
  </details>
1222
1877
  </div>
1223
1878
  } @else {
1224
- <div class="dz-body">
1225
- <aside class="dz-palette">
1226
- <h4>Fields</h4>
1227
- @for (group of catalog(); track group.label) {
1228
- <div class="dz-group">
1229
- <span class="dz-group-label">{{ group.label }}</span>
1230
- <div class="dz-group-entries">
1231
- @for (entry of group.entries; track entry.type) {
1232
- <button type="button" class="dz-palette-item" (click)="add(entry)"
1233
- [title]="'Add ' + entry.label">
1234
- <span class="dz-icon">{{ entry.icon }}</span>{{ entry.label }}
1235
- </button>
1236
- }
1237
- </div>
1238
- </div>
1239
- }
1240
- <h4>Structure</h4>
1241
- <div class="dz-tree" role="tree">
1242
- @for (item of tree(); track item.id) {
1243
- <button
1244
- type="button"
1245
- role="treeitem"
1246
- class="dz-tree-item"
1247
- [class.is-selected]="isSelectedPath(item.path)"
1248
- [style.padding-left.px]="8 + item.depth * 14"
1249
- (click)="select(item.path)"
1250
- >
1251
- {{ item.label }}
1252
- </button>
1253
- } @empty {
1254
- <span class="dz-empty">no fields yet</span>
1879
+ <div class="dz-body" [style.grid-template-columns]="bodyColumns()">
1880
+ @if (dockedPanels('left').length > 0) {
1881
+ <aside class="dz-dock dz-dock-left">
1882
+ @for (panel of dockedPanels('left'); track panel) {
1883
+ <ng-container *ngTemplateOutlet="panelTemplate; context: { $implicit: panel }" />
1255
1884
  }
1256
- </div>
1257
- </aside>
1885
+ </aside>
1886
+ }
1258
1887
 
1259
- <main class="dz-canvas" (click)="select(null)">
1888
+ <main class="dz-canvas" [class.is-dragging]="isDragging()" (click)="select(null)">
1260
1889
  @for (node of draft().fields; track node.key; let i = $index) {
1890
+ <div
1891
+ class="dz-dropzone"
1892
+ [class.is-over]="isDropTarget([i])"
1893
+ (dragover)="allowDrop([i], $event)"
1894
+ (drop)="drop([i], $event)"
1895
+ ></div>
1261
1896
  <mform-designer-node
1262
1897
  [node]="node"
1263
1898
  [path]="[i]"
1264
1899
  [engine]="canvasEngine()"
1265
1900
  [selected]="selected()"
1266
1901
  (selectNode)="select($event)"
1902
+ draggable="true"
1903
+ (dragstart)="startNodeDrag([i], $event)"
1904
+ (dragend)="endDrag()"
1267
1905
  />
1268
1906
  } @empty {
1269
1907
  <div class="dz-canvas-empty">
1270
- Click a field in the palette to start building.
1908
+ Click a field in the palette — or drag it here — to start building.
1271
1909
  </div>
1272
1910
  }
1911
+ <div
1912
+ class="dz-dropzone is-last"
1913
+ [class.is-over]="isDropTarget([draft().fields.length])"
1914
+ (dragover)="allowDrop([draft().fields.length], $event)"
1915
+ (drop)="drop([draft().fields.length], $event)"
1916
+ ></div>
1273
1917
  </main>
1274
1918
 
1275
- <aside class="dz-props">
1276
- @if (selectedNode(); as node) {
1277
- <mform-inspector
1278
- [node]="node"
1279
- [schema]="draft()"
1280
- (nodePatch)="applyPatch($event)"
1281
- />
1282
- } @else {
1283
- <div class="dz-empty dz-props-empty">Select a field to edit its properties.</div>
1284
- }
1285
- </aside>
1919
+ @if (dockedPanels('right').length > 0) {
1920
+ <aside class="dz-dock dz-dock-right">
1921
+ @for (panel of dockedPanels('right'); track panel) {
1922
+ <ng-container *ngTemplateOutlet="panelTemplate; context: { $implicit: panel }" />
1923
+ }
1924
+ </aside>
1925
+ }
1286
1926
  </div>
1287
1927
  }
1288
1928
  </div>
1289
- `, styles: [".dz-shell{display:flex;flex-direction:column;height:100%;min-height:480px;font-family:var(--mform-font, system-ui, \"Segoe UI\", sans-serif);color:var(--mform-text, #1f1b2e);background:var(--mform-bg, #f6f5fa);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);overflow:hidden}.dz-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:8px 12px;background:var(--mform-surface, #fff);border-bottom:1px solid var(--mform-border, #d9d5e6)}.dz-tools{display:flex;align-items:center;gap:6px}.dz-toolbar button{font:inherit;font-size:13px;padding:6px 10px;border:1px solid transparent;border-radius:8px;background:none;color:inherit;cursor:pointer}.dz-toolbar button:hover:not(:disabled){background:var(--mform-bg, #f6f5fa)}.dz-toolbar button:disabled{opacity:.35;cursor:default}.dz-toolbar .dz-primary{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.dz-sep{width:1px;height:18px;background:var(--mform-border, #d9d5e6)}.dz-diag-badge{color:#b45309;font-weight:600}.dz-diagnostics{max-height:140px;overflow-y:auto;padding:8px 14px;background:#fdf5ef;border-bottom:1px solid var(--mform-border, #d9d5e6);font-size:12px}.dz-diag{padding:2px 0;color:#9a3412}.dz-diag.is-warning{color:#92400e}.dz-diag code{font-weight:600;margin-right:6px}.dz-diag em{color:var(--mform-muted, #6b6580);margin-left:6px}.dz-dialog{padding:14px;background:var(--mform-surface, #fff);border-bottom:1px solid var(--mform-border, #d9d5e6)}.dz-dialog h4{margin:0 0 8px;font-size:13px}.dz-dialog textarea{width:100%;font-family:ui-monospace,Consolas,monospace;font-size:12px;border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;padding:10px;box-sizing:border-box;resize:vertical}.dz-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}.dz-dialog-actions button{font:inherit;font-size:13px;padding:7px 14px;border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;background:none;cursor:pointer}.dz-dialog-actions .dz-primary{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border-color:transparent}.dz-import-error{color:var(--mform-danger, #c62842);font-size:12px}.dz-body{display:grid;grid-template-columns:240px 1fr 280px;flex:1;min-height:0}.dz-palette{border-right:1px solid var(--mform-border, #d9d5e6);background:var(--mform-surface, #fff);padding:12px;overflow-y:auto}.dz-palette h4{margin:4px 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mform-muted, #6b6580)}.dz-group{margin-bottom:10px}.dz-group-label{font-size:11px;color:var(--mform-muted, #6b6580)}.dz-group-entries{display:flex;flex-direction:column;gap:2px;margin-top:4px}.dz-palette-item{display:flex;align-items:center;gap:8px;font:inherit;font-size:13px;text-align:left;padding:6px 8px;border:none;border-radius:8px;background:none;color:inherit;cursor:pointer}.dz-palette-item:hover{background:var(--mform-bg, #f6f5fa)}.dz-icon{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--mform-border, #d9d5e6);border-radius:6px;font-size:11px}.dz-tree{display:flex;flex-direction:column;gap:1px}.dz-tree-item{font:inherit;font-size:12px;text-align:left;padding:4px 8px;border:none;border-radius:6px;background:none;color:inherit;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dz-tree-item:hover{background:var(--mform-bg, #f6f5fa)}.dz-tree-item.is-selected{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.dz-canvas{padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:10px}.dz-canvas-empty,.dz-empty{color:var(--mform-muted, #6b6580);font-size:13px;border:1px dashed var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);padding:18px;text-align:center}.dz-node{position:relative;border:1px solid transparent;border-radius:var(--mform-radius, 10px);padding:20px 10px 10px;cursor:pointer;background:var(--mform-surface, #fff)}.dz-node:hover{border-color:var(--mform-border, #d9d5e6)}.dz-node.is-selected{border-color:var(--mform-primary, #6d4bd0);box-shadow:0 0 0 1px var(--mform-primary, #6d4bd0)}.dz-chip{position:absolute;top:2px;left:10px;font-size:10px;color:var(--mform-muted, #6b6580);pointer-events:none}.dz-node.is-selected>.dz-chip{color:var(--mform-primary, #6d4bd0);font-weight:600}.dz-children{display:flex;flex-direction:column;gap:8px}.dz-columns{display:grid;gap:10px}.dz-column{display:flex;flex-direction:column;gap:8px;border:1px dashed var(--mform-border, #d9d5e6);border-radius:8px;padding:8px;min-height:40px}.dz-tabbar{display:flex;gap:4px;margin-bottom:8px;border-bottom:2px solid var(--mform-border, #d9d5e6)}.dz-tab{font:inherit;font-size:12px;padding:5px 12px;border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer}.dz-tab.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600;border-bottom:2px solid var(--mform-primary, #6d4bd0);margin-bottom:-2px}.dz-leaf{pointer-events:none}.dz-props{border-left:1px solid var(--mform-border, #d9d5e6);background:var(--mform-surface, #fff);overflow-y:auto}.dz-props-empty{margin:12px}.dz-inspector{padding:12px;display:flex;flex-direction:column;gap:10px}.dz-inspector h3{margin:0;font-size:13px}.dz-inspector h3 code{color:var(--mform-muted, #6b6580);font-weight:400}.dz-prop{display:flex;flex-direction:column;gap:4px;font-size:12px}.dz-prop span{color:var(--mform-muted, #6b6580)}.dz-prop input,.dz-prop select,.dz-prop textarea{font:inherit;font-size:13px;padding:7px 9px;border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;background:#fff;width:100%;box-sizing:border-box}.dz-check{display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer}.dz-check input{accent-color:var(--mform-primary, #6d4bd0)}.dz-section{border-top:1px solid var(--mform-border, #d9d5e6);padding-top:8px}.dz-section summary{font-size:12px;font-weight:600;cursor:pointer;margin-bottom:8px}.dz-section .dz-prop{margin-bottom:8px}.dz-option-row{display:flex;gap:6px;margin-bottom:6px}.dz-option-row input{flex:1;min-width:0;font-size:12px;padding:6px 8px;border:1px solid var(--mform-border, #d9d5e6);border-radius:6px}.dz-option-row button{border:none;background:none;cursor:pointer;color:var(--mform-muted, #6b6580)}.dz-add{font:inherit;font-size:12px;border:1px dashed var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0);background:none;border-radius:8px;padding:6px 10px;cursor:pointer}.dz-preview{padding:20px;overflow-y:auto}.dz-preview-data{margin-top:16px;font-size:12px}.dz-preview-data pre{background:var(--mform-surface, #fff);border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;padding:10px;overflow-x:auto}\n"] }]
1290
- }], ctorParameters: () => [], propDecorators: { schema: [{ type: i0.Input, args: [{ isSignal: true, alias: "schema", required: false }] }], source: [{ type: i0.Input, args: [{ isSignal: true, alias: "source", required: false }] }], extraCatalog: [{ type: i0.Input, args: [{ isSignal: true, alias: "extraCatalog", required: false }] }], schemaChanged: [{ type: i0.Output, args: ["schemaChanged"] }] } });
1929
+
1930
+ <!-- One template per panel, rendered into whichever dock holds it. -->
1931
+ <ng-template #panelTemplate let-panel>
1932
+ <section class="dz-panel" [attr.data-panel]="panel">
1933
+ <header class="dz-panel-head">
1934
+ <span>{{ panelLabel(panel) }}</span>
1935
+ <span class="dz-panel-actions">
1936
+ <button
1937
+ type="button"
1938
+ [title]="panelSide(panel) === 'left' ? 'Move right' : 'Move left'"
1939
+ (click)="dock(panel, panelSide(panel) === 'left' ? 'right' : 'left')"
1940
+ >{{ panelSide(panel) === 'left' ? '⇥' : '⇤' }}</button>
1941
+ <button type="button" title="Hide panel" (click)="dock(panel, 'hidden')">✕</button>
1942
+ </span>
1943
+ </header>
1944
+ <div class="dz-panel-body">
1945
+ @switch (panel) {
1946
+ @case ('palette') {
1947
+ @for (group of catalog(); track group.label) {
1948
+ <div class="dz-group">
1949
+ <button
1950
+ type="button"
1951
+ class="dz-group-toggle"
1952
+ [class.is-open]="isGroupOpen(group.label)"
1953
+ [attr.aria-expanded]="isGroupOpen(group.label)"
1954
+ (click)="toggleGroup(group.label)"
1955
+ >
1956
+ <span class="dz-caret" aria-hidden="true">{{ isGroupOpen(group.label) ? '▾' : '▸' }}</span>
1957
+ {{ group.label }}
1958
+ <span class="dz-group-count">{{ group.entries.length }}</span>
1959
+ </button>
1960
+ @if (isGroupOpen(group.label)) {
1961
+ <div class="dz-group-entries">
1962
+ @for (entry of group.entries; track entry.type) {
1963
+ <div class="dz-palette-row">
1964
+ <button
1965
+ type="button"
1966
+ class="dz-palette-item"
1967
+ draggable="true"
1968
+ (dragstart)="startEntryDrag(entry, $event)"
1969
+ (dragend)="endDrag()"
1970
+ (click)="add(entry)"
1971
+ [title]="'Add ' + entry.label + ' (or drag onto the canvas)'"
1972
+ >
1973
+ <span class="dz-icon">{{ entry.icon }}</span>{{ entry.label }}
1974
+ </button>
1975
+ @if (isBlockEntry(entry)) {
1976
+ <button
1977
+ type="button"
1978
+ class="dz-block-remove"
1979
+ (click)="removeBlock(blockIdOf(entry))"
1980
+ [attr.aria-label]="'Remove block ' + entry.label"
1981
+ >✕</button>
1982
+ }
1983
+ </div>
1984
+ }
1985
+ </div>
1986
+ }
1987
+ </div>
1988
+ }
1989
+ }
1990
+ @case ('structure') {
1991
+ <div class="dz-tree" role="tree">
1992
+ @for (item of tree(); track item.id) {
1993
+ <button
1994
+ type="button"
1995
+ role="treeitem"
1996
+ class="dz-tree-item"
1997
+ [class.is-selected]="isSelectedPath(item.path)"
1998
+ [style.padding-left.px]="8 + item.depth * 14"
1999
+ (click)="select(item.path)"
2000
+ >
2001
+ {{ item.label }}
2002
+ </button>
2003
+ } @empty {
2004
+ <span class="dz-empty">no fields yet</span>
2005
+ }
2006
+ </div>
2007
+ }
2008
+ @case ('properties') {
2009
+ @if (selectedNode(); as node) {
2010
+ <mform-inspector
2011
+ [node]="node"
2012
+ [schema]="draft()"
2013
+ (nodePatch)="applyPatch($event)"
2014
+ />
2015
+ } @else {
2016
+ <div class="dz-empty dz-props-empty">Select a field to edit its properties.</div>
2017
+ }
2018
+ }
2019
+ }
2020
+ </div>
2021
+ </section>
2022
+ </ng-template>
2023
+ `, styles: [".dz-shell{display:flex;flex-direction:column;height:100%;min-height:480px;font-family:var(--mform-font, system-ui, \"Segoe UI\", sans-serif);color:var(--mform-text, #1f1b2e);background:var(--mform-bg, #f6f5fa);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);overflow:hidden}.dz-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:8px 12px;background:var(--mform-surface, #fff);border-bottom:1px solid var(--mform-border, #d9d5e6)}.dz-tools{display:flex;align-items:center;gap:6px}.dz-toolbar button{font:inherit;font-size:13px;padding:6px 10px;border:1px solid transparent;border-radius:8px;background:none;color:inherit;cursor:pointer}.dz-toolbar button:hover:not(:disabled){background:var(--mform-bg, #f6f5fa)}.dz-toolbar button:disabled{opacity:.35;cursor:default}.dz-toolbar .dz-primary{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.dz-sep{width:1px;height:18px;background:var(--mform-border, #d9d5e6)}.dz-diag-badge{color:#b45309;font-weight:600}.dz-diagnostics{max-height:140px;overflow-y:auto;padding:8px 14px;background:#fdf5ef;border-bottom:1px solid var(--mform-border, #d9d5e6);font-size:12px}.dz-diag{padding:2px 0;color:#9a3412}.dz-diag.is-warning{color:#92400e}.dz-diag code{font-weight:600;margin-right:6px}.dz-diag em{color:var(--mform-muted, #6b6580);margin-left:6px}.dz-dialog{padding:14px;background:var(--mform-surface, #fff);border-bottom:1px solid var(--mform-border, #d9d5e6)}.dz-dialog h4{margin:0 0 8px;font-size:13px}.dz-dialog textarea{width:100%;font-family:ui-monospace,Consolas,monospace;font-size:12px;border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;padding:10px;box-sizing:border-box;resize:vertical}.dz-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}.dz-dialog-actions button{font:inherit;font-size:13px;padding:7px 14px;border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;background:none;cursor:pointer}.dz-dialog-actions .dz-primary{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border-color:transparent}.dz-import-error{color:var(--mform-danger, #c62842);font-size:12px}.dz-body{display:grid;flex:1;min-height:0}.dz-dock{display:flex;flex-direction:column;min-height:0;overflow-y:auto;background:var(--mform-surface, #fff)}.dz-dock-left{border-right:1px solid var(--mform-border, #d9d5e6)}.dz-dock-right{border-left:1px solid var(--mform-border, #d9d5e6)}.dz-panel{display:flex;flex-direction:column;border-bottom:1px solid var(--mform-border, #d9d5e6)}.dz-panel:last-child{border-bottom:none;flex:1}.dz-panel-head{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mform-muted, #6b6580);background:var(--mform-bg, #f6f5fa);position:sticky;top:0;z-index:1}.dz-panel-actions{display:flex;gap:2px;opacity:0;transition:opacity .12s}.dz-panel:hover .dz-panel-actions,.dz-panel:focus-within .dz-panel-actions{opacity:1}.dz-panel-actions button{border:none;background:none;color:inherit;cursor:pointer;font-size:11px;padding:2px 4px;border-radius:4px}.dz-panel-actions button:hover{background:var(--mform-surface, #fff)}.dz-panel-body{padding:10px}.dz-menu{position:relative}.dz-menu-panel{position:absolute;top:calc(100% + 6px);right:0;z-index:20;min-width:260px;padding:8px;background:var(--mform-surface, #fff);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);box-shadow:0 10px 30px #1f1b2e26}.dz-menu-title{display:block;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--mform-muted, #6b6580);padding:6px 6px 4px}.dz-menu-row{display:flex;align-items:center;justify-content:space-between;padding:4px 6px;font-size:13px}.dz-seg{display:flex;gap:2px}.dz-seg button{border:1px solid var(--mform-border, #d9d5e6);background:none;color:var(--mform-muted, #6b6580);border-radius:6px;font-size:11px;padding:3px 8px;cursor:pointer}.dz-seg button.is-on{background:var(--mform-primary, #6d4bd0);border-color:transparent;color:var(--mform-primary-contrast, #fff)}.dz-menu-item{display:block;width:100%;text-align:left;font:inherit;font-size:13px;padding:6px;border:none;border-radius:6px;background:none;color:inherit;cursor:pointer}.dz-menu-item:hover{background:var(--mform-bg, #f6f5fa)}.dz-group{margin-bottom:4px}.dz-group-toggle{display:flex;align-items:center;gap:6px;width:100%;font:inherit;font-size:12px;font-weight:600;text-align:left;padding:6px;border:none;border-radius:6px;background:none;color:inherit;cursor:pointer}.dz-group-toggle:hover{background:var(--mform-bg, #f6f5fa)}.dz-caret{font-size:10px;color:var(--mform-muted, #6b6580);width:10px}.dz-group-count{margin-left:auto;font-size:10px;font-weight:400;color:var(--mform-muted, #6b6580);background:var(--mform-bg, #f6f5fa);border-radius:999px;padding:1px 7px}.dz-group-entries{display:flex;flex-direction:column;gap:2px;margin:2px 0 6px 10px}.dz-palette-item{display:flex;align-items:center;gap:8px;font:inherit;font-size:13px;text-align:left;padding:6px 8px;border:none;border-radius:8px;background:none;color:inherit;cursor:pointer}.dz-palette-item:hover{background:var(--mform-bg, #f6f5fa)}.dz-icon{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--mform-border, #d9d5e6);border-radius:6px;font-size:11px}.dz-tree{display:flex;flex-direction:column;gap:1px}.dz-tree-item{font:inherit;font-size:12px;text-align:left;padding:4px 8px;border:none;border-radius:6px;background:none;color:inherit;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dz-tree-item:hover{background:var(--mform-bg, #f6f5fa)}.dz-tree-item.is-selected{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.dz-canvas{padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:10px}.dz-canvas-empty,.dz-empty{color:var(--mform-muted, #6b6580);font-size:13px;border:1px dashed var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);padding:18px;text-align:center}.dz-dropzone{height:4px;border-radius:999px;transition:height .1s,background .1s}.dz-canvas.is-dragging .dz-dropzone{height:14px;background:repeating-linear-gradient(90deg,var(--mform-border, #d9d5e6) 0 6px,transparent 6px 12px)}.dz-canvas.is-dragging .dz-dropzone.is-over{height:22px;background:var(--mform-primary, #6d4bd0)}.dz-palette-row{display:flex;align-items:center;gap:2px}.dz-palette-row .dz-palette-item{flex:1;cursor:grab}.dz-block-remove{border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer;font-size:11px;padding:2px 4px;opacity:0}.dz-palette-row:hover .dz-block-remove{opacity:1}.dz-node{position:relative;border:1px solid transparent;border-radius:var(--mform-radius, 10px);padding:20px 10px 10px;cursor:pointer;background:var(--mform-surface, #fff)}.dz-node:hover{border-color:var(--mform-border, #d9d5e6)}.dz-node.is-selected{border-color:var(--mform-primary, #6d4bd0);box-shadow:0 0 0 1px var(--mform-primary, #6d4bd0)}.dz-chip{position:absolute;top:2px;left:10px;font-size:10px;color:var(--mform-muted, #6b6580);pointer-events:none}.dz-node.is-selected>.dz-chip{color:var(--mform-primary, #6d4bd0);font-weight:600}.dz-children{display:flex;flex-direction:column;gap:8px}.dz-columns{display:grid;gap:10px}.dz-column{display:flex;flex-direction:column;gap:8px;border:1px dashed var(--mform-border, #d9d5e6);border-radius:8px;padding:8px;min-height:40px}.dz-tabbar{display:flex;gap:4px;margin-bottom:8px;border-bottom:2px solid var(--mform-border, #d9d5e6)}.dz-tab{font:inherit;font-size:12px;padding:5px 12px;border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer}.dz-tab.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600;border-bottom:2px solid var(--mform-primary, #6d4bd0);margin-bottom:-2px}.dz-leaf{pointer-events:none}.dz-props-empty{margin:4px 0}.dz-inspector{display:flex;flex-direction:column;gap:10px}.dz-inspector h3{margin:0;font-size:13px}.dz-inspector h3 code{color:var(--mform-muted, #6b6580);font-weight:400}.dz-prop{display:flex;flex-direction:column;gap:4px;font-size:12px}.dz-prop span{color:var(--mform-muted, #6b6580)}.dz-prop input,.dz-prop select,.dz-prop textarea{font:inherit;font-size:13px;padding:7px 9px;border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;background:#fff;width:100%;box-sizing:border-box}.dz-check{display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer}.dz-check input{accent-color:var(--mform-primary, #6d4bd0)}.dz-section{border-top:1px solid var(--mform-border, #d9d5e6);padding-top:8px}.dz-section summary{font-size:12px;font-weight:600;cursor:pointer;margin-bottom:8px}.dz-section .dz-prop{margin-bottom:8px}.dz-option-row{display:flex;gap:6px;margin-bottom:6px}.dz-option-row input{flex:1;min-width:0;font-size:12px;padding:6px 8px;border:1px solid var(--mform-border, #d9d5e6);border-radius:6px}.dz-option-row button{border:none;background:none;cursor:pointer;color:var(--mform-muted, #6b6580)}.dz-add{font:inherit;font-size:12px;border:1px dashed var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0);background:none;border-radius:8px;padding:6px 10px;cursor:pointer}.dz-preview{padding:20px;overflow-y:auto}.dz-preview-data{margin-top:16px;font-size:12px}.dz-preview-data pre{background:var(--mform-surface, #fff);border:1px solid var(--mform-border, #d9d5e6);border-radius:8px;padding:10px;overflow-x:auto}\n"] }]
2024
+ }], ctorParameters: () => [], propDecorators: { schema: [{ type: i0.Input, args: [{ isSignal: true, alias: "schema", required: false }] }], source: [{ type: i0.Input, args: [{ isSignal: true, alias: "source", required: false }] }], extraCatalog: [{ type: i0.Input, args: [{ isSignal: true, alias: "extraCatalog", required: false }] }], schemaChanged: [{ type: i0.Output, args: ["schemaChanged"] }], blocks: [{ type: i0.Input, args: [{ isSignal: true, alias: "blocks", required: false }] }], blocksChanged: [{ type: i0.Output, args: ["blocksChanged"] }] } });
1291
2025
 
1292
2026
  /*
1293
2027
  * Designer entry point (`@mosaicoo/form-angular/designer`).
@@ -1301,5 +2035,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
1301
2035
  * Generated bundle index. Do not edit.
1302
2036
  */
1303
2037
 
1304
- export { DEFAULT_CATALOG, DesignerNodeComponent, FormDesignerComponent, InspectorComponent };
2038
+ export { DEFAULT_CATALOG, DEFAULT_LAYOUT, DesignerNodeComponent, FormDesignerComponent, InspectorComponent, PANEL_LABELS, WORKSPACE_PRESETS };
1305
2039
  //# sourceMappingURL=mosaicoo-form-angular-designer.mjs.map