diff --git a/.maestro/enrichedMarkdownText/screenshots/android/header_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/header_code_block_combo_display.png index 02b4ffc8..5c1d3642 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/header_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/header_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png index e0098115..55a2fb3a 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_display.png index 384bbc3e..6fb85186 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_image_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_image_combo_display.png index bb9a46eb..8595f6cf 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_image_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_image_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_math_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_math_combo_display.png index 1540d5a9..c0ce7414 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_math_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_math_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/header_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/header_code_block_combo_display.png index e64dc8a9..0be09b6a 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/header_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/header_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_first_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_first_combo_display.png index 38f45942..110f18a8 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_first_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_first_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_loose_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_loose_combo_display.png index 6dbff7e0..e354d674 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_loose_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_loose_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_tight_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_tight_combo_display.png index 44c8a923..2abf0877 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_tight_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/list_code_block_tight_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_code_block_combo_display.png index 3112513b..c1b0c5c2 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/task_list_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/task_list_code_block_combo_display.png index dd303dd0..bc918046 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/task_list_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/task_list_code_block_combo_display.png differ diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt index f0a70659..9e339864 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt @@ -7,7 +7,6 @@ import android.graphics.Canvas import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.PixelFormat -import android.graphics.RectF import android.graphics.Typeface import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable @@ -119,7 +118,10 @@ class CodeBlockContainerView( background = null scaleType = ImageView.ScaleType.CENTER setImageDrawable( - CopyIconDrawable(secondaryColor(codeBlockStyle.color), ceil(codeBlockStyle.fontSize).toInt()), + CopyIconDrawable( + secondaryColor(codeBlockStyle.color), + ceil(codeBlockStyle.fontSize * HEADER_ICON_SCALE).toInt(), + ), ) setOnClickListener { copyCode() } } @@ -254,16 +256,16 @@ class CodeBlockContainerView( private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE - strokeWidth = size / 12f + strokeWidth = size / 20f strokeJoin = Paint.Join.ROUND strokeCap = Paint.Cap.ROUND this.color = color } + private val glyph = CopyGlyph() + override fun draw(canvas: Canvas) { - val u = bounds.width() / 24f - canvas.drawRoundRect(RectF(8 * u, 2 * u, 22 * u, 16 * u), 2 * u, 2 * u, paint) - canvas.drawRoundRect(RectF(2 * u, 8 * u, 16 * u, 22 * u), 2 * u, 2 * u, paint) + glyph.draw(canvas, bounds.width().toFloat(), paint) } override fun getIntrinsicWidth() = size @@ -285,6 +287,12 @@ class CodeBlockContainerView( companion object { private const val HEADER_LABEL_SCALE = 0.85f + // Drawable box for the copy glyph, as a multiple of the code font size. + // iOS sizes the SF Symbol by point size (kENRMHeaderIconScale = 0.72) but + // the symbol renders roughly half again as tall as its point size; this box + // is scaled up to land the drawn glyph at the same on-screen size. + private const val HEADER_ICON_SCALE = 1.08f + private val headerTypeface: Typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) private fun secondaryColor(color: Int): Int = (color and 0x00FFFFFF) or (0x99 shl 24) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/ContextMenuPopup.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/ContextMenuPopup.kt index 93e12e7a..37082b46 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/ContextMenuPopup.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/ContextMenuPopup.kt @@ -183,16 +183,19 @@ object ContextMenuPopup { color = Color.parseColor("#333333") } + private val copyGlyph = CopyGlyph() + + // COPY mirrors SF Symbol doc.on.doc, DOCUMENT mirrors doc.text; the copy + // glyph is shared with the header button in CodeBlockContainerView. override fun draw(canvas: Canvas) { val u = bounds.width() / 24f if (icon == Icon.COPY) { - canvas.drawRoundRect(RectF(8 * u, 2 * u, 22 * u, 16 * u), 2 * u, 2 * u, paint) - canvas.drawRoundRect(RectF(2 * u, 8 * u, 16 * u, 22 * u), 2 * u, 2 * u, paint) + copyGlyph.draw(canvas, bounds.width().toFloat(), paint) } else { - canvas.drawRoundRect(RectF(4 * u, 2 * u, 20 * u, 22 * u), 2 * u, 2 * u, paint) - canvas.drawLine(8 * u, 8 * u, 16 * u, 8 * u, paint) - canvas.drawLine(8 * u, 12 * u, 16 * u, 12 * u, paint) - canvas.drawLine(8 * u, 16 * u, 13 * u, 16 * u, paint) + canvas.drawRoundRect(RectF(5 * u, 3 * u, 19 * u, 21 * u), 2.5f * u, 2.5f * u, paint) + canvas.drawLine(8.5f * u, 9 * u, 15.5f * u, 9 * u, paint) + canvas.drawLine(8.5f * u, 12.5f * u, 15.5f * u, 12.5f * u, paint) + canvas.drawLine(8.5f * u, 16 * u, 13 * u, 16 * u, paint) } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CopyGlyph.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CopyGlyph.kt new file mode 100644 index 00000000..b1a4df01 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CopyGlyph.kt @@ -0,0 +1,101 @@ +package com.swmansion.enriched.markdown.views + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode + +/** + * Draws the copy glyph so Android matches the SF Symbol doc.on.doc rendered on + * iOS: two portrait document pages with a folded (dog-eared) top-right corner, + * a front page in the lower-left laid over a back page in the upper-right. + * + * The back page is punched out where the front page (plus a hairline of + * clearance) covers it, so the front reads as sitting on top exactly as the SF + * Symbol does, instead of both outlines crossing. The clear is done inside an + * offscreen layer so the punched region reveals whatever background the drawable + * sits on, without the glyph needing to know that color. + * + * One instance is held per drawable rather than shared: the paths and clear + * paint are reused across draws to keep draw() allocation-free, and two drawables + * of different sizes must not share those buffers. All coordinates use a 24-unit + * grid scaled to the drawable width, matching the grid the iOS symbol is authored + * on. + */ +internal class CopyGlyph { + private val back = Path() + private val front = Path() + private val backFold = Path() + private val frontFold = Path() + + // Color and colorFilter are irrelevant under PorterDuff.CLEAR; only the + // widened stroke (set per draw) matters, so this is configured once. + private val clearPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL_AND_STROKE + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + } + + fun draw( + canvas: Canvas, + width: Float, + stroke: Paint, + ) { + val u = width / 24f + val fold = 4f * u + val corner = 2f * u + + buildPage(back, 9 * u, 1 * u, 20 * u, 17 * u, fold, corner) + buildPage(front, 3 * u, 7 * u, 14 * u, 23 * u, fold, corner) + buildFold(backFold, 20 * u, 1 * u, fold) + buildFold(frontFold, 14 * u, 7 * u, fold) + clearPaint.strokeWidth = stroke.strokeWidth * 3f + + val saved = canvas.saveLayer(0f, 0f, width, width, null) + canvas.drawPath(back, stroke) + canvas.drawPath(backFold, stroke) + canvas.drawPath(front, clearPaint) + canvas.drawPath(front, stroke) + canvas.drawPath(frontFold, stroke) + canvas.restoreToCount(saved) + } + + // A page outline whose top-right corner is cut back by fold, with the other + // three corners rounded by corner. + private fun buildPage( + path: Path, + l: Float, + t: Float, + r: Float, + b: Float, + fold: Float, + corner: Float, + ) { + path.rewind() + path.moveTo(l, t + corner) + path.quadTo(l, t, l + corner, t) + path.lineTo(r - fold, t) + path.lineTo(r, t + fold) + path.lineTo(r, b - corner) + path.quadTo(r, b, r - corner, b) + path.lineTo(l + corner, b) + path.quadTo(l, b, l, b - corner) + path.close() + } + + // The two inner edges of the folded-over corner, at the page's top-right. + private fun buildFold( + path: Path, + r: Float, + t: Float, + fold: Float, + ) { + path.rewind() + path.moveTo(r - fold, t) + path.lineTo(r - fold, t + fold) + path.lineTo(r, t + fold) + } +} diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m b/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m index 5def6442..e6acd4d2 100644 --- a/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m +++ b/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m @@ -21,6 +21,9 @@ // lines never wrap, the height is independent of the available width. static const CGFloat kENRMHeaderLabelScale = 0.85; +// The copy glyph reads cleaner a touch smaller than the language label and at a +// regular (not medium) weight; sized independently so the two never couple. +static const CGFloat kENRMHeaderIconScale = 0.72; static const CGFloat kENRMHeaderSecondaryAlpha = 0.6; static const CGFloat kENRMHeaderDividerAlpha = 0.2; @@ -193,15 +196,19 @@ - (instancetype)initWithConfig:(StyleConfig *)config _copyButton = [UIButton buttonWithType:UIButtonTypeSystem]; UIImageSymbolConfiguration *symbolConfig = - [UIImageSymbolConfiguration configurationWithPointSize:[config codeBlockFont].pointSize * kENRMHeaderLabelScale - weight:UIImageSymbolWeightMedium]; + [UIImageSymbolConfiguration configurationWithPointSize:[config codeBlockFont].pointSize * kENRMHeaderIconScale + weight:UIImageSymbolWeightRegular]; [_copyButton setImage:[UIImage systemImageNamed:@"doc.on.doc" withConfiguration:symbolConfig] forState:UIControlStateNormal]; _copyButton.tintColor = [[config codeBlockColor] colorWithAlphaComponent:kENRMHeaderSecondaryAlpha]; [_copyButton addTarget:self action:@selector(copyCodeToPasteboard) forControlEvents:UIControlEventTouchUpInside]; [self addSubview:_copyButton]; #else - NSImage *copyImage = [NSImage imageWithSystemSymbolName:@"doc.on.doc" accessibilityDescription:nil]; + NSImageSymbolConfiguration *symbolConfig = + [NSImageSymbolConfiguration configurationWithPointSize:[config codeBlockFont].pointSize * kENRMHeaderIconScale + weight:NSFontWeightRegular]; + NSImage *copyImage = [[NSImage imageWithSystemSymbolName:@"doc.on.doc" + accessibilityDescription:nil] imageWithSymbolConfiguration:symbolConfig]; _copyButton = [NSButton buttonWithImage:copyImage target:self action:@selector(copyCodeToPasteboard)]; _copyButton.bordered = NO; _copyButton.contentTintColor = [[config codeBlockColor] colorWithAlphaComponent:kENRMHeaderSecondaryAlpha];