diff --git a/ios/MarkdownLayoutManager.mm b/ios/MarkdownLayoutManager.mm index 3974ba98..c690111d 100644 --- a/ios/MarkdownLayoutManager.mm +++ b/ios/MarkdownLayoutManager.mm @@ -2,39 +2,154 @@ @implementation MarkdownLayoutManager +- (BOOL)isRange:(NSRange)smallerRange inRange:(NSRange)largerRange { + NSUInteger start = smallerRange.location; + NSUInteger end = start + smallerRange.length; + NSUInteger location = largerRange.location; + return location >= start && location < end; +} + +- (CGRect)rectByAddingPadding:(CGFloat)padding toRect:(CGRect)rect { + rect.origin.x -= padding; + rect.origin.y -= padding; + rect.size.width += padding * 2; + rect.size.height += padding * 2; + return rect; +} + - (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin { - [super drawBackgroundForGlyphRange:glyphsToShow atPoint:origin]; - - [self enumerateLineFragmentsForGlyphRange:glyphsToShow usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer * _Nonnull textContainer, NSRange glyphRange, BOOL * _Nonnull stop) { - __block BOOL isBlockquote = NO; - __block int currentDepth = 0; - RCTMarkdownUtils *markdownUtils = [self valueForKey:@"markdownUtils"]; - [markdownUtils.blockquoteRangesAndLevels enumerateObjectsUsingBlock:^(NSDictionary *item, NSUInteger idx, BOOL * _Nonnull stop) { - NSRange range = [[item valueForKey:@"range"] rangeValue]; - currentDepth = [[item valueForKey:@"depth"] unsignedIntegerValue]; - NSUInteger start = range.location; - NSUInteger end = start + range.length; - NSUInteger location = glyphRange.location; - if (location >= start && location < end) { - isBlockquote = YES; - *stop = YES; - } + [super drawBackgroundForGlyphRange:glyphsToShow atPoint:origin]; + + RCTMarkdownStyle *style = [_markdownUtils markdownStyle]; + [self drawBlockquotesForRanges:[_markdownUtils blockquoteRangesAndLevels] andGlyphRange:glyphsToShow atPoint:origin withColor:[style blockquoteBorderColor] width:[style blockquoteBorderWidth] margin:[style blockquoteMarginLeft] andPadding:[style blockquotePaddingLeft]]; + [self drawPreBackgroundForRanges:[_markdownUtils preRanges] atPoint:origin withColor:[style preBackgroundColor] borderColor:[style preBorderColor] borderWidth:[style preBorderWidth] borderRadius:[style preBorderRadius] andPadding:[style prePadding]]; + [self drawCodeBackgroundForRanges:[_markdownUtils codeRanges] atPoint:origin withColor:[style codeBackgroundColor] borderColor:[style codeBorderColor] borderWidth:[style codeBorderWidth] borderRadius:[style codeBorderRadius] andPadding:[style codePadding]]; +} + +- (void)drawBlockquotesForRanges:(NSArray*)ranges andGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin withColor:(UIColor*)color width:(CGFloat)width margin:(CGFloat)margin andPadding:(CGFloat)padding { + [self enumerateLineFragmentsForGlyphRange:glyphsToShow usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer * _Nonnull textContainer, NSRange glyphRange, BOOL * _Nonnull stop) { + __block BOOL isBlockquote = NO; + __block int currentDepth = 0; + + [ranges enumerateObjectsUsingBlock:^(NSDictionary *item, NSUInteger idx, BOOL * _Nonnull stop) { + NSRange range = [[item valueForKey:@"range"] rangeValue]; + currentDepth = [[item valueForKey:@"depth"] unsignedIntegerValue]; + if ([self isRange:range inRange:glyphRange]) { + isBlockquote = YES; + *stop = YES; + } + }]; + if (isBlockquote) { + CGFloat paddingLeft = origin.x; + CGFloat paddingTop = origin.y; + CGFloat y = paddingTop + rect.origin.y; + CGFloat height = rect.size.height; + CGFloat shift = margin + width + padding; + for (int level = 0; level < currentDepth; level++) { + CGFloat x = paddingLeft + (level * shift) + margin; + CGRect lineRect = CGRectMake(x, y, width, height); + [color setFill]; + UIRectFill(lineRect); + } + } + }]; +} + +- (void)drawPreBackgroundForRanges:(NSArray*)ranges atPoint:(CGPoint)origin withColor:(UIColor*)backgroundColor borderColor:(UIColor*)borderColor borderWidth:(CGFloat)borderWidth borderRadius:(CGFloat)borderRadius andPadding:(CGFloat)padding { + __block CGRect preRect = CGRectNull; + [ranges enumerateObjectsUsingBlock:^(NSValue *item, NSUInteger idx, BOOL * _Nonnull stop) { + NSRange range = [item rangeValue]; + range.location += 1; + range.length -= 1; + + [self enumerateLineFragmentsForGlyphRange:range usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer * _Nonnull textContainer, NSRange glyphRange, BOOL * _Nonnull stop) { + if (CGRectIsNull(preRect)) { + preRect = usedRect; + CGFloat paddingLeft = origin.x; + preRect.origin.x += paddingLeft; + CGFloat paddingTop = origin.y; + preRect.origin.y += paddingTop; + } else { + CGFloat usedWidth = usedRect.size.width; + if (usedWidth > preRect.size.width) { + preRect.size.width = usedWidth; + } + preRect.size.height += usedRect.size.height; + } + }]; + + if (!CGRectIsNull(preRect)) { + preRect = [self rectByAddingPadding:padding toRect:preRect]; + [self drawBackgroundWithColor:backgroundColor borderColor:borderColor borderWidth:borderWidth andBorderRadius:borderRadius forRect:preRect isLeftOpen:NO isRightOpen:NO]; + preRect = CGRectNull; + } + }]; +} + +- (void)drawCodeBackgroundForRanges:(NSArray*)ranges atPoint:(CGPoint)origin withColor:(UIColor*)backgroundColor borderColor:(UIColor*)borderColor borderWidth:(CGFloat)borderWidth borderRadius:(CGFloat)borderRadius andPadding:(CGFloat)padding { + [ranges enumerateObjectsUsingBlock:^(NSValue *item, NSUInteger idx, BOOL * _Nonnull stop) { + NSRange range = [item rangeValue]; + [self enumerateLineFragmentsForGlyphRange:range usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer * _Nonnull textContainer, NSRange glyphRange, BOOL * _Nonnull stop) { + BOOL isLeftSideOpen = YES; + BOOL isRightSideOpen = YES; + + NSRange adjustedRange = glyphRange; + if (range.location > adjustedRange.location) { + adjustedRange.length -= range.location - adjustedRange.location; + adjustedRange.location = range.location; + isLeftSideOpen = NO; + } + + NSUInteger rangeEndLocation = range.location + range.length; + NSUInteger adjustedRangeEndLocation = adjustedRange.location + adjustedRange.length; + if (rangeEndLocation < adjustedRangeEndLocation) { + adjustedRange.length -= adjustedRangeEndLocation - rangeEndLocation; + isRightSideOpen = NO; + } + + CGRect codeRect = [self boundingRectForGlyphRange:adjustedRange inTextContainer:textContainer]; + CGFloat paddingLeft = origin.x; + codeRect.origin.x += paddingLeft; + CGFloat paddingTop = origin.y; + codeRect.origin.y += paddingTop; + codeRect = [self rectByAddingPadding:padding toRect:codeRect]; + [self drawBackgroundWithColor:backgroundColor borderColor:borderColor borderWidth:borderWidth andBorderRadius:borderRadius forRect:codeRect isLeftOpen:isLeftSideOpen isRightOpen:isRightSideOpen]; + }]; }]; - if (isBlockquote) { - CGFloat paddingLeft = origin.x; - CGFloat paddingTop = origin.y; - CGFloat y = paddingTop + rect.origin.y; - CGFloat width = markdownUtils.markdownStyle.blockquoteBorderWidth; - CGFloat height = rect.size.height; - CGFloat shift = markdownUtils.markdownStyle.blockquoteMarginLeft + markdownUtils.markdownStyle.blockquoteBorderWidth + markdownUtils.markdownStyle.blockquotePaddingLeft; - for (int level = 0; level < currentDepth; level++) { - CGFloat x = paddingLeft + (level * shift) + markdownUtils.markdownStyle.blockquoteMarginLeft; - CGRect lineRect = CGRectMake(x, y, width, height); - [markdownUtils.markdownStyle.blockquoteBorderColor setFill]; - UIRectFill(lineRect); - } +} + +- (void)drawBackgroundWithColor:(UIColor*)backgroundColor borderColor:(UIColor*)borderColor borderWidth:(CGFloat)borderWidth andBorderRadius:(CGFloat)radius forRect:(CGRect)rect isLeftOpen:(BOOL)isLeftOpen isRightOpen:(BOOL)isRightOpen { + UIRectCorner corners = 0; + if (!isLeftOpen) { + corners |= UIRectCornerTopLeft | UIRectCornerBottomLeft; } - }]; + if (!isRightOpen) { + corners |= UIRectCornerTopRight | UIRectCornerBottomRight; + } + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:rect byRoundingCorners:corners cornerRadii:CGSizeMake(radius, radius)]; + + [backgroundColor setFill]; + [path fill]; + [borderColor setStroke]; + [path setLineWidth:borderWidth]; + [path stroke]; + + if (isLeftOpen) { + [self openSideForRect:rect withBorderWidth:borderWidth isLeft:YES]; + } + if (isRightOpen) { + [self openSideForRect:rect withBorderWidth:borderWidth isLeft:NO]; + } +} + +- (void)openSideForRect:(CGRect)rect withBorderWidth:(CGFloat)borderWidth isLeft:(BOOL)isLeft { + UIBezierPath *path = [[UIBezierPath alloc] init]; + CGFloat x = isLeft ? CGRectGetMinX(rect) : CGRectGetMaxX(rect); + [path moveToPoint:CGPointMake(x, CGRectGetMinY(rect) - borderWidth)]; + [path addLineToPoint:CGPointMake(x, CGRectGetMaxY(rect) + borderWidth)]; + [[UIColor clearColor] setStroke]; + [path setLineWidth:borderWidth + 1]; + [path strokeWithBlendMode:kCGBlendModeClear alpha:1.0]; } @end diff --git a/ios/RCTMarkdownStyle.h b/ios/RCTMarkdownStyle.h index dc41a369..2be51b33 100644 --- a/ios/RCTMarkdownStyle.h +++ b/ios/RCTMarkdownStyle.h @@ -18,10 +18,18 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) CGFloat codeFontSize; @property (nonatomic) UIColor *codeColor; @property (nonatomic) UIColor *codeBackgroundColor; +@property (nonatomic) UIColor *codeBorderColor; +@property (nonatomic) CGFloat codeBorderWidth; +@property (nonatomic) CGFloat codeBorderRadius; +@property (nonatomic) CGFloat codePadding; @property (nonatomic) NSString *preFontFamily; @property (nonatomic) CGFloat preFontSize; @property (nonatomic) UIColor *preColor; @property (nonatomic) UIColor *preBackgroundColor; +@property (nonatomic) UIColor *preBorderColor; +@property (nonatomic) CGFloat preBorderWidth; +@property (nonatomic) CGFloat preBorderRadius; +@property (nonatomic) CGFloat prePadding; @property (nonatomic) UIColor *mentionHereColor; @property (nonatomic) UIColor *mentionHereBackgroundColor; @property (nonatomic) UIColor *mentionUserColor; diff --git a/ios/RCTMarkdownStyle.mm b/ios/RCTMarkdownStyle.mm index a2752cdf..4909c7b6 100644 --- a/ios/RCTMarkdownStyle.mm +++ b/ios/RCTMarkdownStyle.mm @@ -30,11 +30,19 @@ - (instancetype)initWithStruct:(const facebook::react::MarkdownTextInputDecorato _codeFontSize = style.code.fontSize; _codeColor = RCTUIColorFromSharedColor(style.code.color); _codeBackgroundColor = RCTUIColorFromSharedColor(style.code.backgroundColor); + _codeBorderColor = RCTUIColorFromSharedColor(style.code.borderColor); + _codeBorderWidth = style.code.borderWidth; + _codeBorderRadius = style.code.borderRadius; + _codePadding = style.code.padding; _preFontFamily = RCTNSStringFromString(style.pre.fontFamily); _preFontSize = style.pre.fontSize; _preColor = RCTUIColorFromSharedColor(style.pre.color); _preBackgroundColor = RCTUIColorFromSharedColor(style.pre.backgroundColor); + _preBorderColor = RCTUIColorFromSharedColor(style.pre.borderColor); + _preBorderWidth = style.pre.borderWidth; + _preBorderRadius = style.pre.borderRadius; + _prePadding = style.pre.padding; _mentionHereColor = RCTUIColorFromSharedColor(style.mentionHere.color); _mentionHereBackgroundColor = RCTUIColorFromSharedColor(style.mentionHere.backgroundColor); @@ -71,11 +79,19 @@ - (instancetype)initWithDictionary:(NSDictionary *)json _codeFontSize = [RCTConvert CGFloat:json[@"code"][@"fontSize"]]; _codeColor = [RCTConvert UIColor:json[@"code"][@"color"]]; _codeBackgroundColor = [RCTConvert UIColor:json[@"code"][@"backgroundColor"]]; + _codeBorderColor = [RCTConvert UIColor:json[@"code"][@"borderColor"]]; + _codeBorderWidth = [RCTConvert CGFloat:json[@"code"][@"borderWidth"]]; + _codeBorderRadius = [RCTConvert CGFloat:json[@"code"][@"borderRadius"]]; + _codePadding = [RCTConvert CGFloat:json[@"code"][@"padding"]]; _preFontFamily = [RCTConvert NSString:json[@"pre"][@"fontFamily"]]; _preFontSize = [RCTConvert CGFloat:json[@"pre"][@"fontSize"]]; _preColor = [RCTConvert UIColor:json[@"pre"][@"color"]]; _preBackgroundColor = [RCTConvert UIColor:json[@"pre"][@"backgroundColor"]]; + _preBorderColor = [RCTConvert UIColor:json[@"pre"][@"borderColor"]]; + _preBorderWidth = [RCTConvert CGFloat:json[@"pre"][@"borderWidth"]]; + _preBorderRadius = [RCTConvert CGFloat:json[@"pre"][@"borderRadius"]]; + _prePadding = [RCTConvert CGFloat:json[@"pre"][@"padding"]]; _mentionHereColor = [RCTConvert UIColor:json[@"mentionHere"][@"color"]]; _mentionHereBackgroundColor = [RCTConvert UIColor:json[@"mentionHere"][@"backgroundColor"]]; diff --git a/ios/RCTMarkdownUtils.h b/ios/RCTMarkdownUtils.h index 4d080bb8..bb1ae2ad 100644 --- a/ios/RCTMarkdownUtils.h +++ b/ios/RCTMarkdownUtils.h @@ -7,6 +7,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) RCTMarkdownStyle *markdownStyle; @property (nonatomic) NSMutableArray *blockquoteRangesAndLevels; +@property (nonatomic) NSMutableArray *codeRanges; +@property (nonatomic) NSMutableArray *preRanges; - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withAttributes:(nullable NSDictionary*)attributes; diff --git a/ios/RCTMarkdownUtils.mm b/ios/RCTMarkdownUtils.mm index f188429a..ec5bafc9 100644 --- a/ios/RCTMarkdownUtils.mm +++ b/ios/RCTMarkdownUtils.mm @@ -47,6 +47,8 @@ - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withA [attributedString addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleNone] range:NSMakeRange(0, attributedString.length)]; _blockquoteRangesAndLevels = [NSMutableArray new]; + _codeRanges = [NSMutableArray new]; + _preRanges = [NSMutableArray new]; [ranges enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { NSDictionary *item = obj; @@ -100,7 +102,7 @@ - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withA [attributedString addAttribute:NSStrikethroughStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:range]; } else if ([type isEqualToString:@"code"]) { [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.codeColor range:range]; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.codeBackgroundColor range:range]; + [_codeRanges addObject:[NSValue valueWithRange:range]]; } else if ([type isEqualToString:@"mention-here"]) { [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.mentionHereColor range:range]; [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.mentionHereBackgroundColor range:range]; @@ -126,9 +128,7 @@ - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withA }]; } else if ([type isEqualToString:@"pre"]) { [attributedString addAttribute:NSForegroundColorAttributeName value:_markdownStyle.preColor range:range]; - NSRange rangeForBackground = [inputString characterAtIndex:range.location] == '\n' ? NSMakeRange(range.location + 1, range.length - 1) : range; - [attributedString addAttribute:NSBackgroundColorAttributeName value:_markdownStyle.preBackgroundColor range:rangeForBackground]; - // TODO: pass background color and ranges to layout manager + [_preRanges addObject:[NSValue valueWithRange:range]]; } else if ([type isEqualToString:@"h1"]) { NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; NSRange rangeWithHashAndSpace = NSMakeRange(range.location - 2, range.length + 2); // we also need to include prepending "# " @@ -146,7 +146,6 @@ - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input withA _prevMarkdownStyle = _markdownStyle; return attributedString; - } } diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 0941b8a7..8911f4ed 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -15,8 +15,8 @@ import type {CSSProperties, MutableRefObject, ReactEventHandler, FocusEventHandl import {StyleSheet} from 'react-native'; import * as ParseUtils from './web/parserUtils'; import * as CursorUtils from './web/cursorUtils'; -import * as StyleUtils from './styleUtils'; import * as BrowserUtils from './web/browserUtils'; +import * as StyleUtils from './styleUtils'; import type * as MarkdownTextInputDecoratorViewNativeComponent from './MarkdownTextInputDecoratorViewNativeComponent'; import './web/MarkdownTextInput.css'; import InputHistory from './web/InputHistory'; @@ -299,7 +299,9 @@ const MarkdownTextInput = React.forwardRef( if (!divRef.current) { return; } - const newSelection = predefinedSelection || CursorUtils.getCurrentCursorPosition(divRef.current); + + const isContained = CursorUtils.restrictRanges(divRef.current, (divRef.current as HTMLInputElement)?.value ?? ''); + const newSelection = predefinedSelection && isContained ? predefinedSelection : CursorUtils.getCurrentCursorPosition(divRef.current); if (newSelection && (!contentSelection.current || contentSelection.current.start !== newSelection.start || contentSelection.current.end !== newSelection.end)) { updateRefSelectionVariables(newSelection); @@ -420,6 +422,22 @@ const MarkdownTextInput = React.forwardRef( onKeyPress(event); } + // Ensure user can't move into background spans with arrow keys + if ( + (e.key === 'ArrowDown' || e.key === 'ArrowRight') && + contentSelection.current?.end === (divRef.current as HTMLInputElement).value?.length && + contentSelection.current?.end === contentSelection.current?.start + ) { + e.preventDefault(); + return; + } + + // Making sure that CMD + A works on safari - due to contenteditable div containing multiple spans we need to recreate it ourselves + if (BrowserUtils.isSafari && e.key === 'a' && e.metaKey) { + e.preventDefault(); + CursorUtils.setCursorPosition(divRef.current, 0, (divRef.current as HTMLInputElement).value?.length, false); + } + updateSelection(event as unknown as SyntheticEvent); if ( @@ -628,6 +646,7 @@ const MarkdownTextInput = React.forwardRef( onFocus={handleFocus} onBlur={handleBlur} onPaste={handlePaste} + onScroll={() => ParseUtils.handlePreBlockBackground(divRef.current as HTMLElement)} placeholder={heightSafePlaceholder} spellCheck={spellCheck} dir={dir} @@ -648,6 +667,8 @@ const styles = StyleSheet.create({ overflowY: 'auto', overflowX: 'auto', overflowWrap: 'break-word', + position: 'relative', + clipPath: 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)', }, disabledInputStyles: { opacity: 0.75, diff --git a/src/MarkdownTextInputDecoratorViewNativeComponent.ts b/src/MarkdownTextInputDecoratorViewNativeComponent.ts index 3b79c7be..0d12b4a7 100644 --- a/src/MarkdownTextInputDecoratorViewNativeComponent.ts +++ b/src/MarkdownTextInputDecoratorViewNativeComponent.ts @@ -27,12 +27,22 @@ interface MarkdownStyle { fontSize: Float; color: ColorValue; backgroundColor: ColorValue; + borderColor: ColorValue; + borderWidth: Float; + borderRadius: Float; + borderStyle: string; + padding: Float; }; pre: { fontFamily: string; fontSize: Float; color: ColorValue; backgroundColor: ColorValue; + borderColor: ColorValue; + borderWidth: Float; + borderRadius: Float; + borderStyle: string; + padding: Float; }; mentionHere: { color: ColorValue; diff --git a/src/styleUtils.ts b/src/styleUtils.ts index a6c161ac..9a4a58fe 100644 --- a/src/styleUtils.ts +++ b/src/styleUtils.ts @@ -37,12 +37,22 @@ function makeDefaultMarkdownStyle(): MarkdownStyle { fontSize: 20, color: 'black', backgroundColor: 'lightgray', + borderColor: 'gray', + borderWidth: 1, + borderRadius: 4, + borderStyle: 'solid', + padding: 0, }, pre: { fontFamily: FONT_FAMILY_MONOSPACE, fontSize: 20, color: 'black', backgroundColor: 'lightgray', + borderColor: 'gray', + borderWidth: 1, + borderRadius: 4, + padding: 2, + borderStyle: 'solid', }, mentionHere: { color: 'green', @@ -74,6 +84,10 @@ function mergeMarkdownStyleWithDefault(input: PartialMarkdownStyle | undefined): return output; } +function getStyleNumericValue(style: string) { + return parseInt(style.replace('px', ''), 8); +} + export type {PartialMarkdownStyle}; -export {mergeMarkdownStyleWithDefault}; +export {mergeMarkdownStyleWithDefault, getStyleNumericValue}; diff --git a/src/web/MarkdownTextInput.css b/src/web/MarkdownTextInput.css index 4fc0171d..9e0ee9a6 100644 --- a/src/web/MarkdownTextInput.css +++ b/src/web/MarkdownTextInput.css @@ -22,3 +22,8 @@ display: block; /* For Firefox */ content: attr(placeholder); } + +.react-native-live-markdown-input-multiline *[data-type='pre'] + span[data-type='syntax'], +.react-native-live-markdown-input-multiline span[data-type='syntax']:has(+ span[data-type='pre']) { + line-height: 1.6; +} diff --git a/src/web/browserUtils.ts b/src/web/browserUtils.ts index a70d97c9..ddbe4ccb 100644 --- a/src/web/browserUtils.ts +++ b/src/web/browserUtils.ts @@ -1,11 +1,11 @@ -const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); -const isChromium = 'chrome' in window; - /** * Whether the platform is a mobile browser. * Copied from Expensify App https://github.com/Expensify/App/blob/90dee7accae79c49debf30354c160cab6c52c423/src/libs/Browser/index.website.ts#L41 * */ const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|BB|PlayBook|IEMobile|Windows Phone|Silk|Opera Mini/i.test(navigator.userAgent); +const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); +const isChromium = 'chrome' in window; +const isSafari = navigator.userAgent.indexOf('Safari') !== -1 && navigator.userAgent.indexOf('Chrome') === -1; -export {isFirefox, isChromium, isMobile}; +export {isFirefox, isChromium, isSafari, isMobile}; diff --git a/src/web/cursorUtils.ts b/src/web/cursorUtils.ts index 1cda6599..fcd31afa 100644 --- a/src/web/cursorUtils.ts +++ b/src/web/cursorUtils.ts @@ -27,7 +27,7 @@ function setPrevText(target: HTMLElement) { prevTextLength = text.length; } -function setCursorPosition(target: HTMLElement, start: number, end: number | null = null) { +function setCursorPosition(target: HTMLElement, start: number, end: number | null = null, scrollIntoView = true) { // We don't want to move the cursor if the target is not focused if (target !== document.activeElement) { return; @@ -66,10 +66,10 @@ function setCursorPosition(target: HTMLElement, start: number, end: number | nul // 3. Caret at the end of whole input, when pressing enter // 4. All other placements if (prevChar === '\n' && prevTextLength !== undefined && prevTextLength < textCharacters.length) { - if (nextChar !== '\n') { + if (nextChar !== '\n' && textNodes?.[i - 1]?.data !== '```') { range.setStart(textNodes[i + 1] as Node, 0); - } else if (i !== textNodes.length - 1) { - range.setStart(textNodes[i] as Node, 1); + } else if (i !== textNodes.length - 1 && !(textNodes?.[i + 1]?.data === '```' && nextChar === '`')) { + range.setStart(textNode as Node, 1); } else { range.setStart(textNode, start - charCount); } @@ -97,7 +97,7 @@ function setCursorPosition(target: HTMLElement, start: number, end: number | nul selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); } - scrollCursorIntoView(target as HTMLInputElement); + if (scrollIntoView) scrollCursorIntoView(target as HTMLInputElement); } function moveCursorToEnd(target: HTMLElement) { @@ -158,4 +158,38 @@ function scrollCursorIntoView(target: HTMLInputElement) { } } -export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, setPrevText, removeSelection, scrollCursorIntoView}; +function restrictRanges(target: HTMLElement, value: string) { + const rootSpan = target.querySelector('span.root'); + let isContained = true; + if (rootSpan) { + let range: Range | null; + + if (window.getSelection() && (window.getSelection() as globalThis.Selection).rangeCount > 0) { + range = (window.getSelection() as globalThis.Selection)?.getRangeAt(0); + } else { + range = document.createRange(); + } + + if (range && !(range.commonAncestorContainer === rootSpan || rootSpan.contains(range.commonAncestorContainer))) { + isContained = false; + // The caret or selection is outside rootSpan, move it + if (range.collapsed) { + // The range is a caret + setCursorPosition(target, value.length, value.length); + } else { + // The range is a selection + if (!rootSpan.contains(range.startContainer) && !target.contains(range.startContainer)) { + setCursorPosition(target, value.length, value.length, false); + } + if (!rootSpan.contains(range.endContainer)) { + const cursorPosition = getCurrentCursorPosition(target); + setCursorPosition(target, cursorPosition?.start ?? 0, value.length + 1, false); + } + } + } + } + + return isContained; +} + +export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, setPrevText, removeSelection, scrollCursorIntoView, restrictRanges}; diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index 3758bbaf..a7ae6034 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -1,5 +1,6 @@ import * as CursorUtils from './cursorUtils'; import type * as StyleUtilsTypes from '../styleUtils'; +import * as StyleUtils from '../styleUtils'; import * as BrowserUtils from './browserUtils'; type PartialMarkdownStyle = StyleUtilsTypes.PartialMarkdownStyle; @@ -20,6 +21,9 @@ type NestedNode = { function addStyling(targetElement: HTMLElement, type: MarkdownType, markdownStyle: PartialMarkdownStyle) { const node = targetElement; + Object.assign(node.dataset, { + type, + }); switch (type) { case 'syntax': Object.assign(node.style, markdownStyle.syntax); @@ -52,12 +56,15 @@ function addStyling(targetElement: HTMLElement, type: MarkdownType, markdownStyl }); break; case 'code': - Object.assign(node.style, markdownStyle.code); + Object.assign(node.style, {...markdownStyle.code, lineHeight: 1.4}); break; case 'pre': - Object.assign(node.style, markdownStyle.pre); + Object.assign(node.style, { + ...{...markdownStyle.pre, borderStyle: undefined, padding: undefined, backgroundColor: undefined}, + position: 'relative', + boxSizing: 'border-box', + }); break; - case 'blockquote': Object.assign(node.style, { ...markdownStyle.blockquote, @@ -179,6 +186,66 @@ function moveCursor(isFocused: boolean, alwaysMoveCursorToTheEnd: boolean, curso } } +function hidePreBlockBackgrounds(target: HTMLElement) { + const preBlocks = [...target.querySelectorAll('*[data-type="pre"]')]; + const preBlockBackgrounds = [...target.querySelectorAll('.pre-block-background')]; + for (let i = preBlocks.length - 1; i <= preBlockBackgrounds.length - 1; i++) { + preBlockBackgrounds[i]?.remove(); + } + + return [preBlocks, preBlockBackgrounds] as [Element[], Element[]]; +} + +function handlePreBlockBackground(target: HTMLElement, markdownStyle: PartialMarkdownStyle = {}) { + const [preBlocks, preBlockBackgrounds] = hidePreBlockBackgrounds(target); + if (!preBlocks) return; + + preBlocks.forEach((pre) => { + const preRects = [...pre.getClientRects()]; + preRects.shift(); + + let width = 0; + let height = 0; + let top = 0; + let left = 0; + preRects.forEach((preRect) => { + if (width < preRect.width) width = preRect.width; + if (!top || top > preRect.top) top = preRect.top; + height = preRect.bottom - top; + if (!left || left > preRect.left) left = preRect.left; + }); + const {top: divTop, left: divLeft} = target.getBoundingClientRect(); + + const span = (preBlockBackgrounds?.[preBlocks.indexOf(pre)] as HTMLSpanElement | null) ?? document.createElement('span'); + const {pre: preStyle} = markdownStyle; + // eslint-disable-next-line + const transform = StyleUtils.getStyleNumericValue(preStyle?.padding?.toString() ?? '2') + StyleUtils.getStyleNumericValue(preStyle?.borderWidth?.toString() ?? '1') + 'px'; + span.classList.add('pre-block-background'); + Object.assign(span.style, { + width: `${width}px`, + height: `${height}px`, + padding: preStyle?.padding ?? '2px', + border: `${preStyle?.borderWidth ?? '1px'} solid gray`, + borderRadius: '4px', + backgroundColor: 'lightgray', + display: `block`, + position: 'absolute', + top: `${top - (divTop - target.scrollTop)}px`, + left: `${left - divLeft}px`, + transform: `translate(-${transform}, -${transform})`, + zIndex: '-1', + pointerEvents: 'none', + userSelect: 'none', + caretColor: 'transparent', + }); + span.contentEditable = 'false'; + span.spellcheck = false; + span.ariaAutoComplete = 'false'; + + target.appendChild(span); + }); +} + function parseText(target: HTMLElement, text: string, cursorPositionIndex: number | null, markdownStyle: PartialMarkdownStyle = {}, alwaysMoveCursorToTheEnd = false) { const targetElement = target; @@ -212,6 +279,8 @@ function parseText(target: HTMLElement, text: string, cursorPositionIndex: numbe moveCursor(isFocused, alwaysMoveCursorToTheEnd, cursorPosition, target); } } + // Update pre block backgrounds + handlePreBlockBackground(target, markdownStyle); if (!BrowserUtils.isChromium) { moveCursor(isFocused, alwaysMoveCursorToTheEnd, cursorPosition, target); @@ -223,6 +292,6 @@ function parseText(target: HTMLElement, text: string, cursorPositionIndex: numbe return {text: target.innerText, cursorPosition: cursorPosition || 0}; } -export {parseText, parseRangesToHTMLNodes}; +export {parseText, parseRangesToHTMLNodes, handlePreBlockBackground}; export type {MarkdownRange, MarkdownType};