| author | |
| committer | |
| log | 9159b808d35a88e74fc91e11070f3270cdb321f9 |
| tree | e18eed34f65a9da49a02861e81e563164193f5b6 |
| parent | 2a7a449cca3c7760b689a54b3aa02476a206dbc9 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>17 files changed, 411 insertions(+), 54 deletions(-)
apps/mobile/modules/t3-markdown-text/index.ts+2| ... | ... | @@ -21,6 +21,8 @@ export { |
| 21 | 21 | type MarkdownHighlightedToken, |
| 22 | 22 | } from "./src/SelectableMarkdownText"; |
| 23 | 23 | export type { |
| 24 | MarkdownFileContextMenu, | |
| 25 | MarkdownFileContextMenuAction, | |
| 24 | 26 | NativeMarkdownTextStyle, |
| 25 | 27 | SelectableMarkdownSkill, |
| 26 | 28 | SelectableMarkdownTextProps, |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm+92-9| ... | ... | @@ -198,6 +198,8 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 198 | 198 | BOOL _suppressSelectionChange; |
| 199 | 199 | NSMutableDictionary<NSString *, UIImage *> * _attachmentImages; |
| 200 | 200 | NSMutableSet<NSString *> * _pendingAttachmentUris; |
| 201 | UILongPressGestureRecognizer *_longPressGestureRecognizer; | |
| 202 | UITapGestureRecognizer *_pressGestureRecognizer; | |
| 201 | 203 | } |
| 202 | 204 | |
| 203 | 205 | + (ComponentDescriptorProvider)componentDescriptorProvider |
| ... | ... | @@ -223,21 +225,24 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 223 | 225 | _textView.textContainerInset = UIEdgeInsetsZero; |
| 224 | 226 | _textView.textContainer.lineFragmentPadding = 0; |
| 225 | 227 | _textView.delegate = self; |
| 228 | // Chat text supports selection and contextual actions, but not drag-and-drop. | |
| 229 | _textView.textDragInteraction.enabled = NO; | |
| 230 | _textView.linkTextAttributes = @{}; | |
| 226 | 231 | // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. |
| 227 | 232 | _textView.layoutManager.usesFontLeading = NO; |
| 228 | 233 | [self addSubview:_textView]; |
| 229 | 234 | |
| 230 | const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self | |
| 231 | action:@selector(handleLongPressIfNecessary:)]; | |
| 232 | longPressGestureRecognizer.delegate = self; | |
| 235 | _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self | |
| 236 | action:@selector(handleLongPressIfNecessary:)]; | |
| 237 | _longPressGestureRecognizer.delegate = self; | |
| 233 | 238 | |
| 234 | const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self | |
| 235 | action:@selector(handlePressIfNecessary:)]; | |
| 236 | pressGestureRecognizer.delegate = self; | |
| 237 | [pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer]; | |
| 239 | _pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self | |
| 240 | action:@selector(handlePressIfNecessary:)]; | |
| 241 | _pressGestureRecognizer.delegate = self; | |
| 242 | [_pressGestureRecognizer requireGestureRecognizerToFail:_longPressGestureRecognizer]; | |
| 238 | 243 | |
| 239 | [_textView addGestureRecognizer:pressGestureRecognizer]; | |
| 240 | [_textView addGestureRecognizer:longPressGestureRecognizer]; | |
| 244 | [_textView addGestureRecognizer:_pressGestureRecognizer]; | |
| 245 | [_textView addGestureRecognizer:_longPressGestureRecognizer]; | |
| 241 | 246 | } |
| 242 | 247 | |
| 243 | 248 | return self; |
| ... | ... | @@ -312,6 +317,26 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 312 | 317 | convertedAttrString, |
| 313 | 318 | _state->getData().attachmentRanges, |
| 314 | 319 | _attachmentImages); |
| 320 | NSUInteger runLocation = 0; | |
| 321 | for (UIView *child in self.subviews) { | |
| 322 | if (![child isKindOfClass:[T3MarkdownTextRun class]]) { | |
| 323 | continue; | |
| 324 | } | |
| 325 | ||
| 326 | T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; | |
| 327 | const NSRange runRange = NSMakeRange(runLocation, textChild.text.length); | |
| 328 | runLocation = NSMaxRange(runRange); | |
| 329 | if (![textChild hasContextMenu] || runRange.length == 0 || | |
| 330 | NSMaxRange(runRange) > convertedAttrString.length) { | |
| 331 | continue; | |
| 332 | } | |
| 333 | ||
| 334 | NSURL *link = [NSURL URLWithString: | |
| 335 | [NSString stringWithFormat:@"t3-markdown-run://%ld", (long)textChild.tag]]; | |
| 336 | if (link != nil) { | |
| 337 | [convertedAttrString addAttribute:NSLinkAttributeName value:link range:runRange]; | |
| 338 | } | |
| 339 | } | |
| 315 | 340 | [self loadAttachmentImages:_state->getData().attachmentRanges]; |
| 316 | 341 | |
| 317 | 342 | // Setting attributedText clears any active text selection, and re-assigning |
| ... | ... | @@ -484,6 +509,18 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 484 | 509 | return YES; |
| 485 | 510 | } |
| 486 | 511 | |
| 512 | - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer | |
| 513 | { | |
| 514 | if (gestureRecognizer != _longPressGestureRecognizer && | |
| 515 | gestureRecognizer != _pressGestureRecognizer) { | |
| 516 | return YES; | |
| 517 | } | |
| 518 | ||
| 519 | const auto location = [self getLocationOfPress:gestureRecognizer]; | |
| 520 | const auto child = [self getTouchChild:location]; | |
| 521 | return ![child hasContextMenu]; | |
| 522 | } | |
| 523 | ||
| 487 | 524 | - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch |
| 488 | 525 | { |
| 489 | 526 | return YES; |
| ... | ... | @@ -507,6 +544,24 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 507 | 544 | |
| 508 | 545 | // MARK: - Touch handling |
| 509 | 546 | |
| 547 | - (nullable T3MarkdownTextRun *)childForCharacterRange:(NSRange)characterRange | |
| 548 | { | |
| 549 | NSUInteger location = 0; | |
| 550 | for (UIView *child in self.subviews) { | |
| 551 | if (![child isKindOfClass:[T3MarkdownTextRun class]]) { | |
| 552 | continue; | |
| 553 | } | |
| 554 | ||
| 555 | T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; | |
| 556 | const NSRange range = NSMakeRange(location, textChild.text.length); | |
| 557 | if (NSIntersectionRange(range, characterRange).length > 0) { | |
| 558 | return textChild; | |
| 559 | } | |
| 560 | location = NSMaxRange(range); | |
| 561 | } | |
| 562 | return nil; | |
| 563 | } | |
| 564 | ||
| 510 | 565 | - (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender |
| 511 | 566 | { |
| 512 | 567 | return [sender locationInView:_textView]; |
| ... | ... | @@ -550,6 +605,10 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 550 | 605 | |
| 551 | 606 | - (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender |
| 552 | 607 | { |
| 608 | if (sender.state != UIGestureRecognizerStateBegan) { | |
| 609 | return; | |
| 610 | } | |
| 611 | ||
| 553 | 612 | const auto location = [self getLocationOfPress:sender]; |
| 554 | 613 | const auto child = [self getTouchChild:location]; |
| 555 | 614 | |
| ... | ... | @@ -560,6 +619,30 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 560 | 619 | |
| 561 | 620 | // MARK: - UITextViewDelegate |
| 562 | 621 | |
| 622 | - (nullable UIAction *)textView:(UITextView *)textView | |
| 623 | primaryActionForTextItem:(UITextItem *)textItem | |
| 624 | defaultAction:(UIAction *)defaultAction API_AVAILABLE(ios(17.0)) | |
| 625 | { | |
| 626 | T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; | |
| 627 | if (![child hasContextMenu]) { | |
| 628 | return defaultAction; | |
| 629 | } | |
| 630 | ||
| 631 | __weak T3MarkdownTextRun *weakChild = child; | |
| 632 | return [UIAction actionWithHandler:^(__kindof UIAction *action) { | |
| 633 | [weakChild onPress]; | |
| 634 | }]; | |
| 635 | } | |
| 636 | ||
| 637 | - (nullable UITextItemMenuConfiguration *)textView:(UITextView *)textView | |
| 638 | menuConfigurationForTextItem:(UITextItem *)textItem | |
| 639 | defaultMenu:(UIMenu *)defaultMenu API_AVAILABLE(ios(17.0)) | |
| 640 | { | |
| 641 | T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; | |
| 642 | UIMenu *menu = [child contextMenu]; | |
| 643 | return [UITextItemMenuConfiguration configurationWithMenu:menu ?: defaultMenu]; | |
| 644 | } | |
| 645 | ||
| 563 | 646 | - (void)textViewDidChangeSelection:(UITextView *)textView |
| 564 | 647 | { |
| 565 | 648 | if (_suppressSelectionChange) { |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h+3| ... | ... | @@ -13,6 +13,9 @@ NS_ASSUME_NONNULL_BEGIN |
| 13 | 13 | |
| 14 | 14 | @property (nonatomic, copy, nullable) NSString *text; |
| 15 | 15 | |
| 16 | - (nullable UIMenu *)contextMenu; | |
| 17 | - (BOOL)hasContextMenu; | |
| 18 | - (void)onContextMenuAction:(NSString *)actionIdentifier; | |
| 16 | 19 | - (void)onPress; |
| 17 | 20 | - (void)onLongPress; |
| 18 | 21 |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm+70-2| ... | ... | @@ -15,8 +15,7 @@ using namespace facebook::react; |
| 15 | 15 | |
| 16 | 16 | @implementation T3MarkdownTextRun { |
| 17 | 17 | NSString * _text; |
| 18 | RCTBubblingEventBlock _onPress; | |
| 19 | RCTBubblingEventBlock _onLongPress; | |
| 18 | NSString * _contextMenuConfig; | |
| 20 | 19 | } |
| 21 | 20 | |
| 22 | 21 | + (ComponentDescriptorProvider)componentDescriptorProvider |
| ... | ... | @@ -43,9 +42,78 @@ using namespace facebook::react; |
| 43 | 42 | _text = text; |
| 44 | 43 | } |
| 45 | 44 | |
| 45 | if (newViewProps.contextMenuConfig != oldViewProps.contextMenuConfig) { | |
| 46 | _contextMenuConfig = [NSString stringWithUTF8String:newViewProps.contextMenuConfig.c_str()]; | |
| 47 | } | |
| 48 | ||
| 46 | 49 | [super updateProps:props oldProps:oldProps]; |
| 47 | 50 | } |
| 48 | 51 | |
| 52 | - (BOOL)hasContextMenu | |
| 53 | { | |
| 54 | return _contextMenuConfig.length > 0; | |
| 55 | } | |
| 56 | ||
| 57 | - (nullable UIMenu *)contextMenu | |
| 58 | { | |
| 59 | if (_contextMenuConfig.length == 0) { | |
| 60 | return nil; | |
| 61 | } | |
| 62 | ||
| 63 | NSData *data = [_contextMenuConfig dataUsingEncoding:NSUTF8StringEncoding]; | |
| 64 | NSDictionary *config = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; | |
| 65 | if (![config isKindOfClass:[NSDictionary class]]) { | |
| 66 | return nil; | |
| 67 | } | |
| 68 | ||
| 69 | NSArray *actionConfigs = config[@"actions"]; | |
| 70 | if (![actionConfigs isKindOfClass:[NSArray class]] || actionConfigs.count == 0) { | |
| 71 | return nil; | |
| 72 | } | |
| 73 | ||
| 74 | NSMutableArray<UIMenuElement *> *actions = [NSMutableArray arrayWithCapacity:actionConfigs.count]; | |
| 75 | __weak T3MarkdownTextRun *weakSelf = self; | |
| 76 | for (NSDictionary *actionConfig in actionConfigs) { | |
| 77 | if (![actionConfig isKindOfClass:[NSDictionary class]]) { | |
| 78 | continue; | |
| 79 | } | |
| 80 | NSString *actionIdentifier = actionConfig[@"id"]; | |
| 81 | NSString *title = actionConfig[@"title"]; | |
| 82 | if (![actionIdentifier isKindOfClass:[NSString class]] || | |
| 83 | ![title isKindOfClass:[NSString class]]) { | |
| 84 | continue; | |
| 85 | } | |
| 86 | ||
| 87 | UIAction *action = [UIAction actionWithTitle:title | |
| 88 | image:nil | |
| 89 | identifier:actionIdentifier | |
| 90 | handler:^(__kindof UIAction *selectedAction) { | |
| 91 | [weakSelf onContextMenuAction:selectedAction.identifier]; | |
| 92 | }]; | |
| 93 | if ([actionConfig[@"disabled"] boolValue]) { | |
| 94 | action.attributes = UIMenuElementAttributesDisabled; | |
| 95 | } | |
| 96 | [actions addObject:action]; | |
| 97 | } | |
| 98 | ||
| 99 | if (actions.count == 0) { | |
| 100 | return nil; | |
| 101 | } | |
| 102 | NSString *title = [config[@"title"] isKindOfClass:[NSString class]] ? config[@"title"] : @""; | |
| 103 | return [UIMenu menuWithTitle:title children:actions]; | |
| 104 | } | |
| 105 | ||
| 106 | - (void)onContextMenuAction:(NSString *)actionIdentifier | |
| 107 | { | |
| 108 | if (_eventEmitter != nullptr) { | |
| 109 | std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter) | |
| 110 | ->onContextMenuAction(facebook::react::T3MarkdownTextRunEventEmitter::OnContextMenuAction{ | |
| 111 | static_cast<int>(self.tag), | |
| 112 | actionIdentifier.UTF8String, | |
| 113 | }); | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 49 | 117 | - (void)onPress { |
| 50 | 118 | if (_eventEmitter != nullptr) { |
| 51 | 119 | std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter) |
apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx+6| ... | ... | @@ -24,8 +24,14 @@ export type SelectionChangeEvent = { |
| 24 | 24 | nativeEvent: { target: number; start: number; end: number }; |
| 25 | 25 | }; |
| 26 | 26 | |
| 27 | export type ContextMenuActionEvent = { | |
| 28 | nativeEvent: { target: number; actionIdentifier: string }; | |
| 29 | }; | |
| 30 | ||
| 27 | 31 | export type MarkdownTextPrimitiveProps = TextProps & { |
| 28 | 32 | uiTextView?: boolean; |
| 33 | contextMenuConfig?: string; | |
| 34 | onContextMenuAction?: (event: ContextMenuActionEvent) => void; | |
| 29 | 35 | /** |
| 30 | 36 | * Fired when the native text selection changes. Only fires on iOS when |
| 31 | 37 | * `uiTextView` is true. Note: fires on every selection-edge adjustment |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+23-1| ... | ... | @@ -1,9 +1,23 @@ |
| 1 | import { createContext, useContext } from "react"; | |
| 1 | 2 | import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; |
| 2 | 3 | |
| 3 | 4 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; |
| 4 | 5 | import { markdownFileIconSource } from "./markdownFileIcons"; |
| 5 | 6 | import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; |
| 6 | import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; | |
| 7 | import type { | |
| 8 | MarkdownFileContextMenu, | |
| 9 | NativeMarkdownTextStyle, | |
| 10 | } from "./SelectableMarkdownText.types"; | |
| 11 | ||
| 12 | export interface MarkdownFileContextMenuHandlers { | |
| 13 | readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; | |
| 14 | readonly onFileContextMenuAction: (href: string, actionId: string) => void; | |
| 15 | } | |
| 16 | ||
| 17 | /** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */ | |
| 18 | export const MarkdownFileContextMenuContext = createContext<MarkdownFileContextMenuHandlers | null>( | |
| 19 | null, | |
| 20 | ); | |
| 7 | 21 | |
| 8 | 22 | const EXTERNAL_LINK_PREFIX = "◉ "; |
| 9 | 23 | const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; |
| ... | ... | @@ -139,6 +153,7 @@ export function NativeMarkdownSelectableText(props: { |
| 139 | 153 | readonly onLinkPress?: (href: string) => void; |
| 140 | 154 | }) { |
| 141 | 155 | const colorScheme = useColorScheme(); |
| 156 | const menu = useContext(MarkdownFileContextMenuContext); | |
| 142 | 157 | const occurrences = new Map<string, number>(); |
| 143 | 158 | const prefixedExternalLinks = new Set<string>(); |
| 144 | 159 | const keyedRuns = props.runs.map((run) => { |
| ... | ... | @@ -195,6 +210,7 @@ export function NativeMarkdownSelectableText(props: { |
| 195 | 210 | > |
| 196 | 211 | {keyedRuns.map(({ key, run, text }) => { |
| 197 | 212 | const href = run.href; |
| 213 | const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; | |
| 198 | 214 | return ( |
| 199 | 215 | <MarkdownTextPrimitive |
| 200 | 216 | key={key} |
| ... | ... | @@ -205,6 +221,7 @@ export function NativeMarkdownSelectableText(props: { |
| 205 | 221 | ? "t3-skill:sf:cube" |
| 206 | 222 | : undefined |
| 207 | 223 | } |
| 224 | contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined} | |
| 208 | 225 | style={runStyle(run, props.textStyle)} |
| 209 | 226 | onPress={ |
| 210 | 227 | href |
| ... | ... | @@ -217,6 +234,11 @@ export function NativeMarkdownSelectableText(props: { |
| 217 | 234 | } |
| 218 | 235 | : undefined |
| 219 | 236 | } |
| 237 | onContextMenuAction={ | |
| 238 | contextMenu && href && menu | |
| 239 | ? (event) => menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier) | |
| 240 | : undefined | |
| 241 | } | |
| 220 | 242 | > |
| 221 | 243 | {text} |
| 222 | 244 | </MarkdownTextPrimitive> |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx+46-30| ... | ... | @@ -9,7 +9,11 @@ import { |
| 9 | 9 | nativeMarkdownWithPreservedSoftBreaks, |
| 10 | 10 | } from "./nativeMarkdownText"; |
| 11 | 11 | import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; |
| 12 | import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; | |
| 12 | import { | |
| 13 | MarkdownFileContextMenuContext, | |
| 14 | NativeMarkdownSelectableText, | |
| 15 | type MarkdownFileContextMenuHandlers, | |
| 16 | } from "./NativeMarkdownSelectableText.ios"; | |
| 13 | 17 | import type { |
| 14 | 18 | SelectableMarkdownSkill, |
| 15 | 19 | SelectableMarkdownTextProps, |
| ... | ... | @@ -38,6 +42,8 @@ export function SelectableMarkdownText({ |
| 38 | 42 | highlightCode, |
| 39 | 43 | preserveSoftBreaks = false, |
| 40 | 44 | onLinkPress, |
| 45 | fileContextMenu, | |
| 46 | onFileContextMenuAction, | |
| 41 | 47 | renderImage, |
| 42 | 48 | marginTop = 0, |
| 43 | 49 | marginBottom = 0, |
| ... | ... | @@ -61,41 +67,51 @@ export function SelectableMarkdownText({ |
| 61 | 67 | ); |
| 62 | 68 | }, [markdown, preserveSoftBreaks, skills]); |
| 63 | 69 | |
| 70 | const fileContextMenuHandlers = useMemo<MarkdownFileContextMenuHandlers | null>( | |
| 71 | () => | |
| 72 | fileContextMenu && onFileContextMenuAction | |
| 73 | ? { fileContextMenu, onFileContextMenuAction } | |
| 74 | : null, | |
| 75 | [fileContextMenu, onFileContextMenuAction], | |
| 76 | ); | |
| 77 | ||
| 64 | 78 | return ( |
| 65 | 79 | <MarkdownImageRendererContext.Provider value={renderImage ?? null}> |
| 66 | {/* A percentage width here creates a cyclic intrinsic measurement inside | |
| 80 | <MarkdownFileContextMenuContext.Provider value={fileContextMenuHandlers}> | |
| 81 | {/* A percentage width here creates a cyclic intrinsic measurement inside | |
| 67 | 82 | shrink-to-fit containers such as user-message bubbles. Yoga then gives |
| 68 | 83 | the native text node an unbounded second pass and the parent only clips |
| 69 | 84 | the resulting single-line width instead of reflowing it. */} |
| 70 | <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}> | |
| 71 | {chunks.map((chunk, index) => { | |
| 72 | const content = | |
| 73 | chunk.kind === "rich" ? ( | |
| 74 | <NativeMarkdownBlock | |
| 75 | node={chunk.node} | |
| 76 | skills={skills} | |
| 77 | textStyle={textStyle} | |
| 78 | highlightCode={highlightCode} | |
| 79 | onLinkPress={onLinkPress} | |
| 80 | /> | |
| 81 | ) : ( | |
| 82 | <NativeMarkdownSelectableText | |
| 83 | runs={chunk.runs} | |
| 84 | textStyle={textStyle} | |
| 85 | onLinkPress={onLinkPress} | |
| 86 | /> | |
| 87 | ); | |
| 85 | <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}> | |
| 86 | {chunks.map((chunk, index) => { | |
| 87 | const content = | |
| 88 | chunk.kind === "rich" ? ( | |
| 89 | <NativeMarkdownBlock | |
| 90 | node={chunk.node} | |
| 91 | skills={skills} | |
| 92 | textStyle={textStyle} | |
| 93 | highlightCode={highlightCode} | |
| 94 | onLinkPress={onLinkPress} | |
| 95 | /> | |
| 96 | ) : ( | |
| 97 | <NativeMarkdownSelectableText | |
| 98 | runs={chunk.runs} | |
| 99 | textStyle={textStyle} | |
| 100 | onLinkPress={onLinkPress} | |
| 101 | /> | |
| 102 | ); | |
| 88 | 103 | |
| 89 | return ( | |
| 90 | <View | |
| 91 | key={chunk.key} | |
| 92 | style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }} | |
| 93 | > | |
| 94 | {content} | |
| 95 | </View> | |
| 96 | ); | |
| 97 | })} | |
| 98 | </View> | |
| 104 | return ( | |
| 105 | <View | |
| 106 | key={chunk.key} | |
| 107 | style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }} | |
| 108 | > | |
| 109 | {content} | |
| 110 | </View> | |
| 111 | ); | |
| 112 | })} | |
| 113 | </View> | |
| 114 | </MarkdownFileContextMenuContext.Provider> | |
| 99 | 115 | </MarkdownImageRendererContext.Provider> |
| 100 | 116 | ); |
| 101 | 117 | } |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+13| ... | ... | @@ -50,6 +50,17 @@ export interface MarkdownImageRequest { |
| 50 | 50 | */ |
| 51 | 51 | export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; |
| 52 | 52 | |
| 53 | export interface MarkdownFileContextMenuAction { | |
| 54 | readonly id: string; | |
| 55 | readonly title: string; | |
| 56 | readonly disabled?: boolean; | |
| 57 | } | |
| 58 | ||
| 59 | export interface MarkdownFileContextMenu { | |
| 60 | readonly title?: string; | |
| 61 | readonly actions: ReadonlyArray<MarkdownFileContextMenuAction>; | |
| 62 | } | |
| 63 | ||
| 53 | 64 | export interface SelectableMarkdownTextProps { |
| 54 | 65 | readonly markdown: string; |
| 55 | 66 | readonly textStyle: NativeMarkdownTextStyle; |
| ... | ... | @@ -57,6 +68,8 @@ export interface SelectableMarkdownTextProps { |
| 57 | 68 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 58 | 69 | readonly preserveSoftBreaks?: boolean; |
| 59 | 70 | readonly onLinkPress?: (href: string) => void; |
| 71 | readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined; | |
| 72 | readonly onFileContextMenuAction?: (href: string, actionId: string) => void; | |
| 60 | 73 | readonly renderImage?: MarkdownImageRenderer; |
| 61 | 74 | readonly marginTop?: number; |
| 62 | 75 | readonly marginBottom?: number; |
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts+6| ... | ... | @@ -11,6 +11,10 @@ interface TargetedEvent { |
| 11 | 11 | target: Int32; |
| 12 | 12 | } |
| 13 | 13 | |
| 14 | interface ContextMenuActionEvent extends TargetedEvent { | |
| 15 | actionIdentifier: string; | |
| 16 | } | |
| 17 | ||
| 14 | 18 | type TextDecorationLine = "none" | "underline" | "line-through"; |
| 15 | 19 | |
| 16 | 20 | type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; |
| ... | ... | @@ -42,8 +46,10 @@ interface NativeProps extends ViewProps { |
| 42 | 46 | textDecorationColor?: ColorValue; |
| 43 | 47 | textAlign?: WithDefault<TextAlign, "auto">; |
| 44 | 48 | shadowRadius?: WithDefault<Float, 0>; |
| 49 | contextMenuConfig?: string; | |
| 45 | 50 | onPress?: BubblingEventHandler<TargetedEvent>; |
| 46 | 51 | onLongPress?: BubblingEventHandler<TargetedEvent>; |
| 52 | onContextMenuAction?: BubblingEventHandler<ContextMenuActionEvent>; | |
| 47 | 53 | } |
| 48 | 54 | |
| 49 | 55 | export default codegenNativeComponent<NativeProps>("T3MarkdownTextRun", { |
apps/mobile/src/features/files/filePath.test.ts+1| ... | ... | @@ -32,6 +32,7 @@ describe("resolveWorkspaceRelativeFilePath", () => { |
| 32 | 32 | it("rejects paths outside the workspace", () => { |
| 33 | 33 | expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull(); |
| 34 | 34 | expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull(); |
| 35 | expect(resolveWorkspaceRelativeFilePath("/repo", "/repo/../outside.txt")).toBeNull(); | |
| 35 | 36 | expect(resolveWorkspaceRelativeFilePath(null, "/repo/main.ts")).toBeNull(); |
| 36 | 37 | }); |
| 37 | 38 | }); |
apps/mobile/src/features/files/filePath.ts+6-1| ... | ... | @@ -88,7 +88,12 @@ export function resolveWorkspaceRelativeFilePath( |
| 88 | 88 | return null; |
| 89 | 89 | } |
| 90 | 90 | |
| 91 | return normalizeRelativePath(normalizedTarget.slice(normalizedRoot.length + 1)); | |
| 91 | const relativePath = normalizedTarget.slice(normalizedRoot.length + 1); | |
| 92 | // `/repo/../x` starts with the root but escapes it. | |
| 93 | if (relativePath.split("/").includes("..")) { | |
| 94 | return null; | |
| 95 | } | |
| 96 | return normalizeRelativePath(relativePath); | |
| 92 | 97 | } |
| 93 | 98 | |
| 94 | 99 | export function isVideoPreviewFile(path: string): boolean { |
apps/mobile/src/features/threads/ThreadFeed.tsx+44-10| ... | ... | @@ -91,6 +91,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; |
| 91 | 91 | import { |
| 92 | 92 | hasNativeSelectableMarkdownText, |
| 93 | 93 | SelectableMarkdownText, |
| 94 | type MarkdownFileContextMenu, | |
| 94 | 95 | type MarkdownImageRenderer, |
| 95 | 96 | type NativeMarkdownTextStyle, |
| 96 | 97 | type SelectableMarkdownSkill, |
| ... | ... | @@ -176,6 +177,7 @@ import { |
| 176 | 177 | resolveWorkspaceRelativeFilePath, |
| 177 | 178 | } from "../files/filePath"; |
| 178 | 179 | import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; |
| 180 | import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; | |
| 179 | 181 | |
| 180 | 182 | const WIDE_MARKDOWN_BLOCK_OPTIONS = { |
| 181 | 183 | // Native iOS blockquotes and adjacent selectable text are separate layout |
| ... | ... | @@ -872,10 +874,17 @@ function ArtifactTemplateCard(props: { |
| 872 | 874 | ); |
| 873 | 875 | } |
| 874 | 876 | |
| 877 | /** Tap opens a link; long-press on a native file chip shows its menu. Built once per feed. */ | |
| 878 | interface MarkdownLinkHandlers { | |
| 879 | readonly onLinkPress: (href: string) => void; | |
| 880 | readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; | |
| 881 | readonly onFileContextMenuAction: (href: string, actionId: string) => void; | |
| 882 | } | |
| 883 | ||
| 875 | 884 | const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { |
| 876 | 885 | readonly markdown: string; |
| 877 | 886 | readonly markdownStyles: MarkdownStyleSet; |
| 878 | readonly onLinkPress: (href: string) => void; | |
| 887 | readonly linkHandlers: MarkdownLinkHandlers; | |
| 879 | 888 | readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; |
| 880 | 889 | readonly renderImage: MarkdownImageRenderer; |
| 881 | 890 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined; |
| ... | ... | @@ -904,7 +913,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { |
| 904 | 913 | markdown={markdown} |
| 905 | 914 | skills={props.skills} |
| 906 | 915 | textStyle={props.markdownStyles.nativeTextStyle} |
| 907 | onLinkPress={props.onLinkPress} | |
| 916 | {...props.linkHandlers} | |
| 908 | 917 | renderImage={props.renderImage} |
| 909 | 918 | /> |
| 910 | 919 | ) : ( |
| ... | ... | @@ -1465,7 +1474,7 @@ function renderFeedEntry( |
| 1465 | 1474 | readonly onToggleTurnFold: (turnId: TurnId) => void; |
| 1466 | 1475 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 1467 | 1476 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; |
| 1468 | readonly onMarkdownLinkPress: (href: string) => void; | |
| 1477 | readonly markdownLinkHandlers: MarkdownLinkHandlers; | |
| 1469 | 1478 | readonly renderMarkdownImage: MarkdownImageRenderer; |
| 1470 | 1479 | readonly renderViewedImage: MarkdownImageRenderer; |
| 1471 | 1480 | readonly iconSubtleColor: string | import("react-native").ColorValue; |
| ... | ... | @@ -1573,7 +1582,7 @@ function renderFeedEntry( |
| 1573 | 1582 | markdownStyles={styles} |
| 1574 | 1583 | reviewCommentColors={props.reviewCommentColors} |
| 1575 | 1584 | skills={props.skills} |
| 1576 | onLinkPress={props.onMarkdownLinkPress} | |
| 1585 | linkHandlers={props.markdownLinkHandlers} | |
| 1577 | 1586 | renderImage={props.renderMarkdownImage} |
| 1578 | 1587 | /> |
| 1579 | 1588 | ) : null} |
| ... | ... | @@ -1634,7 +1643,7 @@ function renderFeedEntry( |
| 1634 | 1643 | <AssistantMarkdownContent |
| 1635 | 1644 | markdown={renderedText} |
| 1636 | 1645 | markdownStyles={styles} |
| 1637 | onLinkPress={props.onMarkdownLinkPress} | |
| 1646 | linkHandlers={props.markdownLinkHandlers} | |
| 1638 | 1647 | onUseArtifactTemplate={props.onUseArtifactTemplate} |
| 1639 | 1648 | renderImage={props.renderMarkdownImage} |
| 1640 | 1649 | skills={props.skills} |
| ... | ... | @@ -1704,7 +1713,7 @@ function UserMessageContent(props: { |
| 1704 | 1713 | readonly markdownStyles: MarkdownStyleSet; |
| 1705 | 1714 | readonly reviewCommentColors: ReviewCommentColors; |
| 1706 | 1715 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 1707 | readonly onLinkPress: (href: string) => void; | |
| 1716 | readonly linkHandlers: MarkdownLinkHandlers; | |
| 1708 | 1717 | readonly renderImage: MarkdownImageRenderer; |
| 1709 | 1718 | }) { |
| 1710 | 1719 | const segments = parseReviewCommentMessageSegments(props.text); |
| ... | ... | @@ -1717,7 +1726,7 @@ function UserMessageContent(props: { |
| 1717 | 1726 | skills={props.skills} |
| 1718 | 1727 | textStyle={props.markdownStyles.nativeTextStyle} |
| 1719 | 1728 | preserveSoftBreaks |
| 1720 | onLinkPress={props.onLinkPress} | |
| 1729 | {...props.linkHandlers} | |
| 1721 | 1730 | renderImage={props.renderImage} |
| 1722 | 1731 | /> |
| 1723 | 1732 | ); |
| ... | ... | @@ -1759,7 +1768,7 @@ function UserMessageContent(props: { |
| 1759 | 1768 | skills={props.skills} |
| 1760 | 1769 | textStyle={props.markdownStyles.nativeTextStyle} |
| 1761 | 1770 | preserveSoftBreaks |
| 1762 | onLinkPress={props.onLinkPress} | |
| 1771 | {...props.linkHandlers} | |
| 1763 | 1772 | renderImage={props.renderImage} |
| 1764 | 1773 | /> |
| 1765 | 1774 | ) : ( |
| ... | ... | @@ -2171,6 +2180,31 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2171 | 2180 | }, |
| 2172 | 2181 | [props.environmentId, props.threadId, props.workspaceRoot, navigation], |
| 2173 | 2182 | ); |
| 2183 | const markdownLinkHandlers = useMemo<MarkdownLinkHandlers>( | |
| 2184 | () => ({ | |
| 2185 | onLinkPress: onMarkdownLinkPress, | |
| 2186 | fileContextMenu: (href) => { | |
| 2187 | const target = resolveFileChipTarget(href, props.workspaceRoot); | |
| 2188 | return target ? fileChipMenu(target) : undefined; | |
| 2189 | }, | |
| 2190 | onFileContextMenuAction: (href, actionId) => { | |
| 2191 | const target = resolveFileChipTarget(href, props.workspaceRoot); | |
| 2192 | if (!target) return; | |
| 2193 | switch (actionId as FileChipAction) { | |
| 2194 | case "copy-full-path": | |
| 2195 | if (target.fullPath) copyTextWithHaptic(target.fullPath); | |
| 2196 | return; | |
| 2197 | case "copy-relative-path": | |
| 2198 | if (target.relativePath) copyTextWithHaptic(target.relativePath); | |
| 2199 | return; | |
| 2200 | case "open-file": | |
| 2201 | onMarkdownLinkPress(href); | |
| 2202 | return; | |
| 2203 | } | |
| 2204 | }, | |
| 2205 | }), | |
| 2206 | [onMarkdownLinkPress, props.workspaceRoot], | |
| 2207 | ); | |
| 2174 | 2208 | const renderMarkdownImage = useCallback<MarkdownImageRenderer>( |
| 2175 | 2209 | (image) => { |
| 2176 | 2210 | const media = resolveMarkdownMediaPreview(image.href, { |
| ... | ... | @@ -2696,7 +2730,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2696 | 2730 | onToggleTurnFold, |
| 2697 | 2731 | onPressPreview, |
| 2698 | 2732 | onPressVideo, |
| 2699 | onMarkdownLinkPress, | |
| 2733 | markdownLinkHandlers, | |
| 2700 | 2734 | renderMarkdownImage, |
| 2701 | 2735 | renderViewedImage, |
| 2702 | 2736 | iconSubtleColor, |
| ... | ... | @@ -2726,7 +2760,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2726 | 2760 | reviewCommentBubbleWidth, |
| 2727 | 2761 | userBubbleMaxWidth, |
| 2728 | 2762 | onCopyWorkRow, |
| 2729 | onMarkdownLinkPress, | |
| 2763 | markdownLinkHandlers, | |
| 2730 | 2764 | onPressPreview, |
| 2731 | 2765 | onPressVideo, |
| 2732 | 2766 | onToggleTurnFold, |
apps/mobile/src/features/threads/fileChipMenu.test.ts created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { fileChipMenu, resolveFileChipTarget } from "./fileChipMenu"; | |
| 4 | ||
| 5 | describe("resolveFileChipTarget", () => { | |
| 6 | it("resolves a workspace-relative link to both paths", () => { | |
| 7 | expect(resolveFileChipTarget("src/app.ts:12", "/repo")).toEqual({ | |
| 8 | fullPath: "/repo/src/app.ts", | |
| 9 | relativePath: "src/app.ts", | |
| 10 | }); | |
| 11 | }); | |
| 12 | ||
| 13 | it("keeps only the full path for a host file outside the workspace", () => { | |
| 14 | expect(resolveFileChipTarget("/tmp/report.md", "/repo")).toEqual({ | |
| 15 | fullPath: "/tmp/report.md", | |
| 16 | }); | |
| 17 | }); | |
| 18 | ||
| 19 | it("keeps only the relative path when the workspace root is unknown", () => { | |
| 20 | expect(resolveFileChipTarget("src/app.ts", null)).toEqual({ relativePath: "src/app.ts" }); | |
| 21 | }); | |
| 22 | ||
| 23 | it("ignores links that are not files or cannot be opened", () => { | |
| 24 | expect(resolveFileChipTarget("https://example.com/app.ts", "/repo")).toBeNull(); | |
| 25 | expect(resolveFileChipTarget("~/report.md", "/repo")).toBeNull(); | |
| 26 | expect(resolveFileChipTarget("../other/file.ts", "/repo")).toBeNull(); | |
| 27 | }); | |
| 28 | }); | |
| 29 | ||
| 30 | describe("fileChipMenu", () => { | |
| 31 | it("offers only the copies the target can satisfy", () => { | |
| 32 | expect(fileChipMenu({ fullPath: "/tmp/report.md" })).toEqual({ | |
| 33 | title: "/tmp/report.md", | |
| 34 | actions: [ | |
| 35 | { id: "copy-full-path", title: "Copy full path" }, | |
| 36 | { id: "open-file", title: "Open in file viewer" }, | |
| 37 | ], | |
| 38 | }); | |
| 39 | expect(fileChipMenu({ relativePath: "src/app.ts" }).actions.map(({ id }) => id)).toEqual([ | |
| 40 | "copy-relative-path", | |
| 41 | "open-file", | |
| 42 | ]); | |
| 43 | }); | |
| 44 | }); |
apps/mobile/src/features/threads/fileChipMenu.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | |
| 2 | import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; | |
| 3 | ||
| 4 | import { | |
| 5 | isAbsolutePath, | |
| 6 | resolveWorkspaceFilePath, | |
| 7 | resolveWorkspaceRelativeFilePath, | |
| 8 | } from "../files/filePath"; | |
| 9 | ||
| 10 | export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file"; | |
| 11 | ||
| 12 | export interface FileChipTarget { | |
| 13 | /** The host path, when the link is absolute or the workspace root is known. */ | |
| 14 | readonly fullPath?: string; | |
| 15 | /** The path inside the workspace, when the link resolves there. */ | |
| 16 | readonly relativePath?: string; | |
| 17 | } | |
| 18 | ||
| 19 | /** Null when the link is not a file or resolves nowhere the feed can open, such as `~/x` or `../x`. */ | |
| 20 | export function resolveFileChipTarget( | |
| 21 | href: string, | |
| 22 | workspaceRoot: string | null | undefined, | |
| 23 | ): FileChipTarget | null { | |
| 24 | const presentation = resolveMarkdownLinkPresentation(href); | |
| 25 | if (presentation.kind !== "file") return null; | |
| 26 | const relativePath = resolveWorkspaceRelativeFilePath(workspaceRoot, presentation.path); | |
| 27 | const fullPath = isAbsolutePath(presentation.path) | |
| 28 | ? presentation.path | |
| 29 | : workspaceRoot && relativePath | |
| 30 | ? resolveWorkspaceFilePath(workspaceRoot, relativePath) | |
| 31 | : undefined; | |
| 32 | if (!fullPath && !relativePath) return null; | |
| 33 | return { | |
| 34 | ...(fullPath ? { fullPath } : {}), | |
| 35 | ...(relativePath ? { relativePath } : {}), | |
| 36 | }; | |
| 37 | } | |
| 38 | ||
| 39 | /** The same actions the web file chip offers on right-click. Opening is what a tap does. */ | |
| 40 | export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { | |
| 41 | return { | |
| 42 | title: target.fullPath ?? target.relativePath ?? "", | |
| 43 | actions: [ | |
| 44 | ...(target.fullPath ? [{ id: "copy-full-path", title: "Copy full path" }] : []), | |
| 45 | ...(target.relativePath ? [{ id: "copy-relative-path", title: "Copy relative path" }] : []), | |
| 46 | { id: "open-file", title: "Open in file viewer" }, | |
| 47 | ], | |
| 48 | }; | |
| 49 | } |
apps/mobile/src/native/SelectableMarkdownText.ios.tsx+2| ... | ... | @@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter" |
| 8 | 8 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; |
| 9 | 9 | |
| 10 | 10 | export type { |
| 11 | MarkdownFileContextMenu, | |
| 12 | MarkdownFileContextMenuAction, | |
| 11 | 13 | MarkdownImageRenderer, |
| 12 | 14 | MarkdownImageRequest, |
| 13 | 15 | NativeMarkdownTextStyle, |
apps/mobile/src/native/SelectableMarkdownText.tsx+2| ... | ... | @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/ |
| 3 | 3 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; |
| 4 | 4 | |
| 5 | 5 | export type { |
| 6 | MarkdownFileContextMenu, | |
| 7 | MarkdownFileContextMenuAction, | |
| 6 | 8 | MarkdownImageRenderer, |
| 7 | 9 | MarkdownImageRequest, |
| 8 | 10 | NativeMarkdownTextStyle, |
docs/user/composer.md+2-1| ... | ... | @@ -107,7 +107,8 @@ path** and **Open in file viewer**. These actions are available in expanded prev |
| 107 | 107 | On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its |
| 108 | 108 | source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file |
| 109 | 109 | viewer from the same menu. Saving downloads a copy only when you request it; it does not change |
| 110 | how the video buffers during playback. | |
| 110 | how the video buffers during playback. On iOS, touch and hold a file reference in a message to | |
| 111 | copy its full or relative path or open it in the file viewer. | |
| 111 | 112 | |
| 112 | 113 | Use Markdown image syntax to embed either kind of media: |
| 113 | 114 |