@workbench-kit/shell-react 0.0.2-prototype.0.2.41 → 0.0.2-prototype.0.2.43

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.
Files changed (35) hide show
  1. package/README.md +42 -2
  2. package/package.json +13 -10
  3. package/src/extensions/extension-enablement-controller.ts +40 -9
  4. package/src/extensions/theme-selection-protection.ts +80 -55
  5. package/src/field-remap/chrome-labels.ts +18 -0
  6. package/src/field-remap/convert-note-editor.tsx +30 -24
  7. package/src/field-remap/convert-palette.tsx +6 -0
  8. package/src/field-remap/demo.tsx +8 -2
  9. package/src/field-remap/detail-panel.tsx +225 -192
  10. package/src/field-remap/drag-payload.ts +48 -0
  11. package/src/field-remap/flow-adapter.ts +216 -88
  12. package/src/field-remap/flow-ops.ts +150 -0
  13. package/src/field-remap/flow.tsx +1055 -162
  14. package/src/field-remap/index.ts +2 -0
  15. package/src/field-remap/io-class-browse.tsx +34 -22
  16. package/src/field-remap/keyboard.ts +16 -0
  17. package/src/field-remap/modal-detail.tsx +34 -0
  18. package/src/field-remap/panel.tsx +50 -14
  19. package/src/field-remap/transform-options-editor.tsx +5 -1
  20. package/src/field-remap/view.css +61 -42
  21. package/src/index.ts +7 -0
  22. package/src/management/keybinding-overrides-storage.ts +48 -23
  23. package/src/management/keybinding-settings.tsx +10 -1
  24. package/src/management/use-keybinding-management.ts +69 -19
  25. package/src/shell/appearance-catalog.ts +453 -0
  26. package/src/shell/appearance-controller.ts +331 -0
  27. package/src/shell/appearance-presentation.ts +269 -0
  28. package/src/shell/provider.tsx +147 -18
  29. package/src/shell/settings.tsx +282 -153
  30. package/src/shell/shell.tsx +88 -18
  31. package/src/workbench/appearance-storage.ts +2 -2
  32. package/src/workbench/command-host-controller.tsx +223 -0
  33. package/src/workbench/command-host.tsx +100 -150
  34. package/src/workbench/keybinding-bridge.ts +84 -38
  35. package/src/workbench/shell-command-registration.ts +27 -2
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  Children,
3
+ lazy,
4
+ Suspense,
3
5
  useCallback,
4
6
  useEffect,
5
7
  useImperativeHandle,
@@ -7,6 +9,7 @@ import {
7
9
  useRef,
8
10
  useState,
9
11
  type JSX,
12
+ type DragEvent,
10
13
  type KeyboardEvent,
11
14
  type MouseEvent,
12
15
  type Ref,
@@ -21,19 +24,25 @@ import {
21
24
  Position,
22
25
  ReactFlow,
23
26
  ReactFlowProvider,
27
+ SmoothStepEdge,
24
28
  useEdgesState,
25
29
  useNodesState,
26
30
  useReactFlow,
27
31
  type Connection,
28
32
  type Edge,
33
+ type EdgeProps,
34
+ type FinalConnectionState,
29
35
  type Node,
30
36
  type NodeProps,
37
+ type XYPosition,
31
38
  } from '@xyflow/react';
32
39
  import '@xyflow/react/dist/style.css';
33
40
  import { Badge, IconButton } from '@workbench-kit/react/primitives';
34
41
  import { SplitView } from '@workbench-kit/react/workbench/split-view';
35
42
  import {
36
43
  MAX_TRANSFORM_CHAIN,
44
+ findParentChildMappingConflicts,
45
+ type MappingConflict,
37
46
  type MappingEdge,
38
47
  type MappingOperator,
39
48
  type SourceField,
@@ -42,6 +51,7 @@ import {
42
51
  } from '@workbench-kit/field-remap';
43
52
 
44
53
  import { FieldRemapConvertPalette } from './convert-palette.js';
54
+ import { hasFieldRemapTransformDragType, readFieldRemapTransformDragData } from './drag-payload.js';
45
55
  import {
46
56
  resolveFieldRemapChromeLabels,
47
57
  type FieldRemapChromeLabels,
@@ -50,6 +60,7 @@ import {
50
60
  import { FieldRemapDetailPanel } from './detail-panel.js';
51
61
  import {
52
62
  applyFieldRemapFlowConnection,
63
+ evaluateFieldRemapFlowConnection,
53
64
  isValidFieldRemapFlowConnection,
54
65
  mappingToFlowGraph,
55
66
  parseDraftTransformNodeId,
@@ -57,6 +68,7 @@ import {
57
68
  type FieldRemapCombineOperatorNodeData,
58
69
  type FieldRemapDraftTransformNodeData,
59
70
  type FieldRemapFlowEdgeData,
71
+ type FieldRemapFlowConnectionRejectionReason,
60
72
  type FieldRemapFlowNodeData,
61
73
  type FieldRemapSourceObjectNodeData,
62
74
  type FieldRemapSplitOperatorNodeData,
@@ -77,16 +89,38 @@ import {
77
89
  edgePortTypes,
78
90
  enableListContextOnEdge,
79
91
  finalizeDraftTransform,
92
+ asFieldRemapBulkSelectionRef,
93
+ fieldRemapBulkSelectionKey,
94
+ fieldRemapSelectionKey,
80
95
  listCompatibleTransforms,
96
+ listFieldRemapBulkSelectionRefs,
97
+ normalizeFieldRemapBulkSelection,
98
+ planFieldRemapBulkDelete,
81
99
  removeMappingOperator,
82
- removeTransformStepFromEdge,
100
+ updateFieldRemapBulkSelection,
83
101
  updateMappingOperator,
102
+ type FieldRemapBulkSelectionRef,
84
103
  type FieldRemapDraftTransform,
85
104
  type FieldRemapSelection,
86
105
  } from './flow-ops.js';
106
+ import { isFieldRemapEditableShortcutTarget } from './keyboard.js';
87
107
  import { FieldRemapPreviewRail, type FieldRemapPreviewState } from './preview.js';
88
108
  import './view.css';
89
109
 
110
+ type FieldRemapFocusSurface = 'graph' | 'list';
111
+ type FieldRemapFocusableElement = HTMLElement | SVGElement;
112
+ type RegisterFieldRemapFocusTarget = (
113
+ key: string,
114
+ surface: FieldRemapFocusSurface,
115
+ element: FieldRemapFocusableElement | null,
116
+ ) => void;
117
+
118
+ const loadFieldRemapModalDetail = async () => {
119
+ const module = await import('./modal-detail.js');
120
+ return { default: module.FieldRemapModalDetail };
121
+ };
122
+ const FieldRemapModalDetail = lazy(loadFieldRemapModalDetail);
123
+
90
124
  function TypeBadge({ dataType }: { readonly dataType?: string }): JSX.Element | null {
91
125
  if (dataType !== 'object' && dataType !== 'array') {
92
126
  return null;
@@ -159,8 +193,21 @@ function TargetObjectNode({ data }: NodeProps<Node<FieldRemapTargetObjectNodeDat
159
193
  }
160
194
 
161
195
  function TransformNode({ data }: NodeProps<Node<FieldRemapTransformNodeData>>): JSX.Element {
196
+ const registerFocusTarget = data.registerFocusTarget as RegisterFieldRemapFocusTarget | undefined;
197
+ const selectionKey = fieldRemapBulkSelectionKey({
198
+ kind: 'transformStep',
199
+ edgeId: data.mappingEdgeId,
200
+ stepIndex: data.stepIndex,
201
+ });
202
+ const registerRoot = useCallback(
203
+ (element: HTMLDivElement | null) => {
204
+ registerFocusTarget?.(selectionKey, 'graph', element?.parentElement ?? null);
205
+ },
206
+ [registerFocusTarget, selectionKey],
207
+ );
162
208
  return (
163
209
  <div
210
+ ref={registerRoot}
164
211
  className={
165
212
  data.selected
166
213
  ? 'workbench-field-remap-flow-node workbench-field-remap-flow-node--transform is-selected'
@@ -177,6 +224,31 @@ function TransformNode({ data }: NodeProps<Node<FieldRemapTransformNodeData>>):
177
224
  );
178
225
  }
179
226
 
227
+ function FieldRemapSmoothStepEdge(props: EdgeProps<Edge<FieldRemapFlowEdgeData>>): JSX.Element {
228
+ const registerFocusTarget = props.data?.registerFocusTarget as
229
+ RegisterFieldRemapFocusTarget | undefined;
230
+ const mappingEdgeId = props.data?.mappingEdgeId;
231
+ const canonicalSegment = props.data?.segment === 'direct' || props.data?.segment === 'in';
232
+ const selectionKey =
233
+ mappingEdgeId && canonicalSegment
234
+ ? fieldRemapBulkSelectionKey({ kind: 'edge', edgeId: mappingEdgeId })
235
+ : undefined;
236
+ const registerRoot = useCallback(
237
+ (element: SVGGElement | null) => {
238
+ if (selectionKey) {
239
+ registerFocusTarget?.(selectionKey, 'graph', element?.parentElement ?? null);
240
+ }
241
+ },
242
+ [registerFocusTarget, selectionKey],
243
+ );
244
+
245
+ return (
246
+ <g ref={selectionKey ? registerRoot : undefined}>
247
+ <SmoothStepEdge {...props} />
248
+ </g>
249
+ );
250
+ }
251
+
180
252
  function DraftTransformNode({
181
253
  data,
182
254
  }: NodeProps<Node<FieldRemapDraftTransformNodeData>>): JSX.Element {
@@ -289,6 +361,10 @@ const nodeTypes = {
289
361
  fieldRemapSplitOperator: SplitOperatorNode,
290
362
  };
291
363
 
364
+ const edgeTypes = {
365
+ smoothstep: FieldRemapSmoothStepEdge,
366
+ };
367
+
292
368
  /** Imperative Flow chrome actions for integrating hosts (fit-view without Controls DOM). */
293
369
  export interface FieldRemapFlowActions {
294
370
  fitView: (options?: { padding?: number; maxZoom?: number }) => void;
@@ -327,6 +403,16 @@ export interface FieldRemapFlowMapperProps {
327
403
  readonly edges: readonly MappingEdge[];
328
404
  readonly transforms: ValueTransformRegistry;
329
405
  readonly onEdgesChange: (edges: readonly MappingEdge[]) => void;
406
+ /** Existing target/donor edges are replaced by default; `reject` preserves them unchanged. */
407
+ readonly rewirePolicy?: 'replace' | 'reject' | undefined;
408
+ /** Structured completed-attempt feedback; hover validation never invokes this callback. */
409
+ readonly onConnectionFeedback?:
410
+ ((feedback: FieldRemapConnectionFeedback | null) => void) | undefined;
411
+ /**
412
+ * Authoritative parent/child conflict projection. `undefined` derives from supplied Flow inputs;
413
+ * an explicit empty array suppresses fallback derivation.
414
+ */
415
+ readonly parentChildConflicts?: readonly MappingConflict[] | undefined;
330
416
  /**
331
417
  * `card` preserves the demo chrome. `embed` omits the flow hint and binding list;
332
418
  * explicit `show*` props below take precedence.
@@ -334,6 +420,8 @@ export interface FieldRemapFlowMapperProps {
334
420
  readonly chrome?: 'card' | 'embed' | undefined;
335
421
  /** Show the empty-selection detail hint, or collapse that rail until a selection exists. */
336
422
  readonly emptyDetail?: 'hint' | 'collapse' | undefined;
423
+ /** Render selection detail in the resizable rail (default) or the shared workbench Modal. */
424
+ readonly detailPresentation?: 'rail' | 'modal' | undefined;
337
425
  /** Show the convert-first hint. Defaults to true for `card`, false for `embed`. */
338
426
  readonly showFlowHint?: boolean | undefined;
339
427
  /** Show the bottom binding list. Defaults to true for `card`, false for `embed`. */
@@ -343,6 +431,11 @@ export interface FieldRemapFlowMapperProps {
343
431
  * the workspace expands without leaving an empty grid track.
344
432
  */
345
433
  readonly showConvertPalette?: boolean | undefined;
434
+ /**
435
+ * View-only authoring guard. Existing mappings remain inspectable, while durable mutations and
436
+ * mapper-local drafts are disabled. This is not an authorization boundary.
437
+ */
438
+ readonly readOnly?: boolean | undefined;
346
439
  /** Document v2 n→m operators (display + authoring when onOperatorsChange is set). */
347
440
  readonly operators?: readonly MappingOperator[] | undefined;
348
441
  readonly onOperatorsChange?: ((operators: readonly MappingOperator[]) => void) | undefined;
@@ -404,9 +497,72 @@ export interface FieldRemapFlowMapperProps {
404
497
  readonly t?: FieldRemapTranslate | undefined;
405
498
  }
406
499
 
500
+ export type FieldRemapConnectionFeedbackReason =
501
+ FieldRemapFlowConnectionRejectionReason | 'rewire-policy-rejected';
502
+
503
+ export interface FieldRemapConnectionFeedback {
504
+ readonly reason: FieldRemapConnectionFeedbackReason;
505
+ readonly impactedEdgeIds?: readonly string[];
506
+ }
507
+
508
+ function areFieldRemapBulkSelectionsEqual(
509
+ left: readonly FieldRemapBulkSelectionRef[],
510
+ right: readonly FieldRemapBulkSelectionRef[],
511
+ ): boolean {
512
+ return (
513
+ left.length === right.length &&
514
+ left.every(
515
+ (ref, index) => fieldRemapBulkSelectionKey(ref) === fieldRemapBulkSelectionKey(right[index]!),
516
+ )
517
+ );
518
+ }
519
+
520
+ function fieldRemapBulkDomainKey(refs: readonly FieldRemapBulkSelectionRef[]): string {
521
+ return refs
522
+ .map(fieldRemapBulkSelectionKey)
523
+ .map((key) => `${key.length}:${key}`)
524
+ .join('');
525
+ }
526
+
527
+ function fieldRemapBulkRefFromKeyboardTarget(
528
+ target: EventTarget | null,
529
+ ): FieldRemapBulkSelectionRef | undefined {
530
+ if (!(target instanceof Element)) {
531
+ return undefined;
532
+ }
533
+
534
+ const explicit = target.closest<HTMLElement>('[data-field-remap-bulk-kind]');
535
+ const explicitKind = explicit?.dataset.fieldRemapBulkKind;
536
+ const explicitEdgeId = explicit?.dataset.fieldRemapBulkEdgeId;
537
+ if (explicitKind === 'edge' && explicitEdgeId) {
538
+ return { kind: 'edge', edgeId: explicitEdgeId };
539
+ }
540
+ if (explicitKind === 'transformStep' && explicitEdgeId) {
541
+ const stepIndex = Number(explicit.dataset.fieldRemapBulkStepIndex);
542
+ if (Number.isInteger(stepIndex) && stepIndex >= 0) {
543
+ return { kind: 'transformStep', edgeId: explicitEdgeId, stepIndex };
544
+ }
545
+ }
546
+ return undefined;
547
+ }
548
+
549
+ function connectionFromFinalState(state: FinalConnectionState): Connection | null {
550
+ if (!state.fromNode || !state.fromHandle || !state.toNode || !state.toHandle) {
551
+ return null;
552
+ }
553
+ const reversed = state.fromHandle.type === 'target';
554
+ return {
555
+ source: (reversed ? state.toNode : state.fromNode).id,
556
+ sourceHandle: (reversed ? state.toHandle : state.fromHandle).id ?? null,
557
+ target: (reversed ? state.fromNode : state.toNode).id,
558
+ targetHandle: (reversed ? state.fromHandle : state.toHandle).id ?? null,
559
+ };
560
+ }
561
+
407
562
  interface FieldRemapSplitWorkspaceProps {
408
563
  readonly children: ReactNode;
409
564
  readonly layout: 'wide' | 'medium' | 'narrow';
565
+ readonly reserveHiddenDetailSplit: boolean;
410
566
  readonly showConvertPalette: boolean;
411
567
  readonly showDetail: boolean;
412
568
  readonly surface: 'binding' | 'convert-note' | 'draft-convert' | 'operator';
@@ -420,6 +576,7 @@ interface FieldRemapSplitWorkspaceProps {
420
576
  function FieldRemapSplitWorkspace({
421
577
  children,
422
578
  layout,
579
+ reserveHiddenDetailSplit,
423
580
  showConvertPalette,
424
581
  showDetail,
425
582
  surface,
@@ -438,26 +595,29 @@ function FieldRemapSplitWorkspace({
438
595
  });
439
596
  const paletteSizePx = paletteSizeByLayout[layout];
440
597
  const detailSizePx = detailSizeByLayout[layout];
441
- const canvasWithDetail = (
442
- <SplitView
443
- className={
444
- showDetail
445
- ? 'workbench-field-remap-flow__canvas-detail-split'
446
- : 'workbench-field-remap-flow__canvas-detail-split ui-workbench-split-view--secondary-collapsed'
447
- }
448
- layoutMode="secondary-fixed"
449
- maxSecondarySizePx={isNarrow ? 320 : 480}
450
- minPrimarySizePx={isNarrow ? 200 : 280}
451
- minSecondarySizePx={isNarrow ? 160 : 256}
452
- onSecondarySizePxChange={(nextSize) => {
453
- setDetailSizeByLayout((current) => ({ ...current, [layout]: nextSize }));
454
- }}
455
- orientation={isNarrow ? 'vertical' : 'horizontal'}
456
- primary={canvas}
457
- secondary={detail}
458
- secondarySizePx={detailSizePx}
459
- />
460
- );
598
+ const canvasWithDetail =
599
+ showDetail || reserveHiddenDetailSplit ? (
600
+ <SplitView
601
+ className={
602
+ showDetail
603
+ ? 'workbench-field-remap-flow__canvas-detail-split'
604
+ : 'workbench-field-remap-flow__canvas-detail-split ui-workbench-split-view--secondary-collapsed'
605
+ }
606
+ layoutMode="secondary-fixed"
607
+ maxSecondarySizePx={isNarrow ? 320 : 480}
608
+ minPrimarySizePx={isNarrow ? 200 : 280}
609
+ minSecondarySizePx={isNarrow ? 160 : 256}
610
+ onSecondarySizePxChange={(nextSize) => {
611
+ setDetailSizeByLayout((current) => ({ ...current, [layout]: nextSize }));
612
+ }}
613
+ orientation={isNarrow ? 'vertical' : 'horizontal'}
614
+ primary={canvas}
615
+ secondary={detail}
616
+ secondarySizePx={detailSizePx}
617
+ />
618
+ ) : (
619
+ canvas
620
+ );
461
621
 
462
622
  const content = (
463
623
  <SplitView
@@ -502,11 +662,16 @@ function FieldRemapFlowCanvas({
502
662
  edges,
503
663
  transforms,
504
664
  onEdgesChange,
665
+ rewirePolicy = 'replace',
666
+ onConnectionFeedback,
667
+ parentChildConflicts,
505
668
  chrome = 'card',
506
669
  emptyDetail: emptyDetailProp,
670
+ detailPresentation = 'rail',
507
671
  showFlowHint: showFlowHintProp,
508
672
  showBindingsList: showBindingsListProp,
509
673
  showConvertPalette = true,
674
+ readOnly = false,
510
675
  operators = [],
511
676
  onOperatorsChange,
512
677
  sourceTitle,
@@ -530,21 +695,269 @@ function FieldRemapFlowCanvas({
530
695
  () => resolveFieldRemapChromeLabels(labelOverrides, t),
531
696
  [labelOverrides, t],
532
697
  );
533
- const showFlowHint = showFlowHintProp ?? chrome !== 'embed';
698
+ const showFlowHint = !readOnly && (showFlowHintProp ?? chrome !== 'embed');
534
699
  const showBindingsList = showBindingsListProp ?? chrome !== 'embed';
700
+ const showAuthoringPalette = showConvertPalette && !readOnly;
535
701
  const emptyDetail = emptyDetailProp ?? (chrome === 'embed' ? 'collapse' : 'hint');
536
702
  const mapperRef = useRef<HTMLDivElement>(null);
703
+ const restoreMapperFocusRef = useRef(false);
537
704
  const [workspaceLayout, setWorkspaceLayout] = useState<'wide' | 'medium' | 'narrow'>('wide');
538
705
  const [internalSelection, setInternalSelection] = useState<FieldRemapSelection>(null);
539
- const selection = selectionProp !== undefined ? selectionProp : internalSelection;
540
- const setSelection = onSelectionChangeProp ?? setInternalSelection;
706
+ const authoritativeSelection = selectionProp !== undefined ? selectionProp : internalSelection;
707
+ const selectionExternallyManaged =
708
+ selectionProp !== undefined || onSelectionChangeProp !== undefined;
709
+ const [bulkSelection, setBulkSelection] = useState<readonly FieldRemapBulkSelectionRef[]>(() => {
710
+ const initial = asFieldRemapBulkSelectionRef(selectionProp ?? null);
711
+ return initial ? normalizeFieldRemapBulkSelection(edges, [initial]) : [];
712
+ });
713
+ const selection = authoritativeSelection;
714
+ const selectionRef = useRef(authoritativeSelection);
715
+ selectionRef.current = authoritativeSelection;
716
+ const setSelection = useCallback(
717
+ (next: FieldRemapSelection) => {
718
+ if (fieldRemapSelectionKey(selectionRef.current) === fieldRemapSelectionKey(next)) {
719
+ return;
720
+ }
721
+ if (!selectionExternallyManaged) {
722
+ setInternalSelection(next);
723
+ const nextRef = asFieldRemapBulkSelectionRef(next);
724
+ setBulkSelection(nextRef ? normalizeFieldRemapBulkSelection(edges, [nextRef]) : []);
725
+ }
726
+ onSelectionChangeProp?.(next);
727
+ },
728
+ [edges, onSelectionChangeProp, selectionExternallyManaged],
729
+ );
730
+ const setSelectionRef = useRef(setSelection);
731
+ setSelectionRef.current = setSelection;
732
+ const closeModalDetail = useCallback(() => setSelectionRef.current(null), []);
733
+ const canonicalBulkRefs = useMemo(() => listFieldRemapBulkSelectionRefs(edges), [edges]);
734
+ const bulkDomainKey = useMemo(
735
+ () => fieldRemapBulkDomainKey(canonicalBulkRefs),
736
+ [canonicalBulkRefs],
737
+ );
738
+ const bulkSelectionKeys = useMemo(
739
+ () => new Set(bulkSelection.map(fieldRemapBulkSelectionKey)),
740
+ [bulkSelection],
741
+ );
742
+ const authoritativeSelectionKey = fieldRemapSelectionKey(authoritativeSelection);
743
+ const previousAuthoritativeSelectionKeyRef = useRef(authoritativeSelectionKey);
744
+ const bulkFocusTargetsRef = useRef(
745
+ new Map<string, Partial<Record<FieldRemapFocusSurface, FieldRemapFocusableElement>>>(),
746
+ );
747
+ const registerBulkFocusTarget = useCallback<RegisterFieldRemapFocusTarget>(
748
+ (key, surface, element) => {
749
+ const current = bulkFocusTargetsRef.current.get(key) ?? {};
750
+ if (element) {
751
+ current[surface] = element;
752
+ bulkFocusTargetsRef.current.set(key, current);
753
+ return;
754
+ }
755
+ delete current[surface];
756
+ if (current.graph || current.list) {
757
+ bulkFocusTargetsRef.current.set(key, current);
758
+ } else {
759
+ bulkFocusTargetsRef.current.delete(key);
760
+ }
761
+ },
762
+ [],
763
+ );
764
+ const focusBulkTarget = useCallback(
765
+ (ref: FieldRemapBulkSelectionRef, preferredSurface?: FieldRemapFocusSurface) => {
766
+ const targets = bulkFocusTargetsRef.current.get(fieldRemapBulkSelectionKey(ref));
767
+ const target =
768
+ (preferredSurface ? targets?.[preferredSurface] : undefined) ??
769
+ targets?.list ??
770
+ targets?.graph;
771
+ target?.focus({ preventScroll: true });
772
+ },
773
+ [],
774
+ );
775
+ const lastPrimaryCorrectionRef = useRef<string | undefined>(undefined);
776
+ const pendingPrimaryCorrectionAckRef = useRef<
777
+ { readonly domainKey: string; readonly selectionKey: string } | undefined
778
+ >(undefined);
779
+ const pendingBulkFocusRef = useRef<
780
+ | {
781
+ readonly domainKey: string;
782
+ readonly target: FieldRemapBulkSelectionRef | null;
783
+ }
784
+ | undefined
785
+ >(undefined);
786
+ const pendingBulkCommitRef = useRef<
787
+ | {
788
+ readonly domainKey: string;
789
+ readonly membership: readonly FieldRemapBulkSelectionRef[];
790
+ readonly primary: FieldRemapSelection;
791
+ }
792
+ | undefined
793
+ >(undefined);
541
794
  const previewVisible = showPreview && preview !== undefined && preview.status !== 'unavailable';
795
+ const flowAriaLabelConfig = useMemo(
796
+ () => ({
797
+ 'node.a11yDescription.default': readOnly
798
+ ? 'Press Enter or Space to inspect this item. Control or Command toggles a non-primary item; Shift adds it.'
799
+ : 'Press Enter or Space to select this item. Control or Command toggles a non-primary item; Shift adds it.',
800
+ 'node.a11yDescription.keyboardDisabled': readOnly
801
+ ? 'Press Enter or Space to inspect this item. Workbench editing is read only.'
802
+ : 'Press Enter or Space to select this item. Use the Workbench controls to edit it.',
803
+ 'edge.a11yDescription.default': readOnly
804
+ ? 'Press Enter or Space to inspect this mapping. Control or Command toggles a non-primary mapping; Shift adds it.'
805
+ : 'Press Enter or Space to select this mapping. Control or Command toggles a non-primary mapping; Shift adds it.',
806
+ }),
807
+ [readOnly],
808
+ );
809
+ const detailVisible = emptyDetail === 'hint' || selection !== null;
810
+ const sideRailVisible = previewVisible || (detailPresentation === 'rail' && detailVisible);
542
811
  const [drafts, setDrafts] = useState<readonly FieldRemapDraftTransform[]>([]);
812
+ const [draftPositions, setDraftPositions] = useState<ReadonlyMap<string, XYPosition>>(
813
+ () => new Map(),
814
+ );
815
+ const [connectionFeedback, setConnectionFeedback] = useState<FieldRemapConnectionFeedback | null>(
816
+ null,
817
+ );
818
+ const connectionAttemptCompletedRef = useRef(false);
543
819
  const [placeTransformId, setPlaceTransformId] = useState(() => {
544
820
  const first = transforms.list().find((definition) => definition.id !== 'identity');
545
821
  return first?.id ?? '';
546
822
  });
547
823
  const transformRegistrySignature = createTransformRegistrySignature(transforms);
824
+ const { screenToFlowPosition } = useReactFlow();
825
+
826
+ const removeDraftPosition = useCallback((localId: string) => {
827
+ setDraftPositions((current) => {
828
+ if (!current.has(localId)) {
829
+ return current;
830
+ }
831
+ const next = new Map(current);
832
+ next.delete(localId);
833
+ return next;
834
+ });
835
+ }, []);
836
+
837
+ useEffect(() => {
838
+ if (detailPresentation === 'modal') {
839
+ void loadFieldRemapModalDetail().catch(() => undefined);
840
+ }
841
+ }, [detailPresentation]);
842
+
843
+ useEffect(() => {
844
+ if (!readOnly) {
845
+ return;
846
+ }
847
+ setDrafts([]);
848
+ setDraftPositions(new Map());
849
+ setConnectionFeedback(null);
850
+ connectionAttemptCompletedRef.current = false;
851
+ if (selectionProp === undefined) {
852
+ setInternalSelection((current) => (current?.kind === 'draft' ? null : current));
853
+ }
854
+ }, [readOnly, selectionProp]);
855
+
856
+ useEffect(() => {
857
+ let authoritativeReset: readonly FieldRemapBulkSelectionRef[] | undefined;
858
+ if (previousAuthoritativeSelectionKeyRef.current !== authoritativeSelectionKey) {
859
+ previousAuthoritativeSelectionKeyRef.current = authoritativeSelectionKey;
860
+ lastPrimaryCorrectionRef.current = undefined;
861
+ const pendingAck = pendingPrimaryCorrectionAckRef.current;
862
+ const acceptsCorrection =
863
+ pendingAck?.selectionKey === authoritativeSelectionKey &&
864
+ (pendingAck.domainKey === bulkDomainKey ||
865
+ pendingAck.domainKey === pendingBulkCommitRef.current?.domainKey);
866
+ pendingPrimaryCorrectionAckRef.current = undefined;
867
+ if (!acceptsCorrection) {
868
+ pendingBulkCommitRef.current = undefined;
869
+ const authoritativeRef = asFieldRemapBulkSelectionRef(authoritativeSelection);
870
+ const next = authoritativeRef
871
+ ? normalizeFieldRemapBulkSelection(edges, [authoritativeRef])
872
+ : [];
873
+ authoritativeReset = next;
874
+ setBulkSelection((current) =>
875
+ areFieldRemapBulkSelectionsEqual(current, next) ? current : next,
876
+ );
877
+ if (!authoritativeRef || next.length > 0) {
878
+ return;
879
+ }
880
+ }
881
+ }
882
+
883
+ const pendingCommit = pendingBulkCommitRef.current;
884
+ if (pendingCommit) {
885
+ const primaryAccepted =
886
+ fieldRemapSelectionKey(authoritativeSelection) ===
887
+ fieldRemapSelectionKey(pendingCommit.primary);
888
+ if (primaryAccepted || bulkDomainKey === pendingCommit.domainKey) {
889
+ setBulkSelection((current) =>
890
+ areFieldRemapBulkSelectionsEqual(current, pendingCommit.membership)
891
+ ? current
892
+ : pendingCommit.membership,
893
+ );
894
+ }
895
+ if (bulkDomainKey !== pendingCommit.domainKey) {
896
+ return;
897
+ }
898
+ pendingBulkCommitRef.current = undefined;
899
+ }
900
+
901
+ const primaryRef = asFieldRemapBulkSelectionRef(authoritativeSelection);
902
+ if (!primaryRef) {
903
+ setBulkSelection((current) => (current.length === 0 ? current : []));
904
+ return;
905
+ }
906
+
907
+ const primaryKey = fieldRemapBulkSelectionKey(primaryRef);
908
+ const primaryIsValid = canonicalBulkRefs.some(
909
+ (ref) => fieldRemapBulkSelectionKey(ref) === primaryKey,
910
+ );
911
+ let next = authoritativeReset ?? normalizeFieldRemapBulkSelection(edges, bulkSelection);
912
+ if (primaryIsValid && !next.some((ref) => fieldRemapBulkSelectionKey(ref) === primaryKey)) {
913
+ next = normalizeFieldRemapBulkSelection(edges, [...next, primaryRef]);
914
+ }
915
+ setBulkSelection((current) =>
916
+ areFieldRemapBulkSelectionsEqual(current, next) ? current : next,
917
+ );
918
+
919
+ if (primaryIsValid) {
920
+ lastPrimaryCorrectionRef.current = undefined;
921
+ return;
922
+ }
923
+
924
+ const nextPrimary = next[0] ?? null;
925
+ const correctionKey = `${bulkDomainKey}\u0001${fieldRemapSelectionKey(authoritativeSelection)}\u0001${fieldRemapSelectionKey(nextPrimary)}`;
926
+ if (lastPrimaryCorrectionRef.current === correctionKey) {
927
+ return;
928
+ }
929
+ lastPrimaryCorrectionRef.current = correctionKey;
930
+ pendingPrimaryCorrectionAckRef.current = onSelectionChangeProp
931
+ ? {
932
+ domainKey: bulkDomainKey,
933
+ selectionKey: fieldRemapSelectionKey(nextPrimary),
934
+ }
935
+ : undefined;
936
+ if (!selectionExternallyManaged) {
937
+ setInternalSelection(nextPrimary);
938
+ } else {
939
+ setSelection(nextPrimary);
940
+ }
941
+ }, [
942
+ bulkDomainKey,
943
+ bulkSelection,
944
+ canonicalBulkRefs,
945
+ authoritativeSelectionKey,
946
+ edges,
947
+ onSelectionChangeProp,
948
+ authoritativeSelection,
949
+ selection,
950
+ selectionExternallyManaged,
951
+ setSelection,
952
+ ]);
953
+
954
+ useEffect(() => {
955
+ if (!restoreMapperFocusRef.current || selection !== null || drafts.length > 0) {
956
+ return;
957
+ }
958
+ restoreMapperFocusRef.current = false;
959
+ mapperRef.current?.focus({ preventScroll: true });
960
+ }, [drafts.length, selection]);
548
961
 
549
962
  useEffect(() => {
550
963
  const element = mapperRef.current;
@@ -580,6 +993,7 @@ function FieldRemapFlowCanvas({
580
993
  sourceTitle,
581
994
  targetTitle,
582
995
  drafts,
996
+ draftPositions,
583
997
  }),
584
998
  [
585
999
  sources,
@@ -590,22 +1004,68 @@ function FieldRemapFlowCanvas({
590
1004
  sourceTitle,
591
1005
  targetTitle,
592
1006
  drafts,
1007
+ draftPositions,
593
1008
  transformRegistrySignature,
594
1009
  ],
595
1010
  );
596
1011
 
1012
+ const applyBulkSelectionGesture = useCallback(
1013
+ (
1014
+ target: FieldRemapBulkSelectionRef,
1015
+ modifiers: {
1016
+ readonly ctrlKey: boolean;
1017
+ readonly metaKey: boolean;
1018
+ readonly shiftKey: boolean;
1019
+ },
1020
+ ) => {
1021
+ pendingPrimaryCorrectionAckRef.current = undefined;
1022
+ const gesture =
1023
+ modifiers.ctrlKey || modifiers.metaKey ? 'toggle' : modifiers.shiftKey ? 'add' : 'plain';
1024
+ const next = updateFieldRemapBulkSelection({
1025
+ edges,
1026
+ membership: bulkSelection,
1027
+ primary: selection,
1028
+ target,
1029
+ gesture,
1030
+ });
1031
+ const primaryChanged =
1032
+ fieldRemapSelectionKey(next.primary) !== fieldRemapSelectionKey(selection);
1033
+ if (!primaryChanged || !selectionExternallyManaged) {
1034
+ setBulkSelection((current) =>
1035
+ areFieldRemapBulkSelectionsEqual(current, next.membership) ? current : next.membership,
1036
+ );
1037
+ }
1038
+ if (primaryChanged) {
1039
+ setSelection(next.primary);
1040
+ }
1041
+ },
1042
+ [bulkSelection, edges, selection, selectionExternallyManaged, setSelection],
1043
+ );
1044
+
597
1045
  const nodesWithSelection = useMemo(
598
1046
  () =>
599
1047
  graph.nodes.map((node) => {
600
1048
  if (node.data.kind === 'transform') {
601
- const selected =
602
- selection?.kind === 'transformStep' &&
603
- selection.edgeId === node.data.mappingEdgeId &&
604
- selection.stepIndex === node.data.stepIndex;
1049
+ const ref = {
1050
+ kind: 'transformStep',
1051
+ edgeId: node.data.mappingEdgeId,
1052
+ stepIndex: node.data.stepIndex,
1053
+ } as const;
1054
+ const selected = bulkSelectionKeys.has(fieldRemapBulkSelectionKey(ref));
605
1055
  return {
606
1056
  ...node,
607
- data: { ...node.data, selected },
1057
+ data: { ...node.data, selected, registerFocusTarget: registerBulkFocusTarget },
608
1058
  selected,
1059
+ selectable: false,
1060
+ focusable: true,
1061
+ ariaRole: 'button' as const,
1062
+ ariaLabel: `${node.data.label} convert step`,
1063
+ domAttributes: {
1064
+ 'aria-pressed': selected,
1065
+ 'data-field-remap-bulk-kind': ref.kind,
1066
+ 'data-field-remap-bulk-edge-id': ref.edgeId,
1067
+ 'data-field-remap-bulk-step-index': ref.stepIndex,
1068
+ },
609
1069
  };
610
1070
  }
611
1071
  if (node.data.kind === 'draft-transform') {
@@ -619,50 +1079,168 @@ function FieldRemapFlowCanvas({
619
1079
  }
620
1080
  return node;
621
1081
  }),
622
- [graph.nodes, selection],
1082
+ [bulkSelectionKeys, graph.nodes, registerBulkFocusTarget, selection],
1083
+ );
1084
+
1085
+ const flowEdgesWithSelection = useMemo(
1086
+ () =>
1087
+ graph.edges.map((edge) => {
1088
+ const data = edge.data as FieldRemapFlowEdgeData | undefined;
1089
+ const mappingEdgeId = data?.mappingEdgeId;
1090
+ const selected = mappingEdgeId
1091
+ ? bulkSelectionKeys.has(
1092
+ fieldRemapBulkSelectionKey({ kind: 'edge', edgeId: mappingEdgeId }),
1093
+ )
1094
+ : false;
1095
+ if (!mappingEdgeId) {
1096
+ return { ...edge, selected };
1097
+ }
1098
+ const canonicalSegment = data?.segment === 'direct' || data?.segment === 'in';
1099
+ return {
1100
+ ...edge,
1101
+ data: { ...data, registerFocusTarget: registerBulkFocusTarget },
1102
+ selected,
1103
+ selectable: false,
1104
+ focusable: canonicalSegment,
1105
+ ariaRole: canonicalSegment ? ('button' as const) : ('presentation' as const),
1106
+ ariaLabel: canonicalSegment ? `Mapping ${mappingEdgeId}` : undefined,
1107
+ domAttributes: canonicalSegment
1108
+ ? {
1109
+ 'aria-pressed': selected,
1110
+ 'data-field-remap-bulk-kind': 'edge',
1111
+ 'data-field-remap-bulk-edge-id': mappingEdgeId,
1112
+ }
1113
+ : { 'aria-hidden': true },
1114
+ };
1115
+ }),
1116
+ [bulkSelectionKeys, graph.edges, registerBulkFocusTarget],
623
1117
  );
624
1118
 
625
1119
  const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithSelection);
626
- const [flowEdges, setFlowEdges, onFlowEdgesChange] = useEdgesState(graph.edges);
1120
+ const [flowEdges, setFlowEdges, onFlowEdgesChange] = useEdgesState(flowEdgesWithSelection);
1121
+ const onProjectedNodesChange = useCallback(
1122
+ (changes: Parameters<typeof onNodesChange>[0]) => {
1123
+ onNodesChange(changes.filter((change) => change.type !== 'select'));
1124
+ },
1125
+ [onNodesChange],
1126
+ );
1127
+ const onProjectedFlowEdgesChange = useCallback(
1128
+ (changes: Parameters<typeof onFlowEdgesChange>[0]) => {
1129
+ onFlowEdgesChange(changes.filter((change) => change.type !== 'select'));
1130
+ },
1131
+ [onFlowEdgesChange],
1132
+ );
627
1133
 
628
1134
  // Depending directly on `nodesWithSelection` (a new array after each graph
629
1135
  // calculation) re-enters XYFlow's StoreUpdater. The explicit signature keeps
630
1136
  // that loop guard while still tracking every value copied into rendered nodes.
631
1137
  const graphSyncKey = createFieldRemapGraphSyncKey({
632
1138
  nodes: nodesWithSelection,
633
- edges: graph.edges,
1139
+ edges: flowEdgesWithSelection,
634
1140
  selection,
635
1141
  transformRegistrySignature,
636
1142
  });
637
1143
  const nodesWithSelectionRef = useRef(nodesWithSelection);
638
- const graphEdgesRef = useRef(graph.edges);
1144
+ const graphEdgesRef = useRef(flowEdgesWithSelection);
639
1145
  nodesWithSelectionRef.current = nodesWithSelection;
640
- graphEdgesRef.current = graph.edges;
1146
+ graphEdgesRef.current = flowEdgesWithSelection;
641
1147
 
642
1148
  useEffect(() => {
643
1149
  setNodes(nodesWithSelectionRef.current);
644
1150
  setFlowEdges(graphEdgesRef.current);
645
1151
  }, [graphSyncKey, setFlowEdges, setNodes]);
646
1152
 
1153
+ useEffect(() => {
1154
+ const pending = pendingBulkFocusRef.current;
1155
+ const mapper = mapperRef.current;
1156
+ if (!pending || !mapper || pending.domainKey !== bulkDomainKey) {
1157
+ return;
1158
+ }
1159
+ pendingBulkFocusRef.current = undefined;
1160
+ if (!pending.target) {
1161
+ mapper.focus({ preventScroll: true });
1162
+ return;
1163
+ }
1164
+ const targets = bulkFocusTargetsRef.current.get(fieldRemapBulkSelectionKey(pending.target));
1165
+ (targets?.list ?? targets?.graph ?? mapper).focus({ preventScroll: true });
1166
+ }, [bulkDomainKey]);
1167
+
647
1168
  const connectionContext = useMemo(
648
1169
  () => ({ sources, targets, edges, transforms, drafts, operators }),
649
1170
  [sources, targets, edges, transforms, drafts, operators],
650
1171
  );
651
1172
 
1173
+ const conflicts = useMemo(
1174
+ () => parentChildConflicts ?? findParentChildMappingConflicts(edges, sources, targets),
1175
+ [edges, parentChildConflicts, sources, targets],
1176
+ );
1177
+
1178
+ const publishConnectionFeedback = useCallback(
1179
+ (feedback: FieldRemapConnectionFeedback | null) => {
1180
+ setConnectionFeedback(feedback);
1181
+ onConnectionFeedback?.(feedback);
1182
+ },
1183
+ [onConnectionFeedback],
1184
+ );
1185
+
652
1186
  const isValidConnection = useCallback(
653
1187
  (connection: Connection | Edge) =>
654
- isValidFieldRemapFlowConnection(connection, connectionContext),
655
- [connectionContext],
1188
+ !readOnly && isValidFieldRemapFlowConnection(connection, connectionContext),
1189
+ [connectionContext, readOnly],
1190
+ );
1191
+
1192
+ const onConnectStart = useCallback(() => {
1193
+ if (readOnly) {
1194
+ return;
1195
+ }
1196
+ // Clearing at attempt start lets an identical later rejection be announced once at completion.
1197
+ connectionAttemptCompletedRef.current = false;
1198
+ setConnectionFeedback(null);
1199
+ }, [readOnly]);
1200
+
1201
+ const onConnectEnd = useCallback(
1202
+ (_event: globalThis.MouseEvent | TouchEvent, state: FinalConnectionState) => {
1203
+ if (readOnly) {
1204
+ return;
1205
+ }
1206
+ if (connectionAttemptCompletedRef.current) {
1207
+ return;
1208
+ }
1209
+ connectionAttemptCompletedRef.current = true;
1210
+ const connection = connectionFromFinalState(state);
1211
+ if (!connection) {
1212
+ return;
1213
+ }
1214
+ const evaluation = evaluateFieldRemapFlowConnection(connection, connectionContext);
1215
+ if (evaluation.status === 'rejected') {
1216
+ publishConnectionFeedback({ reason: evaluation.reason });
1217
+ }
1218
+ },
1219
+ [connectionContext, publishConnectionFeedback, readOnly],
656
1220
  );
657
1221
 
658
1222
  const onConnect = useCallback(
659
1223
  (connection: Connection) => {
1224
+ if (readOnly) {
1225
+ return;
1226
+ }
660
1227
  if (!connection.source || !connection.target) {
661
1228
  return;
662
1229
  }
663
- if (!isValidFieldRemapFlowConnection(connection, connectionContext)) {
1230
+ const evaluation = evaluateFieldRemapFlowConnection(connection, connectionContext);
1231
+ if (evaluation.status === 'rejected') {
664
1232
  return;
665
1233
  }
1234
+ if (evaluation.status === 'rewire' && rewirePolicy === 'reject') {
1235
+ connectionAttemptCompletedRef.current = true;
1236
+ publishConnectionFeedback({
1237
+ reason: 'rewire-policy-rejected',
1238
+ impactedEdgeIds: evaluation.impactedEdgeIds,
1239
+ });
1240
+ return;
1241
+ }
1242
+ connectionAttemptCompletedRef.current = true;
1243
+ publishConnectionFeedback(null);
666
1244
 
667
1245
  const draftAsTarget = parseDraftTransformNodeId(connection.target);
668
1246
  if (draftAsTarget && connection.sourceHandle) {
@@ -683,6 +1261,7 @@ function FieldRemapFlowCanvas({
683
1261
  );
684
1262
  onEdgesChange([...withoutTarget, finalized]);
685
1263
  setDrafts(drafts.filter((item) => item.localId !== draft.localId));
1264
+ removeDraftPosition(draft.localId);
686
1265
  setSelection({
687
1266
  kind: 'transformStep',
688
1267
  edgeId: finalized.id,
@@ -714,6 +1293,7 @@ function FieldRemapFlowCanvas({
714
1293
  );
715
1294
  onEdgesChange([...withoutTarget, finalized]);
716
1295
  setDrafts(drafts.filter((item) => item.localId !== draft.localId));
1296
+ removeDraftPosition(draft.localId);
717
1297
  setSelection({
718
1298
  kind: 'transformStep',
719
1299
  edgeId: finalized.id,
@@ -775,6 +1355,10 @@ function FieldRemapFlowCanvas({
775
1355
  onEdgesChange,
776
1356
  onOperatorsChange,
777
1357
  operators,
1358
+ publishConnectionFeedback,
1359
+ readOnly,
1360
+ removeDraftPosition,
1361
+ rewirePolicy,
778
1362
  setSelection,
779
1363
  sources,
780
1364
  targets,
@@ -782,8 +1366,107 @@ function FieldRemapFlowCanvas({
782
1366
  ],
783
1367
  );
784
1368
 
1369
+ const commitBulkDelete = useCallback(
1370
+ (refs: readonly FieldRemapBulkSelectionRef[]): boolean => {
1371
+ const plan = planFieldRemapBulkDelete(edges, refs);
1372
+ if (plan.status !== 'changed') {
1373
+ return false;
1374
+ }
1375
+
1376
+ const removedEdgeIds = new Set(
1377
+ refs.filter((ref) => ref.kind === 'edge').map((ref) => ref.edgeId),
1378
+ );
1379
+ const removedKeys = new Set(refs.map(fieldRemapBulkSelectionKey));
1380
+ let survivingMembership = normalizeFieldRemapBulkSelection(
1381
+ plan.edges,
1382
+ bulkSelection.filter(
1383
+ (ref) =>
1384
+ !removedKeys.has(fieldRemapBulkSelectionKey(ref)) && !removedEdgeIds.has(ref.edgeId),
1385
+ ),
1386
+ );
1387
+ const primaryRef = asFieldRemapBulkSelectionRef(selection);
1388
+ const primaryRemoved = primaryRef
1389
+ ? removedKeys.has(fieldRemapBulkSelectionKey(primaryRef)) ||
1390
+ removedEdgeIds.has(primaryRef.edgeId)
1391
+ : false;
1392
+ const preservesSingleStepFallback =
1393
+ refs.length === 1 &&
1394
+ refs[0]?.kind === 'transformStep' &&
1395
+ primaryRef?.kind === 'transformStep' &&
1396
+ fieldRemapBulkSelectionKey(refs[0]) === fieldRemapBulkSelectionKey(primaryRef);
1397
+ let nextPrimary = primaryRemoved ? (survivingMembership[0] ?? null) : selection;
1398
+ if (preservesSingleStepFallback) {
1399
+ const removedStep = refs[0] as Extract<
1400
+ FieldRemapBulkSelectionRef,
1401
+ { readonly kind: 'transformStep' }
1402
+ >;
1403
+ const nextEdge = plan.edges.find((edge) => edge.id === removedStep.edgeId);
1404
+ nextPrimary =
1405
+ (nextEdge?.transformIds?.length ?? 0) > 0
1406
+ ? {
1407
+ kind: 'transformStep',
1408
+ edgeId: removedStep.edgeId,
1409
+ stepIndex: Math.min(
1410
+ removedStep.stepIndex,
1411
+ (nextEdge?.transformIds?.length ?? 1) - 1,
1412
+ ),
1413
+ }
1414
+ : nextEdge
1415
+ ? { kind: 'edge', edgeId: nextEdge.id }
1416
+ : null;
1417
+ const fallbackRef = asFieldRemapBulkSelectionRef(nextPrimary);
1418
+ survivingMembership = fallbackRef
1419
+ ? normalizeFieldRemapBulkSelection(plan.edges, [fallbackRef])
1420
+ : [];
1421
+ }
1422
+ const nextDomainKey = fieldRemapBulkDomainKey(listFieldRemapBulkSelectionRefs(plan.edges));
1423
+ const primaryKeyChanged =
1424
+ fieldRemapSelectionKey(nextPrimary) !== fieldRemapSelectionKey(selection);
1425
+
1426
+ pendingBulkCommitRef.current = {
1427
+ domainKey: nextDomainKey,
1428
+ membership: survivingMembership,
1429
+ primary: nextPrimary,
1430
+ };
1431
+ pendingBulkFocusRef.current = {
1432
+ domainKey: nextDomainKey,
1433
+ target: asFieldRemapBulkSelectionRef(nextPrimary) ?? null,
1434
+ };
1435
+ if (primaryKeyChanged) {
1436
+ lastPrimaryCorrectionRef.current = `${nextDomainKey}\u0001${fieldRemapSelectionKey(selection)}\u0001${fieldRemapSelectionKey(nextPrimary)}`;
1437
+ pendingPrimaryCorrectionAckRef.current = onSelectionChangeProp
1438
+ ? {
1439
+ domainKey: nextDomainKey,
1440
+ selectionKey: fieldRemapSelectionKey(nextPrimary),
1441
+ }
1442
+ : undefined;
1443
+ }
1444
+ onEdgesChange(plan.edges);
1445
+
1446
+ if (primaryKeyChanged) {
1447
+ if (!selectionExternallyManaged) {
1448
+ setInternalSelection(nextPrimary);
1449
+ } else {
1450
+ onSelectionChangeProp?.(nextPrimary);
1451
+ }
1452
+ }
1453
+ return true;
1454
+ },
1455
+ [
1456
+ bulkSelection,
1457
+ edges,
1458
+ onEdgesChange,
1459
+ onSelectionChangeProp,
1460
+ selection,
1461
+ selectionExternallyManaged,
1462
+ ],
1463
+ );
1464
+
785
1465
  const onEdgesDelete = useCallback(
786
1466
  (deleted: Edge[]) => {
1467
+ if (readOnly) {
1468
+ return;
1469
+ }
787
1470
  const mappingIds = new Set(
788
1471
  deleted
789
1472
  .map((edge) => {
@@ -795,26 +1478,64 @@ function FieldRemapFlowCanvas({
795
1478
  if (mappingIds.size === 0) {
796
1479
  return;
797
1480
  }
798
- onEdgesChange(edges.filter((edge) => !mappingIds.has(edge.id)));
799
- if (
800
- selection &&
801
- (selection.kind === 'edge' || selection.kind === 'transformStep') &&
802
- mappingIds.has(selection.edgeId)
803
- ) {
804
- setSelection(null);
805
- }
1481
+ commitBulkDelete([...mappingIds].map((edgeId) => ({ kind: 'edge' as const, edgeId })));
806
1482
  },
807
- [edges, onEdgesChange, selection, setSelection],
1483
+ [commitBulkDelete, readOnly],
808
1484
  );
809
1485
 
810
1486
  const placeDraft = useCallback(
811
- (transformId: string) => {
1487
+ (transformId: string, position?: XYPosition) => {
1488
+ if (readOnly) {
1489
+ return;
1490
+ }
812
1491
  const draft = createDraftTransform(transformId);
813
1492
  setDrafts((current) => [...current, draft]);
1493
+ if (position) {
1494
+ setDraftPositions((current) => new Map(current).set(draft.localId, position));
1495
+ }
814
1496
  setSelection({ kind: 'draft', localId: draft.localId });
815
1497
  setPlaceTransformId(transformId);
816
1498
  },
817
- [setSelection],
1499
+ [readOnly, setSelection],
1500
+ );
1501
+
1502
+ const resolveDroppedTransformId = useCallback(
1503
+ (dataTransfer: DataTransfer) => {
1504
+ const transformId = readFieldRemapTransformDragData(dataTransfer);
1505
+ const definition = transformId ? transforms.get(transformId) : undefined;
1506
+ if (!transformId || transformId === 'identity' || definition?.id !== transformId) {
1507
+ return undefined;
1508
+ }
1509
+ return transformId;
1510
+ },
1511
+ [transforms],
1512
+ );
1513
+
1514
+ const onCanvasDragOver = useCallback(
1515
+ (event: DragEvent<HTMLDivElement>) => {
1516
+ if (readOnly || !hasFieldRemapTransformDragType(event.dataTransfer)) {
1517
+ return;
1518
+ }
1519
+ event.preventDefault();
1520
+ event.dataTransfer.dropEffect = 'copy';
1521
+ },
1522
+ [readOnly],
1523
+ );
1524
+
1525
+ const onCanvasDrop = useCallback(
1526
+ (event: DragEvent<HTMLDivElement>) => {
1527
+ if (readOnly) {
1528
+ return;
1529
+ }
1530
+ const transformId = resolveDroppedTransformId(event.dataTransfer);
1531
+ if (!transformId) {
1532
+ return;
1533
+ }
1534
+ event.preventDefault();
1535
+ event.stopPropagation();
1536
+ placeDraft(transformId, screenToFlowPosition({ x: event.clientX, y: event.clientY }));
1537
+ },
1538
+ [placeDraft, readOnly, resolveDroppedTransformId, screenToFlowPosition],
818
1539
  );
819
1540
 
820
1541
  const onNodeClick = useCallback(
@@ -825,7 +1546,7 @@ function FieldRemapFlowCanvas({
825
1546
  return;
826
1547
  }
827
1548
  if (data.kind === 'combine-operator' || data.kind === 'split-operator') {
828
- if (event.altKey && onOperatorsChange) {
1549
+ if (!readOnly && event.altKey && onOperatorsChange) {
829
1550
  onOperatorsChange(removeMappingOperator(operators, data.operatorId));
830
1551
  if (selection?.kind === 'operator' && selection.operatorId === data.operatorId) {
831
1552
  setSelection(null);
@@ -838,41 +1559,148 @@ function FieldRemapFlowCanvas({
838
1559
  if (data.kind !== 'transform') {
839
1560
  return;
840
1561
  }
841
- if (event.altKey) {
842
- const edge = edges.find((item) => item.id === data.mappingEdgeId);
843
- if (!edge) {
844
- return;
845
- }
846
- const next = removeTransformStepFromEdge(edge, data.stepIndex);
847
- onEdgesChange(edges.map((item) => (item.id === edge.id ? next : item)));
848
- setSelection(
849
- (next.transformIds?.length ?? 0) > 0
850
- ? {
851
- kind: 'transformStep',
852
- edgeId: edge.id,
853
- stepIndex: Math.min(data.stepIndex, (next.transformIds?.length ?? 1) - 1),
854
- }
855
- : { kind: 'edge', edgeId: edge.id },
856
- );
857
- return;
858
- }
859
- setSelection({
1562
+ const target = {
860
1563
  kind: 'transformStep',
861
1564
  edgeId: data.mappingEdgeId,
862
1565
  stepIndex: data.stepIndex,
863
- });
1566
+ } as const;
1567
+ if (!readOnly && event.altKey) {
1568
+ commitBulkDelete([target]);
1569
+ return;
1570
+ }
1571
+ applyBulkSelectionGesture(target, event);
1572
+ focusBulkTarget(target, 'graph');
864
1573
  },
865
- [edges, onEdgesChange, onOperatorsChange, operators, selection, setSelection],
1574
+ [
1575
+ applyBulkSelectionGesture,
1576
+ commitBulkDelete,
1577
+ focusBulkTarget,
1578
+ onOperatorsChange,
1579
+ operators,
1580
+ readOnly,
1581
+ selection,
1582
+ setSelection,
1583
+ ],
1584
+ );
1585
+
1586
+ const onEdgeClick = useCallback(
1587
+ (event: MouseEvent, edge: Edge) => {
1588
+ const mappingEdgeId = (edge.data as FieldRemapFlowEdgeData | undefined)?.mappingEdgeId;
1589
+ if (!mappingEdgeId) {
1590
+ return;
1591
+ }
1592
+ const target = { kind: 'edge', edgeId: mappingEdgeId } as const;
1593
+ applyBulkSelectionGesture(target, event);
1594
+ focusBulkTarget(target, 'graph');
1595
+ },
1596
+ [applyBulkSelectionGesture, focusBulkTarget],
1597
+ );
1598
+
1599
+ const onBulkSelectionKeyDownCapture = useCallback(
1600
+ (event: KeyboardEvent<HTMLDivElement>) => {
1601
+ if ((event.key !== 'Enter' && event.key !== ' ') || event.defaultPrevented || event.altKey) {
1602
+ return;
1603
+ }
1604
+ const target = fieldRemapBulkRefFromKeyboardTarget(event.target);
1605
+ if (!target) {
1606
+ return;
1607
+ }
1608
+ event.preventDefault();
1609
+ event.stopPropagation();
1610
+ pendingPrimaryCorrectionAckRef.current = undefined;
1611
+ applyBulkSelectionGesture(target, event);
1612
+ },
1613
+ [applyBulkSelectionGesture],
866
1614
  );
867
1615
 
868
1616
  const onKeyDown = useCallback(
869
1617
  (event: KeyboardEvent<HTMLDivElement>) => {
870
1618
  if (event.key === 'Escape') {
1619
+ if (event.defaultPrevented || (selection === null && drafts.length === 0)) {
1620
+ return;
1621
+ }
1622
+ const mapper = mapperRef.current;
1623
+ const focused = mapper?.ownerDocument.activeElement;
1624
+ const detail = mapper?.querySelector<HTMLElement>(
1625
+ '[data-testid="field-remap-detail"], [data-testid="field-remap-convert-note"]',
1626
+ );
1627
+ const detailSeparator = mapper?.querySelector<HTMLElement>(
1628
+ '.workbench-field-remap-flow__canvas-detail-split > [role="separator"]',
1629
+ );
1630
+ restoreMapperFocusRef.current =
1631
+ detailPresentation === 'rail' &&
1632
+ emptyDetail === 'collapse' &&
1633
+ focused instanceof Element &&
1634
+ (detail?.contains(focused) === true ||
1635
+ (!previewVisible && detailSeparator?.contains(focused) === true));
1636
+ event.preventDefault();
1637
+ event.stopPropagation();
871
1638
  setSelection(null);
872
1639
  setDrafts([]);
1640
+ setDraftPositions(new Map());
1641
+ return;
1642
+ }
1643
+
1644
+ if (readOnly) {
1645
+ return;
1646
+ }
1647
+
1648
+ if (
1649
+ (event.key !== 'Delete' && event.key !== 'Backspace') ||
1650
+ event.ctrlKey ||
1651
+ event.metaKey ||
1652
+ event.altKey ||
1653
+ event.defaultPrevented ||
1654
+ isFieldRemapEditableShortcutTarget(event.target) ||
1655
+ selection === null
1656
+ ) {
1657
+ return;
1658
+ }
1659
+
1660
+ let consumed = false;
1661
+ if (selection.kind === 'edge' || selection.kind === 'transformStep') {
1662
+ const primaryKey = fieldRemapBulkSelectionKey(selection);
1663
+ const refs = bulkSelection.some((ref) => fieldRemapBulkSelectionKey(ref) === primaryKey)
1664
+ ? bulkSelection
1665
+ : [...bulkSelection, selection];
1666
+ consumed = commitBulkDelete(refs);
1667
+ } else if (selection.kind === 'operator') {
1668
+ if (
1669
+ onOperatorsChange &&
1670
+ operators.some((operator) => operator.id === selection.operatorId)
1671
+ ) {
1672
+ onOperatorsChange(removeMappingOperator(operators, selection.operatorId));
1673
+ setSelection(null);
1674
+ consumed = true;
1675
+ }
1676
+ } else if (selection.kind === 'draft') {
1677
+ if (drafts.some((draft) => draft.localId === selection.localId)) {
1678
+ setDrafts((current) => current.filter((draft) => draft.localId !== selection.localId));
1679
+ removeDraftPosition(selection.localId);
1680
+ setSelection(null);
1681
+ consumed = true;
1682
+ }
1683
+ }
1684
+
1685
+ if (consumed) {
1686
+ event.preventDefault();
1687
+ event.stopPropagation();
873
1688
  }
874
1689
  },
875
- [setSelection],
1690
+ [
1691
+ drafts,
1692
+ bulkSelection,
1693
+ commitBulkDelete,
1694
+ detailPresentation,
1695
+ emptyDetail,
1696
+ onOperatorsChange,
1697
+ operators,
1698
+ previewVisible,
1699
+ readOnly,
1700
+ removeDraftPosition,
1701
+ selection,
1702
+ setSelection,
1703
+ ],
876
1704
  );
877
1705
 
878
1706
  const handlePaneContextMenu = useCallback(
@@ -896,6 +1724,32 @@ function FieldRemapFlowCanvas({
896
1724
  [onEdgeContextMenu, selection],
897
1725
  );
898
1726
 
1727
+ const detailPanel = detailVisible ? (
1728
+ <FieldRemapDetailPanel
1729
+ selection={selection}
1730
+ edges={edges}
1731
+ sources={sources}
1732
+ targets={targets}
1733
+ transforms={transforms}
1734
+ readOnly={readOnly}
1735
+ onEdgesChange={onEdgesChange}
1736
+ onSelectionChange={setSelection}
1737
+ drafts={drafts}
1738
+ onDiscardDraft={(localId) => {
1739
+ setDrafts((current) => current.filter((item) => item.localId !== localId));
1740
+ removeDraftPosition(localId);
1741
+ }}
1742
+ operators={operators}
1743
+ onOperatorsChange={onOperatorsChange}
1744
+ emptyDetailTitle={
1745
+ readOnly ? chromeLabels.readOnlyEmptyDetailTitle : chromeLabels.emptyDetailTitle
1746
+ }
1747
+ emptyDetailDescription={
1748
+ readOnly ? chromeLabels.readOnlyEmptyDetailDescription : chromeLabels.emptyDetailDescription
1749
+ }
1750
+ />
1751
+ ) : null;
1752
+
899
1753
  return (
900
1754
  <div
901
1755
  ref={mapperRef}
@@ -904,11 +1758,15 @@ function FieldRemapFlowCanvas({
904
1758
  data-chrome={chrome}
905
1759
  data-flow-hint={showFlowHint ? 'on' : 'off'}
906
1760
  data-bindings-list={showBindingsList ? 'on' : 'off'}
907
- data-convert-palette={showConvertPalette ? 'on' : 'off'}
1761
+ data-convert-palette={showAuthoringPalette ? 'on' : 'off'}
1762
+ data-read-only={readOnly ? 'true' : 'false'}
908
1763
  data-empty-detail={emptyDetail}
1764
+ data-detail-presentation={detailPresentation}
909
1765
  data-minimap={showMinimap ? 'on' : 'off'}
910
1766
  data-hidden-fields={includeHidden ? 'on' : 'off'}
911
1767
  data-preview={previewVisible ? 'on' : 'off'}
1768
+ tabIndex={-1}
1769
+ onKeyDownCapture={onBulkSelectionKeyDownCapture}
912
1770
  onKeyDown={onKeyDown}
913
1771
  >
914
1772
  {showFlowHint ? (
@@ -920,10 +1778,25 @@ function FieldRemapFlowCanvas({
920
1778
  </p>
921
1779
  ) : null}
922
1780
 
1781
+ {connectionFeedback ? (
1782
+ <p className="workbench-field-remap-demo__warn" role="status">
1783
+ {connectionFeedback.reason}
1784
+ </p>
1785
+ ) : null}
1786
+
1787
+ {conflicts.length > 0 ? (
1788
+ <p className="workbench-field-remap-demo__warn" role="status">
1789
+ Warning: parent and child fields are both mapped (
1790
+ {conflicts.map((item) => `${item.parentId} / ${item.childId}`).join('; ')}). Prefer one
1791
+ level.
1792
+ </p>
1793
+ ) : null}
1794
+
923
1795
  <FieldRemapSplitWorkspace
924
1796
  layout={workspaceLayout}
925
- showConvertPalette={showConvertPalette}
926
- showDetail={emptyDetail === 'hint' || selection !== null || previewVisible}
1797
+ reserveHiddenDetailSplit={detailPresentation === 'rail'}
1798
+ showConvertPalette={showAuthoringPalette}
1799
+ showDetail={sideRailVisible}
927
1800
  surface={
928
1801
  selection?.kind === 'transformStep'
929
1802
  ? 'convert-note'
@@ -935,7 +1808,7 @@ function FieldRemapFlowCanvas({
935
1808
  }
936
1809
  >
937
1810
  <>
938
- {showConvertPalette ? (
1811
+ {showAuthoringPalette ? (
939
1812
  <FieldRemapConvertPalette
940
1813
  transforms={transforms}
941
1814
  selectedTransformId={placeTransformId}
@@ -969,15 +1842,27 @@ function FieldRemapFlowCanvas({
969
1842
  nodes={nodes}
970
1843
  edges={flowEdges}
971
1844
  nodeTypes={nodeTypes}
972
- onNodesChange={onNodesChange}
973
- onEdgesChange={onFlowEdgesChange}
974
- onConnect={onConnect}
975
- onEdgesDelete={onEdgesDelete}
1845
+ edgeTypes={edgeTypes}
1846
+ onNodesChange={onProjectedNodesChange}
1847
+ onEdgesChange={onProjectedFlowEdgesChange}
1848
+ onConnect={readOnly ? undefined : onConnect}
1849
+ onConnectStart={readOnly ? undefined : onConnectStart}
1850
+ onConnectEnd={readOnly ? undefined : onConnectEnd}
1851
+ onEdgesDelete={readOnly ? undefined : onEdgesDelete}
1852
+ onDragOver={onCanvasDragOver}
1853
+ onDrop={onCanvasDrop}
976
1854
  onNodeClick={onNodeClick}
1855
+ onEdgeClick={onEdgeClick}
977
1856
  onPaneContextMenu={onPaneContextMenu ? handlePaneContextMenu : undefined}
978
1857
  onNodeContextMenu={onNodeContextMenu ? handleNodeContextMenu : undefined}
979
1858
  onEdgeContextMenu={onEdgeContextMenu ? handleEdgeContextMenu : undefined}
980
1859
  isValidConnection={isValidConnection}
1860
+ nodesDraggable={!readOnly}
1861
+ nodesConnectable={!readOnly}
1862
+ edgesReconnectable={!readOnly}
1863
+ elementsSelectable={false}
1864
+ ariaLabelConfig={flowAriaLabelConfig}
1865
+ deleteKeyCode={null}
981
1866
  fitView
982
1867
  fitViewOptions={DEFAULT_FIT_VIEW_OPTIONS}
983
1868
  proOptions={{ hideAttribution: true }}
@@ -1094,25 +1979,7 @@ function FieldRemapFlowCanvas({
1094
1979
  </div>
1095
1980
 
1096
1981
  <div className="workbench-field-remap-flow__side-rail">
1097
- {emptyDetail === 'hint' || selection !== null ? (
1098
- <FieldRemapDetailPanel
1099
- selection={selection}
1100
- edges={edges}
1101
- sources={sources}
1102
- targets={targets}
1103
- transforms={transforms}
1104
- onEdgesChange={onEdgesChange}
1105
- onSelectionChange={setSelection}
1106
- drafts={drafts}
1107
- onDiscardDraft={(localId) => {
1108
- setDrafts((current) => current.filter((item) => item.localId !== localId));
1109
- }}
1110
- operators={operators}
1111
- onOperatorsChange={onOperatorsChange}
1112
- emptyDetailTitle={chromeLabels.emptyDetailTitle}
1113
- emptyDetailDescription={chromeLabels.emptyDetailDescription}
1114
- />
1115
- ) : null}
1982
+ {detailPresentation === 'rail' ? detailPanel : null}
1116
1983
  {previewVisible && preview ? (
1117
1984
  <FieldRemapPreviewRail
1118
1985
  preview={preview}
@@ -1129,6 +1996,18 @@ function FieldRemapFlowCanvas({
1129
1996
  </div>
1130
1997
  </FieldRemapSplitWorkspace>
1131
1998
 
1999
+ {detailPresentation === 'modal' && selection !== null ? (
2000
+ <Suspense fallback={null}>
2001
+ <FieldRemapModalDetail
2002
+ closeLabel={chromeLabels.closeDetailModal ?? 'Close details'}
2003
+ title={chromeLabels.detailModalTitle ?? 'Mapping details'}
2004
+ onClose={closeModalDetail}
2005
+ >
2006
+ {detailPanel}
2007
+ </FieldRemapModalDetail>
2008
+ </Suspense>
2009
+ ) : null}
2010
+
1132
2011
  {showBindingsList ? (
1133
2012
  <div className="workbench-field-remap-flow__bindings" data-testid="field-remap-edges">
1134
2013
  <h4>{chromeLabels.bindingsTitle}</h4>
@@ -1145,21 +2024,36 @@ function FieldRemapFlowCanvas({
1145
2024
  });
1146
2025
  const defaultAddId = appendCatalog[0]?.id;
1147
2026
  const listContext = canEditListContext(edge, sources, targets);
1148
- const selected =
1149
- (selection?.kind === 'edge' || selection?.kind === 'transformStep') &&
1150
- selection.edgeId === edge.id;
2027
+ const edgeRef = { kind: 'edge', edgeId: edge.id } as const;
2028
+ const edgeKey = fieldRemapBulkSelectionKey(edgeRef);
2029
+ const edgeSelected = bulkSelectionKeys.has(edgeKey);
2030
+ const visibleRemoveRefs = edgeSelected ? bulkSelection : [edgeRef];
2031
+ const laneSelected = canonicalBulkRefs.some(
2032
+ (ref) =>
2033
+ ref.edgeId === edge.id && bulkSelectionKeys.has(fieldRemapBulkSelectionKey(ref)),
2034
+ );
2035
+ const edgeIsPrimary = selection?.kind === 'edge' && selection.edgeId === edge.id;
1151
2036
 
1152
2037
  return (
1153
2038
  <li
1154
2039
  key={edge.id}
1155
- className={selected ? 'is-selected' : undefined}
2040
+ className={laneSelected ? 'is-selected' : undefined}
1156
2041
  data-testid={`field-remap-lane-${edge.id}`}
1157
2042
  >
1158
2043
  <button
2044
+ ref={(element) => registerBulkFocusTarget(edgeKey, 'list', element)}
1159
2045
  type="button"
1160
- className="workbench-field-remap-flow__binding-select"
2046
+ aria-pressed={edgeSelected}
2047
+ className={
2048
+ edgeSelected
2049
+ ? 'workbench-field-remap-flow__binding-select is-selected'
2050
+ : 'workbench-field-remap-flow__binding-select'
2051
+ }
2052
+ data-field-remap-bulk-edge-id={edge.id}
2053
+ data-field-remap-bulk-kind="edge"
2054
+ data-primary={edgeIsPrimary ? 'true' : 'false'}
1161
2055
  data-testid={`field-remap-select-edge-${edge.id}`}
1162
- onClick={() => setSelection({ kind: 'edge', edgeId: edge.id })}
2056
+ onClick={(event) => applyBulkSelectionGesture(edgeRef, event)}
1163
2057
  >
1164
2058
  <code>
1165
2059
  {edge.sourceFieldId} →{' '}
@@ -1170,70 +2064,69 @@ function FieldRemapFlowCanvas({
1170
2064
  {edge.itemEdges ? ` · ${edge.itemEdges.length} item fields` : ''}
1171
2065
  </code>
1172
2066
  </button>
1173
- <span className="workbench-field-remap-mapper__edge-actions">
1174
- {(edge.transformIds?.length ?? 0) < MAX_TRANSFORM_CHAIN && defaultAddId ? (
2067
+ {!readOnly ? (
2068
+ <span className="workbench-field-remap-mapper__edge-actions">
2069
+ {(edge.transformIds?.length ?? 0) < MAX_TRANSFORM_CHAIN && defaultAddId ? (
2070
+ <IconButton
2071
+ compact
2072
+ type="button"
2073
+ data-testid={`field-remap-add-node-${edge.id}`}
2074
+ icon="codicon-add"
2075
+ label={chromeLabels.addTransform}
2076
+ onClick={() => {
2077
+ const next = addTransformStepToEdge(edge, defaultAddId, {
2078
+ registry: transforms,
2079
+ sourceType: portTypes.sourceType,
2080
+ targetType: portTypes.targetType,
2081
+ });
2082
+ if (!next) {
2083
+ return;
2084
+ }
2085
+ onEdgesChange(edges.map((item) => (item.id === edge.id ? next : item)));
2086
+ setSelection({
2087
+ kind: 'transformStep',
2088
+ edgeId: edge.id,
2089
+ stepIndex: (next.transformIds?.length ?? 1) - 1,
2090
+ });
2091
+ }}
2092
+ />
2093
+ ) : null}
2094
+ {listContext ? (
2095
+ <IconButton
2096
+ compact
2097
+ type="button"
2098
+ data-testid={`field-remap-edit-items-${edge.id}`}
2099
+ icon="codicon-edit"
2100
+ label={chromeLabels.editItems}
2101
+ onClick={() => {
2102
+ if (!edge.itemEdges) {
2103
+ onEdgesChange(
2104
+ edges.map((item) =>
2105
+ item.id === edge.id ? enableListContextOnEdge(item) : item,
2106
+ ),
2107
+ );
2108
+ }
2109
+ setSelection({ kind: 'edge', edgeId: edge.id });
2110
+ }}
2111
+ />
2112
+ ) : null}
1175
2113
  <IconButton
1176
2114
  compact
1177
2115
  type="button"
1178
- data-testid={`field-remap-add-node-${edge.id}`}
1179
- icon="codicon-add"
1180
- label={chromeLabels.addTransform}
1181
- onClick={() => {
1182
- const next = addTransformStepToEdge(edge, defaultAddId, {
1183
- registry: transforms,
1184
- sourceType: portTypes.sourceType,
1185
- targetType: portTypes.targetType,
1186
- });
1187
- if (!next) {
1188
- return;
1189
- }
1190
- onEdgesChange(edges.map((item) => (item.id === edge.id ? next : item)));
1191
- setSelection({
1192
- kind: 'transformStep',
1193
- edgeId: edge.id,
1194
- stepIndex: (next.transformIds?.length ?? 1) - 1,
1195
- });
1196
- }}
1197
- />
1198
- ) : null}
1199
- {listContext ? (
1200
- <IconButton
1201
- compact
1202
- type="button"
1203
- data-testid={`field-remap-edit-items-${edge.id}`}
1204
- icon="codicon-edit"
1205
- label={chromeLabels.editItems}
2116
+ data-testid={`field-remap-remove-edge-${edge.id}`}
2117
+ icon="codicon-trash"
2118
+ label={
2119
+ visibleRemoveRefs.length > 1
2120
+ ? `Remove ${visibleRemoveRefs.length} selected items`
2121
+ : chromeLabels.removeBinding
2122
+ }
2123
+ variant="danger"
1206
2124
  onClick={() => {
1207
- if (!edge.itemEdges) {
1208
- onEdgesChange(
1209
- edges.map((item) =>
1210
- item.id === edge.id ? enableListContextOnEdge(item) : item,
1211
- ),
1212
- );
1213
- }
1214
- setSelection({ kind: 'edge', edgeId: edge.id });
2125
+ commitBulkDelete(visibleRemoveRefs);
1215
2126
  }}
1216
2127
  />
1217
- ) : null}
1218
- <IconButton
1219
- compact
1220
- type="button"
1221
- data-testid={`field-remap-remove-edge-${edge.id}`}
1222
- icon="codicon-trash"
1223
- label={chromeLabels.removeBinding}
1224
- variant="danger"
1225
- onClick={() => {
1226
- onEdgesChange(edges.filter((item) => item.id !== edge.id));
1227
- if (
1228
- selection &&
1229
- (selection.kind === 'edge' || selection.kind === 'transformStep') &&
1230
- selection.edgeId === edge.id
1231
- ) {
1232
- setSelection(null);
1233
- }
1234
- }}
1235
- />
1236
- </span>
2128
+ </span>
2129
+ ) : null}
1237
2130
  </li>
1238
2131
  );
1239
2132
  })}