Skip to content

Commit ed286d7

Browse files
committed
Merge branch 'trunk' into dodroid-433-the-editor-fails-to-add-checklist-indentation
2 parents 187e981 + 6bc0048 commit ed286d7

File tree

2 files changed

+247
-28
lines changed

2 files changed

+247
-28
lines changed

media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderAdapter.kt

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,90 @@ import androidx.compose.runtime.Composable
44
import org.wordpress.aztec.AztecAttributes
55

66
interface ComposePlaceholderAdapter : PlaceholderManager.PlaceholderAdapter {
7+
/**
8+
* Optional sizing hints for the manager.
9+
*
10+
* Usage:
11+
* - Return [SizingPolicy.MatchWidthWrapContentHeight] if your content should
12+
* match the editor width and wrap to its intrinsic height. The manager will
13+
* pre-measure once offscreen to obtain the final height and avoid flicker.
14+
* - Return [SizingPolicy.AspectRatio] for media with known aspect ratio. The
15+
* manager calculates height = width * ratio without composition.
16+
* - Return [SizingPolicy.FixedHeightPx] for fixed-height embeds.
17+
* - Return [SizingPolicy.Unknown] to keep legacy behavior; the manager will
18+
* call your existing [calculateHeight] implementation.
19+
*/
20+
fun sizingPolicy(attrs: AztecAttributes): SizingPolicy = SizingPolicy.Unknown
21+
22+
/**
23+
* Optional hook to compute a final height before first paint.
24+
*
25+
* When to use:
26+
* - Your content height depends on Compose measurement (e.g., text wrapping)
27+
* and you want a single pass without interim sizes.
28+
*
29+
* How it works:
30+
* - Manager provides a [measurer] that composes your content offscreen at an
31+
* exact width and returns its measured height in pixels.
32+
* - Return that value to have the placeholder sized correctly up-front.
33+
* - Return null to let the manager fall back to [sizingPolicy] or legacy
34+
* [calculateHeight].
35+
*
36+
* Notes:
37+
* - Runs on the main thread. Do not perform long blocking work here.
38+
* - Keep the content passed to [measurer.measure] minimal (only what affects
39+
* size) to make pre-measure cheap.
40+
*/
41+
suspend fun preComposeMeasureHeight(
42+
attrs: AztecAttributes,
43+
widthPx: Int,
44+
measurer: PlaceholderMeasurer
45+
): Int? = null
46+
47+
/**
48+
* Optional spacing added after the placeholder, in pixels.
49+
*
50+
* This increases the reserved text-flow height while keeping the overlay
51+
* view at the content height, producing a visual margin below the embed
52+
* without an extra redraw.
53+
*/
54+
fun bottomSpacingPx(attrs: AztecAttributes): Int = 0
55+
56+
/** Abstraction to measure Compose content offscreen at an exact width. */
57+
interface PlaceholderMeasurer {
58+
suspend fun measure(content: @Composable () -> Unit, widthPx: Int): Int
59+
}
60+
61+
/** Sizing policy hints used by the manager to choose a measurement path. */
62+
sealed interface SizingPolicy {
63+
object Unknown : SizingPolicy
64+
object MatchWidthWrapContentHeight : SizingPolicy
65+
data class AspectRatio(val ratio: Float) : SizingPolicy
66+
data class FixedHeightPx(val heightPx: Int) : SizingPolicy
67+
}
68+
69+
/**
70+
* Insets for positioning the overlay view within the reserved text area.
71+
*
72+
* This affects only the overlay position/size, not the reserved text-flow
73+
* height. Use this to keep content away from edges (e.g., rounded corners)
74+
* or to eliminate bottom inset if it causes clipping.
75+
*
76+
* Defaults match legacy behavior (10 px on each side). Return zeros for
77+
* edge-to-edge rendering.
78+
*/
79+
data class OverlayPadding(val left: Int, val top: Int, val right: Int, val bottom: Int)
80+
81+
fun overlayPaddingPx(attrs: AztecAttributes): OverlayPadding = OverlayPadding(10, 10, 10, 10)
82+
83+
/**
84+
* Optional tiny positive adjustment added to the overlay height (pixels).
85+
*
86+
* Purpose: guard against 1 px rounding differences between pre-measure and
87+
* runtime composition that could otherwise clip the last row/baseline.
88+
* Leave at 0 unless you observe such edge cases.
89+
*/
90+
fun contentHeightAdjustmentPx(attrs: AztecAttributes): Int = 0
791
/**
892
* Use this method to draw the placeholder using Jetpack Compose.
993
* @param placeholderUuid the placeholder UUID

media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt

Lines changed: 163 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ package org.wordpress.aztec.placeholders
44

55
import android.graphics.Rect
66
import android.graphics.drawable.Drawable
7+
import android.os.Looper
78
import android.text.Editable
89
import android.text.Layout
910
import android.text.Spanned
1011
import android.view.View
12+
import android.view.View.MeasureSpec
13+
import android.view.ViewGroup
1114
import android.view.ViewTreeObserver
1215
import androidx.compose.foundation.layout.Box
1316
import androidx.compose.foundation.layout.height
@@ -17,6 +20,7 @@ import androidx.compose.runtime.Composable
1720
import androidx.compose.runtime.collectAsState
1821
import androidx.compose.runtime.key
1922
import androidx.compose.ui.Modifier
23+
import androidx.compose.ui.platform.ComposeView
2024
import androidx.compose.ui.platform.LocalDensity
2125
import androidx.compose.ui.zIndex
2226
import androidx.core.content.ContextCompat
@@ -339,12 +343,23 @@ class ComposePlaceholderManager(
339343
val editorWidth = if (aztecText.width > 0) {
340344
aztecText.width - aztecText.paddingStart - aztecText.paddingEnd
341345
} else aztecText.maxImagesWidth
342-
drawable.setBounds(
343-
0,
344-
0,
345-
adapter.calculateWidth(attrs, editorWidth),
346-
adapter.calculateHeight(attrs, editorWidth)
347-
)
346+
347+
if (adapter.sizingPolicy(attrs) != ComposePlaceholderAdapter.SizingPolicy.Unknown) {
348+
// New behavior with enhanced measuring
349+
val widthPx = adapter.calculateWidth(attrs, editorWidth)
350+
val heightPx = computeHeightPx(adapter, attrs, editorWidth, widthPx)
351+
// Reserve additional flow space after the placeholder to visually separate following blocks
352+
val flowHeight = heightPx + (adapter.bottomSpacingPx(attrs))
353+
drawable.setBounds(0, 0, widthPx, flowHeight)
354+
} else {
355+
// Legacy behavior
356+
drawable.setBounds(
357+
0,
358+
0,
359+
adapter.calculateWidth(attrs, editorWidth),
360+
adapter.calculateHeight(attrs, editorWidth)
361+
)
362+
}
348363
return drawable
349364
}
350365

@@ -409,40 +424,160 @@ class ComposePlaceholderManager(
409424

410425
val adapter = adapters[type]!!
411426
val windowWidth = parentTextViewRect.right - parentTextViewRect.left - EDITOR_INNER_PADDING
427+
428+
// Check if using new sizing policy or legacy behavior
429+
val newComposeView = if (adapter.sizingPolicy(attrs) != ComposePlaceholderAdapter.SizingPolicy.Unknown) {
430+
createComposeViewWithSizingPolicy(
431+
adapter, attrs, uuid, windowWidth, parentTextViewRect, parentTextViewTopAndBottomOffset
432+
)
433+
} else {
434+
createComposeViewWithLegacy(
435+
adapter, attrs, uuid, windowWidth, parentTextViewRect, parentTextViewTopAndBottomOffset
436+
)
437+
}
438+
439+
// Check if view needs updating
440+
val existingView = _composeViewState.value[uuid]
441+
if (existingView != null &&
442+
existingView.width == newComposeView.width &&
443+
existingView.height == newComposeView.height &&
444+
existingView.topMargin == newComposeView.topMargin &&
445+
existingView.leftMargin == newComposeView.leftMargin &&
446+
existingView.attrs == attrs
447+
) {
448+
return
449+
}
450+
451+
// Update compose view state
452+
_composeViewState.value = _composeViewState.value.toMutableMap().apply {
453+
this[uuid] = newComposeView
454+
}
455+
}
456+
457+
private suspend fun createComposeViewWithSizingPolicy(
458+
adapter: ComposePlaceholderAdapter,
459+
attrs: AztecAttributes,
460+
uuid: String,
461+
windowWidth: Int,
462+
parentTextViewRect: Rect,
463+
parentTextViewTopAndBottomOffset: Int
464+
): ComposeView {
465+
val targetWidth = adapter.calculateWidth(attrs, windowWidth)
466+
val measuredHeight = computeHeightPx(adapter, attrs, windowWidth, targetWidth)
467+
val extraBottom = adapter.bottomSpacingPx(attrs)
468+
val height = measuredHeight + extraBottom
469+
parentTextViewRect.top += parentTextViewTopAndBottomOffset
470+
parentTextViewRect.bottom = parentTextViewRect.top + height
471+
472+
val overlayPad = adapter.overlayPaddingPx(attrs)
473+
val newLeftPadding = parentTextViewRect.left + overlayPad.left + aztecText.paddingStart
474+
val newTopPadding = parentTextViewRect.top + overlayPad.top
475+
val adjustedHeight = measuredHeight + adapter.contentHeightAdjustmentPx(attrs)
476+
477+
return ComposeView(
478+
uuid = uuid,
479+
width = targetWidth,
480+
height = adjustedHeight,
481+
topMargin = newTopPadding,
482+
leftMargin = newLeftPadding,
483+
visible = true,
484+
adapterKey = adapter.type,
485+
attrs = attrs
486+
)
487+
}
488+
489+
private suspend fun createComposeViewWithLegacy(
490+
adapter: ComposePlaceholderAdapter,
491+
attrs: AztecAttributes,
492+
uuid: String,
493+
windowWidth: Int,
494+
parentTextViewRect: Rect,
495+
parentTextViewTopAndBottomOffset: Int
496+
): ComposeView {
412497
val height = adapter.calculateHeight(attrs, windowWidth)
413498
parentTextViewRect.top += parentTextViewTopAndBottomOffset
414499
parentTextViewRect.bottom = parentTextViewRect.top + height
415500

416-
val box = _composeViewState.value[uuid]
417501
val newWidth = adapter.calculateWidth(attrs, windowWidth) - EDITOR_INNER_PADDING
418502
val newHeight = height - EDITOR_INNER_PADDING
419503
val padding = 10
420504
val newLeftPadding = parentTextViewRect.left + padding + aztecText.paddingStart
421505
val newTopPadding = parentTextViewRect.top + padding
422-
box?.let { existingView ->
423-
val widthSame = existingView.width == newWidth
424-
val heightSame = existingView.height == newHeight
425-
val topMarginSame = existingView.topMargin == newTopPadding
426-
val leftMarginSame = existingView.leftMargin == newLeftPadding
427-
val attrsSame = existingView.attrs == attrs
428-
if (widthSame && heightSame && topMarginSame && leftMarginSame && attrsSame) {
429-
return
506+
507+
return ComposeView(
508+
uuid = uuid,
509+
width = newWidth,
510+
height = newHeight,
511+
topMargin = newTopPadding,
512+
leftMargin = newLeftPadding,
513+
visible = true,
514+
adapterKey = adapter.type,
515+
attrs = attrs
516+
)
517+
}
518+
519+
private suspend fun computeHeightPx(
520+
adapter: ComposePlaceholderAdapter,
521+
attrs: AztecAttributes,
522+
windowWidth: Int,
523+
contentWidthPx: Int
524+
): Int =
525+
when (val policy = adapter.sizingPolicy(attrs)) {
526+
is ComposePlaceholderAdapter.SizingPolicy.FixedHeightPx -> policy.heightPx
527+
528+
is ComposePlaceholderAdapter.SizingPolicy.AspectRatio -> (policy.ratio * contentWidthPx).toInt()
529+
530+
ComposePlaceholderAdapter.SizingPolicy.MatchWidthWrapContentHeight ->
531+
preMeasureHeight(adapter, attrs, contentWidthPx) ?: adapter.calculateHeight(attrs, windowWidth)
532+
533+
ComposePlaceholderAdapter.SizingPolicy.Unknown -> adapter.calculateHeight(attrs, windowWidth)
534+
}
535+
536+
private suspend fun preMeasureHeight(
537+
adapter: ComposePlaceholderAdapter,
538+
attrs: AztecAttributes,
539+
widthPx: Int
540+
): Int? {
541+
// Pre-measure only on main thread. If not on main, fall back to legacy path
542+
if (Looper.myLooper() != Looper.getMainLooper()) return null
543+
val measurer = object : ComposePlaceholderAdapter.PlaceholderMeasurer {
544+
override suspend fun measure(content: @Composable () -> Unit, widthPx: Int): Int {
545+
if (!aztecText.isAttachedToWindow) return -1
546+
val parent = aztecText.parent as? ViewGroup ?: return -1
547+
val composeView = ComposeView(aztecText.context)
548+
composeView.visibility = View.GONE
549+
composeView.layoutParams = ViewGroup.LayoutParams(0, 0)
550+
try {
551+
parent.addView(composeView)
552+
composeView.setContent {
553+
Box(
554+
Modifier
555+
.width(with(LocalDensity.current) { widthPx.toDp() })
556+
) {
557+
content()
558+
}
559+
}
560+
val wSpec = MeasureSpec.makeMeasureSpec(widthPx, MeasureSpec.EXACTLY)
561+
val hSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
562+
composeView.measure(wSpec, hSpec)
563+
return composeView.measuredHeight
564+
} catch (_: IllegalStateException) {
565+
return -1
566+
} finally {
567+
parent.removeView(composeView)
568+
}
430569
}
431570
}
432-
_composeViewState.value = _composeViewState.value.let { state ->
433-
val mutableState = state.toMutableMap()
434-
mutableState[uuid] = ComposeView(
435-
uuid = uuid,
436-
width = newWidth,
437-
height = newHeight,
438-
topMargin = newTopPadding,
439-
leftMargin = newLeftPadding,
440-
visible = true,
441-
adapterKey = adapter.type,
442-
attrs = attrs
443-
)
444-
mutableState
571+
// Let adapter compute/measure if it wants to
572+
val fromAdapter = adapter.preComposeMeasureHeight(attrs, widthPx, measurer)
573+
if (fromAdapter != null && fromAdapter >= 0) return fromAdapter
574+
// If adapter did not implement it but hinted wrap content policy, measure the actual content once
575+
if (adapter.sizingPolicy(attrs) == ComposePlaceholderAdapter.SizingPolicy.MatchWidthWrapContentHeight) {
576+
val uuid = attrs.getValue(UUID_ATTRIBUTE)
577+
val h = measurer.measure(content = { adapter.Placeholder(uuid, attrs) }, widthPx = widthPx)
578+
return if (h >= 0) h else null
445579
}
580+
return null
446581
}
447582

448583
private fun validateAttributes(attributes: AztecAttributes): Boolean {

0 commit comments

Comments
 (0)