Skip to content

feat: localize selection menu labels via selectionMenuLabels prop#415

Open
marcellov7 wants to merge 8 commits into
software-mansion:mainfrom
marcellov7:feat/localizable-selection-menu-labels
Open

feat: localize selection menu labels via selectionMenuLabels prop#415
marcellov7 wants to merge 8 commits into
software-mansion:mainfrom
marcellov7:feat/localizable-selection-menu-labels

Conversation

@marcellov7

Copy link
Copy Markdown

Summary

Closes #198.

The built-in copy actions (Copy, Copy as Markdown, Copy Image URL) are hardcoded in English in the native code. On non-English apps they stay in English while the OS-provided menu items (Look Up, Translate…) are localized, creating an inconsistent menu — exactly the problem reported in #198.

This adds a selectionMenuLabels prop on EnrichedMarkdownText so the labels can be overridden from JS (option 2 in the issue), typically wired to the app's i18n:

<EnrichedMarkdownText
  markdown={content}
  selectionMenuLabels={{
    copy: t('copy'),
    copyAsMarkdown: t('copyAsMarkdown'),
    copyImageUrl: t('copyImageUrl'),
    copyImageUrls: t('copyImageUrls'), // "{count}" is replaced by the image count
  }}
/>

Any label left undefined keeps its English default, so the change is fully backward compatible.

How it works

The labels are folded into the existing selectionMenuConfig codegen struct (normalized JS-side, where an empty string means "use the native default"), so no new native prop plumbing is introduced.

  • TS — new SelectionMenuLabels type + prop; normalization in native/EnrichedMarkdownText.tsx; struct fields added to both text/github codegen specs.
  • Android — labels read in MarkdownViewManagerUtils, applied in SelectionActionMode and threaded to the table and math copy menus.
  • iOS / macOS — labels carried on ENRMSelectionMenuConfig (strong owners on the view) and applied in EditMenuUtils(+macOS) and the TableContainerView / ENRMMathContainerView menus.

Scope

Covers EnrichedMarkdownText (commonmark + github flavor), including the table and math block copy menus listed in #198. EnrichedMarkdownTextInput keeps only its visibility config for now (the Format submenu labels could be a follow-up).

Notes for reviewers

  • Docs updated: docs/COPY_OPTIONS.md (new "Localizing Menu Labels" section) and docs/API_REFERENCE.md.
  • yarn typecheck and eslint pass locally. I couldn't run the native (Xcode/Gradle) builds or clang-format/ktlint in my environment, so please double-check the native formatting matches the hooks — happy to adjust.
  • No public API removed; existing behavior is unchanged when the prop is omitted.

🤖 Generated with Claude Code

The built-in copy actions (Copy, Copy as Markdown, Copy Image URL) were
hardcoded in English in the native code, so on non-English apps they did
not match the rest of the UI (the OS-provided items are localized, these
were not).

Add a `selectionMenuLabels` prop on `EnrichedMarkdownText` to override the
labels from JS — typically wired to the app's i18n. Any label left
undefined keeps its English default, so it is fully backward compatible.

- TS: new `SelectionMenuLabels` type + prop; labels folded into the
  existing `selectionMenuConfig` codegen struct and normalized JS-side
  (empty string = use native default).
- Android: labels read in `MarkdownViewManagerUtils`, applied in
  `SelectionActionMode` and threaded to the table and math copy menus.
- iOS/macOS: labels carried on `ENRMSelectionMenuConfig` and applied in
  `EditMenuUtils` (+macOS) and the table/math container menus.
- `copyImageUrls` is a `{count}` template for the multi-image case.
- Docs: COPY_OPTIONS.md and API_REFERENCE.md.

Scope: `EnrichedMarkdownText` (and github flavor). `EnrichedMarkdownTextInput`
keeps only the visibility config for now — can follow up.

Closes software-mansion#198

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@eszlamczyk eszlamczyk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @marcellov7 Thanks for the pull request! The cross-platform coverage is mostly solid, although I have one structural request, one open question about plurals, and a handful of smaller things to address before we merge.

Main issue is:
Consolidate selectionMenuLabels into selectionMenuConfig

for example:

export interface SelectionMenuConfig {
  /** System Copy item can't be hidden — only its label is configurable. */
  copy?: { label?: string };
  copyAsMarkdown?: { enabled?: boolean; label?: string };
  copyImageUrl?: {
    enabled?: boolean;
    label?: string;
    pluralLabels?: TBD;
  };
}

This is a breaking change for selectionMenuConfig ({ copyAsMarkdown: false } → { copyAsMarkdown: { enabled: false } }), but the 0.6 → 0.7 bump pays for it. Drop the standalone selectionMenuLabels prop entirely - it hasn't been released yet.

Although we want to make sure to mark this change as deprecated in non-ts apps, my proposition is to soften the break with a one-release runtime fallback. TS consumers will get a compile error on upgrade and migrate cleanly, but JS-only consumers and anyone whose config flows through any would silently keep using the old shape with no signal anything changed. Accept the boolean form at runtime for 0.7 with a one-time warnOnce (but not __DEV__-gated — deprecation warnings need to surface in staging/TestFlight/CI prod builds too, otherwise developers miss them until end-users hit them), and mark it as deprecated for removal in 0.8.

the code might look something like:

const warned = new Set<string>();
const warnOnce = (key: string, msg: string) => {
  if (warned.has(key)) return;
  warned.add(key);
  console.warn(msg);
};
const normalizeItem = (raw: unknown, field: string, defaultEnabled: boolean) => {
  if (raw === undefined) return { enabled: defaultEnabled, label: '' };
  if (typeof raw === 'boolean') {
    warnOnce(
      `selectionMenuConfig.${field}`,
      `[react-native-enriched-markdown] selectionMenuConfig.${field} as boolean is deprecated; ` +
      `use { enabled: ${raw} }. Boolean form will be removed in 0.8.`
    );
    return { enabled: raw, label: '' };
  }
  const obj = raw as { enabled?: boolean; label?: string };
  return { enabled: obj.enabled ?? defaultEnabled, label: obj.label ?? '' };
};

Keep the public type pure (only the new shape) and confine the boolean acceptance behind a single as unknown cast at the wrapper boundary - the type stays clean while the runtime catches stragglers.


About the plural labels:
In some non-germanic languages there are multiple plurar form, so using "just a string" for plural form is probably incorrect. I see two viable options:

  1. Create a callback function f(count) => string that user can implement the value itself. This would be the best but I have concerns about performance of this option (there might be some race conditions that we render the incorrect label for the value). If you go this route, the race window is the gap between the native selection event and the JS-resolved label arriving back via a prop commit. The case that matters is fast long-press: user makes a selection that includes images and immediately invokes the menu - if the menu opens before the JS round-trip completes, native will fall back to the English template. Please verify the fallback is acceptable on both Android (ActionMode) and iOS (UIEditMenuInteraction), and that no platform shows the menu with an empty title in the worst case.

  2. Use pluralRules. This for me looks like the most practical solution for this pr as it is not so complicated and shouldn't need to do a lot of changes. (Its good enough).


As a Nit, I'd like also this pull request to include a storybook entrance for the prop. Mirror the shape of the existing selectionMenuConfig storybook entry (or whichever is closest).

Comment thread packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm
Comment thread packages/react-native-enriched-markdown/ios/utils/EditMenuUtils+macOS.m Outdated
Comment thread packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx Outdated
Comment thread packages/react-native-enriched-markdown/ios/views/TableContainerView.h Outdated
Comment thread packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts Outdated
Drop the unreleased selectionMenuLabels prop and fold labels into
selectionMenuConfig with a nested shape: copy { label },
copyAsMarkdown { enabled, label }, copyImageUrl { enabled, label,
pluralLabels }.

- Accept the legacy boolean shape at runtime with a one-time warnOnce
  (deprecated, removed in 0.8); public type stays object-only
- Resolve default English labels JS-side so native always receives a
  concrete string (no empty-string sentinel)
- Update macos example, storybook and docs to the new shape
@marcellov7 marcellov7 force-pushed the feat/localizable-selection-menu-labels branch from 66bfca4 to acedcad Compare June 24, 2026 08:41
Add copyImageUrl.pluralLabels (CLDR categories) to selectionMenuConfig.
JS precomputes a per-count template table (0..100) with Intl.PluralRules
and packs it into one codegen string; native indexes it (wrapping counts
> 100 with period 100) and substitutes {count}. Falls back to the
singular/{count} templates when no plural labels are set or Intl is
unavailable.

Shared ENRMResolveImageURLsTitle helper on iOS/macOS and a Kotlin
extension on Android keep the selection logic in one place. Also drops
the now-inaccurate "empty means default" comments from the codegen specs.
Expand the EnrichedMarkdownText Selection Menu story with text controls
for copy/copyAsMarkdown/copyImageUrl labels and the plural template, on
top of the existing enabled toggles, mirroring the new nested config.
setSelectionMenuConfig only updated the text segments, so table and math
copy menus kept stale labels when selectionMenuConfig changed without a
remount (e.g. a language switch). Push the copy labels to the cached
table and math segment views as well.
Mirror pushWritingDirectionToTableSegments: add
pushSelectionMenuLabelsToSegments and call it from updateProps after the
label ivars are reassigned, so table and math copy menus pick up label
changes (e.g. a language switch) without a remount.
Wrap the resolveMenuLabel fallback in NSLocalizedString so the main edit
menu matches the table and math context menus, which already fall back to
NSLocalizedString(@"Copy"/@"Copy as Markdown"). The fallback is defensive
now that labels are resolved JS-side.
The "Localized labels … empty means default" comments just restated the
property name and the behaviour now documented in the JS types and docs.
@marcellov7

Copy link
Copy Markdown
Author

Thanks for the thorough review @eszlamczyk! All points addressed — pushed as separate commits on top:

Structural

  • Consolidated selectionMenuLabels into selectionMenuConfig with the nested shape you proposed (copy { label }, copyAsMarkdown { enabled, label }, copyImageUrl { enabled, label, pluralLabels }); dropped the standalone prop. The legacy boolean form is accepted at runtime behind a single as unknown cast with a one-time warnOnce (not __DEV__-gated), deprecated for removal in 0.8. Public type stays object-only.
  • Defaults resolved JS-side (label ?? DEFAULT), so native always receives a concrete string — no empty-string sentinel.
  • Plurals via Intl.PluralRules: copyImageUrl.pluralLabels takes CLDR categories. JS precomputes a per-count template table (0..100) and packs it into one codegen string; native indexes it (wrapping counts > 100 with period 100, since the CLDR integer rules only depend on n, n%10, n%100) and substitutes {count}. Falls back to the singular/{count} templates when no plural labels are set or Intl is unavailable.
  • Storybook: expanded the Selection Menu story with controls for the labels and the plural template.

Native prop-update

  • Android: setSelectionMenuConfig now re-pushes labels to the table/math views (pushCopyLabelsToBlockSegments).
  • iOS: added pushSelectionMenuLabelsToSegments (mirrors pushWritingDirectionToTableSegments), called from updateProps:.

Nits

  • macOS resolveMenuLabel fallback now uses NSLocalizedString; the Copy %lu Image URLs literal is gone (shared ENRMResolveImageURLsTitle helper used by iOS + macOS).
  • Dropped the redundant copy-label comments.

All review threads resolved. As before, I couldn't run the native (Xcode/Gradle) builds or clang-format/ktlint locally, so please double-check the native formatting.

@eszlamczyk eszlamczyk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @marcellov7! Most of the fixes look clean, unfortunately I do have to block this pr second time, due to mostly small and quick-to-fix issues. You can tag me when you're done and Im gonna review and verify everything then (and hopefully we'll be able to merge it tomorrow so it can be added to the day after tomorrow's nightly release)

Comment on lines +58 to +62
// Defaults shown in Italian to demonstrate localization.
copyLabel: 'Copia',
copyAsMarkdownLabel: 'Copia come Markdown',
copyImageUrlLabel: 'Copia URL immagine',
copyImageUrlsLabel: 'Copia {count} URL immagini',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Praise: good idea to default it to other language!

Comment thread docs/API_REFERENCE.md
| Type | Default Value | Platform |
| -------------------- | ---------------------------------------------- | -------- |
| `SelectionMenuConfig` | `{ copyAsMarkdown: true, copyImageUrl: true }` | iOS, Android, macOS |
| `SelectionMenuConfig` | `{ copyAsMarkdown: { enabled: true }, copyImageUrl: { enabled: true } }` | iOS, Android, macOS |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: looks like this default value does not match actual default value, lets replace it with

Suggested change
| `SelectionMenuConfig` | `{ copyAsMarkdown: { enabled: true }, copyImageUrl: { enabled: true } }` | iOS, Android, macOS |
| `SelectionMenuConfig` | `{} (see shape below for per-field defaults)` | iOS, Android, macOS |

Comment thread docs/API_REFERENCE.md
two?: string;
few?: string;
many?: string;
other?: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acording to what docs state, what was agreed upon, I believe other should be the only one required

Suggested change
other?: string;
other: string;

so we don't fall back to english

Comment on lines +40 to +62
/**
* Resolves the "Copy Image URL(s)" menu title for the given image count. Uses the
* precomputed plural templates (wrapping counts > 100 with period 100) when
* present, otherwise the singular/`{count}` templates. Labels are resolved
* JS-side, so the `ifEmpty` fallbacks only guard the no-Intl path.
*/
private fun SelectionMenuConfig.imageUrlsTitle(count: Int): String {
val template =
copyImageUrlPluralTemplates
.takeIf { it.isNotEmpty() }
?.split(PLURAL_SEPARATOR)
?.let { templates ->
val index = if (count <= 0) 0 else if (count <= 100) count else ((count - 1) % 100) + 1
templates.getOrNull(index)
}
?: if (count == 1) {
copyImageUrlLabel.ifEmpty { "Copy Image URL" }
} else {
copyImageUrlsLabel.ifEmpty { "Copy {count} Image URLs" }
}
return template.replace("{count}", count.toString())
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a big blocker:

Assume we have 101 images (a bit irrational but in case) then the index would calculate to (101 - 1 ) % 100 + 1 = 1

and we would get Copy Image Url (even though we have 101 images).

I think the safest and good enough solution would be to resolve for "other" in case of count > 100

If you'd like to pursuit the "other if greater than 100" solution, please document it

Menu.NONE,
MENU_ITEM_COPY_MARKDOWN,
Menu.NONE,
selectionMenuConfig.copyAsMarkdownLabel.ifEmpty { "Copy as Markdown" },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the previous iteration I asked for these fallbacks to be removed. Keeping the ifEmpty blocks gives us two sources of truth: whatever JS resolves the defaults to, and whatever's written here. If someone updates the wording on the JS side, the default values drift - and a reader of the native code still thinks the label is "Copy as Markdown" when it's actually something else.

Let's strip the sentinels from all the native render paths. The only ?: "" worth keeping is in parseSelectionMenuConfig (MarkdownViewManagerUtils.kt), which exists purely because ReadableMap.getString() returns String?. Everything else should trust the JS-resolved value.

Sites to clean up: SelectionActionMode.kt:56,58,101, TableContainerView.kt:…, MathContainerView.kt:…, plus iOS/macOS equivalents.

NSString *packed = config.copyImageUrlPluralTemplates;
if (packed.length > 0) {
NSArray<NSString *> *templates = [packed componentsSeparatedByString:@"\x1f"];
NSUInteger index = count == 0 ? 0 : (count <= 100 ? count : ((count - 1) % 100) + 1);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as for android

copyImageUrlsLabel: string;
// Precomputed plural templates (count 0..100) joined by U+001F. Empty when no
// pluralLabels are set; native then uses copyImageUrlLabel/copyImageUrlsLabel.
copyImageUrlPluralTemplates: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this could just be ReadonlyArray<string> (same change at EnrichedMarkdownNativeComponent.ts:239). Codegen already produces std::vector<std::string> for that - see mentionIndicators in EnrichedMarkdownTextInputNativeComponent.ts:236 → Props.h:2798. Drops the \u001F separator constant, the templates.join(...) in buildPluralTemplates, and the componentsSeparatedByString: / split(PLURAL_SEPARATOR) in native consumers (SelectionActionMode.kt:50, EditMenuUtils.h:30). Same wire-format cost, strictly more honest types

Comment on lines +28 to +30
* Each value is a template where the `{count}` token is replaced with the
* number of selected images. Only `other` is required by CLDR; any category
* left `undefined` falls back to `other`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While you're fixing other stuff its worth tagging that this is technically speaking incorrect as value for 'one' fallbacks to the default label (non-plular). It would be lovely if you could fix that along the way

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Context menu items are not localized

2 participants