@apollohg/react-native-prose-editor 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/android/build.gradle +7 -2
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +289 -2
- package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +51 -1
- package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +16 -3
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +82 -1
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +403 -45
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +246 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +841 -155
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +125 -8
- package/{src/EditorTheme.ts → dist/EditorTheme.d.ts} +12 -52
- package/dist/EditorTheme.js +29 -0
- package/dist/EditorToolbar.d.ts +129 -0
- package/dist/EditorToolbar.js +394 -0
- package/dist/NativeEditorBridge.d.ts +242 -0
- package/dist/NativeEditorBridge.js +647 -0
- package/dist/NativeRichTextEditor.d.ts +142 -0
- package/dist/NativeRichTextEditor.js +649 -0
- package/dist/YjsCollaboration.d.ts +83 -0
- package/dist/YjsCollaboration.js +585 -0
- package/dist/addons.d.ts +70 -0
- package/dist/addons.js +77 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +26 -0
- package/dist/schemas.d.ts +35 -0
- package/{src/schemas.ts → dist/schemas.js} +62 -27
- package/dist/useNativeEditor.d.ts +40 -0
- package/dist/useNativeEditor.js +117 -0
- package/ios/EditorAddons.swift +26 -3
- package/ios/EditorCore.xcframework/Info.plist +5 -5
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/EditorLayoutManager.swift +236 -0
- package/ios/EditorTheme.swift +51 -1
- package/ios/Generated_editor_core.swift +270 -2
- package/ios/NativeEditorExpoView.swift +612 -45
- package/ios/NativeEditorModule.swift +81 -0
- package/ios/PositionBridge.swift +22 -0
- package/ios/RenderBridge.swift +427 -39
- package/ios/RichTextEditorView.swift +1342 -18
- package/ios/editor_coreFFI/editor_coreFFI.h +209 -0
- package/package.json +80 -64
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +404 -4
- package/src/EditorToolbar.tsx +0 -620
- package/src/NativeEditorBridge.ts +0 -607
- package/src/NativeRichTextEditor.tsx +0 -951
- package/src/addons.ts +0 -158
- package/src/index.ts +0 -63
- package/src/useNativeEditor.ts +0 -173
package/ios/RenderBridge.swift
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import UIKit
|
|
2
2
|
|
|
3
|
+
extension Notification.Name {
|
|
4
|
+
static let editorImageAttachmentDidLoad = Notification.Name(
|
|
5
|
+
"com.apollohg.editor.imageAttachmentDidLoad"
|
|
6
|
+
)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
private enum RenderImageCache {
|
|
10
|
+
static let cache = NSCache<NSString, UIImage>()
|
|
11
|
+
static let queue = DispatchQueue(label: "com.apollohg.editor.image-loader", qos: .userInitiated)
|
|
12
|
+
}
|
|
13
|
+
|
|
3
14
|
// MARK: - Constants
|
|
4
15
|
|
|
5
16
|
/// Custom NSAttributedString attribute keys for editor metadata.
|
|
@@ -34,6 +45,18 @@ enum RenderBridgeAttributes {
|
|
|
34
45
|
|
|
35
46
|
/// Stores the reserved list marker gutter width.
|
|
36
47
|
static let listMarkerWidth = NSAttributedString.Key("com.apollohg.editor.listMarkerWidth")
|
|
48
|
+
|
|
49
|
+
/// Stores the rendered blockquote border color.
|
|
50
|
+
static let blockquoteBorderColor = NSAttributedString.Key("com.apollohg.editor.blockquoteBorderColor")
|
|
51
|
+
|
|
52
|
+
/// Stores the rendered blockquote border width.
|
|
53
|
+
static let blockquoteBorderWidth = NSAttributedString.Key("com.apollohg.editor.blockquoteBorderWidth")
|
|
54
|
+
|
|
55
|
+
/// Stores the rendered blockquote gap between border and text.
|
|
56
|
+
static let blockquoteMarkerGap = NSAttributedString.Key("com.apollohg.editor.blockquoteMarkerGap")
|
|
57
|
+
|
|
58
|
+
/// Marks synthetic zero-width placeholders used only for UIKit layout.
|
|
59
|
+
static let syntheticPlaceholder = NSAttributedString.Key("com.apollohg.editor.syntheticPlaceholder")
|
|
37
60
|
}
|
|
38
61
|
|
|
39
62
|
/// Layout constants for paragraph styles.
|
|
@@ -56,6 +79,15 @@ enum LayoutConstants {
|
|
|
56
79
|
/// Vertical padding above and below the horizontal rule (points).
|
|
57
80
|
static let horizontalRuleVerticalPadding: CGFloat = 8.0
|
|
58
81
|
|
|
82
|
+
/// Total leading inset reserved for each blockquote depth.
|
|
83
|
+
static let blockquoteIndent: CGFloat = 18.0
|
|
84
|
+
|
|
85
|
+
/// Width of the rendered blockquote border bar.
|
|
86
|
+
static let blockquoteBorderWidth: CGFloat = 3.0
|
|
87
|
+
|
|
88
|
+
/// Gap between the blockquote border bar and the text that follows.
|
|
89
|
+
static let blockquoteMarkerGap: CGFloat = 8.0
|
|
90
|
+
|
|
59
91
|
/// Bullet character for unordered list items.
|
|
60
92
|
static let unorderedListBullet = "\u{2022} "
|
|
61
93
|
|
|
@@ -141,7 +173,7 @@ final class RenderBridge {
|
|
|
141
173
|
switch type {
|
|
142
174
|
case "textRun":
|
|
143
175
|
let text = element["text"] as? String ?? ""
|
|
144
|
-
let marks = element["marks"] as? [
|
|
176
|
+
let marks = element["marks"] as? [Any] ?? []
|
|
145
177
|
let blockFont = resolvedFont(
|
|
146
178
|
for: blockStack,
|
|
147
179
|
baseFont: baseFont,
|
|
@@ -167,9 +199,14 @@ final class RenderBridge {
|
|
|
167
199
|
case "voidInline":
|
|
168
200
|
let nodeType = element["nodeType"] as? String ?? ""
|
|
169
201
|
let docPos = jsonUInt32(element["docPos"])
|
|
202
|
+
let attrs = element["attrs"] as? [String: Any] ?? [:]
|
|
203
|
+
if nodeType == "hardBreak" {
|
|
204
|
+
overrideTrailingParagraphSpacing(in: result, paragraphSpacing: 0)
|
|
205
|
+
}
|
|
170
206
|
let attrStr = attributedStringForVoidInline(
|
|
171
207
|
nodeType: nodeType,
|
|
172
208
|
docPos: docPos,
|
|
209
|
+
attrs: attrs,
|
|
173
210
|
baseFont: baseFont,
|
|
174
211
|
textColor: textColor,
|
|
175
212
|
blockStack: blockStack,
|
|
@@ -180,6 +217,7 @@ final class RenderBridge {
|
|
|
180
217
|
case "voidBlock":
|
|
181
218
|
let nodeType = element["nodeType"] as? String ?? ""
|
|
182
219
|
let docPos = jsonUInt32(element["docPos"])
|
|
220
|
+
let attrs = element["attrs"] as? [String: Any] ?? [:]
|
|
183
221
|
|
|
184
222
|
// Add inter-block newline if not the first block.
|
|
185
223
|
if !isFirstBlock {
|
|
@@ -187,13 +225,21 @@ final class RenderBridge {
|
|
|
187
225
|
in: result,
|
|
188
226
|
pendingParagraphSpacing: &pendingTrailingParagraphSpacing
|
|
189
227
|
)
|
|
190
|
-
result.append(
|
|
228
|
+
result.append(
|
|
229
|
+
interBlockNewline(
|
|
230
|
+
baseFont: baseFont,
|
|
231
|
+
textColor: textColor,
|
|
232
|
+
blockStack: [],
|
|
233
|
+
theme: theme
|
|
234
|
+
)
|
|
235
|
+
)
|
|
191
236
|
}
|
|
192
237
|
isFirstBlock = false
|
|
193
238
|
|
|
194
239
|
let attrStr = attributedStringForVoidBlock(
|
|
195
240
|
nodeType: nodeType,
|
|
196
241
|
docPos: docPos,
|
|
242
|
+
elementAttrs: attrs,
|
|
197
243
|
baseFont: baseFont,
|
|
198
244
|
textColor: textColor,
|
|
199
245
|
theme: theme
|
|
@@ -225,7 +271,14 @@ final class RenderBridge {
|
|
|
225
271
|
in: result,
|
|
226
272
|
pendingParagraphSpacing: &pendingTrailingParagraphSpacing
|
|
227
273
|
)
|
|
228
|
-
result.append(
|
|
274
|
+
result.append(
|
|
275
|
+
interBlockNewline(
|
|
276
|
+
baseFont: baseFont,
|
|
277
|
+
textColor: textColor,
|
|
278
|
+
blockStack: [],
|
|
279
|
+
theme: theme
|
|
280
|
+
)
|
|
281
|
+
)
|
|
229
282
|
}
|
|
230
283
|
isFirstBlock = false
|
|
231
284
|
|
|
@@ -244,18 +297,40 @@ final class RenderBridge {
|
|
|
244
297
|
let depth = jsonUInt8(element["depth"])
|
|
245
298
|
let listContext = element["listContext"] as? [String: Any]
|
|
246
299
|
let isListItemContainer = nodeType == "listItem" && listContext != nil
|
|
300
|
+
let isTransparentContainer = nodeType == "blockquote"
|
|
301
|
+
let ctx = BlockContext(
|
|
302
|
+
nodeType: nodeType,
|
|
303
|
+
depth: depth,
|
|
304
|
+
listContext: listContext,
|
|
305
|
+
markerPending: isListItemContainer
|
|
306
|
+
)
|
|
247
307
|
let nestedListItemContainer =
|
|
248
308
|
isListItemContainer && (theme?.list?.itemSpacing != nil)
|
|
249
309
|
&& blockStack.contains(where: { $0.nodeType == "listItem" && $0.listContext != nil })
|
|
250
310
|
|
|
251
|
-
if !isListItemContainer {
|
|
311
|
+
if !isListItemContainer && !isTransparentContainer {
|
|
252
312
|
// Add inter-block newline before non-first rendered blocks.
|
|
253
313
|
if !isFirstBlock {
|
|
254
314
|
applyPendingTrailingParagraphSpacing(
|
|
255
315
|
in: result,
|
|
256
316
|
pendingParagraphSpacing: &pendingTrailingParagraphSpacing
|
|
257
317
|
)
|
|
258
|
-
|
|
318
|
+
let newlineBlockStack: [BlockContext]
|
|
319
|
+
if blockquoteDepth(in: blockStack + [ctx]) > 0,
|
|
320
|
+
!trailingRenderedContentHasBlockquote(in: result)
|
|
321
|
+
{
|
|
322
|
+
newlineBlockStack = []
|
|
323
|
+
} else {
|
|
324
|
+
newlineBlockStack = blockStack + [ctx]
|
|
325
|
+
}
|
|
326
|
+
result.append(
|
|
327
|
+
interBlockNewline(
|
|
328
|
+
baseFont: baseFont,
|
|
329
|
+
textColor: textColor,
|
|
330
|
+
blockStack: newlineBlockStack,
|
|
331
|
+
theme: theme
|
|
332
|
+
)
|
|
333
|
+
)
|
|
259
334
|
}
|
|
260
335
|
isFirstBlock = false
|
|
261
336
|
} else if applyPendingTrailingParagraphSpacing(
|
|
@@ -271,12 +346,6 @@ final class RenderBridge {
|
|
|
271
346
|
}
|
|
272
347
|
|
|
273
348
|
// Push block context for inline children to reference.
|
|
274
|
-
let ctx = BlockContext(
|
|
275
|
-
nodeType: nodeType,
|
|
276
|
-
depth: depth,
|
|
277
|
-
listContext: listContext,
|
|
278
|
-
markerPending: isListItemContainer
|
|
279
|
-
)
|
|
280
349
|
blockStack.append(ctx)
|
|
281
350
|
|
|
282
351
|
var markerListContext: [String: Any]? = nil
|
|
@@ -291,7 +360,7 @@ final class RenderBridge {
|
|
|
291
360
|
if markerListContext != nil {
|
|
292
361
|
if var currentBlock = blockStack.popLast() {
|
|
293
362
|
currentBlock.listMarkerContext = markerListContext
|
|
294
|
-
if currentBlock.listContext
|
|
363
|
+
if currentBlock.listContext != nil {
|
|
295
364
|
currentBlock.listContext = markerListContext
|
|
296
365
|
}
|
|
297
366
|
blockStack.append(currentBlock)
|
|
@@ -301,8 +370,18 @@ final class RenderBridge {
|
|
|
301
370
|
}
|
|
302
371
|
|
|
303
372
|
case "blockEnd":
|
|
304
|
-
if let endedBlock = blockStack.popLast()
|
|
305
|
-
|
|
373
|
+
if let endedBlock = blockStack.popLast() {
|
|
374
|
+
appendTrailingHardBreakPlaceholderIfNeeded(
|
|
375
|
+
in: result,
|
|
376
|
+
endedBlock: endedBlock,
|
|
377
|
+
remainingBlockStack: blockStack,
|
|
378
|
+
baseFont: baseFont,
|
|
379
|
+
textColor: textColor,
|
|
380
|
+
theme: theme
|
|
381
|
+
)
|
|
382
|
+
if endedBlock.listContext != nil {
|
|
383
|
+
pendingTrailingParagraphSpacing = theme?.list?.itemSpacing
|
|
384
|
+
}
|
|
306
385
|
}
|
|
307
386
|
|
|
308
387
|
default:
|
|
@@ -315,7 +394,7 @@ final class RenderBridge {
|
|
|
315
394
|
|
|
316
395
|
// MARK: - Mark Handling
|
|
317
396
|
|
|
318
|
-
/// Build NSAttributedString attributes for a set of
|
|
397
|
+
/// Build NSAttributedString attributes for a set of render marks.
|
|
319
398
|
///
|
|
320
399
|
/// Supported marks:
|
|
321
400
|
/// - `bold` -> adds `.traitBold` to the font descriptor
|
|
@@ -326,7 +405,7 @@ final class RenderBridge {
|
|
|
326
405
|
///
|
|
327
406
|
/// Multiple marks are combined: "bold italic" produces a bold-italic font.
|
|
328
407
|
static func attributesForMarks(
|
|
329
|
-
_ marks: [
|
|
408
|
+
_ marks: [Any],
|
|
330
409
|
baseFont: UIFont,
|
|
331
410
|
textColor: UIColor
|
|
332
411
|
) -> [NSAttributedString.Key: Any] {
|
|
@@ -338,9 +417,18 @@ final class RenderBridge {
|
|
|
338
417
|
|
|
339
418
|
var traits: UIFontDescriptor.SymbolicTraits = []
|
|
340
419
|
var useMonospace = false
|
|
341
|
-
|
|
342
420
|
for mark in marks {
|
|
343
|
-
|
|
421
|
+
let markType: String
|
|
422
|
+
if let markName = mark as? String {
|
|
423
|
+
markType = markName
|
|
424
|
+
} else if let markObject = mark as? [String: Any],
|
|
425
|
+
let resolvedType = markObject["type"] as? String {
|
|
426
|
+
markType = resolvedType
|
|
427
|
+
} else {
|
|
428
|
+
continue
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
switch markType {
|
|
344
432
|
case "bold", "strong":
|
|
345
433
|
traits.insert(.traitBold)
|
|
346
434
|
case "italic", "em":
|
|
@@ -351,6 +439,9 @@ final class RenderBridge {
|
|
|
351
439
|
attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
352
440
|
case "code":
|
|
353
441
|
useMonospace = true
|
|
442
|
+
case "link":
|
|
443
|
+
attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
444
|
+
attrs[.foregroundColor] = UIColor.systemBlue
|
|
354
445
|
default:
|
|
355
446
|
break
|
|
356
447
|
}
|
|
@@ -391,6 +482,7 @@ final class RenderBridge {
|
|
|
391
482
|
private static func attributedStringForVoidInline(
|
|
392
483
|
nodeType: String,
|
|
393
484
|
docPos: UInt32,
|
|
485
|
+
attrs _: [String: Any],
|
|
394
486
|
baseFont: UIFont,
|
|
395
487
|
textColor: UIColor,
|
|
396
488
|
blockStack: [BlockContext],
|
|
@@ -409,7 +501,14 @@ final class RenderBridge {
|
|
|
409
501
|
|
|
410
502
|
switch nodeType {
|
|
411
503
|
case "hardBreak":
|
|
412
|
-
|
|
504
|
+
var hardBreakAttrs = styledAttrs
|
|
505
|
+
if let paragraphStyle = (hardBreakAttrs[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy()
|
|
506
|
+
as? NSMutableParagraphStyle
|
|
507
|
+
{
|
|
508
|
+
paragraphStyle.paragraphSpacing = 0
|
|
509
|
+
hardBreakAttrs[.paragraphStyle] = paragraphStyle
|
|
510
|
+
}
|
|
511
|
+
return NSAttributedString(string: "\n", attributes: hardBreakAttrs)
|
|
413
512
|
default:
|
|
414
513
|
// Unknown void inline: render as object replacement character.
|
|
415
514
|
return NSAttributedString(
|
|
@@ -428,6 +527,7 @@ final class RenderBridge {
|
|
|
428
527
|
private static func attributedStringForVoidBlock(
|
|
429
528
|
nodeType: String,
|
|
430
529
|
docPos: UInt32,
|
|
530
|
+
elementAttrs: [String: Any],
|
|
431
531
|
baseFont: UIFont,
|
|
432
532
|
textColor: UIColor,
|
|
433
533
|
theme: EditorTheme?
|
|
@@ -449,6 +549,25 @@ final class RenderBridge {
|
|
|
449
549
|
let range = NSRange(location: 0, length: attrStr.length)
|
|
450
550
|
attrStr.addAttributes(attrs, range: range)
|
|
451
551
|
return attrStr
|
|
552
|
+
case "image":
|
|
553
|
+
guard let source = (elementAttrs["src"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
554
|
+
!source.isEmpty
|
|
555
|
+
else {
|
|
556
|
+
return NSAttributedString(
|
|
557
|
+
string: LayoutConstants.objectReplacementCharacter,
|
|
558
|
+
attributes: attrs
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
let attachment = BlockImageAttachment(
|
|
562
|
+
source: source,
|
|
563
|
+
placeholderTint: textColor,
|
|
564
|
+
preferredWidth: jsonCGFloat(elementAttrs["width"]),
|
|
565
|
+
preferredHeight: jsonCGFloat(elementAttrs["height"])
|
|
566
|
+
)
|
|
567
|
+
let attrStr = NSMutableAttributedString(attachment: attachment)
|
|
568
|
+
let range = NSRange(location: 0, length: attrStr.length)
|
|
569
|
+
attrStr.addAttributes(attrs, range: range)
|
|
570
|
+
return attrStr
|
|
452
571
|
default:
|
|
453
572
|
// Unknown void block: render as object replacement character.
|
|
454
573
|
return NSAttributedString(
|
|
@@ -539,11 +658,15 @@ final class RenderBridge {
|
|
|
539
658
|
/// a hanging indent so the bullet/number sits in the margin.
|
|
540
659
|
static func paragraphStyleForBlock(
|
|
541
660
|
_ context: BlockContext,
|
|
661
|
+
blockStack: [BlockContext],
|
|
542
662
|
theme: EditorTheme? = nil,
|
|
543
663
|
baseFont: UIFont = .systemFont(ofSize: 16)
|
|
544
664
|
) -> NSMutableParagraphStyle {
|
|
545
665
|
let style = NSMutableParagraphStyle()
|
|
546
|
-
let blockStyle = theme?.effectiveTextStyle(
|
|
666
|
+
let blockStyle = theme?.effectiveTextStyle(
|
|
667
|
+
for: context.nodeType,
|
|
668
|
+
inBlockquote: blockquoteDepth(in: blockStack) > 0
|
|
669
|
+
)
|
|
547
670
|
let spacing = blockStyle?.spacingAfter
|
|
548
671
|
?? (context.listContext != nil ? theme?.list?.itemSpacing : nil)
|
|
549
672
|
?? LayoutConstants.paragraphSpacing
|
|
@@ -551,7 +674,15 @@ final class RenderBridge {
|
|
|
551
674
|
|
|
552
675
|
let indentPerDepth = theme?.list?.indent ?? LayoutConstants.indentPerDepth
|
|
553
676
|
let markerWidth = listMarkerWidth(for: context, theme: theme, baseFont: baseFont)
|
|
554
|
-
let
|
|
677
|
+
let quoteDepth = CGFloat(blockquoteDepth(in: blockStack))
|
|
678
|
+
let quoteIndent = max(
|
|
679
|
+
theme?.blockquote?.indent ?? LayoutConstants.blockquoteIndent,
|
|
680
|
+
(theme?.blockquote?.markerGap ?? LayoutConstants.blockquoteMarkerGap)
|
|
681
|
+
+ (theme?.blockquote?.borderWidth ?? LayoutConstants.blockquoteBorderWidth)
|
|
682
|
+
)
|
|
683
|
+
let baseIndent = (CGFloat(context.depth) * indentPerDepth)
|
|
684
|
+
- (quoteDepth * indentPerDepth)
|
|
685
|
+
+ (quoteDepth * quoteIndent)
|
|
555
686
|
|
|
556
687
|
if context.listContext != nil {
|
|
557
688
|
// List item: reserve a fixed gutter and align all wrapped lines to
|
|
@@ -587,11 +718,7 @@ final class RenderBridge {
|
|
|
587
718
|
|
|
588
719
|
// MARK: - Private Helpers
|
|
589
720
|
|
|
590
|
-
///
|
|
591
|
-
///
|
|
592
|
-
/// `JSONSerialization` produces `NSNumber` for numeric values. Direct `as? UInt32`
|
|
593
|
-
/// cast may fail depending on the stored numeric type. This helper handles all
|
|
594
|
-
/// numeric types that `JSONSerialization` can produce.
|
|
721
|
+
/// Extract a `UInt32` from a JSON value produced by `JSONSerialization`.
|
|
595
722
|
static func jsonUInt32(_ value: Any?) -> UInt32 {
|
|
596
723
|
if let number = value as? NSNumber {
|
|
597
724
|
return number.uint32Value
|
|
@@ -599,7 +726,7 @@ final class RenderBridge {
|
|
|
599
726
|
return 0
|
|
600
727
|
}
|
|
601
728
|
|
|
602
|
-
///
|
|
729
|
+
/// Extract a `UInt8` from a JSON value produced by `JSONSerialization`.
|
|
603
730
|
static func jsonUInt8(_ value: Any?) -> UInt8 {
|
|
604
731
|
if let number = value as? NSNumber {
|
|
605
732
|
return number.uint8Value
|
|
@@ -607,7 +734,21 @@ final class RenderBridge {
|
|
|
607
734
|
return 0
|
|
608
735
|
}
|
|
609
736
|
|
|
610
|
-
///
|
|
737
|
+
/// Extract a positive `CGFloat` from a JSON value produced by `JSONSerialization`.
|
|
738
|
+
static func jsonCGFloat(_ value: Any?) -> CGFloat? {
|
|
739
|
+
if let number = value as? NSNumber {
|
|
740
|
+
let resolved = CGFloat(truncating: number)
|
|
741
|
+
return resolved > 0 ? resolved : nil
|
|
742
|
+
}
|
|
743
|
+
if let string = value as? String,
|
|
744
|
+
let resolved = Double(string.trimmingCharacters(in: .whitespacesAndNewlines)),
|
|
745
|
+
resolved > 0
|
|
746
|
+
{
|
|
747
|
+
return CGFloat(resolved)
|
|
748
|
+
}
|
|
749
|
+
return nil
|
|
750
|
+
}
|
|
751
|
+
|
|
611
752
|
private static func defaultAttributes(
|
|
612
753
|
baseFont: UIFont,
|
|
613
754
|
textColor: UIColor
|
|
@@ -618,9 +759,6 @@ final class RenderBridge {
|
|
|
618
759
|
]
|
|
619
760
|
}
|
|
620
761
|
|
|
621
|
-
/// Apply the current block context's paragraph style to a mutable attributes dictionary.
|
|
622
|
-
///
|
|
623
|
-
/// This is a no-op if no block context is active.
|
|
624
762
|
@discardableResult
|
|
625
763
|
private static func applyBlockStyle(
|
|
626
764
|
to attrs: [NSAttributedString.Key: Any],
|
|
@@ -632,6 +770,7 @@ final class RenderBridge {
|
|
|
632
770
|
let blockFont = mutableAttrs[.font] as? UIFont ?? .systemFont(ofSize: 16)
|
|
633
771
|
mutableAttrs[.paragraphStyle] = paragraphStyleForBlock(
|
|
634
772
|
currentBlock,
|
|
773
|
+
blockStack: blockStack,
|
|
635
774
|
theme: theme,
|
|
636
775
|
baseFont: blockFont
|
|
637
776
|
)
|
|
@@ -650,6 +789,16 @@ final class RenderBridge {
|
|
|
650
789
|
baseFont: blockFont
|
|
651
790
|
)
|
|
652
791
|
}
|
|
792
|
+
if blockquoteDepth(in: blockStack) > 0 {
|
|
793
|
+
let foreground = mutableAttrs[.foregroundColor] as? UIColor ?? .separator
|
|
794
|
+
mutableAttrs[RenderBridgeAttributes.blockquoteBorderColor] =
|
|
795
|
+
theme?.blockquote?.borderColor
|
|
796
|
+
?? foreground.withAlphaComponent(0.3)
|
|
797
|
+
mutableAttrs[RenderBridgeAttributes.blockquoteBorderWidth] =
|
|
798
|
+
theme?.blockquote?.borderWidth ?? LayoutConstants.blockquoteBorderWidth
|
|
799
|
+
mutableAttrs[RenderBridgeAttributes.blockquoteMarkerGap] =
|
|
800
|
+
theme?.blockquote?.markerGap ?? LayoutConstants.blockquoteMarkerGap
|
|
801
|
+
}
|
|
653
802
|
return mutableAttrs
|
|
654
803
|
}
|
|
655
804
|
|
|
@@ -659,9 +808,15 @@ final class RenderBridge {
|
|
|
659
808
|
/// It carries minimal styling (base font, no special attributes).
|
|
660
809
|
private static func interBlockNewline(
|
|
661
810
|
baseFont: UIFont,
|
|
662
|
-
textColor: UIColor
|
|
811
|
+
textColor: UIColor,
|
|
812
|
+
blockStack: [BlockContext],
|
|
813
|
+
theme: EditorTheme?
|
|
663
814
|
) -> NSAttributedString {
|
|
664
|
-
let attrs =
|
|
815
|
+
let attrs = applyBlockStyle(
|
|
816
|
+
to: defaultAttributes(baseFont: baseFont, textColor: textColor),
|
|
817
|
+
blockStack: blockStack,
|
|
818
|
+
theme: theme
|
|
819
|
+
)
|
|
665
820
|
return NSAttributedString(string: "\n", attributes: attrs)
|
|
666
821
|
}
|
|
667
822
|
|
|
@@ -670,24 +825,46 @@ final class RenderBridge {
|
|
|
670
825
|
if currentBlock.listContext != nil {
|
|
671
826
|
return currentBlock
|
|
672
827
|
}
|
|
673
|
-
guard let
|
|
828
|
+
guard let inheritedListBlock = nearestListBlock(in: Array(blockStack.dropLast())) else {
|
|
674
829
|
return currentBlock
|
|
675
830
|
}
|
|
676
831
|
return BlockContext(
|
|
677
832
|
nodeType: currentBlock.nodeType,
|
|
678
833
|
depth: currentBlock.depth,
|
|
679
|
-
listContext:
|
|
834
|
+
listContext: inheritedListBlock.listContext,
|
|
835
|
+
listMarkerContext: currentBlock.listMarkerContext,
|
|
680
836
|
markerPending: false
|
|
681
837
|
)
|
|
682
838
|
}
|
|
683
839
|
|
|
684
|
-
private static func
|
|
840
|
+
private static func nearestListBlock(in contexts: [BlockContext]) -> BlockContext? {
|
|
685
841
|
for context in contexts.reversed() where context.listContext != nil {
|
|
686
|
-
return context
|
|
842
|
+
return context
|
|
687
843
|
}
|
|
688
844
|
return nil
|
|
689
845
|
}
|
|
690
846
|
|
|
847
|
+
private static func trailingRenderedContentHasBlockquote(
|
|
848
|
+
in result: NSAttributedString
|
|
849
|
+
) -> Bool {
|
|
850
|
+
guard result.length > 0 else { return false }
|
|
851
|
+
let nsString = result.string as NSString
|
|
852
|
+
|
|
853
|
+
for index in stride(from: result.length - 1, through: 0, by: -1) {
|
|
854
|
+
let scalar = nsString.character(at: index)
|
|
855
|
+
if scalar == 0x000A || scalar == 0x000D {
|
|
856
|
+
continue
|
|
857
|
+
}
|
|
858
|
+
return result.attribute(
|
|
859
|
+
RenderBridgeAttributes.blockquoteBorderColor,
|
|
860
|
+
at: index,
|
|
861
|
+
effectiveRange: nil
|
|
862
|
+
) != nil
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
return false
|
|
866
|
+
}
|
|
867
|
+
|
|
691
868
|
private static func consumePendingListMarker(from blockStack: inout [BlockContext]) -> [String: Any]? {
|
|
692
869
|
guard blockStack.count >= 2 else { return nil }
|
|
693
870
|
for idx in stride(from: blockStack.count - 2, through: 0, by: -1) {
|
|
@@ -725,6 +902,51 @@ final class RenderBridge {
|
|
|
725
902
|
return true
|
|
726
903
|
}
|
|
727
904
|
|
|
905
|
+
private static func appendTrailingHardBreakPlaceholderIfNeeded(
|
|
906
|
+
in result: NSMutableAttributedString,
|
|
907
|
+
endedBlock: BlockContext,
|
|
908
|
+
remainingBlockStack: [BlockContext],
|
|
909
|
+
baseFont: UIFont,
|
|
910
|
+
textColor: UIColor,
|
|
911
|
+
theme: EditorTheme?
|
|
912
|
+
) {
|
|
913
|
+
guard result.length > 0 else { return }
|
|
914
|
+
guard endedBlock.nodeType != "listItem" else { return }
|
|
915
|
+
guard result.attribute(
|
|
916
|
+
RenderBridgeAttributes.voidNodeType,
|
|
917
|
+
at: result.length - 1,
|
|
918
|
+
effectiveRange: nil
|
|
919
|
+
) as? String == "hardBreak" else {
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
let placeholderBlockStack = remainingBlockStack + [endedBlock]
|
|
924
|
+
let blockFont = resolvedFont(
|
|
925
|
+
for: placeholderBlockStack,
|
|
926
|
+
baseFont: baseFont,
|
|
927
|
+
theme: theme
|
|
928
|
+
)
|
|
929
|
+
let blockColor = resolvedTextColor(
|
|
930
|
+
for: placeholderBlockStack,
|
|
931
|
+
textColor: textColor,
|
|
932
|
+
theme: theme
|
|
933
|
+
)
|
|
934
|
+
var attrs = defaultAttributes(baseFont: blockFont, textColor: blockColor)
|
|
935
|
+
attrs[RenderBridgeAttributes.syntheticPlaceholder] = true
|
|
936
|
+
var styledAttrs = applyBlockStyle(
|
|
937
|
+
to: attrs,
|
|
938
|
+
blockStack: placeholderBlockStack,
|
|
939
|
+
theme: theme
|
|
940
|
+
)
|
|
941
|
+
if let paragraphStyle = (styledAttrs[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy()
|
|
942
|
+
as? NSMutableParagraphStyle
|
|
943
|
+
{
|
|
944
|
+
paragraphStyle.paragraphSpacing = 0
|
|
945
|
+
styledAttrs[.paragraphStyle] = paragraphStyle
|
|
946
|
+
}
|
|
947
|
+
result.append(NSAttributedString(string: "\u{200B}", attributes: styledAttrs))
|
|
948
|
+
}
|
|
949
|
+
|
|
728
950
|
private static func listMarkerWidth(
|
|
729
951
|
for context: BlockContext,
|
|
730
952
|
theme: EditorTheme?,
|
|
@@ -741,10 +963,19 @@ final class RenderBridge {
|
|
|
741
963
|
for blockStack: [BlockContext],
|
|
742
964
|
theme: EditorTheme?
|
|
743
965
|
) -> EditorTextStyle? {
|
|
966
|
+
let inBlockquote = blockquoteDepth(in: blockStack) > 0
|
|
744
967
|
guard let currentBlock = effectiveBlockContext(blockStack) else {
|
|
745
|
-
return theme?.effectiveTextStyle(for: "paragraph")
|
|
968
|
+
return theme?.effectiveTextStyle(for: "paragraph", inBlockquote: inBlockquote)
|
|
969
|
+
}
|
|
970
|
+
return theme?.effectiveTextStyle(for: currentBlock.nodeType, inBlockquote: inBlockquote)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
private static func blockquoteDepth(in blockStack: [BlockContext]) -> Int {
|
|
974
|
+
blockStack.reduce(into: 0) { count, context in
|
|
975
|
+
if context.nodeType == "blockquote" {
|
|
976
|
+
count += 1
|
|
977
|
+
}
|
|
746
978
|
}
|
|
747
|
-
return theme?.effectiveTextStyle(for: currentBlock.nodeType)
|
|
748
979
|
}
|
|
749
980
|
|
|
750
981
|
private static func resolvedFont(
|
|
@@ -823,3 +1054,160 @@ final class HorizontalRuleAttachment: NSTextAttachment {
|
|
|
823
1054
|
}
|
|
824
1055
|
}
|
|
825
1056
|
}
|
|
1057
|
+
|
|
1058
|
+
final class BlockImageAttachment: NSTextAttachment {
|
|
1059
|
+
private let source: String
|
|
1060
|
+
private let placeholderTint: UIColor
|
|
1061
|
+
private var preferredWidth: CGFloat?
|
|
1062
|
+
private var preferredHeight: CGFloat?
|
|
1063
|
+
private var loadedImage: UIImage?
|
|
1064
|
+
|
|
1065
|
+
init(
|
|
1066
|
+
source: String,
|
|
1067
|
+
placeholderTint: UIColor,
|
|
1068
|
+
preferredWidth: CGFloat?,
|
|
1069
|
+
preferredHeight: CGFloat?
|
|
1070
|
+
) {
|
|
1071
|
+
self.source = source
|
|
1072
|
+
self.placeholderTint = placeholderTint
|
|
1073
|
+
self.preferredWidth = preferredWidth
|
|
1074
|
+
self.preferredHeight = preferredHeight
|
|
1075
|
+
super.init(data: nil, ofType: nil)
|
|
1076
|
+
loadImageIfNeeded()
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
required init?(coder: NSCoder) {
|
|
1080
|
+
return nil
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
func setPreferredSize(width: CGFloat, height: CGFloat) {
|
|
1084
|
+
preferredWidth = width
|
|
1085
|
+
preferredHeight = height
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
func previewImage() -> UIImage? {
|
|
1089
|
+
loadedImage ?? image
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
override func attachmentBounds(
|
|
1093
|
+
for textContainer: NSTextContainer?,
|
|
1094
|
+
proposedLineFragment lineFrag: CGRect,
|
|
1095
|
+
glyphPosition position: CGPoint,
|
|
1096
|
+
characterIndex charIndex: Int
|
|
1097
|
+
) -> CGRect {
|
|
1098
|
+
let lineFragmentWidth = lineFrag.width.isFinite ? lineFrag.width : 0
|
|
1099
|
+
let containerWidth = textContainer.map {
|
|
1100
|
+
max(0, $0.size.width - ($0.lineFragmentPadding * 2))
|
|
1101
|
+
} ?? 0
|
|
1102
|
+
let widthCandidates = [lineFragmentWidth, containerWidth].filter { $0.isFinite && $0 > 0 }
|
|
1103
|
+
let maxWidth = max(160, widthCandidates.min() ?? 160)
|
|
1104
|
+
let fallbackAspectRatio = loadedImage.flatMap { image -> CGFloat? in
|
|
1105
|
+
let imageSize = image.size
|
|
1106
|
+
guard imageSize.width > 0, imageSize.height > 0 else { return nil }
|
|
1107
|
+
return imageSize.height / imageSize.width
|
|
1108
|
+
} ?? 0.56
|
|
1109
|
+
|
|
1110
|
+
var resolvedWidth = preferredWidth
|
|
1111
|
+
var resolvedHeight = preferredHeight
|
|
1112
|
+
|
|
1113
|
+
if resolvedWidth == nil, resolvedHeight == nil, let loadedImage {
|
|
1114
|
+
let imageSize = loadedImage.size
|
|
1115
|
+
if imageSize.width > 0, imageSize.height > 0 {
|
|
1116
|
+
resolvedWidth = imageSize.width
|
|
1117
|
+
resolvedHeight = imageSize.height
|
|
1118
|
+
}
|
|
1119
|
+
} else if resolvedWidth == nil, let resolvedHeight {
|
|
1120
|
+
resolvedWidth = resolvedHeight / fallbackAspectRatio
|
|
1121
|
+
} else if resolvedHeight == nil, let resolvedWidth {
|
|
1122
|
+
resolvedHeight = resolvedWidth * fallbackAspectRatio
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
let width = max(1, resolvedWidth ?? maxWidth)
|
|
1126
|
+
let height = max(1, resolvedHeight ?? min(180, maxWidth * fallbackAspectRatio))
|
|
1127
|
+
let scale = min(1, maxWidth / width)
|
|
1128
|
+
return CGRect(x: 0, y: 0, width: width * scale, height: height * scale)
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
override func image(
|
|
1132
|
+
forBounds imageBounds: CGRect,
|
|
1133
|
+
textContainer: NSTextContainer?,
|
|
1134
|
+
characterIndex charIndex: Int
|
|
1135
|
+
) -> UIImage? {
|
|
1136
|
+
if let loadedImage {
|
|
1137
|
+
return loadedImage
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
let renderer = UIGraphicsImageRenderer(bounds: imageBounds)
|
|
1141
|
+
return renderer.image { _ in
|
|
1142
|
+
let path = UIBezierPath(roundedRect: imageBounds, cornerRadius: 12)
|
|
1143
|
+
UIColor.secondarySystemFill.setFill()
|
|
1144
|
+
path.fill()
|
|
1145
|
+
|
|
1146
|
+
let iconSize = min(imageBounds.width, imageBounds.height) * 0.28
|
|
1147
|
+
let iconOrigin = CGPoint(
|
|
1148
|
+
x: imageBounds.midX - (iconSize / 2),
|
|
1149
|
+
y: imageBounds.midY - (iconSize / 2)
|
|
1150
|
+
)
|
|
1151
|
+
let iconRect = CGRect(origin: iconOrigin, size: CGSize(width: iconSize, height: iconSize))
|
|
1152
|
+
|
|
1153
|
+
if #available(iOS 13.0, *) {
|
|
1154
|
+
let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .medium)
|
|
1155
|
+
let icon = UIImage(systemName: "photo", withConfiguration: config)?
|
|
1156
|
+
.withTintColor(placeholderTint.withAlphaComponent(0.7), renderingMode: .alwaysOriginal)
|
|
1157
|
+
icon?.draw(in: iconRect)
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
private func loadImageIfNeeded() {
|
|
1163
|
+
if let cached = RenderImageCache.cache.object(forKey: source as NSString) {
|
|
1164
|
+
loadedImage = cached
|
|
1165
|
+
image = cached
|
|
1166
|
+
return
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if let inlineData = Self.decodeDataURL(source),
|
|
1170
|
+
let image = UIImage(data: inlineData)
|
|
1171
|
+
{
|
|
1172
|
+
RenderImageCache.cache.setObject(image, forKey: source as NSString)
|
|
1173
|
+
loadedImage = image
|
|
1174
|
+
self.image = image
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
guard let url = URL(string: source) else { return }
|
|
1179
|
+
RenderImageCache.queue.async { [weak self] in
|
|
1180
|
+
guard let self else { return }
|
|
1181
|
+
let data: Data?
|
|
1182
|
+
if url.isFileURL {
|
|
1183
|
+
data = try? Data(contentsOf: url)
|
|
1184
|
+
} else {
|
|
1185
|
+
data = try? Data(contentsOf: url)
|
|
1186
|
+
}
|
|
1187
|
+
guard let data,
|
|
1188
|
+
let image = UIImage(data: data)
|
|
1189
|
+
else {
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1192
|
+
RenderImageCache.cache.setObject(image, forKey: self.source as NSString)
|
|
1193
|
+
DispatchQueue.main.async {
|
|
1194
|
+
self.loadedImage = image
|
|
1195
|
+
self.image = image
|
|
1196
|
+
NotificationCenter.default.post(name: .editorImageAttachmentDidLoad, object: self)
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
private static func decodeDataURL(_ source: String) -> Data? {
|
|
1202
|
+
let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1203
|
+
guard trimmed.lowercased().hasPrefix("data:image/"),
|
|
1204
|
+
let commaIndex = trimmed.firstIndex(of: ",")
|
|
1205
|
+
else {
|
|
1206
|
+
return nil
|
|
1207
|
+
}
|
|
1208
|
+
let metadata = String(trimmed[..<commaIndex]).lowercased()
|
|
1209
|
+
let payload = String(trimmed[trimmed.index(after: commaIndex)...])
|
|
1210
|
+
guard metadata.contains(";base64") else { return nil }
|
|
1211
|
+
return Data(base64Encoded: payload, options: [.ignoreUnknownCharacters])
|
|
1212
|
+
}
|
|
1213
|
+
}
|