Card drag preview renders at the source face's laid-out width
The replica framed to a font-derived nominal width, so faces in wide lanes' interior masonry columns and narrow lanes dragged a preview of the wrong size and the cursor could sit over empty space beside it. CardFaceView's existing onGeometryChange now reports size, not height alone - height still feeds LaneDropRegistry, width feeds the replica - and BoardMetrics.cardReplicaWidth(measured:bodyPointSize:) keeps the old figure as the documented not-yet-laid-out fallback. Same row, same paddings, same lineLimit at the same width means the same height, so the preview is now an exact overlay of the face it left. Manual verification pending alongside the lane grab-point card: 3x vs 1x lanes, interior masonry columns, window re-divide, trash rows, large text sizes, drag-at-creation fallback. 3 tests. 1661 green on both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -164,8 +164,24 @@ enum BoardMetrics {
|
||||
|
||||
// MARK: - The drag replica
|
||||
|
||||
/// The width the card drag's replica is drawn at — a card at a representative lane width, since
|
||||
/// the image under the cursor has no lane to measure itself against.
|
||||
/// The width the card drag's replica is drawn at: **the width of the face it was lifted from**.
|
||||
///
|
||||
/// A face is as wide as the interior masonry column it sits in — a function of its lane's slot
|
||||
/// width and its lane's column count (`LaneLayoutMath`, `MasonryLayout`) — so it is not a figure
|
||||
/// this file can derive at all, only one the live face can report (`CardFaceView` measures it
|
||||
/// alongside the height the drop model already takes). Drawn at anything else, the image under
|
||||
/// the cursor is a card the board does not contain, and the pointer sits beside it rather than on
|
||||
/// it.
|
||||
///
|
||||
/// `measured` is that width, and the fallback below is for the face that has not reported one
|
||||
/// yet.
|
||||
static func cardReplicaWidth(measured: CGFloat, bodyPointSize: CGFloat) -> CGFloat {
|
||||
guard measured.isFinite, measured > 0 else { return cardReplicaWidth(bodyPointSize: bodyPointSize) }
|
||||
return measured
|
||||
}
|
||||
|
||||
/// The replica's fallback width — a card at a representative lane width, for the face that has
|
||||
/// not laid out yet and so has no width of its own to give.
|
||||
static func cardReplicaWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||
em(17, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
@@ -129,6 +129,13 @@ struct CardFaceView: View {
|
||||
/// treatment shootout).
|
||||
private var stripeWidth: CGFloat { BoardMetrics.cardStripeWidth(bodyPointSize: pointSize) }
|
||||
|
||||
/// This face's drawn width — **the replica's** (`replicaFace`). Measured rather than derived,
|
||||
/// because a face is as wide as the interior masonry column its lane gives it, and that is a
|
||||
/// function of the lane's slot width and its column count rather than of the font
|
||||
/// (`LaneLayoutMath`, `MasonryLayout`). Zero until the first layout, which is what the metric's
|
||||
/// fallback is for.
|
||||
@State private var measuredWidth: CGFloat = 0
|
||||
|
||||
/// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both
|
||||
/// sides share is in `face`; what the board has and the trash does not is attached here, so the
|
||||
/// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by
|
||||
@@ -251,13 +258,18 @@ struct CardFaceView: View {
|
||||
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
|
||||
// the session off until the pointer really moves, so selecting and opening stay instant.
|
||||
.onDrag(startDrag, preview: { dragReplica })
|
||||
// The card's height, for the drop model's analytic resting grid. A height is content-driven
|
||||
// and does not animate under the reflow — only positions do, and those are never measured
|
||||
// (`LaneDropRegistry`). The trash side registers too: a trash card dragged out is an
|
||||
// ordinary card session, and the shadow it opens in the destination lane should be its real
|
||||
// footprint rather than the nominal guess.
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
|
||||
drops.registry.update(height: height, for: card.id)
|
||||
// The card's drawn size. Its **height** goes to the drop model's analytic resting grid — a
|
||||
// height is content-driven and does not animate under the reflow, only positions do, and
|
||||
// those are never measured (`LaneDropRegistry`). The trash side registers too: a trash card
|
||||
// dragged out is an ordinary card session, and the shadow it opens in the destination lane
|
||||
// should be its real footprint rather than the nominal guess.
|
||||
//
|
||||
// Its **width** stays here, for the drag replica: a face is as wide as its lane's interior
|
||||
// column, which no metric can derive and only the laid-out face can report
|
||||
// (`BoardMetrics.cardReplicaWidth(measured:bodyPointSize:)`).
|
||||
.onGeometryChange(for: CGSize.self) { $0.size } action: { size in
|
||||
drops.registry.update(height: size.height, for: card.id)
|
||||
measuredWidth = size.width
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||||
.marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry)
|
||||
@@ -414,10 +426,17 @@ struct CardFaceView: View {
|
||||
}
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.padding(.leading, stripeWidth)
|
||||
// The replica has no lane to measure itself against, so it takes a representative card
|
||||
// width — font-derived like everything else here, so the image under the cursor is the size
|
||||
// the cards on the board actually are at this text size (`BoardMetrics`).
|
||||
.frame(width: BoardMetrics.cardReplicaWidth(bodyPointSize: pointSize), alignment: .leading)
|
||||
// **The size of the face it was lifted from**, taken from that face's own measurement rather
|
||||
// than from a representative figure: a card is as wide as its lane's interior column, so a
|
||||
// replica drawn at a nominal width is visibly a different card from the one under the cursor,
|
||||
// and — since the system centres a preview on the view the drag started from — leaves the
|
||||
// pointer sitting beside the image instead of on it. The width is the only frame this needs:
|
||||
// the replica lays the same row out with the same paddings and the same `lineLimit`, so at
|
||||
// the face's width it comes out at the face's height (`BoardMetrics`).
|
||||
.frame(
|
||||
width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize),
|
||||
alignment: .leading
|
||||
)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user