Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle context menu format bold and italic #556

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

bernhardoj
Copy link
Contributor

Details

Handle the context menu Format command for Bold and Italic.

Related Issues

Expensify/App#53145

Manual Tests

  1. Run the web example app on Safari web/mWeb
  2. Type anything on the text input
  3. Select any of the text
  4. Right-click/long-press and choose Format context menu option
  5. Choose Bold or Italic
  6. Verify it wraps the selected text with the selected markdown (* for Bold, _ for Italic)
ios.mweb.mp4
web.mp4

Linked PRs

@tomekzaw
Copy link
Collaborator

tomekzaw commented Dec 4, 2024

Hi @bernhardoj, thanks for submitting this PR. I'm okay with the changes.

However, this PR only implements Format menu on web. I believe the same feature is missing on native iOS. Are there any plans to implement this missing feature also in native iOS?

@Skalakid and @BartoszGrajdek Could you please review and test this PR? Thanks in advance.

@bernhardoj
Copy link
Contributor Author

I don't see the Format option on iOS native. I think it's only available on the web.

ios.2.mp4

@tomekzaw
Copy link
Collaborator

tomekzaw commented Dec 5, 2024

@bernhardoj You're right, if there's no menu on native iOS then we don't need to add one right now.

In that case I think we can proceed with web-only implementation.

Comment on lines 255 to 256
default:
markdown = '';
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this state is unreachable so we could just throw an error here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is it better if I add underline as the default value?

switch (formatCommand) {
  case 'formatBold':
    formatType = 'bold';
    break;
  case 'formatItalic':
    formatType = 'italic';
    break;
  default:
    formatType = 'underline';
    break;
}

@Skalakid
Copy link
Collaborator

Skalakid commented Dec 5, 2024

Hello @bernhardoj, thank you for your PR!

Yesterday, we merged support for adding custom parsers to the Live Markdown Input - PR. It's a pretty big change, and it extends the use cases for our library. Because of that, I think we should slightly update your solution in this PR. Now you've implemented adding static syntaxes in the format function, like: * for bold and _ for italics. However, because now users can use other custom parsers together with the Live Markdown library, it would be perfect to allow them to choose other custom syntaxes for their styles. They can implement for example a parser that transforms following /sample text/ string into bold and use / instead of *.
To solve this problem, how about adding the formatSelection prop to the Live Markdown Input, so users can handle this case on their own? In the case of Expensify, it will look something like this:

formatSelection={(selectedText: string, style: 'bold' | 'italic' | 'underline') => style === 'bold' ? `*${selectedText}*` : style === 'italic' ? `_${selectedText}_` : selectedText}

What do you think about it?

@bernhardoj
Copy link
Contributor Author

That sounds good, I'll update it tomorrow!

@bernhardoj
Copy link
Contributor Author

@Skalakid I need help with defining the type for formatSelection. Since this is only for web, is it fine to add the type to MarkdownTextInputProps on both MarkdownTextInput.tsx and MarkdownTextInput.web.tsx files?

interface MarkdownTextInputProps extends TextInputProps, InlineImagesInputProps {
markdownStyle?: PartialMarkdownStyle;
parser: (value: string) => MarkdownRange[];
}

interface MarkdownTextInputProps extends TextInputProps, InlineImagesInputProps {
markdownStyle?: MarkdownStyle;
parser: (text: string) => MarkdownRange[];
onClick?: (e: MouseEvent<HTMLDivElement>) => void;
dir?: string;
disabled?: boolean;
}

formatSelection?: (selectedText: string, formatType: FormatType) => string;

@tomekzaw
Copy link
Collaborator

tomekzaw commented Dec 7, 2024

@bernhardoj Just FYI, I was able to add "Format" button in context menu on native iOS with the following code snippet:

// inside UITextView
UIMenuItem *formatItem = [[UIMenuItem alloc] initWithTitle:@"Format" action:@selector(command:textView:)];
UIMenuController.sharedMenuController.menuItems = @[formatItem];
- (void)command:(nonnull UICommand *)command textView:(nonnull UITextView *)textView
{
  // Do something
}

@bernhardoj
Copy link
Contributor Author

Should I add it here?

+ (void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class cls = [self class];
SEL originalSelector = @selector(textDidChange);
SEL swizzledSelector = @selector(markdown_textDidChange);
Method originalMethod = class_getInstanceMethod(cls, originalSelector);
Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector);
method_exchangeImplementations(originalMethod, swizzledMethod);
});
}

Also, I get this error when trying to run the iOS example app.

error export CLANG_WARN_DOCUMENTATION_COMMENTS=YES
error export CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER=NO
error export GCC_WARN_UNDECLARED_SELECTOR=YES
error export VALIDATE_PRODUCT=NO
error Failed to build ios project. "xcodebuild" exited with error code '65'. To debug build logs further, consider building your app with Xcode.app, by opening 'LiveMarkdownExample.xcworkspace'.
npm ERR! Lifecycle script ios failed with error:
npm ERR! Error: command failed
npm ERR! in workspace: @expensify/[email protected]
npm ERR! at location: /Users/bernhardoj/Documents/dev/freelance/react-native-live-markdown/example

@tomekzaw
Copy link
Collaborator

tomekzaw commented Dec 9, 2024

@bernhardoj I think we can implement native iOS in a separate PR. Once we merge #520 it should be a bit simpler to implement that. I think we'll need to send a RN event from the native side to the JS side when bold or italic button in context menu is pressed so the function can modify the React state of the component in order to modify the current value by adding/removing some characters when toggling bold/italic.

@tomekzaw
Copy link
Collaborator

tomekzaw commented Dec 9, 2024

@bernhardoj Also, I'm curious what's the expected behavior when *foo* is selected and "Bold" button is pressed. I would assume that formatting should be removed then, i.e. the asterisks should be removed.

@bernhardoj
Copy link
Contributor Author

I would assume that formatting should be removed then, i.e. the asterisks should be removed.

I tested using the contenteditable and it indeed removes the bold when we select the bold format again. Also, Safari nicely shows that the Bold format is selected (I noticed that it shows the Bold as selected as long as the selected text has a bold font-weight style).
image

I wonder how we are going to handle the "unwrap" since we now allow a custom parser and handle cases like only selecting part of the bolded text (*b|ol|d*) or a combination of bolded and non-bolded texts (*bo|ld* nor|mal).

Also, the header markdown shows Bold format as selected.
image

@Skalakid
Copy link
Collaborator

Skalakid commented Dec 9, 2024

Since this is only for web, is it fine to add the type to MarkdownTextInputProps on both MarkdownTextInput.tsx and MarkdownTextInput.web.tsx files?

@bernhardoj I think, yes. You can add it to both files. Thanks to that there won't be any TS errors in the app

@bernhardoj bernhardoj force-pushed the feat/handle-ctx-menu-format branch from c732cd4 to 292f1d8 Compare December 10, 2024 03:19
@bernhardoj
Copy link
Contributor Author

@Skalakid updated.

Also, I'm curious what's the expected behavior when foo is selected and "Bold" button is pressed. I would assume that formatting should be removed then, i.e. the asterisks should be removed.

I want to see how WhatsApp handles this case, but WA doesn't handle the format command on the web at all.

src/MarkdownTextInput.web.tsx Outdated Show resolved Hide resolved
@bernhardoj
Copy link
Contributor Author

@Skalakid hi, how's the review going?

@Skalakid
Copy link
Collaborator

Skalakid commented Dec 17, 2024

Hi, I reviewed the PR, and the code looks good to me, however, together with @tomekzaw, we noticed that this issue is more complex than we thought. The current implementation allows to modify the selected text, however, there are many other cases connected to removing style from the text, that should be handled. Here is a list of them:

  • Removing style when all the text between style syntaxes is selected:
    *[world]* -> [world]
    In this case the "*" syntaxes from outside the text selection, should be removed. In current implementation we are only returning the currently selected text so it will be hard to remove them

  • Removing style from the middle of the text:
    *hello [world] test* -> *hello* [world] *test*
    The same situation here, with by only having the selected text (world) it would be hard to split the styled text in the middle

I think we should reconsider the current solution and create simple to use API for react-native-live-markdown library that will allow to implement style formatting in both directions (adding and removing the style). How about adding more props to the formatSelection function like: selection start, selection end, active markdown ranges in the current selection. What's your thought on this? Do you have any other solution for that?

@Skalakid
Copy link
Collaborator

On the other hand, I checked now, and WhatsApp doesn't support removing the styles in the format option.

ScreenRecording_12-17-2024.14-13-21_1.MP4

So I think we can merge it as it is and enhance the formatSelection function in the future

Comment on lines 258 to 261
default:
formatType = 'underline';
break;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why underline is set as default?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I actually asked about this here. Since we only handle the bold, italic, and underline command, I default it to underline. Should I use the "throw" approach?

Comment on lines 250 to 261
let formatType: FormatType;
switch (formatCommand) {
case 'formatBold':
formatType = 'bold';
break;
case 'formatItalic':
formatType = 'italic';
break;
default:
formatType = 'underline';
break;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's move this switch elsewhere, for example to blockUtils.ts

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

break;
}

const beforeSelectedText = parsedText.slice(0, contentSelection.current.start);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const beforeSelectedText = parsedText.slice(0, contentSelection.current.start);
const prefix = parsedText.slice(0, contentSelection.current.start);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

const selectedText = parsedText.slice(contentSelection.current.start, contentSelection.current.end);
const formattedText = formatSelection?.(selectedText, formatType) ?? selectedText;
const formattedTextDiffLength = formattedText.length - selectedText.length;
const afterSelectedText = parsedText.slice(contentSelection.current.end);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const afterSelectedText = parsedText.slice(contentSelection.current.end);
const suffix = parsedText.slice(contentSelection.current.end);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

const beforeSelectedText = parsedText.slice(0, contentSelection.current.start);
const selectedText = parsedText.slice(contentSelection.current.start, contentSelection.current.end);
const formattedText = formatSelection?.(selectedText, formatType) ?? selectedText;
const formattedTextDiffLength = formattedText.length - selectedText.length;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const formattedTextDiffLength = formattedText.length - selectedText.length;
const diffLength = formattedText.length - selectedText.length;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done


const beforeSelectedText = parsedText.slice(0, contentSelection.current.start);
const selectedText = parsedText.slice(contentSelection.current.start, contentSelection.current.end);
const formattedText = formatSelection?.(selectedText, formatType) ?? selectedText;
Copy link
Collaborator

Choose a reason for hiding this comment

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

We can check if the returned text actually changed after running formatSelection, to avoid unnecessary text parsing

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment on lines 243 to 248
if (!contentSelection.current) {
return {
text: '',
cursorPosition: 0,
};
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

How about early returning also when contentSelection length between start and end is less than 1?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@blimpich
Copy link

@Skalakid @tomekzaw friendly bump on re-reviewing this PR 🙂

@mananjadhav
Copy link

@Skalakid @tomekzaw quick bump on this one.

@blimpich blimpich requested review from tomekzaw and Skalakid January 2, 2025 19:02
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.

5 participants