Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +38 to 39

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's move it one line, there is no need to call it for inline shortcut style

Suggested change
val isAlignmentShortcut = isAlignmentShortcutStyle(styleName)
if (isInlineShortcutStyle(styleName)) continue
if (isInlineShortcutStyle(styleName)) continue
val isAlignmentShortcut = isAlignmentShortcutStyle(styleName)

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, "")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
hejsztynx marked this conversation as resolved.

fun isStyleBlockedOnRange(
styleName: String,
start: Int,
Expand Down
4 changes: 4 additions & 0 deletions apps/example-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,10 @@ function App() {
htmlStyle={WEB_DEFAULT_HTML_STYLE}
linkRegex={LINK_REGEX}
sanitizationConfig={SANITIZATION_CONFIG}
textShortcuts={[
{ trigger: '++', style: 'center' },
{ trigger: '##', style: 'h6' },
]}
/>
<MentionPopup
variant="user"
Expand Down
4 changes: 4 additions & 0 deletions apps/example/src/screens/DevScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export function DevScreen({ onSwitch }: DevScreenProps) {
ANDROID_EXPERIMENTAL_SYNCHRONOUS_EVENTS
}
onPasteImages={(e) => editor.handlePasteImagesEvent(e.nativeEvent)}
textShortcuts={[
{ trigger: '++', style: 'center' },
{ trigger: '##', style: 'h6' },
]}
testID="editor-input"
/>
<Toolbar
Expand Down
3 changes: 3 additions & 0 deletions docs/docs/api-reference/enriched-text-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,9 @@ list item), typing the trigger pattern has no effect.

:::

**[Text alignment](/rich-text-formatting/text-alignment)** fire with the same scheme
as paragraph styles, but doesn't require a plain paragraph to be effective.
Comment thread
hejsztynx marked this conversation as resolved.
Outdated

**[Inline styles](/fundamentals/html-format-and-supported-tags#inline-tags)**
fire when a closing delimiter is typed around text (e.g. `**text**` → bold). The
trigger is the delimiter string (e.g. `**`, `*`, `~~`). Supported styles:
Expand Down
3 changes: 3 additions & 0 deletions docs/docs/rich-text-formatting/text-shortcuts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ things:
one paragraph style, these only fire on a **plain** paragraph: if the line is
already a heading or a list item, typing the prefix does nothing.

- **Alignment shortcuts** (`left`, `center`, `right`, `justify`) can be applied with no limits,
to any already styled paragraph.

- **Inline shortcuts** (`bold`, `italic`, `underline`, `strikethrough`,
`inline_code`) fire when you type a **closing delimiter** around some text, so
typing `**word**` bolds `word`.
Expand Down
2 changes: 2 additions & 0 deletions ios/EnrichedTextInputView.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ NS_ASSUME_NONNULL_BEGIN
NSValue *dotReplacementRange;
@public
NSArray<NSDictionary *> *textShortcuts;
@public
BOOL preserveTypingAttributesOnNextEmptyCheck;
}
- (CGSize)measureSize:(CGFloat)maxWidth;
- (void)emitOnLinkDetectedEvent:(LinkData *)linkData range:(NSRange)range;
Expand Down
9 changes: 7 additions & 2 deletions ios/EnrichedTextInputView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ - (void)setDefaults {
_recentlyEmittedHtml = @"<html>\n<p></p>\n</html>";
_emitHtml = NO;
blockEmitting = NO;
preserveTypingAttributesOnNextEmptyCheck = NO;
_emitFocusBlur = YES;
_emitTextChange = NO;
dotReplacementRange = nullptr;
Expand Down Expand Up @@ -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 =
Expand Down
95 changes: 67 additions & 28 deletions ios/utils/ShortcutsUtils.mm
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 with withTyping:YES so the next typed
Comment thread
hejsztynx marked this conversation as resolved.
Outdated
/// character inherits it immediately.
+ (BOOL)tryHandlingParagraphShortcutsInRange:(NSRange)range
replacementText:(NSString *)text
input:(EnrichedTextInputView *)input {
Expand All @@ -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]) {
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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;
}

Expand Down
6 changes: 5 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 24 additions & 2 deletions src/web/pmPlugins/TextShortcutsPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +22,13 @@ const INLINE_STYLES = new Set<TextShortcutStyle>([
'inline_code',
]);

const ALIGNMENT_STYLES = new Set<TextShortcutStyle>([
'left',
'center',
'right',
'justify',
]);

// Maps every TextShortcutStyle to its corresponding TipTap extension name.
const TIPTAP_NAME: Record<TextShortcutStyle, string> = {
bold: 'bold',
Expand All @@ -39,6 +47,10 @@ const TIPTAP_NAME: Record<TextShortcutStyle, string> = {
unordered_list: 'unorderedList',
ordered_list: 'orderedList',
checkbox_list: 'checkboxList',
left: 'textAlign',
center: 'textAlign',
right: 'textAlign',
justify: 'textAlign',
};

function applyParagraphCommand(style: string, editor: Editor): boolean {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand All @@ -133,16 +152,16 @@ function tryParagraphShortcut(
shortcuts: TextShortcut[],
htmlStyle: Required<HtmlStyle>
): 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]!;
Expand All @@ -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;

Comment thread
hejsztynx marked this conversation as resolved.
const marksToPreserve = view.state.selection.$from.marks();
Expand Down Expand Up @@ -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;
Expand Down
Loading