feat: localize selection menu labels via selectionMenuLabels prop#415
feat: localize selection menu labels via selectionMenuLabels prop#415marcellov7 wants to merge 8 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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:
-
Create a callback function
f(count) => stringthat 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. -
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).
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
66bfca4 to
acedcad
Compare
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.
|
Thanks for the thorough review @eszlamczyk! All points addressed — pushed as separate commits on top: Structural
Native prop-update
Nits
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
left a comment
There was a problem hiding this comment.
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)
| // Defaults shown in Italian to demonstrate localization. | ||
| copyLabel: 'Copia', | ||
| copyAsMarkdownLabel: 'Copia come Markdown', | ||
| copyImageUrlLabel: 'Copia URL immagine', | ||
| copyImageUrlsLabel: 'Copia {count} URL immagini', |
There was a problem hiding this comment.
Praise: good idea to default it to other language!
| | Type | Default Value | Platform | | ||
| | -------------------- | ---------------------------------------------- | -------- | | ||
| | `SelectionMenuConfig` | `{ copyAsMarkdown: true, copyImageUrl: true }` | iOS, Android, macOS | | ||
| | `SelectionMenuConfig` | `{ copyAsMarkdown: { enabled: true }, copyImageUrl: { enabled: true } }` | iOS, Android, macOS | |
There was a problem hiding this comment.
Nit: looks like this default value does not match actual default value, lets replace it with
| | `SelectionMenuConfig` | `{ copyAsMarkdown: { enabled: true }, copyImageUrl: { enabled: true } }` | iOS, Android, macOS | | |
| | `SelectionMenuConfig` | `{} (see shape below for per-field defaults)` | iOS, Android, macOS | |
| two?: string; | ||
| few?: string; | ||
| many?: string; | ||
| other?: string; |
There was a problem hiding this comment.
Acording to what docs state, what was agreed upon, I believe other should be the only one required
| other?: string; | |
| other: string; |
so we don't fall back to english
| /** | ||
| * 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()) | ||
| } | ||
|
|
There was a problem hiding this comment.
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" }, |
There was a problem hiding this comment.
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); |
| 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; |
There was a problem hiding this comment.
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
| * 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`. |
There was a problem hiding this comment.
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
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
selectionMenuLabelsprop onEnrichedMarkdownTextso the labels can be overridden from JS (option 2 in the issue), typically wired to the app's i18n:Any label left
undefinedkeeps its English default, so the change is fully backward compatible.How it works
The labels are folded into the existing
selectionMenuConfigcodegen struct (normalized JS-side, where an empty string means "use the native default"), so no new native prop plumbing is introduced.SelectionMenuLabelstype + prop; normalization innative/EnrichedMarkdownText.tsx; struct fields added to both text/github codegen specs.MarkdownViewManagerUtils, applied inSelectionActionModeand threaded to the table and math copy menus.ENRMSelectionMenuConfig(strong owners on the view) and applied inEditMenuUtils(+macOS) and theTableContainerView/ENRMMathContainerViewmenus.Scope
Covers
EnrichedMarkdownText(commonmark + github flavor), including the table and math block copy menus listed in #198.EnrichedMarkdownTextInputkeeps only its visibility config for now (the Format submenu labels could be a follow-up).Notes for reviewers
docs/COPY_OPTIONS.md(new "Localizing Menu Labels" section) anddocs/API_REFERENCE.md.yarn typecheckandeslintpass 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.🤖 Generated with Claude Code