-
Notifications
You must be signed in to change notification settings - Fork 67
Expand file tree
/
Copy pathShortcutsUtils.mm
More file actions
527 lines (461 loc) · 18.4 KB
/
Copy pathShortcutsUtils.mm
File metadata and controls
527 lines (461 loc) · 18.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
#import "ShortcutsUtils.h"
#import "AlignmentUtils.h"
#import "ParagraphAttributesUtils.h"
#import "StyleBase.h"
#import "StyleHeaders.h"
#import "StyleUtils.h"
#import "TextInsertionUtils.h"
typedef struct {
EnrichedTextInputView *input;
NSString *fullText;
NSRange paragraphRange;
NSRange changeRange;
NSString *replacementText;
} ShortcutsTextContext;
typedef struct {
ShortcutsTextContext text;
NSArray<NSDictionary *> *inlineShortcuts;
} ShortcutsInlineContext;
typedef struct {
NSString *trigger;
StyleType styleType;
NSInteger delimStart;
NSInteger delimPrefixLen;
} ShortcutsTriggerMatch;
typedef struct {
NSRange finalContentRange;
NSRange closeDeleteRange;
NSRange openDeleteRange;
} ShortcutsInlineApplyRanges;
@implementation ShortcutsUtils
+ (NSDictionary<NSString *, NSNumber *> *)shortcutStyleNameMap {
static NSDictionary<NSString *, NSNumber *> *map = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
map = @{
// Paragraph shortcuts
@"h1" : @(H1),
@"h2" : @(H2),
@"h3" : @(H3),
@"h4" : @(H4),
@"h5" : @(H5),
@"h6" : @(H6),
@"blockquote" : @(BlockQuote),
@"codeblock" : @(CodeBlock),
@"unordered_list" : @(UnorderedList),
@"ordered_list" : @(OrderedList),
@"checkbox_list" : @(CheckboxList),
@"left" : @(Alignment),
@"center" : @(Alignment),
@"right" : @(Alignment),
@"justify" : @(Alignment),
// Inline shortcuts
@"bold" : @(Bold),
@"italic" : @(Italic),
@"underline" : @(Underline),
@"strikethrough" : @(Strikethrough),
@"inline_code" : @(InlineCode),
};
});
return map;
}
+ (StyleType)styleTypeForShortcutName:(NSString *)name {
NSNumber *styleType = [self shortcutStyleNameMap][name];
return styleType ? (StyleType)[styleType integerValue] : None;
}
+ (BOOL)isInlineShortcutStyleName:(NSString *)name
input:(EnrichedTextInputView *)input {
StyleType type = [self styleTypeForShortcutName:name];
if (type == None) {
return NO;
}
StyleBase *style = input->stylesDict[@(type)];
if (style == nil) {
return NO;
}
return ![style isParagraph];
}
+ (BOOL)anyTextShortcutsInInput:(EnrichedTextInputView *)input {
return input != nullptr && input->textShortcuts != nil &&
input->textShortcuts.count > 0;
}
+ (ShortcutsTextContext)textContextWithChangeRange:(NSRange)changeRange
replacementText:(NSString *)replacementText
input:(EnrichedTextInputView *)
input {
NSString *fullText = input->textView.textStorage.string;
return (ShortcutsTextContext){
.input = input,
.fullText = fullText,
.paragraphRange = [fullText paragraphRangeForRange:changeRange],
.changeRange = changeRange,
.replacementText = replacementText,
};
}
+ (ShortcutsInlineContext)
inlineContextWithChangeRange:(NSRange)changeRange
replacementText:(NSString *)replacementText
input:(EnrichedTextInputView *)input {
return (ShortcutsInlineContext){
.text = [self textContextWithChangeRange:changeRange
replacementText:replacementText
input:input],
.inlineShortcuts = [self inlineShortcutsFrom:input->textShortcuts
input:input],
};
}
+ (NSArray<NSDictionary *> *)
inlineShortcutsFrom:(NSArray<NSDictionary *> *)textShortcuts
input:(EnrichedTextInputView *)input {
NSMutableArray<NSDictionary *> *inlineShortcuts = [NSMutableArray array];
for (NSDictionary *shortcut in textShortcuts) {
if ([self isInlineShortcutStyleName:shortcut[@"style"] input:input]) {
[inlineShortcuts addObject:shortcut];
}
}
[inlineShortcuts sortUsingComparator:^NSComparisonResult(NSDictionary *a,
NSDictionary *b) {
NSUInteger lenA = [a[@"trigger"] length];
NSUInteger lenB = [b[@"trigger"] length];
if (lenA > lenB) {
return NSOrderedAscending;
}
if (lenA < lenB) {
return NSOrderedDescending;
}
return NSOrderedSame;
}];
return inlineShortcuts;
}
/// When [requiredDelimStart] is NSNotFound, the trigger may appear anywhere in
/// the text. Otherwise the matched delimiter must start at that index
/// (paragraph shortcuts at paragraph start).
+ (BOOL)isCompletingTrigger:(NSString *)trigger
context:(const ShortcutsTextContext *)context
requiredDelimStart:(NSInteger)requiredDelimStart
match:(ShortcutsTriggerMatch *)outMatch {
if (trigger.length == 0) {
return NO;
}
NSString *lastTriggerChar = [trigger substringFromIndex:trigger.length - 1];
if (![context->replacementText isEqualToString:lastTriggerChar]) {
return NO;
}
NSInteger delimPrefixLen = (NSInteger)trigger.length - 1;
if (delimPrefixLen > 0) {
if (context->changeRange.location < delimPrefixLen) {
return NO;
}
NSString *prefix = [trigger substringToIndex:delimPrefixLen];
NSString *beforeCursor = [context->fullText
substringWithRange:NSMakeRange(context->changeRange.location -
delimPrefixLen,
delimPrefixLen)];
if (![beforeCursor isEqualToString:prefix]) {
return NO;
}
}
NSInteger delimStart = context->changeRange.location - delimPrefixLen;
if (requiredDelimStart != NSNotFound && delimStart != requiredDelimStart) {
return NO;
}
if (outMatch != nullptr) {
outMatch->trigger = trigger;
outMatch->delimStart = delimStart;
outMatch->delimPrefixLen = delimPrefixLen;
}
return YES;
}
/// Delimiter at [delimStart] is part of a longer inline trigger (e.g. `*`
/// inside `**`).
+ (BOOL)isDelimiterPartOfLongerInlineTrigger:(NSString *)trigger
delimStart:(NSInteger)delimStart
context:(const ShortcutsInlineContext *)
context {
NSInteger delimEnd = delimStart + trigger.length;
NSString *fullText = context->text.fullText;
for (NSDictionary *shortcut in context->inlineShortcuts) {
NSString *longerTrigger = shortcut[@"trigger"];
if (longerTrigger.length <= trigger.length) {
continue;
}
if (![longerTrigger hasSuffix:trigger]) {
continue;
}
NSInteger longerStart = delimEnd - longerTrigger.length;
if (longerStart < 0 ||
longerStart + longerTrigger.length > fullText.length) {
continue;
}
NSRange longerRange = NSMakeRange(longerStart, longerTrigger.length);
if ([[fullText substringWithRange:longerRange]
isEqualToString:longerTrigger]) {
return YES;
}
}
return NO;
}
/// Removes delimiters (close first, then open), then applies [style] on
/// [contentRange].
+ (void)applyInlineShortcutWithMatch:(const ShortcutsTriggerMatch *)match
context:(const ShortcutsInlineContext *)context
ranges:
(const ShortcutsInlineApplyRanges *)ranges {
EnrichedTextInputView *input = context->text.input;
input->blockEmitting = YES;
if (ranges->closeDeleteRange.length > 0) {
[TextInsertionUtils replaceText:@""
at:ranges->closeDeleteRange
additionalAttributes:nullptr
host:input
withSelection:NO];
}
if (ranges->openDeleteRange.length > 0) {
[TextInsertionUtils replaceText:@""
at:ranges->openDeleteRange
additionalAttributes:nullptr
host:input
withSelection:NO];
}
input->blockEmitting = NO;
StyleBase *style = input->stylesDict[@(match->styleType)];
if (style == nil) {
return;
}
[style add:ranges->finalContentRange withTyping:NO withDirtyRange:YES];
input->textView.selectedRange =
NSMakeRange(NSMaxRange(ranges->finalContentRange), 0);
[style removeTyping];
}
+ (BOOL)applyInlineShortcutWithMatch:(const ShortcutsTriggerMatch *)match
context:(const ShortcutsInlineContext *)context
contentRange:(NSRange)contentRange
ranges:
(const ShortcutsInlineApplyRanges *)ranges {
if (![StyleUtils handleStyleBlocksAndConflicts:match->styleType
range:contentRange
forHost:context->text.input]) {
return NO;
}
[self applyInlineShortcutWithMatch:match context:context ranges:ranges];
return YES;
}
/// Closing delimiter just completed: find opening trigger before content.
+ (BOOL)tryInlineShortcutClosingFirst:(const ShortcutsTriggerMatch *)match
context:(const ShortcutsInlineContext *)context {
const ShortcutsTextContext *text = &context->text;
NSInteger searchStart = text->paragraphRange.location;
NSInteger searchLength = match->delimStart - searchStart;
if (searchLength <= 0) {
return NO;
}
NSRange openRange =
[text->fullText rangeOfString:match->trigger
options:NSBackwardsSearch
range:NSMakeRange(searchStart, searchLength)];
if (openRange.location == NSNotFound) {
return NO;
}
if ([self isDelimiterPartOfLongerInlineTrigger:match->trigger
delimStart:openRange.location
context:context]) {
return NO;
}
NSInteger contentStart = openRange.location + match->trigger.length;
NSInteger contentEnd = match->delimStart;
if (contentEnd <= contentStart) {
return NO;
}
NSInteger finalContentEnd = match->delimStart - match->trigger.length;
ShortcutsInlineApplyRanges ranges = {
.finalContentRange =
NSMakeRange(openRange.location, finalContentEnd - openRange.location),
.closeDeleteRange = NSMakeRange(match->delimStart, match->delimPrefixLen),
.openDeleteRange = NSMakeRange(openRange.location, match->trigger.length),
};
return
[self applyInlineShortcutWithMatch:match
context:context
contentRange:NSMakeRange(contentStart,
contentEnd - contentStart)
ranges:&ranges];
}
/// Paragraph already has a paragraph-level style (list, quote, heading, …).
/// Alignment is ignored.
+ (BOOL)paragraphHasActiveParagraphStyleInRange:(NSRange)paragraphRange
input:(EnrichedTextInputView *)input {
for (NSNumber *typeKey in input->stylesDict) {
StyleBase *style = input->stylesDict[typeKey];
if (![style isParagraph] || [[style class] getType] == Alignment) {
continue;
}
if ([style detect:paragraphRange]) {
return YES;
}
}
return NO;
}
/// Handles a paragraph-level shortcut (e.g. `# ` → H1, `- ` → unordered list)
/// on character insertion.
///
/// 1. Skip if no shortcuts configured.
/// 2. Find a paragraph shortcut whose trigger is anchored to the paragraph
/// 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 {
if (![self anyTextShortcutsInInput:input]) {
return NO;
}
ShortcutsTextContext context = [self textContextWithChangeRange:range
replacementText:text
input:input];
BOOL paragraphHasActiveStyle =
[self paragraphHasActiveParagraphStyleInRange:context.paragraphRange
input:input];
for (NSDictionary *shortcut in input->textShortcuts) {
if ([self isInlineShortcutStyleName:shortcut[@"style"] input:input]) {
continue;
}
NSString *trigger = shortcut[@"trigger"];
NSString *styleName = shortcut[@"style"];
if (trigger.length == 0 || styleName.length == 0) {
continue;
}
ShortcutsTriggerMatch match = {};
if (![self isCompletingTrigger:trigger
context:&context
requiredDelimStart:context.paragraphRange.location
match:&match]) {
continue;
}
StyleType type = [self styleTypeForShortcutName:styleName];
if (type == None) {
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;
}
NSRange triggerRange = NSMakeRange(match.delimStart, match.delimPrefixLen);
input->blockEmitting = YES;
[TextInsertionUtils replaceText:@""
at:triggerRange
additionalAttributes:nullptr
host:input
withSelection:YES];
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
paragraphRangeForRange:input->textView.selectedRange];
[StyleUtils handleStyleBlocksAndConflicts:type
range:paragraphRange
forHost:input];
[ParagraphAttributesUtils resetTypingAttributes:input
preservingAlignment:savedAlignment];
[style add:input->textView.selectedRange withTyping:YES withDirtyRange:YES];
return YES;
}
return NO;
}
/// Handles an inline shortcut (e.g. `**text**` → bold) on character insertion.
/// Inline shortcuts are symmetric delimiter pairs — the same string opens and
/// closes the style (e.g. `**`).
///
/// 1. Build the inline context: inline-only shortcuts sorted longest-first so
/// `**` is never pre-empted by its shorter suffix `*`.
/// 2. Check if the just-typed character, together with the characters
/// immediately before the cursor, completes a closing delimiter.
/// 3. Search backwards for a matching opening delimiter. Reject if it is part
/// of a longer trigger, or if there is no content between the pair.
/// 4. Check style blocks/conflicts; skip if the style cannot be applied.
/// 5. Suppress events, delete the closing-delimiter prefix then the opening
/// delimiter (close first so the open's earlier index stays valid).
/// 6. Apply the style to the content range, move the cursor to its end, and
/// clear the typing style.
+ (BOOL)tryHandlingInlineShortcutsInRange:(NSRange)range
replacementText:(NSString *)text
input:(EnrichedTextInputView *)input {
if (![self anyTextShortcutsInInput:input]) {
return NO;
}
ShortcutsInlineContext context = [self inlineContextWithChangeRange:range
replacementText:text
input:input];
for (NSDictionary *shortcut in context.inlineShortcuts) {
NSString *trigger = shortcut[@"trigger"];
NSString *styleName = shortcut[@"style"];
if (trigger.length == 0 || styleName.length == 0) {
continue;
}
ShortcutsTriggerMatch match = {};
if (![self isCompletingTrigger:trigger
context:&context.text
requiredDelimStart:NSNotFound
match:&match]) {
continue;
}
StyleType type = [self styleTypeForShortcutName:styleName];
if (type == None) {
continue;
}
match.styleType = type;
if ([self tryInlineShortcutClosingFirst:&match context:&context]) {
return YES;
}
}
return NO;
}
@end