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

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 (39) hide show
  1. package/README.md +81 -2
  2. package/package.json +14 -10
  3. package/src/editor/workspace-reconcile.tsx +12 -7
  4. package/src/extensions/extension-enablement-controller.ts +40 -9
  5. package/src/extensions/theme-selection-protection.ts +80 -55
  6. package/src/field-remap/chrome-labels.ts +117 -0
  7. package/src/field-remap/convert-note-editor.tsx +119 -104
  8. package/src/field-remap/convert-palette.tsx +6 -0
  9. package/src/field-remap/demo.tsx +8 -2
  10. package/src/field-remap/detail-panel.tsx +535 -434
  11. package/src/field-remap/document-io.tsx +299 -0
  12. package/src/field-remap/drag-payload.ts +48 -0
  13. package/src/field-remap/flow-adapter.ts +216 -88
  14. package/src/field-remap/flow-ops.ts +150 -0
  15. package/src/field-remap/flow.tsx +1227 -272
  16. package/src/field-remap/index.ts +2 -0
  17. package/src/field-remap/io-class-browse.tsx +34 -22
  18. package/src/field-remap/keyboard.ts +16 -0
  19. package/src/field-remap/modal-detail.tsx +34 -0
  20. package/src/field-remap/panel.tsx +110 -14
  21. package/src/field-remap/transform-options-editor.tsx +68 -48
  22. package/src/field-remap/view.css +107 -189
  23. package/src/index.ts +7 -0
  24. package/src/keybinding-management-settings.ts +4 -0
  25. package/src/management/keybinding-overrides-storage.ts +48 -23
  26. package/src/management/keybinding-settings-view.tsx +31 -0
  27. package/src/management/keybinding-settings.tsx +4 -12
  28. package/src/management/use-keybinding-management.ts +72 -22
  29. package/src/shell/appearance-catalog.ts +453 -0
  30. package/src/shell/appearance-controller.ts +331 -0
  31. package/src/shell/appearance-presentation.ts +269 -0
  32. package/src/shell/provider.tsx +157 -19
  33. package/src/shell/settings.tsx +282 -153
  34. package/src/shell/shell.tsx +88 -18
  35. package/src/workbench/appearance-storage.ts +2 -2
  36. package/src/workbench/command-host-controller.tsx +223 -0
  37. package/src/workbench/command-host.tsx +100 -150
  38. package/src/workbench/keybinding-bridge.ts +84 -38
  39. 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;
@@ -301,8 +377,10 @@ const DEFAULT_FIT_VIEW_OPTIONS = { padding: 0.12, maxZoom: 1.15 } as const;
301
377
  * to the React Flow store (avoids update loops with controlled nodes).
302
378
  */
303
379
  function FieldRemapFlowActionsBridge({
380
+ canFitView,
304
381
  flowActionsRef,
305
382
  }: {
383
+ readonly canFitView: () => boolean;
306
384
  readonly flowActionsRef?: Ref<FieldRemapFlowActions | null> | undefined;
307
385
  }): null {
308
386
  const { fitView } = useReactFlow();
@@ -310,13 +388,16 @@ function FieldRemapFlowActionsBridge({
310
388
  flowActionsRef,
311
389
  () => ({
312
390
  fitView: (options) => {
391
+ if (!canFitView()) {
392
+ return;
393
+ }
313
394
  void fitView({
314
395
  padding: options?.padding ?? DEFAULT_FIT_VIEW_OPTIONS.padding,
315
396
  maxZoom: options?.maxZoom ?? DEFAULT_FIT_VIEW_OPTIONS.maxZoom,
316
397
  });
317
398
  },
318
399
  }),
319
- [fitView],
400
+ [canFitView, fitView],
320
401
  );
321
402
  return null;
322
403
  }
@@ -327,6 +408,16 @@ export interface FieldRemapFlowMapperProps {
327
408
  readonly edges: readonly MappingEdge[];
328
409
  readonly transforms: ValueTransformRegistry;
329
410
  readonly onEdgesChange: (edges: readonly MappingEdge[]) => void;
411
+ /** Existing target/donor edges are replaced by default; `reject` preserves them unchanged. */
412
+ readonly rewirePolicy?: 'replace' | 'reject' | undefined;
413
+ /** Structured completed-attempt feedback; hover validation never invokes this callback. */
414
+ readonly onConnectionFeedback?:
415
+ ((feedback: FieldRemapConnectionFeedback | null) => void) | undefined;
416
+ /**
417
+ * Authoritative parent/child conflict projection. `undefined` derives from supplied Flow inputs;
418
+ * an explicit empty array suppresses fallback derivation.
419
+ */
420
+ readonly parentChildConflicts?: readonly MappingConflict[] | undefined;
330
421
  /**
331
422
  * `card` preserves the demo chrome. `embed` omits the flow hint and binding list;
332
423
  * explicit `show*` props below take precedence.
@@ -334,6 +425,8 @@ export interface FieldRemapFlowMapperProps {
334
425
  readonly chrome?: 'card' | 'embed' | undefined;
335
426
  /** Show the empty-selection detail hint, or collapse that rail until a selection exists. */
336
427
  readonly emptyDetail?: 'hint' | 'collapse' | undefined;
428
+ /** Render selection detail in the resizable rail (default) or the shared workbench Modal. */
429
+ readonly detailPresentation?: 'rail' | 'modal' | undefined;
337
430
  /** Show the convert-first hint. Defaults to true for `card`, false for `embed`. */
338
431
  readonly showFlowHint?: boolean | undefined;
339
432
  /** Show the bottom binding list. Defaults to true for `card`, false for `embed`. */
@@ -343,6 +436,11 @@ export interface FieldRemapFlowMapperProps {
343
436
  * the workspace expands without leaving an empty grid track.
344
437
  */
345
438
  readonly showConvertPalette?: boolean | undefined;
439
+ /**
440
+ * View-only authoring guard. Existing mappings remain inspectable, while durable mutations and
441
+ * mapper-local drafts are disabled. This is not an authorization boundary.
442
+ */
443
+ readonly readOnly?: boolean | undefined;
346
444
  /** Document v2 n→m operators (display + authoring when onOperatorsChange is set). */
347
445
  readonly operators?: readonly MappingOperator[] | undefined;
348
446
  readonly onOperatorsChange?: ((operators: readonly MappingOperator[]) => void) | undefined;
@@ -404,9 +502,72 @@ export interface FieldRemapFlowMapperProps {
404
502
  readonly t?: FieldRemapTranslate | undefined;
405
503
  }
406
504
 
505
+ export type FieldRemapConnectionFeedbackReason =
506
+ FieldRemapFlowConnectionRejectionReason | 'rewire-policy-rejected';
507
+
508
+ export interface FieldRemapConnectionFeedback {
509
+ readonly reason: FieldRemapConnectionFeedbackReason;
510
+ readonly impactedEdgeIds?: readonly string[];
511
+ }
512
+
513
+ function areFieldRemapBulkSelectionsEqual(
514
+ left: readonly FieldRemapBulkSelectionRef[],
515
+ right: readonly FieldRemapBulkSelectionRef[],
516
+ ): boolean {
517
+ return (
518
+ left.length === right.length &&
519
+ left.every(
520
+ (ref, index) => fieldRemapBulkSelectionKey(ref) === fieldRemapBulkSelectionKey(right[index]!),
521
+ )
522
+ );
523
+ }
524
+
525
+ function fieldRemapBulkDomainKey(refs: readonly FieldRemapBulkSelectionRef[]): string {
526
+ return refs
527
+ .map(fieldRemapBulkSelectionKey)
528
+ .map((key) => `${key.length}:${key}`)
529
+ .join('');
530
+ }
531
+
532
+ function fieldRemapBulkRefFromKeyboardTarget(
533
+ target: EventTarget | null,
534
+ ): FieldRemapBulkSelectionRef | undefined {
535
+ if (!(target instanceof Element)) {
536
+ return undefined;
537
+ }
538
+
539
+ const explicit = target.closest<HTMLElement>('[data-field-remap-bulk-kind]');
540
+ const explicitKind = explicit?.dataset.fieldRemapBulkKind;
541
+ const explicitEdgeId = explicit?.dataset.fieldRemapBulkEdgeId;
542
+ if (explicitKind === 'edge' && explicitEdgeId) {
543
+ return { kind: 'edge', edgeId: explicitEdgeId };
544
+ }
545
+ if (explicitKind === 'transformStep' && explicitEdgeId) {
546
+ const stepIndex = Number(explicit.dataset.fieldRemapBulkStepIndex);
547
+ if (Number.isInteger(stepIndex) && stepIndex >= 0) {
548
+ return { kind: 'transformStep', edgeId: explicitEdgeId, stepIndex };
549
+ }
550
+ }
551
+ return undefined;
552
+ }
553
+
554
+ function connectionFromFinalState(state: FinalConnectionState): Connection | null {
555
+ if (!state.fromNode || !state.fromHandle || !state.toNode || !state.toHandle) {
556
+ return null;
557
+ }
558
+ const reversed = state.fromHandle.type === 'target';
559
+ return {
560
+ source: (reversed ? state.toNode : state.fromNode).id,
561
+ sourceHandle: (reversed ? state.toHandle : state.fromHandle).id ?? null,
562
+ target: (reversed ? state.fromNode : state.toNode).id,
563
+ targetHandle: (reversed ? state.fromHandle : state.toHandle).id ?? null,
564
+ };
565
+ }
566
+
407
567
  interface FieldRemapSplitWorkspaceProps {
408
568
  readonly children: ReactNode;
409
569
  readonly layout: 'wide' | 'medium' | 'narrow';
570
+ readonly reserveHiddenDetailSplit: boolean;
410
571
  readonly showConvertPalette: boolean;
411
572
  readonly showDetail: boolean;
412
573
  readonly surface: 'binding' | 'convert-note' | 'draft-convert' | 'operator';
@@ -420,6 +581,7 @@ interface FieldRemapSplitWorkspaceProps {
420
581
  function FieldRemapSplitWorkspace({
421
582
  children,
422
583
  layout,
584
+ reserveHiddenDetailSplit,
423
585
  showConvertPalette,
424
586
  showDetail,
425
587
  surface,
@@ -438,26 +600,29 @@ function FieldRemapSplitWorkspace({
438
600
  });
439
601
  const paletteSizePx = paletteSizeByLayout[layout];
440
602
  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
- );
603
+ const canvasWithDetail =
604
+ showDetail || reserveHiddenDetailSplit ? (
605
+ <SplitView
606
+ className={
607
+ showDetail
608
+ ? 'workbench-field-remap-flow__canvas-detail-split'
609
+ : 'workbench-field-remap-flow__canvas-detail-split ui-workbench-split-view--secondary-collapsed'
610
+ }
611
+ layoutMode="secondary-fixed"
612
+ maxSecondarySizePx={isNarrow ? 320 : 480}
613
+ minPrimarySizePx={isNarrow ? 200 : 280}
614
+ minSecondarySizePx={isNarrow ? 160 : 256}
615
+ onSecondarySizePxChange={(nextSize) => {
616
+ setDetailSizeByLayout((current) => ({ ...current, [layout]: nextSize }));
617
+ }}
618
+ orientation={isNarrow ? 'vertical' : 'horizontal'}
619
+ primary={canvas}
620
+ secondary={detail}
621
+ secondarySizePx={detailSizePx}
622
+ />
623
+ ) : (
624
+ canvas
625
+ );
461
626
 
462
627
  const content = (
463
628
  <SplitView
@@ -502,11 +667,16 @@ function FieldRemapFlowCanvas({
502
667
  edges,
503
668
  transforms,
504
669
  onEdgesChange,
670
+ rewirePolicy = 'replace',
671
+ onConnectionFeedback,
672
+ parentChildConflicts,
505
673
  chrome = 'card',
506
674
  emptyDetail: emptyDetailProp,
675
+ detailPresentation = 'rail',
507
676
  showFlowHint: showFlowHintProp,
508
677
  showBindingsList: showBindingsListProp,
509
678
  showConvertPalette = true,
679
+ readOnly = false,
510
680
  operators = [],
511
681
  onOperatorsChange,
512
682
  sourceTitle,
@@ -530,21 +700,271 @@ function FieldRemapFlowCanvas({
530
700
  () => resolveFieldRemapChromeLabels(labelOverrides, t),
531
701
  [labelOverrides, t],
532
702
  );
533
- const showFlowHint = showFlowHintProp ?? chrome !== 'embed';
703
+ const showFlowHint = !readOnly && (showFlowHintProp ?? chrome !== 'embed');
534
704
  const showBindingsList = showBindingsListProp ?? chrome !== 'embed';
705
+ const showAuthoringPalette = showConvertPalette && !readOnly;
535
706
  const emptyDetail = emptyDetailProp ?? (chrome === 'embed' ? 'collapse' : 'hint');
536
707
  const mapperRef = useRef<HTMLDivElement>(null);
708
+ const canvasRef = useRef<HTMLDivElement>(null);
709
+ const restoreMapperFocusRef = useRef(false);
710
+ const [hasPositiveCanvasSize, setHasPositiveCanvasSize] = useState(false);
537
711
  const [workspaceLayout, setWorkspaceLayout] = useState<'wide' | 'medium' | 'narrow'>('wide');
538
712
  const [internalSelection, setInternalSelection] = useState<FieldRemapSelection>(null);
539
- const selection = selectionProp !== undefined ? selectionProp : internalSelection;
540
- const setSelection = onSelectionChangeProp ?? setInternalSelection;
713
+ const authoritativeSelection = selectionProp !== undefined ? selectionProp : internalSelection;
714
+ const selectionExternallyManaged =
715
+ selectionProp !== undefined || onSelectionChangeProp !== undefined;
716
+ const [bulkSelection, setBulkSelection] = useState<readonly FieldRemapBulkSelectionRef[]>(() => {
717
+ const initial = asFieldRemapBulkSelectionRef(selectionProp ?? null);
718
+ return initial ? normalizeFieldRemapBulkSelection(edges, [initial]) : [];
719
+ });
720
+ const selection = authoritativeSelection;
721
+ const selectionRef = useRef(authoritativeSelection);
722
+ selectionRef.current = authoritativeSelection;
723
+ const setSelection = useCallback(
724
+ (next: FieldRemapSelection) => {
725
+ if (fieldRemapSelectionKey(selectionRef.current) === fieldRemapSelectionKey(next)) {
726
+ return;
727
+ }
728
+ if (!selectionExternallyManaged) {
729
+ setInternalSelection(next);
730
+ const nextRef = asFieldRemapBulkSelectionRef(next);
731
+ setBulkSelection(nextRef ? normalizeFieldRemapBulkSelection(edges, [nextRef]) : []);
732
+ }
733
+ onSelectionChangeProp?.(next);
734
+ },
735
+ [edges, onSelectionChangeProp, selectionExternallyManaged],
736
+ );
737
+ const setSelectionRef = useRef(setSelection);
738
+ setSelectionRef.current = setSelection;
739
+ const closeModalDetail = useCallback(() => setSelectionRef.current(null), []);
740
+ const canonicalBulkRefs = useMemo(() => listFieldRemapBulkSelectionRefs(edges), [edges]);
741
+ const bulkDomainKey = useMemo(
742
+ () => fieldRemapBulkDomainKey(canonicalBulkRefs),
743
+ [canonicalBulkRefs],
744
+ );
745
+ const bulkSelectionKeys = useMemo(
746
+ () => new Set(bulkSelection.map(fieldRemapBulkSelectionKey)),
747
+ [bulkSelection],
748
+ );
749
+ const authoritativeSelectionKey = fieldRemapSelectionKey(authoritativeSelection);
750
+ const previousAuthoritativeSelectionKeyRef = useRef(authoritativeSelectionKey);
751
+ const bulkFocusTargetsRef = useRef(
752
+ new Map<string, Partial<Record<FieldRemapFocusSurface, FieldRemapFocusableElement>>>(),
753
+ );
754
+ const registerBulkFocusTarget = useCallback<RegisterFieldRemapFocusTarget>(
755
+ (key, surface, element) => {
756
+ const current = bulkFocusTargetsRef.current.get(key) ?? {};
757
+ if (element) {
758
+ current[surface] = element;
759
+ bulkFocusTargetsRef.current.set(key, current);
760
+ return;
761
+ }
762
+ delete current[surface];
763
+ if (current.graph || current.list) {
764
+ bulkFocusTargetsRef.current.set(key, current);
765
+ } else {
766
+ bulkFocusTargetsRef.current.delete(key);
767
+ }
768
+ },
769
+ [],
770
+ );
771
+ const focusBulkTarget = useCallback(
772
+ (ref: FieldRemapBulkSelectionRef, preferredSurface?: FieldRemapFocusSurface) => {
773
+ const targets = bulkFocusTargetsRef.current.get(fieldRemapBulkSelectionKey(ref));
774
+ const target =
775
+ (preferredSurface ? targets?.[preferredSurface] : undefined) ??
776
+ targets?.list ??
777
+ targets?.graph;
778
+ target?.focus({ preventScroll: true });
779
+ },
780
+ [],
781
+ );
782
+ const lastPrimaryCorrectionRef = useRef<string | undefined>(undefined);
783
+ const pendingPrimaryCorrectionAckRef = useRef<
784
+ { readonly domainKey: string; readonly selectionKey: string } | undefined
785
+ >(undefined);
786
+ const pendingBulkFocusRef = useRef<
787
+ | {
788
+ readonly domainKey: string;
789
+ readonly target: FieldRemapBulkSelectionRef | null;
790
+ }
791
+ | undefined
792
+ >(undefined);
793
+ const pendingBulkCommitRef = useRef<
794
+ | {
795
+ readonly domainKey: string;
796
+ readonly membership: readonly FieldRemapBulkSelectionRef[];
797
+ readonly primary: FieldRemapSelection;
798
+ }
799
+ | undefined
800
+ >(undefined);
541
801
  const previewVisible = showPreview && preview !== undefined && preview.status !== 'unavailable';
802
+ const flowAriaLabelConfig = useMemo(
803
+ () => ({
804
+ 'node.a11yDescription.default': readOnly
805
+ ? 'Press Enter or Space to inspect this item. Control or Command toggles a non-primary item; Shift adds it.'
806
+ : 'Press Enter or Space to select this item. Control or Command toggles a non-primary item; Shift adds it.',
807
+ 'node.a11yDescription.keyboardDisabled': readOnly
808
+ ? 'Press Enter or Space to inspect this item. Workbench editing is read only.'
809
+ : 'Press Enter or Space to select this item. Use the Workbench controls to edit it.',
810
+ 'edge.a11yDescription.default': readOnly
811
+ ? 'Press Enter or Space to inspect this mapping. Control or Command toggles a non-primary mapping; Shift adds it.'
812
+ : 'Press Enter or Space to select this mapping. Control or Command toggles a non-primary mapping; Shift adds it.',
813
+ }),
814
+ [readOnly],
815
+ );
816
+ const detailVisible = emptyDetail === 'hint' || selection !== null;
817
+ const sideRailVisible = previewVisible || (detailPresentation === 'rail' && detailVisible);
542
818
  const [drafts, setDrafts] = useState<readonly FieldRemapDraftTransform[]>([]);
819
+ const [draftPositions, setDraftPositions] = useState<ReadonlyMap<string, XYPosition>>(
820
+ () => new Map(),
821
+ );
822
+ const [connectionFeedback, setConnectionFeedback] = useState<FieldRemapConnectionFeedback | null>(
823
+ null,
824
+ );
825
+ const connectionAttemptCompletedRef = useRef(false);
543
826
  const [placeTransformId, setPlaceTransformId] = useState(() => {
544
827
  const first = transforms.list().find((definition) => definition.id !== 'identity');
545
828
  return first?.id ?? '';
546
829
  });
547
830
  const transformRegistrySignature = createTransformRegistrySignature(transforms);
831
+ const { screenToFlowPosition } = useReactFlow();
832
+
833
+ const removeDraftPosition = useCallback((localId: string) => {
834
+ setDraftPositions((current) => {
835
+ if (!current.has(localId)) {
836
+ return current;
837
+ }
838
+ const next = new Map(current);
839
+ next.delete(localId);
840
+ return next;
841
+ });
842
+ }, []);
843
+
844
+ useEffect(() => {
845
+ if (detailPresentation === 'modal') {
846
+ void loadFieldRemapModalDetail().catch(() => undefined);
847
+ }
848
+ }, [detailPresentation]);
849
+
850
+ useEffect(() => {
851
+ if (!readOnly) {
852
+ return;
853
+ }
854
+ setDrafts([]);
855
+ setDraftPositions(new Map());
856
+ setConnectionFeedback(null);
857
+ connectionAttemptCompletedRef.current = false;
858
+ if (selectionProp === undefined) {
859
+ setInternalSelection((current) => (current?.kind === 'draft' ? null : current));
860
+ }
861
+ }, [readOnly, selectionProp]);
862
+
863
+ useEffect(() => {
864
+ let authoritativeReset: readonly FieldRemapBulkSelectionRef[] | undefined;
865
+ if (previousAuthoritativeSelectionKeyRef.current !== authoritativeSelectionKey) {
866
+ previousAuthoritativeSelectionKeyRef.current = authoritativeSelectionKey;
867
+ lastPrimaryCorrectionRef.current = undefined;
868
+ const pendingAck = pendingPrimaryCorrectionAckRef.current;
869
+ const acceptsCorrection =
870
+ pendingAck?.selectionKey === authoritativeSelectionKey &&
871
+ (pendingAck.domainKey === bulkDomainKey ||
872
+ pendingAck.domainKey === pendingBulkCommitRef.current?.domainKey);
873
+ pendingPrimaryCorrectionAckRef.current = undefined;
874
+ if (!acceptsCorrection) {
875
+ pendingBulkCommitRef.current = undefined;
876
+ const authoritativeRef = asFieldRemapBulkSelectionRef(authoritativeSelection);
877
+ const next = authoritativeRef
878
+ ? normalizeFieldRemapBulkSelection(edges, [authoritativeRef])
879
+ : [];
880
+ authoritativeReset = next;
881
+ setBulkSelection((current) =>
882
+ areFieldRemapBulkSelectionsEqual(current, next) ? current : next,
883
+ );
884
+ if (!authoritativeRef || next.length > 0) {
885
+ return;
886
+ }
887
+ }
888
+ }
889
+
890
+ const pendingCommit = pendingBulkCommitRef.current;
891
+ if (pendingCommit) {
892
+ const primaryAccepted =
893
+ fieldRemapSelectionKey(authoritativeSelection) ===
894
+ fieldRemapSelectionKey(pendingCommit.primary);
895
+ if (primaryAccepted || bulkDomainKey === pendingCommit.domainKey) {
896
+ setBulkSelection((current) =>
897
+ areFieldRemapBulkSelectionsEqual(current, pendingCommit.membership)
898
+ ? current
899
+ : pendingCommit.membership,
900
+ );
901
+ }
902
+ if (bulkDomainKey !== pendingCommit.domainKey) {
903
+ return;
904
+ }
905
+ pendingBulkCommitRef.current = undefined;
906
+ }
907
+
908
+ const primaryRef = asFieldRemapBulkSelectionRef(authoritativeSelection);
909
+ if (!primaryRef) {
910
+ setBulkSelection((current) => (current.length === 0 ? current : []));
911
+ return;
912
+ }
913
+
914
+ const primaryKey = fieldRemapBulkSelectionKey(primaryRef);
915
+ const primaryIsValid = canonicalBulkRefs.some(
916
+ (ref) => fieldRemapBulkSelectionKey(ref) === primaryKey,
917
+ );
918
+ let next = authoritativeReset ?? normalizeFieldRemapBulkSelection(edges, bulkSelection);
919
+ if (primaryIsValid && !next.some((ref) => fieldRemapBulkSelectionKey(ref) === primaryKey)) {
920
+ next = normalizeFieldRemapBulkSelection(edges, [...next, primaryRef]);
921
+ }
922
+ setBulkSelection((current) =>
923
+ areFieldRemapBulkSelectionsEqual(current, next) ? current : next,
924
+ );
925
+
926
+ if (primaryIsValid) {
927
+ lastPrimaryCorrectionRef.current = undefined;
928
+ return;
929
+ }
930
+
931
+ const nextPrimary = next[0] ?? null;
932
+ const correctionKey = `${bulkDomainKey}\u0001${fieldRemapSelectionKey(authoritativeSelection)}\u0001${fieldRemapSelectionKey(nextPrimary)}`;
933
+ if (lastPrimaryCorrectionRef.current === correctionKey) {
934
+ return;
935
+ }
936
+ lastPrimaryCorrectionRef.current = correctionKey;
937
+ pendingPrimaryCorrectionAckRef.current = onSelectionChangeProp
938
+ ? {
939
+ domainKey: bulkDomainKey,
940
+ selectionKey: fieldRemapSelectionKey(nextPrimary),
941
+ }
942
+ : undefined;
943
+ if (!selectionExternallyManaged) {
944
+ setInternalSelection(nextPrimary);
945
+ } else {
946
+ setSelection(nextPrimary);
947
+ }
948
+ }, [
949
+ bulkDomainKey,
950
+ bulkSelection,
951
+ canonicalBulkRefs,
952
+ authoritativeSelectionKey,
953
+ edges,
954
+ onSelectionChangeProp,
955
+ authoritativeSelection,
956
+ selection,
957
+ selectionExternallyManaged,
958
+ setSelection,
959
+ ]);
960
+
961
+ useEffect(() => {
962
+ if (!restoreMapperFocusRef.current || selection !== null || drafts.length > 0) {
963
+ return;
964
+ }
965
+ restoreMapperFocusRef.current = false;
966
+ mapperRef.current?.focus({ preventScroll: true });
967
+ }, [drafts.length, selection]);
548
968
 
549
969
  useEffect(() => {
550
970
  const element = mapperRef.current;
@@ -569,6 +989,48 @@ function FieldRemapFlowCanvas({
569
989
  return () => observer.disconnect();
570
990
  }, []);
571
991
 
992
+ const canFitView = useCallback(() => {
993
+ const bounds = canvasRef.current?.getBoundingClientRect();
994
+ return bounds !== undefined && bounds.width > 0 && bounds.height > 0;
995
+ }, []);
996
+
997
+ useEffect(() => {
998
+ const element = canvasRef.current;
999
+ if (!element) {
1000
+ return;
1001
+ }
1002
+
1003
+ const updateMountEligibility = () => {
1004
+ const bounds = element.getBoundingClientRect();
1005
+ if (bounds.width > 0 && bounds.height > 0) {
1006
+ setHasPositiveCanvasSize(true);
1007
+ }
1008
+ };
1009
+
1010
+ updateMountEligibility();
1011
+ if (typeof ResizeObserver === 'undefined') {
1012
+ return;
1013
+ }
1014
+ let pendingFrame: number | undefined;
1015
+ const scheduleMountEligibilityUpdate = () => {
1016
+ if (pendingFrame !== undefined) {
1017
+ return;
1018
+ }
1019
+ pendingFrame = requestAnimationFrame(() => {
1020
+ pendingFrame = undefined;
1021
+ updateMountEligibility();
1022
+ });
1023
+ };
1024
+ const observer = new ResizeObserver(scheduleMountEligibilityUpdate);
1025
+ observer.observe(element);
1026
+ return () => {
1027
+ observer.disconnect();
1028
+ if (pendingFrame !== undefined) {
1029
+ cancelAnimationFrame(pendingFrame);
1030
+ }
1031
+ };
1032
+ }, []);
1033
+
572
1034
  const graph = useMemo(
573
1035
  () =>
574
1036
  mappingToFlowGraph({
@@ -580,6 +1042,7 @@ function FieldRemapFlowCanvas({
580
1042
  sourceTitle,
581
1043
  targetTitle,
582
1044
  drafts,
1045
+ draftPositions,
583
1046
  }),
584
1047
  [
585
1048
  sources,
@@ -590,22 +1053,68 @@ function FieldRemapFlowCanvas({
590
1053
  sourceTitle,
591
1054
  targetTitle,
592
1055
  drafts,
1056
+ draftPositions,
593
1057
  transformRegistrySignature,
594
1058
  ],
595
1059
  );
596
1060
 
1061
+ const applyBulkSelectionGesture = useCallback(
1062
+ (
1063
+ target: FieldRemapBulkSelectionRef,
1064
+ modifiers: {
1065
+ readonly ctrlKey: boolean;
1066
+ readonly metaKey: boolean;
1067
+ readonly shiftKey: boolean;
1068
+ },
1069
+ ) => {
1070
+ pendingPrimaryCorrectionAckRef.current = undefined;
1071
+ const gesture =
1072
+ modifiers.ctrlKey || modifiers.metaKey ? 'toggle' : modifiers.shiftKey ? 'add' : 'plain';
1073
+ const next = updateFieldRemapBulkSelection({
1074
+ edges,
1075
+ membership: bulkSelection,
1076
+ primary: selection,
1077
+ target,
1078
+ gesture,
1079
+ });
1080
+ const primaryChanged =
1081
+ fieldRemapSelectionKey(next.primary) !== fieldRemapSelectionKey(selection);
1082
+ if (!primaryChanged || !selectionExternallyManaged) {
1083
+ setBulkSelection((current) =>
1084
+ areFieldRemapBulkSelectionsEqual(current, next.membership) ? current : next.membership,
1085
+ );
1086
+ }
1087
+ if (primaryChanged) {
1088
+ setSelection(next.primary);
1089
+ }
1090
+ },
1091
+ [bulkSelection, edges, selection, selectionExternallyManaged, setSelection],
1092
+ );
1093
+
597
1094
  const nodesWithSelection = useMemo(
598
1095
  () =>
599
1096
  graph.nodes.map((node) => {
600
1097
  if (node.data.kind === 'transform') {
601
- const selected =
602
- selection?.kind === 'transformStep' &&
603
- selection.edgeId === node.data.mappingEdgeId &&
604
- selection.stepIndex === node.data.stepIndex;
1098
+ const ref = {
1099
+ kind: 'transformStep',
1100
+ edgeId: node.data.mappingEdgeId,
1101
+ stepIndex: node.data.stepIndex,
1102
+ } as const;
1103
+ const selected = bulkSelectionKeys.has(fieldRemapBulkSelectionKey(ref));
605
1104
  return {
606
1105
  ...node,
607
- data: { ...node.data, selected },
1106
+ data: { ...node.data, selected, registerFocusTarget: registerBulkFocusTarget },
608
1107
  selected,
1108
+ selectable: false,
1109
+ focusable: true,
1110
+ ariaRole: 'button' as const,
1111
+ ariaLabel: `${node.data.label} convert step`,
1112
+ domAttributes: {
1113
+ 'aria-pressed': selected,
1114
+ 'data-field-remap-bulk-kind': ref.kind,
1115
+ 'data-field-remap-bulk-edge-id': ref.edgeId,
1116
+ 'data-field-remap-bulk-step-index': ref.stepIndex,
1117
+ },
609
1118
  };
610
1119
  }
611
1120
  if (node.data.kind === 'draft-transform') {
@@ -619,50 +1128,168 @@ function FieldRemapFlowCanvas({
619
1128
  }
620
1129
  return node;
621
1130
  }),
622
- [graph.nodes, selection],
1131
+ [bulkSelectionKeys, graph.nodes, registerBulkFocusTarget, selection],
1132
+ );
1133
+
1134
+ const flowEdgesWithSelection = useMemo(
1135
+ () =>
1136
+ graph.edges.map((edge) => {
1137
+ const data = edge.data as FieldRemapFlowEdgeData | undefined;
1138
+ const mappingEdgeId = data?.mappingEdgeId;
1139
+ const selected = mappingEdgeId
1140
+ ? bulkSelectionKeys.has(
1141
+ fieldRemapBulkSelectionKey({ kind: 'edge', edgeId: mappingEdgeId }),
1142
+ )
1143
+ : false;
1144
+ if (!mappingEdgeId) {
1145
+ return { ...edge, selected };
1146
+ }
1147
+ const canonicalSegment = data?.segment === 'direct' || data?.segment === 'in';
1148
+ return {
1149
+ ...edge,
1150
+ data: { ...data, registerFocusTarget: registerBulkFocusTarget },
1151
+ selected,
1152
+ selectable: false,
1153
+ focusable: canonicalSegment,
1154
+ ariaRole: canonicalSegment ? ('button' as const) : ('presentation' as const),
1155
+ ariaLabel: canonicalSegment ? `Mapping ${mappingEdgeId}` : undefined,
1156
+ domAttributes: canonicalSegment
1157
+ ? {
1158
+ 'aria-pressed': selected,
1159
+ 'data-field-remap-bulk-kind': 'edge',
1160
+ 'data-field-remap-bulk-edge-id': mappingEdgeId,
1161
+ }
1162
+ : { 'aria-hidden': true },
1163
+ };
1164
+ }),
1165
+ [bulkSelectionKeys, graph.edges, registerBulkFocusTarget],
623
1166
  );
624
1167
 
625
1168
  const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithSelection);
626
- const [flowEdges, setFlowEdges, onFlowEdgesChange] = useEdgesState(graph.edges);
1169
+ const [flowEdges, setFlowEdges, onFlowEdgesChange] = useEdgesState(flowEdgesWithSelection);
1170
+ const onProjectedNodesChange = useCallback(
1171
+ (changes: Parameters<typeof onNodesChange>[0]) => {
1172
+ onNodesChange(changes.filter((change) => change.type !== 'select'));
1173
+ },
1174
+ [onNodesChange],
1175
+ );
1176
+ const onProjectedFlowEdgesChange = useCallback(
1177
+ (changes: Parameters<typeof onFlowEdgesChange>[0]) => {
1178
+ onFlowEdgesChange(changes.filter((change) => change.type !== 'select'));
1179
+ },
1180
+ [onFlowEdgesChange],
1181
+ );
627
1182
 
628
1183
  // Depending directly on `nodesWithSelection` (a new array after each graph
629
1184
  // calculation) re-enters XYFlow's StoreUpdater. The explicit signature keeps
630
1185
  // that loop guard while still tracking every value copied into rendered nodes.
631
1186
  const graphSyncKey = createFieldRemapGraphSyncKey({
632
1187
  nodes: nodesWithSelection,
633
- edges: graph.edges,
1188
+ edges: flowEdgesWithSelection,
634
1189
  selection,
635
1190
  transformRegistrySignature,
636
1191
  });
637
1192
  const nodesWithSelectionRef = useRef(nodesWithSelection);
638
- const graphEdgesRef = useRef(graph.edges);
1193
+ const graphEdgesRef = useRef(flowEdgesWithSelection);
639
1194
  nodesWithSelectionRef.current = nodesWithSelection;
640
- graphEdgesRef.current = graph.edges;
1195
+ graphEdgesRef.current = flowEdgesWithSelection;
641
1196
 
642
1197
  useEffect(() => {
643
1198
  setNodes(nodesWithSelectionRef.current);
644
1199
  setFlowEdges(graphEdgesRef.current);
645
1200
  }, [graphSyncKey, setFlowEdges, setNodes]);
646
1201
 
1202
+ useEffect(() => {
1203
+ const pending = pendingBulkFocusRef.current;
1204
+ const mapper = mapperRef.current;
1205
+ if (!pending || !mapper || pending.domainKey !== bulkDomainKey) {
1206
+ return;
1207
+ }
1208
+ pendingBulkFocusRef.current = undefined;
1209
+ if (!pending.target) {
1210
+ mapper.focus({ preventScroll: true });
1211
+ return;
1212
+ }
1213
+ const targets = bulkFocusTargetsRef.current.get(fieldRemapBulkSelectionKey(pending.target));
1214
+ (targets?.list ?? targets?.graph ?? mapper).focus({ preventScroll: true });
1215
+ }, [bulkDomainKey]);
1216
+
647
1217
  const connectionContext = useMemo(
648
1218
  () => ({ sources, targets, edges, transforms, drafts, operators }),
649
1219
  [sources, targets, edges, transforms, drafts, operators],
650
1220
  );
651
1221
 
1222
+ const conflicts = useMemo(
1223
+ () => parentChildConflicts ?? findParentChildMappingConflicts(edges, sources, targets),
1224
+ [edges, parentChildConflicts, sources, targets],
1225
+ );
1226
+
1227
+ const publishConnectionFeedback = useCallback(
1228
+ (feedback: FieldRemapConnectionFeedback | null) => {
1229
+ setConnectionFeedback(feedback);
1230
+ onConnectionFeedback?.(feedback);
1231
+ },
1232
+ [onConnectionFeedback],
1233
+ );
1234
+
652
1235
  const isValidConnection = useCallback(
653
1236
  (connection: Connection | Edge) =>
654
- isValidFieldRemapFlowConnection(connection, connectionContext),
655
- [connectionContext],
1237
+ !readOnly && isValidFieldRemapFlowConnection(connection, connectionContext),
1238
+ [connectionContext, readOnly],
1239
+ );
1240
+
1241
+ const onConnectStart = useCallback(() => {
1242
+ if (readOnly) {
1243
+ return;
1244
+ }
1245
+ // Clearing at attempt start lets an identical later rejection be announced once at completion.
1246
+ connectionAttemptCompletedRef.current = false;
1247
+ setConnectionFeedback(null);
1248
+ }, [readOnly]);
1249
+
1250
+ const onConnectEnd = useCallback(
1251
+ (_event: globalThis.MouseEvent | TouchEvent, state: FinalConnectionState) => {
1252
+ if (readOnly) {
1253
+ return;
1254
+ }
1255
+ if (connectionAttemptCompletedRef.current) {
1256
+ return;
1257
+ }
1258
+ connectionAttemptCompletedRef.current = true;
1259
+ const connection = connectionFromFinalState(state);
1260
+ if (!connection) {
1261
+ return;
1262
+ }
1263
+ const evaluation = evaluateFieldRemapFlowConnection(connection, connectionContext);
1264
+ if (evaluation.status === 'rejected') {
1265
+ publishConnectionFeedback({ reason: evaluation.reason });
1266
+ }
1267
+ },
1268
+ [connectionContext, publishConnectionFeedback, readOnly],
656
1269
  );
657
1270
 
658
1271
  const onConnect = useCallback(
659
1272
  (connection: Connection) => {
1273
+ if (readOnly) {
1274
+ return;
1275
+ }
660
1276
  if (!connection.source || !connection.target) {
661
1277
  return;
662
1278
  }
663
- if (!isValidFieldRemapFlowConnection(connection, connectionContext)) {
1279
+ const evaluation = evaluateFieldRemapFlowConnection(connection, connectionContext);
1280
+ if (evaluation.status === 'rejected') {
664
1281
  return;
665
1282
  }
1283
+ if (evaluation.status === 'rewire' && rewirePolicy === 'reject') {
1284
+ connectionAttemptCompletedRef.current = true;
1285
+ publishConnectionFeedback({
1286
+ reason: 'rewire-policy-rejected',
1287
+ impactedEdgeIds: evaluation.impactedEdgeIds,
1288
+ });
1289
+ return;
1290
+ }
1291
+ connectionAttemptCompletedRef.current = true;
1292
+ publishConnectionFeedback(null);
666
1293
 
667
1294
  const draftAsTarget = parseDraftTransformNodeId(connection.target);
668
1295
  if (draftAsTarget && connection.sourceHandle) {
@@ -683,6 +1310,7 @@ function FieldRemapFlowCanvas({
683
1310
  );
684
1311
  onEdgesChange([...withoutTarget, finalized]);
685
1312
  setDrafts(drafts.filter((item) => item.localId !== draft.localId));
1313
+ removeDraftPosition(draft.localId);
686
1314
  setSelection({
687
1315
  kind: 'transformStep',
688
1316
  edgeId: finalized.id,
@@ -714,6 +1342,7 @@ function FieldRemapFlowCanvas({
714
1342
  );
715
1343
  onEdgesChange([...withoutTarget, finalized]);
716
1344
  setDrafts(drafts.filter((item) => item.localId !== draft.localId));
1345
+ removeDraftPosition(draft.localId);
717
1346
  setSelection({
718
1347
  kind: 'transformStep',
719
1348
  edgeId: finalized.id,
@@ -775,6 +1404,10 @@ function FieldRemapFlowCanvas({
775
1404
  onEdgesChange,
776
1405
  onOperatorsChange,
777
1406
  operators,
1407
+ publishConnectionFeedback,
1408
+ readOnly,
1409
+ removeDraftPosition,
1410
+ rewirePolicy,
778
1411
  setSelection,
779
1412
  sources,
780
1413
  targets,
@@ -782,8 +1415,107 @@ function FieldRemapFlowCanvas({
782
1415
  ],
783
1416
  );
784
1417
 
1418
+ const commitBulkDelete = useCallback(
1419
+ (refs: readonly FieldRemapBulkSelectionRef[]): boolean => {
1420
+ const plan = planFieldRemapBulkDelete(edges, refs);
1421
+ if (plan.status !== 'changed') {
1422
+ return false;
1423
+ }
1424
+
1425
+ const removedEdgeIds = new Set(
1426
+ refs.filter((ref) => ref.kind === 'edge').map((ref) => ref.edgeId),
1427
+ );
1428
+ const removedKeys = new Set(refs.map(fieldRemapBulkSelectionKey));
1429
+ let survivingMembership = normalizeFieldRemapBulkSelection(
1430
+ plan.edges,
1431
+ bulkSelection.filter(
1432
+ (ref) =>
1433
+ !removedKeys.has(fieldRemapBulkSelectionKey(ref)) && !removedEdgeIds.has(ref.edgeId),
1434
+ ),
1435
+ );
1436
+ const primaryRef = asFieldRemapBulkSelectionRef(selection);
1437
+ const primaryRemoved = primaryRef
1438
+ ? removedKeys.has(fieldRemapBulkSelectionKey(primaryRef)) ||
1439
+ removedEdgeIds.has(primaryRef.edgeId)
1440
+ : false;
1441
+ const preservesSingleStepFallback =
1442
+ refs.length === 1 &&
1443
+ refs[0]?.kind === 'transformStep' &&
1444
+ primaryRef?.kind === 'transformStep' &&
1445
+ fieldRemapBulkSelectionKey(refs[0]) === fieldRemapBulkSelectionKey(primaryRef);
1446
+ let nextPrimary = primaryRemoved ? (survivingMembership[0] ?? null) : selection;
1447
+ if (preservesSingleStepFallback) {
1448
+ const removedStep = refs[0] as Extract<
1449
+ FieldRemapBulkSelectionRef,
1450
+ { readonly kind: 'transformStep' }
1451
+ >;
1452
+ const nextEdge = plan.edges.find((edge) => edge.id === removedStep.edgeId);
1453
+ nextPrimary =
1454
+ (nextEdge?.transformIds?.length ?? 0) > 0
1455
+ ? {
1456
+ kind: 'transformStep',
1457
+ edgeId: removedStep.edgeId,
1458
+ stepIndex: Math.min(
1459
+ removedStep.stepIndex,
1460
+ (nextEdge?.transformIds?.length ?? 1) - 1,
1461
+ ),
1462
+ }
1463
+ : nextEdge
1464
+ ? { kind: 'edge', edgeId: nextEdge.id }
1465
+ : null;
1466
+ const fallbackRef = asFieldRemapBulkSelectionRef(nextPrimary);
1467
+ survivingMembership = fallbackRef
1468
+ ? normalizeFieldRemapBulkSelection(plan.edges, [fallbackRef])
1469
+ : [];
1470
+ }
1471
+ const nextDomainKey = fieldRemapBulkDomainKey(listFieldRemapBulkSelectionRefs(plan.edges));
1472
+ const primaryKeyChanged =
1473
+ fieldRemapSelectionKey(nextPrimary) !== fieldRemapSelectionKey(selection);
1474
+
1475
+ pendingBulkCommitRef.current = {
1476
+ domainKey: nextDomainKey,
1477
+ membership: survivingMembership,
1478
+ primary: nextPrimary,
1479
+ };
1480
+ pendingBulkFocusRef.current = {
1481
+ domainKey: nextDomainKey,
1482
+ target: asFieldRemapBulkSelectionRef(nextPrimary) ?? null,
1483
+ };
1484
+ if (primaryKeyChanged) {
1485
+ lastPrimaryCorrectionRef.current = `${nextDomainKey}\u0001${fieldRemapSelectionKey(selection)}\u0001${fieldRemapSelectionKey(nextPrimary)}`;
1486
+ pendingPrimaryCorrectionAckRef.current = onSelectionChangeProp
1487
+ ? {
1488
+ domainKey: nextDomainKey,
1489
+ selectionKey: fieldRemapSelectionKey(nextPrimary),
1490
+ }
1491
+ : undefined;
1492
+ }
1493
+ onEdgesChange(plan.edges);
1494
+
1495
+ if (primaryKeyChanged) {
1496
+ if (!selectionExternallyManaged) {
1497
+ setInternalSelection(nextPrimary);
1498
+ } else {
1499
+ onSelectionChangeProp?.(nextPrimary);
1500
+ }
1501
+ }
1502
+ return true;
1503
+ },
1504
+ [
1505
+ bulkSelection,
1506
+ edges,
1507
+ onEdgesChange,
1508
+ onSelectionChangeProp,
1509
+ selection,
1510
+ selectionExternallyManaged,
1511
+ ],
1512
+ );
1513
+
785
1514
  const onEdgesDelete = useCallback(
786
1515
  (deleted: Edge[]) => {
1516
+ if (readOnly) {
1517
+ return;
1518
+ }
787
1519
  const mappingIds = new Set(
788
1520
  deleted
789
1521
  .map((edge) => {
@@ -795,26 +1527,64 @@ function FieldRemapFlowCanvas({
795
1527
  if (mappingIds.size === 0) {
796
1528
  return;
797
1529
  }
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
- }
1530
+ commitBulkDelete([...mappingIds].map((edgeId) => ({ kind: 'edge' as const, edgeId })));
806
1531
  },
807
- [edges, onEdgesChange, selection, setSelection],
1532
+ [commitBulkDelete, readOnly],
808
1533
  );
809
1534
 
810
1535
  const placeDraft = useCallback(
811
- (transformId: string) => {
1536
+ (transformId: string, position?: XYPosition) => {
1537
+ if (readOnly) {
1538
+ return;
1539
+ }
812
1540
  const draft = createDraftTransform(transformId);
813
1541
  setDrafts((current) => [...current, draft]);
1542
+ if (position) {
1543
+ setDraftPositions((current) => new Map(current).set(draft.localId, position));
1544
+ }
814
1545
  setSelection({ kind: 'draft', localId: draft.localId });
815
1546
  setPlaceTransformId(transformId);
816
1547
  },
817
- [setSelection],
1548
+ [readOnly, setSelection],
1549
+ );
1550
+
1551
+ const resolveDroppedTransformId = useCallback(
1552
+ (dataTransfer: DataTransfer) => {
1553
+ const transformId = readFieldRemapTransformDragData(dataTransfer);
1554
+ const definition = transformId ? transforms.get(transformId) : undefined;
1555
+ if (!transformId || transformId === 'identity' || definition?.id !== transformId) {
1556
+ return undefined;
1557
+ }
1558
+ return transformId;
1559
+ },
1560
+ [transforms],
1561
+ );
1562
+
1563
+ const onCanvasDragOver = useCallback(
1564
+ (event: DragEvent<HTMLDivElement>) => {
1565
+ if (readOnly || !hasFieldRemapTransformDragType(event.dataTransfer)) {
1566
+ return;
1567
+ }
1568
+ event.preventDefault();
1569
+ event.dataTransfer.dropEffect = 'copy';
1570
+ },
1571
+ [readOnly],
1572
+ );
1573
+
1574
+ const onCanvasDrop = useCallback(
1575
+ (event: DragEvent<HTMLDivElement>) => {
1576
+ if (readOnly) {
1577
+ return;
1578
+ }
1579
+ const transformId = resolveDroppedTransformId(event.dataTransfer);
1580
+ if (!transformId) {
1581
+ return;
1582
+ }
1583
+ event.preventDefault();
1584
+ event.stopPropagation();
1585
+ placeDraft(transformId, screenToFlowPosition({ x: event.clientX, y: event.clientY }));
1586
+ },
1587
+ [placeDraft, readOnly, resolveDroppedTransformId, screenToFlowPosition],
818
1588
  );
819
1589
 
820
1590
  const onNodeClick = useCallback(
@@ -825,7 +1595,7 @@ function FieldRemapFlowCanvas({
825
1595
  return;
826
1596
  }
827
1597
  if (data.kind === 'combine-operator' || data.kind === 'split-operator') {
828
- if (event.altKey && onOperatorsChange) {
1598
+ if (!readOnly && event.altKey && onOperatorsChange) {
829
1599
  onOperatorsChange(removeMappingOperator(operators, data.operatorId));
830
1600
  if (selection?.kind === 'operator' && selection.operatorId === data.operatorId) {
831
1601
  setSelection(null);
@@ -838,41 +1608,148 @@ function FieldRemapFlowCanvas({
838
1608
  if (data.kind !== 'transform') {
839
1609
  return;
840
1610
  }
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({
1611
+ const target = {
860
1612
  kind: 'transformStep',
861
1613
  edgeId: data.mappingEdgeId,
862
1614
  stepIndex: data.stepIndex,
863
- });
1615
+ } as const;
1616
+ if (!readOnly && event.altKey) {
1617
+ commitBulkDelete([target]);
1618
+ return;
1619
+ }
1620
+ applyBulkSelectionGesture(target, event);
1621
+ focusBulkTarget(target, 'graph');
864
1622
  },
865
- [edges, onEdgesChange, onOperatorsChange, operators, selection, setSelection],
1623
+ [
1624
+ applyBulkSelectionGesture,
1625
+ commitBulkDelete,
1626
+ focusBulkTarget,
1627
+ onOperatorsChange,
1628
+ operators,
1629
+ readOnly,
1630
+ selection,
1631
+ setSelection,
1632
+ ],
1633
+ );
1634
+
1635
+ const onEdgeClick = useCallback(
1636
+ (event: MouseEvent, edge: Edge) => {
1637
+ const mappingEdgeId = (edge.data as FieldRemapFlowEdgeData | undefined)?.mappingEdgeId;
1638
+ if (!mappingEdgeId) {
1639
+ return;
1640
+ }
1641
+ const target = { kind: 'edge', edgeId: mappingEdgeId } as const;
1642
+ applyBulkSelectionGesture(target, event);
1643
+ focusBulkTarget(target, 'graph');
1644
+ },
1645
+ [applyBulkSelectionGesture, focusBulkTarget],
1646
+ );
1647
+
1648
+ const onBulkSelectionKeyDownCapture = useCallback(
1649
+ (event: KeyboardEvent<HTMLDivElement>) => {
1650
+ if ((event.key !== 'Enter' && event.key !== ' ') || event.defaultPrevented || event.altKey) {
1651
+ return;
1652
+ }
1653
+ const target = fieldRemapBulkRefFromKeyboardTarget(event.target);
1654
+ if (!target) {
1655
+ return;
1656
+ }
1657
+ event.preventDefault();
1658
+ event.stopPropagation();
1659
+ pendingPrimaryCorrectionAckRef.current = undefined;
1660
+ applyBulkSelectionGesture(target, event);
1661
+ },
1662
+ [applyBulkSelectionGesture],
866
1663
  );
867
1664
 
868
1665
  const onKeyDown = useCallback(
869
1666
  (event: KeyboardEvent<HTMLDivElement>) => {
870
1667
  if (event.key === 'Escape') {
1668
+ if (event.defaultPrevented || (selection === null && drafts.length === 0)) {
1669
+ return;
1670
+ }
1671
+ const mapper = mapperRef.current;
1672
+ const focused = mapper?.ownerDocument.activeElement;
1673
+ const detail = mapper?.querySelector<HTMLElement>(
1674
+ '[data-testid="field-remap-detail"], [data-testid="field-remap-convert-note"]',
1675
+ );
1676
+ const detailSeparator = mapper?.querySelector<HTMLElement>(
1677
+ '.workbench-field-remap-flow__canvas-detail-split > [role="separator"]',
1678
+ );
1679
+ restoreMapperFocusRef.current =
1680
+ detailPresentation === 'rail' &&
1681
+ emptyDetail === 'collapse' &&
1682
+ focused instanceof Element &&
1683
+ (detail?.contains(focused) === true ||
1684
+ (!previewVisible && detailSeparator?.contains(focused) === true));
1685
+ event.preventDefault();
1686
+ event.stopPropagation();
871
1687
  setSelection(null);
872
1688
  setDrafts([]);
1689
+ setDraftPositions(new Map());
1690
+ return;
1691
+ }
1692
+
1693
+ if (readOnly) {
1694
+ return;
1695
+ }
1696
+
1697
+ if (
1698
+ (event.key !== 'Delete' && event.key !== 'Backspace') ||
1699
+ event.ctrlKey ||
1700
+ event.metaKey ||
1701
+ event.altKey ||
1702
+ event.defaultPrevented ||
1703
+ isFieldRemapEditableShortcutTarget(event.target) ||
1704
+ selection === null
1705
+ ) {
1706
+ return;
1707
+ }
1708
+
1709
+ let consumed = false;
1710
+ if (selection.kind === 'edge' || selection.kind === 'transformStep') {
1711
+ const primaryKey = fieldRemapBulkSelectionKey(selection);
1712
+ const refs = bulkSelection.some((ref) => fieldRemapBulkSelectionKey(ref) === primaryKey)
1713
+ ? bulkSelection
1714
+ : [...bulkSelection, selection];
1715
+ consumed = commitBulkDelete(refs);
1716
+ } else if (selection.kind === 'operator') {
1717
+ if (
1718
+ onOperatorsChange &&
1719
+ operators.some((operator) => operator.id === selection.operatorId)
1720
+ ) {
1721
+ onOperatorsChange(removeMappingOperator(operators, selection.operatorId));
1722
+ setSelection(null);
1723
+ consumed = true;
1724
+ }
1725
+ } else if (selection.kind === 'draft') {
1726
+ if (drafts.some((draft) => draft.localId === selection.localId)) {
1727
+ setDrafts((current) => current.filter((draft) => draft.localId !== selection.localId));
1728
+ removeDraftPosition(selection.localId);
1729
+ setSelection(null);
1730
+ consumed = true;
1731
+ }
1732
+ }
1733
+
1734
+ if (consumed) {
1735
+ event.preventDefault();
1736
+ event.stopPropagation();
873
1737
  }
874
1738
  },
875
- [setSelection],
1739
+ [
1740
+ drafts,
1741
+ bulkSelection,
1742
+ commitBulkDelete,
1743
+ detailPresentation,
1744
+ emptyDetail,
1745
+ onOperatorsChange,
1746
+ operators,
1747
+ previewVisible,
1748
+ readOnly,
1749
+ removeDraftPosition,
1750
+ selection,
1751
+ setSelection,
1752
+ ],
876
1753
  );
877
1754
 
878
1755
  const handlePaneContextMenu = useCallback(
@@ -896,6 +1773,32 @@ function FieldRemapFlowCanvas({
896
1773
  [onEdgeContextMenu, selection],
897
1774
  );
898
1775
 
1776
+ const detailPanel = detailVisible ? (
1777
+ <FieldRemapDetailPanel
1778
+ selection={selection}
1779
+ edges={edges}
1780
+ sources={sources}
1781
+ targets={targets}
1782
+ transforms={transforms}
1783
+ readOnly={readOnly}
1784
+ onEdgesChange={onEdgesChange}
1785
+ onSelectionChange={setSelection}
1786
+ drafts={drafts}
1787
+ onDiscardDraft={(localId) => {
1788
+ setDrafts((current) => current.filter((item) => item.localId !== localId));
1789
+ removeDraftPosition(localId);
1790
+ }}
1791
+ operators={operators}
1792
+ onOperatorsChange={onOperatorsChange}
1793
+ emptyDetailTitle={
1794
+ readOnly ? chromeLabels.readOnlyEmptyDetailTitle : chromeLabels.emptyDetailTitle
1795
+ }
1796
+ emptyDetailDescription={
1797
+ readOnly ? chromeLabels.readOnlyEmptyDetailDescription : chromeLabels.emptyDetailDescription
1798
+ }
1799
+ />
1800
+ ) : null;
1801
+
899
1802
  return (
900
1803
  <div
901
1804
  ref={mapperRef}
@@ -904,11 +1807,15 @@ function FieldRemapFlowCanvas({
904
1807
  data-chrome={chrome}
905
1808
  data-flow-hint={showFlowHint ? 'on' : 'off'}
906
1809
  data-bindings-list={showBindingsList ? 'on' : 'off'}
907
- data-convert-palette={showConvertPalette ? 'on' : 'off'}
1810
+ data-convert-palette={showAuthoringPalette ? 'on' : 'off'}
1811
+ data-read-only={readOnly ? 'true' : 'false'}
908
1812
  data-empty-detail={emptyDetail}
1813
+ data-detail-presentation={detailPresentation}
909
1814
  data-minimap={showMinimap ? 'on' : 'off'}
910
1815
  data-hidden-fields={includeHidden ? 'on' : 'off'}
911
1816
  data-preview={previewVisible ? 'on' : 'off'}
1817
+ tabIndex={-1}
1818
+ onKeyDownCapture={onBulkSelectionKeyDownCapture}
912
1819
  onKeyDown={onKeyDown}
913
1820
  >
914
1821
  {showFlowHint ? (
@@ -920,10 +1827,25 @@ function FieldRemapFlowCanvas({
920
1827
  </p>
921
1828
  ) : null}
922
1829
 
1830
+ {connectionFeedback ? (
1831
+ <p className="workbench-field-remap-demo__warn" role="status">
1832
+ {connectionFeedback.reason}
1833
+ </p>
1834
+ ) : null}
1835
+
1836
+ {conflicts.length > 0 ? (
1837
+ <p className="workbench-field-remap-demo__warn" role="status">
1838
+ Warning: parent and child fields are both mapped (
1839
+ {conflicts.map((item) => `${item.parentId} / ${item.childId}`).join('; ')}). Prefer one
1840
+ level.
1841
+ </p>
1842
+ ) : null}
1843
+
923
1844
  <FieldRemapSplitWorkspace
924
1845
  layout={workspaceLayout}
925
- showConvertPalette={showConvertPalette}
926
- showDetail={emptyDetail === 'hint' || selection !== null || previewVisible}
1846
+ reserveHiddenDetailSplit={detailPresentation === 'rail'}
1847
+ showConvertPalette={showAuthoringPalette}
1848
+ showDetail={sideRailVisible}
927
1849
  surface={
928
1850
  selection?.kind === 'transformStep'
929
1851
  ? 'convert-note'
@@ -935,7 +1857,7 @@ function FieldRemapFlowCanvas({
935
1857
  }
936
1858
  >
937
1859
  <>
938
- {showConvertPalette ? (
1860
+ {showAuthoringPalette ? (
939
1861
  <FieldRemapConvertPalette
940
1862
  transforms={transforms}
941
1863
  selectedTransformId={placeTransformId}
@@ -964,78 +1886,61 @@ function FieldRemapFlowCanvas({
964
1886
  ) : null}
965
1887
  </>
966
1888
 
967
- <div className="workbench-field-remap-flow__canvas" data-testid="field-remap-flow">
968
- <ReactFlow
969
- nodes={nodes}
970
- edges={flowEdges}
971
- nodeTypes={nodeTypes}
972
- onNodesChange={onNodesChange}
973
- onEdgesChange={onFlowEdgesChange}
974
- onConnect={onConnect}
975
- onEdgesDelete={onEdgesDelete}
976
- onNodeClick={onNodeClick}
977
- onPaneContextMenu={onPaneContextMenu ? handlePaneContextMenu : undefined}
978
- onNodeContextMenu={onNodeContextMenu ? handleNodeContextMenu : undefined}
979
- onEdgeContextMenu={onEdgeContextMenu ? handleEdgeContextMenu : undefined}
980
- isValidConnection={isValidConnection}
981
- fitView
982
- fitViewOptions={DEFAULT_FIT_VIEW_OPTIONS}
983
- proOptions={{ hideAttribution: true }}
984
- >
985
- <FieldRemapFlowActionsBridge flowActionsRef={flowActionsRef} />
986
- <Background gap={16} color="var(--xy-background-pattern-color)" />
987
- <Controls showInteractive={false} fitViewOptions={DEFAULT_FIT_VIEW_OPTIONS}>
988
- {onShowMinimapChange ? (
989
- <ControlButton
990
- aria-label={showMinimap ? chromeLabels.hideMinimap : chromeLabels.showMinimap}
991
- className={
992
- showMinimap
993
- ? 'workbench-field-remap-flow__minimap-toggle is-active'
994
- : 'workbench-field-remap-flow__minimap-toggle'
995
- }
996
- data-testid="field-remap-toggle-minimap"
997
- title={showMinimap ? chromeLabels.hideMinimap : chromeLabels.showMinimap}
998
- onClick={() => {
999
- onShowMinimapChange(!showMinimap);
1000
- }}
1001
- >
1002
- <svg
1003
- aria-hidden="true"
1004
- fill="none"
1005
- height="16"
1006
- stroke="currentColor"
1007
- strokeLinecap="round"
1008
- strokeLinejoin="round"
1009
- strokeWidth="1.75"
1010
- viewBox="0 0 24 24"
1011
- width="16"
1889
+ <div
1890
+ ref={canvasRef}
1891
+ className="workbench-field-remap-flow__canvas"
1892
+ data-testid="field-remap-flow"
1893
+ >
1894
+ {hasPositiveCanvasSize ? (
1895
+ <ReactFlow
1896
+ nodes={nodes}
1897
+ edges={flowEdges}
1898
+ nodeTypes={nodeTypes}
1899
+ edgeTypes={edgeTypes}
1900
+ onNodesChange={onProjectedNodesChange}
1901
+ onEdgesChange={onProjectedFlowEdgesChange}
1902
+ onConnect={readOnly ? undefined : onConnect}
1903
+ onConnectStart={readOnly ? undefined : onConnectStart}
1904
+ onConnectEnd={readOnly ? undefined : onConnectEnd}
1905
+ onEdgesDelete={readOnly ? undefined : onEdgesDelete}
1906
+ onDragOver={onCanvasDragOver}
1907
+ onDrop={onCanvasDrop}
1908
+ onNodeClick={onNodeClick}
1909
+ onEdgeClick={onEdgeClick}
1910
+ onPaneContextMenu={onPaneContextMenu ? handlePaneContextMenu : undefined}
1911
+ onNodeContextMenu={onNodeContextMenu ? handleNodeContextMenu : undefined}
1912
+ onEdgeContextMenu={onEdgeContextMenu ? handleEdgeContextMenu : undefined}
1913
+ isValidConnection={isValidConnection}
1914
+ nodesDraggable={!readOnly}
1915
+ nodesConnectable={!readOnly}
1916
+ edgesReconnectable={!readOnly}
1917
+ elementsSelectable={false}
1918
+ ariaLabelConfig={flowAriaLabelConfig}
1919
+ deleteKeyCode={null}
1920
+ fitView
1921
+ fitViewOptions={DEFAULT_FIT_VIEW_OPTIONS}
1922
+ proOptions={{ hideAttribution: true }}
1923
+ >
1924
+ <FieldRemapFlowActionsBridge
1925
+ canFitView={canFitView}
1926
+ flowActionsRef={flowActionsRef}
1927
+ />
1928
+ <Background gap={16} color="var(--xy-background-pattern-color)" />
1929
+ <Controls showInteractive={false} fitViewOptions={DEFAULT_FIT_VIEW_OPTIONS}>
1930
+ {onShowMinimapChange ? (
1931
+ <ControlButton
1932
+ aria-label={showMinimap ? chromeLabels.hideMinimap : chromeLabels.showMinimap}
1933
+ className={
1934
+ showMinimap
1935
+ ? 'workbench-field-remap-flow__minimap-toggle is-active'
1936
+ : 'workbench-field-remap-flow__minimap-toggle'
1937
+ }
1938
+ data-testid="field-remap-toggle-minimap"
1939
+ title={showMinimap ? chromeLabels.hideMinimap : chromeLabels.showMinimap}
1940
+ onClick={() => {
1941
+ onShowMinimapChange(!showMinimap);
1942
+ }}
1012
1943
  >
1013
- <path d="M3 6.5 9 4l6 2.5L21 4v13.5L15 20l-6-2.5L3 20z" />
1014
- <path d="M9 4v13.5" />
1015
- <path d="M15 6.5V20" />
1016
- </svg>
1017
- </ControlButton>
1018
- ) : null}
1019
- {onIncludeHiddenChange ? (
1020
- <ControlButton
1021
- aria-label={
1022
- includeHidden ? chromeLabels.hideHiddenFields : chromeLabels.showHiddenFields
1023
- }
1024
- aria-pressed={includeHidden}
1025
- className={
1026
- includeHidden
1027
- ? 'workbench-field-remap-flow__hidden-toggle is-active'
1028
- : 'workbench-field-remap-flow__hidden-toggle'
1029
- }
1030
- data-testid="field-remap-toggle-hidden-fields"
1031
- title={
1032
- includeHidden ? chromeLabels.hideHiddenFields : chromeLabels.showHiddenFields
1033
- }
1034
- onClick={() => {
1035
- onIncludeHiddenChange(!includeHidden);
1036
- }}
1037
- >
1038
- {includeHidden ? (
1039
1944
  <svg
1040
1945
  aria-hidden="true"
1041
1946
  fill="none"
@@ -1047,72 +1952,96 @@ function FieldRemapFlowCanvas({
1047
1952
  viewBox="0 0 24 24"
1048
1953
  width="16"
1049
1954
  >
1050
- <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
1051
- <circle cx="12" cy="12" r="3" />
1955
+ <path d="M3 6.5 9 4l6 2.5L21 4v13.5L15 20l-6-2.5L3 20z" />
1956
+ <path d="M9 4v13.5" />
1957
+ <path d="M15 6.5V20" />
1052
1958
  </svg>
1053
- ) : (
1054
- <svg
1055
- aria-hidden="true"
1056
- fill="none"
1057
- height="16"
1058
- stroke="currentColor"
1059
- strokeLinecap="round"
1060
- strokeLinejoin="round"
1061
- strokeWidth="1.75"
1062
- viewBox="0 0 24 24"
1063
- width="16"
1064
- >
1065
- <path d="M3 3l18 18" />
1066
- <path d="M10.6 10.6a3 3 0 0 0 4.2 4.2" />
1067
- <path d="M9.9 5.1A10.6 10.6 0 0 1 12 5c6.5 0 10 7 10 7a17.4 17.4 0 0 1-3.2 4.4" />
1068
- <path d="M6.1 6.1C3.9 7.7 2 12 2 12s3.5 7 10 7a10.4 10.4 0 0 0 4.2-.9" />
1069
- </svg>
1070
- )}
1071
- </ControlButton>
1959
+ </ControlButton>
1960
+ ) : null}
1961
+ {onIncludeHiddenChange ? (
1962
+ <ControlButton
1963
+ aria-label={
1964
+ includeHidden ? chromeLabels.hideHiddenFields : chromeLabels.showHiddenFields
1965
+ }
1966
+ aria-pressed={includeHidden}
1967
+ className={
1968
+ includeHidden
1969
+ ? 'workbench-field-remap-flow__hidden-toggle is-active'
1970
+ : 'workbench-field-remap-flow__hidden-toggle'
1971
+ }
1972
+ data-testid="field-remap-toggle-hidden-fields"
1973
+ title={
1974
+ includeHidden ? chromeLabels.hideHiddenFields : chromeLabels.showHiddenFields
1975
+ }
1976
+ onClick={() => {
1977
+ onIncludeHiddenChange(!includeHidden);
1978
+ }}
1979
+ >
1980
+ {includeHidden ? (
1981
+ <svg
1982
+ aria-hidden="true"
1983
+ fill="none"
1984
+ height="16"
1985
+ stroke="currentColor"
1986
+ strokeLinecap="round"
1987
+ strokeLinejoin="round"
1988
+ strokeWidth="1.75"
1989
+ viewBox="0 0 24 24"
1990
+ width="16"
1991
+ >
1992
+ <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
1993
+ <circle cx="12" cy="12" r="3" />
1994
+ </svg>
1995
+ ) : (
1996
+ <svg
1997
+ aria-hidden="true"
1998
+ fill="none"
1999
+ height="16"
2000
+ stroke="currentColor"
2001
+ strokeLinecap="round"
2002
+ strokeLinejoin="round"
2003
+ strokeWidth="1.75"
2004
+ viewBox="0 0 24 24"
2005
+ width="16"
2006
+ >
2007
+ <path d="M3 3l18 18" />
2008
+ <path d="M10.6 10.6a3 3 0 0 0 4.2 4.2" />
2009
+ <path d="M9.9 5.1A10.6 10.6 0 0 1 12 5c6.5 0 10 7 10 7a17.4 17.4 0 0 1-3.2 4.4" />
2010
+ <path d="M6.1 6.1C3.9 7.7 2 12 2 12s3.5 7 10 7a10.4 10.4 0 0 0 4.2-.9" />
2011
+ </svg>
2012
+ )}
2013
+ </ControlButton>
2014
+ ) : null}
2015
+ </Controls>
2016
+ {showMinimap ? (
2017
+ <MiniMap
2018
+ pannable
2019
+ zoomable
2020
+ bgColor="var(--xy-minimap-background-color)"
2021
+ maskColor="var(--xy-minimap-mask-background-color)"
2022
+ nodeColor={(node) => {
2023
+ const kind = (node.data as FieldRemapFlowNodeData | undefined)?.kind;
2024
+ if (kind === 'source-object') {
2025
+ return 'var(--vscode-charts-blue, #3794ff)';
2026
+ }
2027
+ if (kind === 'target-object') {
2028
+ return 'var(--vscode-charts-green, #89d185)';
2029
+ }
2030
+ return 'var(--vscode-focusBorder, var(--color-accent, #3794ff))';
2031
+ }}
2032
+ nodeStrokeColor="var(--xy-minimap-node-stroke-color)"
2033
+ />
1072
2034
  ) : null}
1073
- </Controls>
1074
- {showMinimap ? (
1075
- <MiniMap
1076
- pannable
1077
- zoomable
1078
- bgColor="var(--xy-minimap-background-color)"
1079
- maskColor="var(--xy-minimap-mask-background-color)"
1080
- nodeColor={(node) => {
1081
- const kind = (node.data as FieldRemapFlowNodeData | undefined)?.kind;
1082
- if (kind === 'source-object') {
1083
- return 'var(--vscode-charts-blue, #3794ff)';
1084
- }
1085
- if (kind === 'target-object') {
1086
- return 'var(--vscode-charts-green, #89d185)';
1087
- }
1088
- return 'var(--vscode-focusBorder, var(--color-accent, #3794ff))';
1089
- }}
1090
- nodeStrokeColor="var(--xy-minimap-node-stroke-color)"
1091
- />
1092
- ) : null}
1093
- </ReactFlow>
2035
+ </ReactFlow>
2036
+ ) : (
2037
+ <p className="workbench-field-remap-demo__warn" role="status">
2038
+ Mapping canvas is waiting for available space.
2039
+ </p>
2040
+ )}
1094
2041
  </div>
1095
2042
 
1096
2043
  <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}
2044
+ {detailPresentation === 'rail' ? detailPanel : null}
1116
2045
  {previewVisible && preview ? (
1117
2046
  <FieldRemapPreviewRail
1118
2047
  preview={preview}
@@ -1129,6 +2058,18 @@ function FieldRemapFlowCanvas({
1129
2058
  </div>
1130
2059
  </FieldRemapSplitWorkspace>
1131
2060
 
2061
+ {detailPresentation === 'modal' && selection !== null ? (
2062
+ <Suspense fallback={null}>
2063
+ <FieldRemapModalDetail
2064
+ closeLabel={chromeLabels.closeDetailModal ?? 'Close details'}
2065
+ title={chromeLabels.detailModalTitle ?? 'Mapping details'}
2066
+ onClose={closeModalDetail}
2067
+ >
2068
+ {detailPanel}
2069
+ </FieldRemapModalDetail>
2070
+ </Suspense>
2071
+ ) : null}
2072
+
1132
2073
  {showBindingsList ? (
1133
2074
  <div className="workbench-field-remap-flow__bindings" data-testid="field-remap-edges">
1134
2075
  <h4>{chromeLabels.bindingsTitle}</h4>
@@ -1145,21 +2086,36 @@ function FieldRemapFlowCanvas({
1145
2086
  });
1146
2087
  const defaultAddId = appendCatalog[0]?.id;
1147
2088
  const listContext = canEditListContext(edge, sources, targets);
1148
- const selected =
1149
- (selection?.kind === 'edge' || selection?.kind === 'transformStep') &&
1150
- selection.edgeId === edge.id;
2089
+ const edgeRef = { kind: 'edge', edgeId: edge.id } as const;
2090
+ const edgeKey = fieldRemapBulkSelectionKey(edgeRef);
2091
+ const edgeSelected = bulkSelectionKeys.has(edgeKey);
2092
+ const visibleRemoveRefs = edgeSelected ? bulkSelection : [edgeRef];
2093
+ const laneSelected = canonicalBulkRefs.some(
2094
+ (ref) =>
2095
+ ref.edgeId === edge.id && bulkSelectionKeys.has(fieldRemapBulkSelectionKey(ref)),
2096
+ );
2097
+ const edgeIsPrimary = selection?.kind === 'edge' && selection.edgeId === edge.id;
1151
2098
 
1152
2099
  return (
1153
2100
  <li
1154
2101
  key={edge.id}
1155
- className={selected ? 'is-selected' : undefined}
2102
+ className={laneSelected ? 'is-selected' : undefined}
1156
2103
  data-testid={`field-remap-lane-${edge.id}`}
1157
2104
  >
1158
2105
  <button
2106
+ ref={(element) => registerBulkFocusTarget(edgeKey, 'list', element)}
1159
2107
  type="button"
1160
- className="workbench-field-remap-flow__binding-select"
2108
+ aria-pressed={edgeSelected}
2109
+ className={
2110
+ edgeSelected
2111
+ ? 'workbench-field-remap-flow__binding-select is-selected'
2112
+ : 'workbench-field-remap-flow__binding-select'
2113
+ }
2114
+ data-field-remap-bulk-edge-id={edge.id}
2115
+ data-field-remap-bulk-kind="edge"
2116
+ data-primary={edgeIsPrimary ? 'true' : 'false'}
1161
2117
  data-testid={`field-remap-select-edge-${edge.id}`}
1162
- onClick={() => setSelection({ kind: 'edge', edgeId: edge.id })}
2118
+ onClick={(event) => applyBulkSelectionGesture(edgeRef, event)}
1163
2119
  >
1164
2120
  <code>
1165
2121
  {edge.sourceFieldId} →{' '}
@@ -1170,70 +2126,69 @@ function FieldRemapFlowCanvas({
1170
2126
  {edge.itemEdges ? ` · ${edge.itemEdges.length} item fields` : ''}
1171
2127
  </code>
1172
2128
  </button>
1173
- <span className="workbench-field-remap-mapper__edge-actions">
1174
- {(edge.transformIds?.length ?? 0) < MAX_TRANSFORM_CHAIN && defaultAddId ? (
1175
- <IconButton
1176
- compact
1177
- 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 ? (
2129
+ {!readOnly ? (
2130
+ <span className="workbench-field-remap-mapper__edge-actions">
2131
+ {(edge.transformIds?.length ?? 0) < MAX_TRANSFORM_CHAIN && defaultAddId ? (
2132
+ <IconButton
2133
+ compact
2134
+ type="button"
2135
+ data-testid={`field-remap-add-node-${edge.id}`}
2136
+ icon="codicon-add"
2137
+ label={chromeLabels.addTransform}
2138
+ onClick={() => {
2139
+ const next = addTransformStepToEdge(edge, defaultAddId, {
2140
+ registry: transforms,
2141
+ sourceType: portTypes.sourceType,
2142
+ targetType: portTypes.targetType,
2143
+ });
2144
+ if (!next) {
2145
+ return;
2146
+ }
2147
+ onEdgesChange(edges.map((item) => (item.id === edge.id ? next : item)));
2148
+ setSelection({
2149
+ kind: 'transformStep',
2150
+ edgeId: edge.id,
2151
+ stepIndex: (next.transformIds?.length ?? 1) - 1,
2152
+ });
2153
+ }}
2154
+ />
2155
+ ) : null}
2156
+ {listContext ? (
2157
+ <IconButton
2158
+ compact
2159
+ type="button"
2160
+ data-testid={`field-remap-edit-items-${edge.id}`}
2161
+ icon="codicon-edit"
2162
+ label={chromeLabels.editItems}
2163
+ onClick={() => {
2164
+ if (!edge.itemEdges) {
2165
+ onEdgesChange(
2166
+ edges.map((item) =>
2167
+ item.id === edge.id ? enableListContextOnEdge(item) : item,
2168
+ ),
2169
+ );
2170
+ }
2171
+ setSelection({ kind: 'edge', edgeId: edge.id });
2172
+ }}
2173
+ />
2174
+ ) : null}
1200
2175
  <IconButton
1201
2176
  compact
1202
2177
  type="button"
1203
- data-testid={`field-remap-edit-items-${edge.id}`}
1204
- icon="codicon-edit"
1205
- label={chromeLabels.editItems}
2178
+ data-testid={`field-remap-remove-edge-${edge.id}`}
2179
+ icon="codicon-trash"
2180
+ label={
2181
+ visibleRemoveRefs.length > 1
2182
+ ? `Remove ${visibleRemoveRefs.length} selected items`
2183
+ : chromeLabels.removeBinding
2184
+ }
2185
+ variant="danger"
1206
2186
  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 });
2187
+ commitBulkDelete(visibleRemoveRefs);
1215
2188
  }}
1216
2189
  />
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>
2190
+ </span>
2191
+ ) : null}
1237
2192
  </li>
1238
2193
  );
1239
2194
  })}