diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt index d63466903..f3b1389b2 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt @@ -30,19 +30,26 @@ class ShortcutsHandler( val (start, end) = s.getParagraphBounds(cursorPosition) val paragraphText = s.substring(start, end) - val effectiveTriggerStart = - if (paragraphText.startsWith(EnrichedConstants.ZWS_STRING)) { - if (paragraphHasNonAlignmentSpan(s, start, end)) return - start + 1 - } else { - start - } + val startsWithZws = paragraphText.startsWith(EnrichedConstants.ZWS_STRING) + val effectiveTriggerStart = if (startsWithZws) start + 1 else start + val paragraphHasActiveStyle = startsWithZws && paragraphHasNonAlignmentSpan(s, start, end) for ((trigger, styleName) in shortcuts) { + val isAlignmentShortcut = isAlignmentShortcutStyle(styleName) if (isInlineShortcutStyle(styleName)) continue + if (paragraphHasActiveStyle && !isAlignmentShortcut) continue if (trigger.isEmpty()) continue if (!s.substring(effectiveTriggerStart, end).startsWith(trigger)) continue + if (isAlignmentShortcut) { + if (view.alignmentStyles?.getCurrentAlignment() == styleName) continue + + s.replace(effectiveTriggerStart, effectiveTriggerStart + trigger.length, "") + view.alignmentStyles?.setAlignment(styleName) + view.selection?.validateStyles() + return + } + val resolvedStyle = resolveStyleName(styleName) ?: continue s.replace(effectiveTriggerStart, effectiveTriggerStart + trigger.length, "") diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt index 111d98412..0de150408 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/StyleUtils.kt @@ -30,6 +30,10 @@ fun isInlineShortcutStyle(styleName: String): Boolean { return EnrichedSpans.inlineSpans.containsKey(resolvedStyle) } +private val ALIGNMENT_SHORTCUT_STYLES = setOf("left", "center", "right", "justify") + +fun isAlignmentShortcutStyle(styleName: String): Boolean = styleName in ALIGNMENT_SHORTCUT_STYLES + fun isStyleBlockedOnRange( styleName: String, start: Int, diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 3a6409919..630377646 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -284,6 +284,10 @@ function App() { htmlStyle={WEB_DEFAULT_HTML_STYLE} linkRegex={LINK_REGEX} sanitizationConfig={SANITIZATION_CONFIG} + textShortcuts={[ + { trigger: '++', style: 'center' }, + { trigger: '##', style: 'h6' }, + ]} /> editor.handlePasteImagesEvent(e.nativeEvent)} + textShortcuts={[ + { trigger: '++', style: 'center' }, + { trigger: '##', style: 'h6' }, + ]} testID="editor-input" /> *textShortcuts; +@public + BOOL preserveTypingAttributesOnNextEmptyCheck; } - (CGSize)measureSize:(CGFloat)maxWidth; - (void)emitOnLinkDetectedEvent:(LinkData *)linkData range:(NSRange)range; diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 9862738ef..f7370f0b5 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -137,6 +137,7 @@ - (void)setDefaults { _recentlyEmittedHtml = @"\n

\n"; _emitHtml = NO; blockEmitting = NO; + preserveTypingAttributesOnNextEmptyCheck = NO; _emitFocusBlur = YES; _emitTextChange = NO; dotReplacementRange = nullptr; @@ -1673,10 +1674,14 @@ - (void)anyTextMayHaveBeenModified { // emptying input typing attributes management if (textView.textStorage.string.length == 0 && - _recentInputString.length > 0) { - // reset typing attribtues + _recentInputString.length > 0 && + !preserveTypingAttributesOnNextEmptyCheck) { + // reset typing attributes if we emptied the string + // adding alignment via shortcuts wants to preserve typing attributes, so we + // don't reset then textView.typingAttributes = defaultTypingAttributes; } + preserveTypingAttributesOnNextEmptyCheck = NO; // mentions management: removal and editing MentionStyle *mentionStyleClass = diff --git a/ios/utils/ShortcutsUtils.mm b/ios/utils/ShortcutsUtils.mm index bd084ad17..447ebdeee 100644 --- a/ios/utils/ShortcutsUtils.mm +++ b/ios/utils/ShortcutsUtils.mm @@ -1,6 +1,8 @@ #import "ShortcutsUtils.h" +#import "AlignmentUtils.h" #import "ParagraphAttributesUtils.h" #import "StyleBase.h" +#import "StyleHeaders.h" #import "StyleUtils.h" #import "TextInsertionUtils.h" @@ -49,6 +51,10 @@ @implementation ShortcutsUtils @"unordered_list" : @(UnorderedList), @"ordered_list" : @(OrderedList), @"checkbox_list" : @(CheckboxList), + @"left" : @(Alignment), + @"center" : @(Alignment), + @"right" : @(Alignment), + @"justify" : @(Alignment), // Inline shortcuts @"bold" : @(Bold), @"italic" : @(Italic), @@ -331,20 +337,22 @@ + (BOOL)paragraphHasActiveParagraphStyleInRange:(NSRange)paragraphRange /// Handles a paragraph-level shortcut (e.g. `# ` → H1, `- ` → unordered list) /// on character insertion. /// -/// 1. Skip if no shortcuts configured, or the paragraph already has an active -/// paragraph style — triggers only apply to plain paragraphs. +/// 1. Skip if no shortcuts configured. /// 2. Find a paragraph shortcut whose trigger is anchored to the paragraph -/// start. Skip if the resolved style is blocked by another active style. -/// 3. Save the current text alignment. -/// 4. Suppress events, delete the trigger text, unsuppress. -/// 5. Remove styles from the range that conflict with the new style (e.g. -/// italic is removed when applying codeblock). -// 6. Reset typing attrs to defaults preserving alignment — without this, the -// new paragraph -/// style would inherit the alignment of the previous paragraph. -/// 7. Apply the paragraph style with withTyping:YES so the next typed -/// character -/// inherits it immediately. +/// start. Non-alignment shortcuts only trigger on a plain paragraph; +/// alignment is not a real paragraph style and can combine with one +/// (e.g. an already-heading paragraph). Skip if the resolved style is +/// blocked by another active style. +/// 3. Suppress events, delete the trigger text, unsuppress. +/// 4. For alignment, just layer the alignment marker on top of whatever +/// paragraph style is already active. +/// 5. For other paragraph styles: remove styles from the range that +/// conflict with the new style (e.g. italic is removed when applying +/// codeblock), then reset typing attrs to defaults preserving alignment +/// — without this, the new paragraph style would inherit stale typing +/// attributes from the previous style. +/// 6. Apply the paragraph style withTyping:YES so the next typed +/// character inherits it immediately. + (BOOL)tryHandlingParagraphShortcutsInRange:(NSRange)range replacementText:(NSString *)text input:(EnrichedTextInputView *)input { @@ -356,10 +364,9 @@ + (BOOL)tryHandlingParagraphShortcutsInRange:(NSRange)range replacementText:text input:input]; - if ([self paragraphHasActiveParagraphStyleInRange:context.paragraphRange - input:input]) { - return NO; - } + BOOL paragraphHasActiveStyle = + [self paragraphHasActiveParagraphStyleInRange:context.paragraphRange + input:input]; for (NSDictionary *shortcut in input->textShortcuts) { if ([self isInlineShortcutStyleName:shortcut[@"style"] input:input]) { @@ -385,17 +392,29 @@ + (BOOL)tryHandlingParagraphShortcutsInRange:(NSRange)range continue; } + // we don't allow for paragraph style shortcuts in non-plain text + // paragraphs, where the only exception is a text alignment, which we can + // always apply. paragraphHasActiveStyle doesn't consider text alignment, + // so we can apply paragraph style shortcuts also when text alignment + // is already applied + if (type != Alignment && paragraphHasActiveStyle) { + continue; + } + + if (type == Alignment) { + AlignmentStyle *alignmentStyle = + (AlignmentStyle *)input->stylesDict[@(type)]; + if ([[alignmentStyle getStyleState] isEqualToString:styleName]) { + continue; + } + } + if ([StyleUtils isStyleBlocked:type range:context.paragraphRange forHost:input]) { continue; } - NSParagraphStyle *currentParaStyle = - input->textView.typingAttributes[NSParagraphStyleAttributeName]; - NSTextAlignment savedAlignment = - currentParaStyle ? currentParaStyle.alignment : NSTextAlignmentNatural; - NSRange triggerRange = NSMakeRange(match.delimStart, match.delimPrefixLen); input->blockEmitting = YES; @@ -407,6 +426,31 @@ + (BOOL)tryHandlingParagraphShortcutsInRange:(NSRange)range input->blockEmitting = NO; + StyleBase *style = input->stylesDict[@(type)]; + if (style == nil) { + return YES; + } + + if (type == Alignment) { + [(AlignmentStyle *)style + addAlignment:[AlignmentUtils stringToAlignment:styleName] + range:input->textView.selectedRange + withTyping:YES + withDirtyRange:YES]; + // the trigger may have consumed the whole paragraph (e.g. an empty + // editor); don't let the empty-text reset wipe the alignment we just + // set on the typing attributes. This is an issue only with alignment + // shortcuts, as alignment doesn't use ZWS, which makes e.g. headings + // survive the empty-text typing attributes reset + input->preserveTypingAttributesOnNextEmptyCheck = YES; + return YES; + } + + NSParagraphStyle *currentParaStyle = + input->textView.typingAttributes[NSParagraphStyleAttributeName]; + NSTextAlignment savedAlignment = + currentParaStyle ? currentParaStyle.alignment : NSTextAlignmentNatural; + // Drop conflicting inline styles (e.g. italic) across the whole paragraph // before applying the block style. NSRange paragraphRange = [input->textView.textStorage.string @@ -418,12 +462,7 @@ + (BOOL)tryHandlingParagraphShortcutsInRange:(NSRange)range [ParagraphAttributesUtils resetTypingAttributes:input preservingAlignment:savedAlignment]; - StyleBase *style = input->stylesDict[@(type)]; - if (style != nil) { - [style add:input->textView.selectedRange - withTyping:YES - withDirtyRange:YES]; - } + [style add:input->textView.selectedRange withTyping:YES withDirtyRange:YES]; return YES; } diff --git a/src/types.ts b/src/types.ts index d4d0dfd8b..84d059e50 100644 --- a/src/types.ts +++ b/src/types.ts @@ -257,7 +257,11 @@ export type TextShortcutStyle = | 'codeblock' | 'unordered_list' | 'ordered_list' - | 'checkbox_list'; + | 'checkbox_list' + | 'left' + | 'center' + | 'right' + | 'justify'; /** * Defines a single text shortcut: a character sequence that, when typed is replaced by the corresponding paragraph or inline style. diff --git a/src/web/pmPlugins/TextShortcutsPlugin.ts b/src/web/pmPlugins/TextShortcutsPlugin.ts index 032879b8b..09e5b57ce 100644 --- a/src/web/pmPlugins/TextShortcutsPlugin.ts +++ b/src/web/pmPlugins/TextShortcutsPlugin.ts @@ -4,6 +4,7 @@ import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; import type { EditorView } from '@tiptap/pm/view'; import type { TextShortcut, TextShortcutStyle, HtmlStyle } from '../../types'; import { + getCurrentAlignment, isAnyParagraphFormatActive, isFormatBlocked, } from '../formats/formatRules'; @@ -21,6 +22,13 @@ const INLINE_STYLES = new Set([ 'inline_code', ]); +const ALIGNMENT_STYLES = new Set([ + 'left', + 'center', + 'right', + 'justify', +]); + // Maps every TextShortcutStyle to its corresponding TipTap extension name. const TIPTAP_NAME: Record = { bold: 'bold', @@ -39,6 +47,10 @@ const TIPTAP_NAME: Record = { unordered_list: 'unorderedList', ordered_list: 'orderedList', checkbox_list: 'checkboxList', + left: 'textAlign', + center: 'textAlign', + right: 'textAlign', + justify: 'textAlign', }; function applyParagraphCommand(style: string, editor: Editor): boolean { @@ -65,6 +77,11 @@ function applyParagraphCommand(style: string, editor: Editor): boolean { return editor.commands.toggleOrderedList(); case 'checkbox_list': return editor.commands.toggleCheckboxList(false); + case 'left': + case 'center': + case 'right': + case 'justify': + return editor.commands.setTextAlign(style); default: return false; } @@ -124,6 +141,8 @@ function isDelimPartOfLongerTrigger( * * Fires only when the trigger is anchored at the very start of the current * text block and no paragraph style is already active on that block. + * Alignment shortcuts are the exception: they may fire regardless of the + * active paragraph style, as long as that alignment isn't already applied. */ function tryParagraphShortcut( view: EditorView, @@ -133,16 +152,16 @@ function tryParagraphShortcut( shortcuts: TextShortcut[], htmlStyle: Required ): boolean { - if (isAnyParagraphFormatActive(editor)) return false; - const ctx = getBlockContext(view.state.doc, from); if (!ctx) return false; const { blockStart } = ctx; const offsetInBlock = from - blockStart; + const anyParagraphFormatActive = isAnyParagraphFormatActive(editor); for (const { trigger, style } of shortcuts) { if (INLINE_STYLES.has(style)) continue; + if (anyParagraphFormatActive && !ALIGNMENT_STYLES.has(style)) continue; if (!trigger) continue; const lastChar = trigger[trigger.length - 1]!; @@ -159,6 +178,8 @@ function tryParagraphShortcut( if (docPrefix !== trigger.slice(0, prefixLen)) continue; } + if (ALIGNMENT_STYLES.has(style) && getCurrentAlignment(editor) === style) + continue; if (isFormatBlocked(TIPTAP_NAME[style], editor, htmlStyle)) continue; const marksToPreserve = view.state.selection.$from.marks(); @@ -213,6 +234,7 @@ function tryInlineShortcut( for (const { trigger, style } of inlineShortcuts) { const markName = TIPTAP_NAME[style]; + if (markName === undefined) continue; const lastChar = trigger[trigger.length - 1]!; if (text !== lastChar) continue;