From 72e8f31cffb77592a46a5d8afed58ca30e20c181 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 15 May 2026 10:59:30 +0200 Subject: [PATCH] Enhance TrainingUnitSectionsEditor with new drag-and-drop functionality for parallel phases - Added new event handlers for drag-and-drop operations to manage sections above and below split headers in parallel phases. - Implemented utility functions to reorder sections as whole groups or as the first entry in parallel phases, improving section management. - Updated CSS styles to visually represent new drop zones for sections, enhancing user experience during reordering. - Refactored existing logic to accommodate new features and ensure proper handling of section placements within parallel streams. --- frontend/src/app.css | 17 ++ .../components/TrainingUnitSectionsEditor.jsx | 240 +++++++++++++++++- .../src/utils/trainingUnitSectionsForm.js | 42 +++ 3 files changed, 286 insertions(+), 13 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index d512b48..bacc69e 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5990,6 +5990,23 @@ a.analysis-split__nav-item { width: 100%; } +/* Parallele Phase: feste Einfügebänder oberhalb/unterhalb des Split-Headers */ +.tu-phase-drop--above-split, +.tu-phase-drop--below-split { + margin: 6px 2px 8px; + min-height: 14px; +} + +.tu-section-dropband--phase-parallel-slot { + height: 14px; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--accent) 12%, var(--surface2)) 0%, + hsl(200 30% 94%) 100% + ); + border: 1px dashed color-mix(in srgb, var(--accent) 38%, transparent); +} + .tu-sec-drag-grip { flex-shrink: 0; display: inline-flex; diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index 7342ed9..c11ae35 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -24,6 +24,8 @@ import { phaseRunsFromSections, swapAdjacentPhaseRuns, reorderBlocksImmutableWithPlanLoc, + reorderSectionBeforeParallelRunAsWholeGroup, + reorderSectionAsFirstInParallelPhase, reorderBlockIntoParallelStreamEnd, globalInsertBeforeIndexForParallelStreamEnd, movePhaseRunUpByPhaseOrder, @@ -729,6 +731,145 @@ export default function TrainingUnitSectionsEditor({ setDropSectionBand({ slot: sectionToSlot, beforeIdx }) } + const onPhaseAboveSplitDragOver = (e, po) => { + if (!enableSectionDragReorder || !enableParallelPhaseControls) return + if (!dtHasType(e, DND_TU_SECTION)) return + e.preventDefault() + e.stopPropagation() + try { + e.dataTransfer.dropEffect = 'move' + } catch { + /* ignore */ + } + setDropSectionBand({ slot: sectionToSlot, phaseAboveSplitPo: Number(po) || 0 }) + } + + const onPhaseBelowSplitDragOver = (e, po) => { + if (!enableSectionDragReorder || !enableParallelPhaseControls) return + if (!dtHasType(e, DND_TU_SECTION)) return + e.preventDefault() + e.stopPropagation() + try { + e.dataTransfer.dropEffect = 'move' + } catch { + /* ignore */ + } + setDropSectionBand({ slot: sectionToSlot, phaseBelowSplitPo: Number(po) || 0 }) + } + + const applyParsedSectionDrop = (data) => { + const phaseRunMove = data.phaseRunMove + const fromSi = data.fromSectionIdx + const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1 + + if (phaseRunMove != null && phaseRunMove.phaseOrderIndex != null) { + return { kind: 'phaseRun', phaseRunMove, fromSlot } + } + if (typeof fromSi !== 'number') return null + + if ( + typeof onMoveSectionsAcrossSlots === 'function' && + sectionToSlot >= 0 && + fromSlot >= 0 + ) { + return { kind: 'crossSlot', fromSi, fromSlot } + } + + return { kind: 'local', fromSi } + } + + const onPhaseAboveSplitDrop = (e, po) => { + if (!enableSectionDragReorder || !enableParallelPhaseControls) return + e.preventDefault() + e.stopPropagation() + clearSectionDnD() + let raw = '' + try { + raw = e.dataTransfer.getData(DND_TU_SECTION) + } catch { + return + } + if (!raw) return + let data + try { + data = JSON.parse(raw) + } catch { + return + } + const targetPo = Number(po) || 0 + const parsed = applyParsedSectionDrop(data) + if (!parsed) return + + if (parsed.kind === 'phaseRun') { + const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0 + if (dragPo === targetPo) return + patch((prev) => { + const idxs = indicesOfParallelPhase(prev, targetPo) + const fg = idxs.length ? idxs[0] : -1 + if (fg < 0) return prev + let next = moveParallelPhaseRunToInsertBefore(prev, dragPo, fg) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + return + } + + if (parsed.kind === 'crossSlot') return + + const { fromSi } = parsed + patch((prev) => { + let next = reorderSectionBeforeParallelRunAsWholeGroup(prev, fromSi, targetPo) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + } + + const onPhaseBelowSplitDrop = (e, po) => { + if (!enableSectionDragReorder || !enableParallelPhaseControls) return + e.preventDefault() + e.stopPropagation() + clearSectionDnD() + let raw = '' + try { + raw = e.dataTransfer.getData(DND_TU_SECTION) + } catch { + return + } + if (!raw) return + let data + try { + data = JSON.parse(raw) + } catch { + return + } + const targetPo = Number(po) || 0 + const parsed = applyParsedSectionDrop(data) + if (!parsed) return + + if (parsed.kind === 'phaseRun') { + const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0 + if (dragPo === targetPo) return + patch((prev) => { + const idxs = indicesOfParallelPhase(prev, targetPo) + const fg = idxs.length ? idxs[0] : -1 + if (fg < 0) return prev + let next = moveParallelPhaseRunToInsertBefore(prev, dragPo, fg) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + return + } + + if (parsed.kind === 'crossSlot') return + + const { fromSi } = parsed + patch((prev) => { + let next = reorderSectionAsFirstInParallelPhase(prev, fromSi, targetPo) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + } + const onSectionBandDrop = (e, insertBeforeIdx) => { if (!enableSectionDragReorder) return e.preventDefault() @@ -763,6 +904,32 @@ export default function TrainingUnitSectionsEditor({ if (typeof fromSi !== 'number') return + if (enableParallelPhaseControls) { + const fromPl = list[fromSi]?.planLoc + if (fromPl?.phaseKind === 'parallel' && insertBeforeIdx >= 0 && insertBeforeIdx < list.length) { + const po = fromPl.phaseOrderIndex ?? 0 + const fromSo = fromPl.parallelStreamOrderIndex ?? 0 + const tabSo = + parallelStreamTabByPhase[po] ?? streamsForParallelPhaseOrders(list, po)[0] ?? 0 + const targetPl = list[insertBeforeIdx]?.planLoc + if ( + targetPl?.phaseKind === 'parallel' && + (targetPl.phaseOrderIndex ?? 0) === po && + (targetPl.parallelStreamOrderIndex ?? 0) !== fromSo && + tabSo === fromSo + ) { + return + } + } + } + + if ( + enableParallelPhaseControls && + (insertBeforeIdx === fromSi || insertBeforeIdx === fromSi + 1) + ) { + return + } + if ( typeof onMoveSectionsAcrossSlots === 'function' && sectionToSlot >= 0 && @@ -1182,12 +1349,6 @@ export default function TrainingUnitSectionsEditor({ parallelPhaseOrder != null ? firstSectionIndexByParallelPhase.get(parallelPhaseOrder) : null - const phaseIndicesAll = - parallelPhaseOrder != null ? indicesOfParallelPhase(list, parallelPhaseOrder) : [] - const isLastGlobalRowOfParallelPhase = - pl?.phaseKind === 'parallel' && - phaseIndicesAll.length > 0 && - sIdx === phaseIndicesAll[phaseIndicesAll.length - 1] const firstVisibleIdxActiveStream = parallelPhaseOrder != null && streamOrdersForParallelPhase.length ? sectionIndicesForParallelStream( @@ -1203,18 +1364,26 @@ export default function TrainingUnitSectionsEditor({ firstGlobalIdxThisPhase != null && sIdx !== firstGlobalIdxThisPhase + const isFirstSectionOfParallelPhase = + parallelPhaseOrder != null && + firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx + const hideDropBeforeFirstParallelBecauseDedicatedSlot = + enableParallelPhaseControls && + isFirstSectionOfParallelPhase && + pl?.phaseKind === 'parallel' const showSectionDropBandBefore = - (pl?.phaseKind !== 'parallel' || - !hideParallelSection || - isLastGlobalRowOfParallelPhase) && - !hideDropBandBeforeOrphanFirstVisible + (pl?.phaseKind !== 'parallel' || !hideParallelSection) && + !hideDropBandBeforeOrphanFirstVisible && + !hideDropBeforeFirstParallelBecauseDedicatedSlot const bandActiveBefore = (bx) => enableSectionDragReorder && dropSectionBand && dropSectionBand.slot === sectionToSlot && dropSectionBand.beforeIdx === bx && - !dropSectionBand.streamDrop + !dropSectionBand.streamDrop && + dropSectionBand.phaseAboveSplitPo == null && + dropSectionBand.phaseBelowSplitPo == null const streamChipDropActive = (po, so) => useStreamTagDropUx && @@ -1222,9 +1391,15 @@ export default function TrainingUnitSectionsEditor({ dropSectionBand?.streamDrop?.po === po && dropSectionBand?.streamDrop?.so === so - const isFirstSectionOfParallelPhase = + const phaseAboveSplitDnd = parallelPhaseOrder != null && - firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx + dropSectionBand?.slot === sectionToSlot && + dropSectionBand?.phaseAboveSplitPo === parallelPhaseOrder + const phaseBelowSplitDnd = + parallelPhaseOrder != null && + dropSectionBand?.slot === sectionToSlot && + dropSectionBand?.phaseBelowSplitPo === parallelPhaseOrder + const streamVisual = enableParallelPhaseControls && pl?.phaseKind === 'parallel' ? parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0) @@ -1256,6 +1431,28 @@ export default function TrainingUnitSectionsEditor({ {isFirstSectionOfParallelPhase && enableParallelPhaseControls && streamOrdersForParallelPhase.length ? ( + + {enableSectionDragReorder ? ( +
onPhaseAboveSplitDragOver(e, parallelPhaseOrder)} + onDragLeave={(e) => { + if (e.currentTarget.contains(e.relatedTarget)) return + clearSectionDnD() + }} + onDrop={(e) => onPhaseAboveSplitDrop(e, parallelPhaseOrder)} + /> + ) : null}
+ {enableSectionDragReorder ? ( +
onPhaseBelowSplitDragOver(e, parallelPhaseOrder)} + onDragLeave={(e) => { + if (e.currentTarget.contains(e.relatedTarget)) return + clearSectionDnD() + }} + onDrop={(e) => onPhaseBelowSplitDrop(e, parallelPhaseOrder)} + /> + ) : null}
+ ) : null} {!hideParallelSection ? ( <> diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index 9d1ad80..a8865fc 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -863,6 +863,48 @@ export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) { return arr } +/** + * Abschnitt direkt vor den Parallel-Lauf setzen (immer Ganzgruppe oberhalb der Split-Phase). + */ +export function reorderSectionBeforeParallelRunAsWholeGroup(prev, fromI, phaseOrderIndex) { + const po = Number(phaseOrderIndex) || 0 + const idxs = indicesOfParallelPhase(prev, po) + if (!idxs.length || fromI < 0 || fromI >= (prev?.length ?? 0)) return prev + const fg = idxs[0] + const arr = [...prev] + const [moved] = arr.splice(fromI, 1) + let insertAt = fg + if (fromI < insertAt) insertAt -= 1 + insertAt = Math.max(0, Math.min(insertAt, arr.length)) + const above = insertAt > 0 ? arr[insertAt - 1] : undefined + let planLocNext + if (above?.planLoc?.phaseKind === 'whole_group') { + planLocNext = { ...above.planLoc } + } else if (!above) { + planLocNext = defaultPlanLocWholeGroup(0) + } else { + const mx = maxPhaseOrderIndexFromSections(arr) + planLocNext = defaultPlanLocWholeGroup(mx + 1) + } + arr.splice(insertAt, 0, { ...moved, planLoc: planLocNext }) + return arr +} + +/** Abschnitt als neuen ersten Eintrag der Parallel-Phase (gleiche Phasen-/Stream-Metadaten wie bisheriger Kopf). */ +export function reorderSectionAsFirstInParallelPhase(prev, fromI, phaseOrderIndex) { + const po = Number(phaseOrderIndex) || 0 + const idxs = indicesOfParallelPhase(prev, po) + if (!idxs.length || fromI < 0 || fromI >= (prev?.length ?? 0)) return prev + let fg = idxs[0] + const arr = [...prev] + const headTpl = { ...arr[fg].planLoc } + const [moved] = arr.splice(fromI, 1) + if (fromI < fg) fg -= 1 + fg = Math.max(0, Math.min(fg, arr.length)) + arr.splice(fg, 0, { ...moved, planLoc: { ...headTpl } }) + return arr +} + /** * Abschnitt ans Ende eines parallelen Streams setzen (planLoc wie dieser Stream). * Leerer Stream: Einfügen hinter den letzten Abschnitt der zugehörigen parallelen Phase, planLoc vom Referenz-Abschnitt mit angepasstem streamIndex.