Enhance TrainingUnitSectionsEditor with new drag-and-drop functionality for parallel phases
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
- 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.
This commit is contained in:
parent
73975d3402
commit
72e8f31cff
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<Fragment key={`phase-edge-${parallelPhaseOrder}`}>
|
||||
{enableSectionDragReorder ? (
|
||||
<div
|
||||
className={
|
||||
'tu-section-dropband tu-phase-drop--above-split tu-section-dropband--phase-parallel-slot' +
|
||||
sectionDropBandRegionClass(
|
||||
list,
|
||||
firstGlobalIdxThisPhase ?? sIdx,
|
||||
enableParallelPhaseControls
|
||||
) +
|
||||
(phaseAboveSplitDnd ? ' tu-section-dropband--active' : '')
|
||||
}
|
||||
title="Oberhalb der Split-Phase: Abschnitt in die Ganzgruppe ziehen"
|
||||
aria-label="Dropzone Ganzgruppe oberhalb der Split-Phase"
|
||||
onDragOver={(e) => onPhaseAboveSplitDragOver(e, parallelPhaseOrder)}
|
||||
onDragLeave={(e) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget)) return
|
||||
clearSectionDnD()
|
||||
}}
|
||||
onDrop={(e) => onPhaseAboveSplitDrop(e, parallelPhaseOrder)}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '12px',
|
||||
|
|
@ -1425,6 +1622,22 @@ export default function TrainingUnitSectionsEditor({
|
|||
+ Abschnitt in diesem Stream
|
||||
</button>
|
||||
</div>
|
||||
{enableSectionDragReorder ? (
|
||||
<div
|
||||
className={
|
||||
'tu-section-dropband tu-phase-drop--below-split tu-section-dropband--phase-parallel-slot' +
|
||||
(phaseBelowSplitDnd ? ' tu-section-dropband--active' : '')
|
||||
}
|
||||
title="Erster Abschnitt dieser Split-Phase (vorne einfügen)"
|
||||
aria-label="Dropzone erster Abschnitt unter dem Split-Kopf"
|
||||
onDragOver={(e) => onPhaseBelowSplitDragOver(e, parallelPhaseOrder)}
|
||||
onDragLeave={(e) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget)) return
|
||||
clearSectionDnD()
|
||||
}}
|
||||
onDrop={(e) => onPhaseBelowSplitDrop(e, parallelPhaseOrder)}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={`Streams · Phase ${parallelPhaseOrder}`}
|
||||
|
|
@ -1584,6 +1797,7 @@ export default function TrainingUnitSectionsEditor({
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
) : null}
|
||||
{!hideParallelSection ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user