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
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)** shortcuts fire with the same scheme
as paragraph styles, but don't require a plain paragraph to be effective.

**[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 withTyping:YES so the next typed
/// 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