@octane-xplat/richtext 0.10.0 → 0.11.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 +21 -3
- package/dist/native/RichTextEditor.android.js +39 -0
- package/dist/native/RichTextEditor.ios.js +194 -8
- package/dist/native/octane-client-build.json +1 -1
- package/dist/web/octane-client-build.json +1 -1
- package/nativescript.config.ts +22 -0
- package/package.json +4 -3
- package/platforms/ios/LICENSE-Aztec-MPL-2.0.txt +355 -0
- package/platforms/ios/UPSTREAM.md +14 -0
- package/platforms/ios/src/XplatAztecEditor.swift +357 -0
- package/src/RichTextEditor.android.tsrx +39 -0
- package/src/RichTextEditor.ios.tsrx +221 -6
- package/src/host-session.macos.ts +9 -0
- package/src/index.ts +2 -2
- package/src/types.ts +49 -7
- package/types/types.d.ts +49 -7
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import Aztec
|
|
2
|
+
import Foundation
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
/// NativeScript-facing facade over AztecEditor-iOS `TextView`.
|
|
6
|
+
///
|
|
7
|
+
/// Every member reachable from JavaScript is `@objc`-visible and uses only
|
|
8
|
+
/// ObjC-representable types (String, Bool, NSNumber, NSArray, blocks), so the
|
|
9
|
+
/// NativeScript metadata generator sees the whole surface. The leaf drives
|
|
10
|
+
/// this view directly; block properties carry the event callbacks.
|
|
11
|
+
///
|
|
12
|
+
/// `placeholder` and sub/superscript toggles are wrapper-built: Aztec exposes
|
|
13
|
+
/// no native API for either. `taskList` and text alignment have no Aztec-iOS
|
|
14
|
+
/// equivalent at all — `applyFormat`/`isFormatActive` report them as
|
|
15
|
+
/// unsupported instead of faking a result.
|
|
16
|
+
@objcMembers
|
|
17
|
+
public final class XplatAztecEditorView: UIView, UITextViewDelegate {
|
|
18
|
+
|
|
19
|
+
/// FormattingIdentifier rawValue -> leaf format name. Aztec's identifier
|
|
20
|
+
/// map has no entries for `pre`, sub/superscript, task lists, or
|
|
21
|
+
/// alignment; those are checked at the attribute level below or honestly
|
|
22
|
+
/// reported as unsupported.
|
|
23
|
+
private static let identifierFormats: [String: String] = [
|
|
24
|
+
"bold": "bold",
|
|
25
|
+
"italic": "italic",
|
|
26
|
+
"underline": "underline",
|
|
27
|
+
"strikethrough": "strikethrough",
|
|
28
|
+
"code": "code",
|
|
29
|
+
"blockquote": "blockquote",
|
|
30
|
+
"orderedlist": "orderedList",
|
|
31
|
+
"unorderedlist": "bulletList",
|
|
32
|
+
"mark": "highlight",
|
|
33
|
+
"p": "paragraph",
|
|
34
|
+
"header1": "heading1",
|
|
35
|
+
"header2": "heading2",
|
|
36
|
+
"header3": "heading3",
|
|
37
|
+
"header4": "heading4",
|
|
38
|
+
"header5": "heading5",
|
|
39
|
+
"header6": "heading6",
|
|
40
|
+
"horizontalruler": "horizontalRule",
|
|
41
|
+
"link": "link",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
public let textView = TextView(
|
|
45
|
+
defaultFont: UIFont.preferredFont(forTextStyle: .body),
|
|
46
|
+
defaultMissingImage: UIImage())
|
|
47
|
+
private let placeholderLabel = UILabel()
|
|
48
|
+
|
|
49
|
+
// MARK: - JS callbacks
|
|
50
|
+
|
|
51
|
+
public var onTextChange: (() -> Void)?
|
|
52
|
+
public var onSelectionChange: ((NSNumber, NSNumber, NSArray) -> Void)?
|
|
53
|
+
public var onFocus: (() -> Void)?
|
|
54
|
+
public var onBlur: (() -> Void)?
|
|
55
|
+
|
|
56
|
+
public override init(frame: CGRect) {
|
|
57
|
+
super.init(frame: frame)
|
|
58
|
+
textView.delegate = self
|
|
59
|
+
textView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
60
|
+
addSubview(textView)
|
|
61
|
+
|
|
62
|
+
placeholderLabel.textColor = .secondaryLabel
|
|
63
|
+
placeholderLabel.font = textView.font
|
|
64
|
+
placeholderLabel.numberOfLines = 1
|
|
65
|
+
placeholderLabel.isUserInteractionEnabled = false
|
|
66
|
+
addSubview(placeholderLabel)
|
|
67
|
+
updatePlaceholderVisibility()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@available(*, unavailable)
|
|
71
|
+
public required init?(coder: NSCoder) {
|
|
72
|
+
fatalError("init(coder:) is not supported")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public override func layoutSubviews() {
|
|
76
|
+
super.layoutSubviews()
|
|
77
|
+
textView.frame = bounds
|
|
78
|
+
let inset = textView.textContainerInset
|
|
79
|
+
let padding = textView.textContainer.lineFragmentPadding
|
|
80
|
+
placeholderLabel.frame = CGRect(
|
|
81
|
+
x: inset.left + padding,
|
|
82
|
+
y: inset.top,
|
|
83
|
+
width: max(0, bounds.width - inset.left - inset.right - padding * 2),
|
|
84
|
+
height: placeholderLabel.font.lineHeight)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// MARK: - Props
|
|
88
|
+
|
|
89
|
+
public var placeholder: String? {
|
|
90
|
+
get { placeholderLabel.text }
|
|
91
|
+
set {
|
|
92
|
+
placeholderLabel.text = newValue
|
|
93
|
+
updatePlaceholderVisibility()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public var editable: Bool {
|
|
98
|
+
get { textView.isEditable }
|
|
99
|
+
set { textView.isEditable = newValue }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private func updatePlaceholderVisibility() {
|
|
103
|
+
let hasText = textView.textStorage.length > 0
|
|
104
|
+
placeholderLabel.isHidden = hasText || (placeholderLabel.text ?? "").isEmpty
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// MARK: - Document
|
|
108
|
+
|
|
109
|
+
public func getHTML() -> String {
|
|
110
|
+
textView.getHTML(prettify: false)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public func setHTML(_ html: String) {
|
|
114
|
+
textView.setHTML(html)
|
|
115
|
+
updatePlaceholderVisibility()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// MARK: - Formats
|
|
119
|
+
|
|
120
|
+
/// Returns false for formats this engine cannot apply (`taskList`,
|
|
121
|
+
/// `align*`, `link` — the link API is `linkTo`) and unknown names.
|
|
122
|
+
public func applyFormat(_ format: String) -> Bool {
|
|
123
|
+
let range = textView.selectedRange
|
|
124
|
+
switch format {
|
|
125
|
+
case "bold": textView.toggleBold(range: range)
|
|
126
|
+
case "italic": textView.toggleItalic(range: range)
|
|
127
|
+
case "underline": textView.toggleUnderline(range: range)
|
|
128
|
+
case "strikethrough": textView.toggleStrikethrough(range: range)
|
|
129
|
+
case "code": textView.toggleCode(range: range)
|
|
130
|
+
case "blockquote": textView.toggleBlockquote(range: range)
|
|
131
|
+
case "bulletList": textView.toggleUnorderedList(range: range)
|
|
132
|
+
case "orderedList": textView.toggleOrderedList(range: range)
|
|
133
|
+
case "codeBlock": textView.togglePre(range: range)
|
|
134
|
+
case "paragraph": textView.toggleHeader(.none, range: range)
|
|
135
|
+
case "heading1": textView.toggleHeader(.h1, range: range)
|
|
136
|
+
case "heading2": textView.toggleHeader(.h2, range: range)
|
|
137
|
+
case "heading3": textView.toggleHeader(.h3, range: range)
|
|
138
|
+
case "heading4": textView.toggleHeader(.h4, range: range)
|
|
139
|
+
case "heading5": textView.toggleHeader(.h5, range: range)
|
|
140
|
+
case "heading6": textView.toggleHeader(.h6, range: range)
|
|
141
|
+
case "highlight": textView.toggleMark(range: range, color: nil, resetColor: true)
|
|
142
|
+
case "horizontalRule": textView.replaceWithHorizontalRuler(at: range)
|
|
143
|
+
case "subscript": toggleBaselineOffset(-4, in: range)
|
|
144
|
+
case "superscript": toggleBaselineOffset(4, in: range)
|
|
145
|
+
default: return false
|
|
146
|
+
}
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public func isFormatActive(_ format: String) -> Bool {
|
|
151
|
+
activeFormatSet().contains(format)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
public func activeFormats() -> [String] {
|
|
155
|
+
activeFormatSet().sorted()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private func activeFormatSet() -> Set<String> {
|
|
159
|
+
var out = Set<String>()
|
|
160
|
+
for identifier in textView.formattingIdentifiersSpanningRange(textView.selectedRange) {
|
|
161
|
+
if let name = XplatAztecEditorView.identifierFormats[identifier.rawValue] {
|
|
162
|
+
out.insert(name)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
let attributeSets = activeAttributeSets()
|
|
166
|
+
if !attributeSets.isEmpty {
|
|
167
|
+
if attributeSets.allSatisfy(hasPreProperty) { out.insert("codeBlock") }
|
|
168
|
+
if attributeSets.allSatisfy({ baselineOffset(of: $0) > 0 }) { out.insert("superscript") }
|
|
169
|
+
if attributeSets.allSatisfy({ baselineOffset(of: $0) < 0 }) { out.insert("subscript") }
|
|
170
|
+
}
|
|
171
|
+
return out
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// The attribute dictionaries the identifier check would consult:
|
|
175
|
+
/// typing attributes on an empty document, the attributes at the caret
|
|
176
|
+
/// for a collapsed selection, every run in the selection otherwise.
|
|
177
|
+
private func activeAttributeSets() -> [[NSAttributedString.Key: Any]] {
|
|
178
|
+
let storage = textView.textStorage
|
|
179
|
+
let range = textView.selectedRange
|
|
180
|
+
guard storage.length > 0 else { return [textView.typingAttributes] }
|
|
181
|
+
if range.length == 0 {
|
|
182
|
+
let index = min(range.location, storage.length - 1)
|
|
183
|
+
return [storage.attributes(at: index, effectiveRange: nil)]
|
|
184
|
+
}
|
|
185
|
+
var sets: [[NSAttributedString.Key: Any]] = []
|
|
186
|
+
storage.enumerateAttributes(in: range, options: []) { attributes, _, _ in
|
|
187
|
+
sets.append(attributes)
|
|
188
|
+
}
|
|
189
|
+
return sets
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private func hasPreProperty(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
|
|
193
|
+
guard let style = attributes[.paragraphStyle] as? ParagraphStyle else { return false }
|
|
194
|
+
// HTMLPre is module-internal to Aztec — match the property by type name.
|
|
195
|
+
return style.hasProperty { String(describing: Swift.type(of: $0)) == "HTMLPre" }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private func baselineOffset(of attributes: [NSAttributedString.Key: Any]) -> Int {
|
|
199
|
+
(attributes[.baselineOffset] as? NSNumber)?.intValue ?? 0
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// Aztec-iOS has no public sub/superscript toggle — apply or clear
|
|
203
|
+
/// `.baselineOffset` directly; the HTML converter serializes it back as
|
|
204
|
+
/// `<sub>`/`<sup>`. Collapsed selections toggle the typing attributes.
|
|
205
|
+
private func toggleBaselineOffset(_ offset: Int, in range: NSRange) {
|
|
206
|
+
let storage = textView.textStorage
|
|
207
|
+
if range.length == 0 || storage.length == 0 {
|
|
208
|
+
var attributes = textView.typingAttributes
|
|
209
|
+
let current = baselineOffset(of: attributes)
|
|
210
|
+
attributes[.baselineOffset] = current == offset ? nil : NSNumber(value: offset)
|
|
211
|
+
textView.typingAttributes = attributes
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
guard range.location + range.length <= storage.length else { return }
|
|
215
|
+
|
|
216
|
+
let original = storage.attributedSubstring(from: range)
|
|
217
|
+
let restoredRange = NSRange(location: range.location, length: range.length)
|
|
218
|
+
textView.undoManager?.registerUndo(withTarget: textView) { target in
|
|
219
|
+
target.textStorage.replaceCharacters(in: restoredRange, with: original)
|
|
220
|
+
target.delegate?.textViewDidChange?(target)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
storage.beginEditing()
|
|
224
|
+
storage.enumerateAttribute(.baselineOffset, in: range, options: []) { value, subrange, _ in
|
|
225
|
+
let current = (value as? NSNumber)?.intValue ?? 0
|
|
226
|
+
if current == offset {
|
|
227
|
+
storage.removeAttribute(.baselineOffset, range: subrange)
|
|
228
|
+
} else {
|
|
229
|
+
storage.addAttribute(.baselineOffset, value: NSNumber(value: offset), range: subrange)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
storage.endEditing()
|
|
233
|
+
textViewDidChange(textView)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// MARK: - Links
|
|
237
|
+
|
|
238
|
+
public func linkTo(_ urlString: String, anchor: String?) -> Bool {
|
|
239
|
+
guard let url = URL(string: urlString) else { return false }
|
|
240
|
+
textView.setLink(url, title: anchor ?? urlString, inRange: textView.selectedRange)
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
public func removeLink() {
|
|
245
|
+
textView.removeLink(inRange: textView.selectedRange)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// MARK: - History
|
|
249
|
+
|
|
250
|
+
public func undo() {
|
|
251
|
+
textView.undoManager?.undo()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
public func redo() {
|
|
255
|
+
textView.undoManager?.redo()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// MARK: - Structural commands
|
|
259
|
+
|
|
260
|
+
/// Block split at the caret — Aztec's `insertText` runs the same
|
|
261
|
+
/// paragraph/list handling as a real Enter keystroke. A non-collapsed
|
|
262
|
+
/// selection is deleted first, matching the leaf contract.
|
|
263
|
+
public func split() -> Bool {
|
|
264
|
+
let range = textView.selectedRange
|
|
265
|
+
guard range.location != NSNotFound,
|
|
266
|
+
range.location + range.length <= textView.textStorage.length
|
|
267
|
+
else { return false }
|
|
268
|
+
if range.length > 0 {
|
|
269
|
+
textView.deleteBackward()
|
|
270
|
+
}
|
|
271
|
+
textView.insertText("\n")
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/// Merge into the previous block — Backspace at a collapsed caret that
|
|
276
|
+
/// sits directly after a `\n` boundary, matching the leaf contract.
|
|
277
|
+
public func join() -> Bool {
|
|
278
|
+
let range = textView.selectedRange
|
|
279
|
+
guard range.length == 0, range.location > 0,
|
|
280
|
+
range.location <= textView.textStorage.length
|
|
281
|
+
else { return false }
|
|
282
|
+
let text = textView.textStorage.string as NSString
|
|
283
|
+
guard text.character(at: range.location - 1) == 0x000A else { return false }
|
|
284
|
+
textView.deleteBackward()
|
|
285
|
+
return true
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// `increaseIndent` nests a list item under its previous sibling or
|
|
289
|
+
/// deepens a blockquote; on plain blocks Aztec falls back to a `\t`
|
|
290
|
+
/// insert, so indent is available whenever the view can edit.
|
|
291
|
+
public func canIndent() -> Bool {
|
|
292
|
+
textView.isEditable
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
public func indent() -> Bool {
|
|
296
|
+
guard canIndent() else { return false }
|
|
297
|
+
textView.increaseIndent()
|
|
298
|
+
return true
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// `decreaseIndent` only acts on list/blockquote typing attributes —
|
|
302
|
+
/// there is no plain-block outdent, so report availability up front.
|
|
303
|
+
public func canOutdent() -> Bool {
|
|
304
|
+
guard textView.isEditable else { return false }
|
|
305
|
+
guard let style = textView.typingAttributes[.paragraphStyle] as? ParagraphStyle else {
|
|
306
|
+
return false
|
|
307
|
+
}
|
|
308
|
+
// TextList/Blockquote/HTMLLi are module-internal to Aztec.
|
|
309
|
+
return style.hasProperty {
|
|
310
|
+
let name = String(describing: Swift.type(of: $0))
|
|
311
|
+
return name == "TextList" || name == "Blockquote" || name == "HTMLLi"
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
public func outdent() -> Bool {
|
|
316
|
+
guard canOutdent() else { return false }
|
|
317
|
+
textView.decreaseIndent()
|
|
318
|
+
return true
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// MARK: - Focus
|
|
322
|
+
|
|
323
|
+
public func focus() {
|
|
324
|
+
_ = textView.becomeFirstResponder()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
public func blur() {
|
|
328
|
+
_ = textView.resignFirstResponder()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
public func focused() -> Bool {
|
|
332
|
+
textView.isFirstResponder
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// MARK: - UITextViewDelegate
|
|
336
|
+
|
|
337
|
+
public func textViewDidChange(_ textView: UITextView) {
|
|
338
|
+
updatePlaceholderVisibility()
|
|
339
|
+
onTextChange?()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
public func textViewDidChangeSelection(_ textView: UITextView) {
|
|
343
|
+
let range = self.textView.selectedRange
|
|
344
|
+
onSelectionChange?(
|
|
345
|
+
NSNumber(value: range.location),
|
|
346
|
+
NSNumber(value: range.location + range.length),
|
|
347
|
+
activeFormats() as NSArray)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
public func textViewDidBeginEditing(_ textView: UITextView) {
|
|
351
|
+
onFocus?()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
public func textViewDidEndEditing(_ textView: UITextView) {
|
|
355
|
+
onBlur?()
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -178,6 +178,45 @@ export function RichTextEditor(props: RichTextEditorProps) @{
|
|
|
178
178
|
},
|
|
179
179
|
undo: () => e.undo(),
|
|
180
180
|
redo: () => e.redo(),
|
|
181
|
+
split: () => {
|
|
182
|
+
const selStart = e.getSelectionStart()
|
|
183
|
+
const selEnd = e.getSelectionEnd()
|
|
184
|
+
if (selStart < 0 || selEnd < 0) return false
|
|
185
|
+
const start = Math.min(selStart, selEnd)
|
|
186
|
+
const end = Math.max(selStart, selEnd)
|
|
187
|
+
const text = e.getText()
|
|
188
|
+
if (end > String(text).length) return false
|
|
189
|
+
if (end > start) text.delete(start, end)
|
|
190
|
+
text.insert(start, '\n')
|
|
191
|
+
e.setSelection(start + 1)
|
|
192
|
+
emitChange()
|
|
193
|
+
return true
|
|
194
|
+
},
|
|
195
|
+
join: () => {
|
|
196
|
+
const start = e.getSelectionStart()
|
|
197
|
+
if (start <= 0 || e.getSelectionEnd() !== start) return false
|
|
198
|
+
const text = e.getText()
|
|
199
|
+
const s = String(text)
|
|
200
|
+
if (start > s.length || s.charAt(start - 1) !== '\n') return false
|
|
201
|
+
text.delete(start - 1, start)
|
|
202
|
+
e.setSelection(start - 1)
|
|
203
|
+
emitChange()
|
|
204
|
+
return true
|
|
205
|
+
},
|
|
206
|
+
indent: () => {
|
|
207
|
+
if (!e.isIndentAvailable()) return false
|
|
208
|
+
e.indent()
|
|
209
|
+
emitChange()
|
|
210
|
+
return true
|
|
211
|
+
},
|
|
212
|
+
outdent: () => {
|
|
213
|
+
if (!e.isOutdentAvailable()) return false
|
|
214
|
+
e.outdent()
|
|
215
|
+
emitChange()
|
|
216
|
+
return true
|
|
217
|
+
},
|
|
218
|
+
canIndent: () => Boolean(e.isIndentAvailable()),
|
|
219
|
+
canOutdent: () => Boolean(e.isOutdentAvailable()),
|
|
181
220
|
focus: () => {
|
|
182
221
|
e.requestFocus()
|
|
183
222
|
showKeyboard(e)
|
|
@@ -1,15 +1,230 @@
|
|
|
1
1
|
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
-
import
|
|
2
|
+
import { useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
|
|
3
|
+
import { View } from '@nativescript/core'
|
|
4
|
+
import { registerElement } from '@nativescript-community/octane'
|
|
5
|
+
import type { Attributes } from '@nativescript-community/octane/intrinsics'
|
|
6
|
+
import type {
|
|
7
|
+
RichTextEditorHandle,
|
|
8
|
+
RichTextEditorProps,
|
|
9
|
+
RichTextFormat,
|
|
10
|
+
} from './types'
|
|
3
11
|
|
|
4
|
-
|
|
12
|
+
/** `XplatAztecEditorView` arrives through the leaf's
|
|
13
|
+
* `platforms/ios/src` Swift facade over `Aztec.TextView` — there are no
|
|
14
|
+
* generated typings for it, so every touch goes through `as any` at one
|
|
15
|
+
* boundary instead of `declare const` per call site. */
|
|
16
|
+
declare const XplatAztecEditorView: any
|
|
5
17
|
|
|
6
|
-
/** iOS
|
|
7
|
-
*
|
|
18
|
+
/** AztecEditor-iOS has no task-list or text-alignment API — the facade
|
|
19
|
+
* reports these as unsupported rather than faking a result. `link` is
|
|
20
|
+
* valid but goes through `linkTo`, matching the Android leaf. */
|
|
21
|
+
const UNSUPPORTED: ReadonlySet<RichTextFormat> = new Set([
|
|
22
|
+
'taskList',
|
|
23
|
+
'alignLeft',
|
|
24
|
+
'alignCenter',
|
|
25
|
+
'alignRight',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
class AztecEditor extends View {
|
|
29
|
+
createNativeView() {
|
|
30
|
+
return new XplatAztecEditorView()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
declare module '@nativescript-community/octane/intrinsics' {
|
|
35
|
+
interface NativeScriptElements {
|
|
36
|
+
xplatrichtext: Attributes<typeof AztecEditor>
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
registerElement('xplatrichtext', AztecEditor)
|
|
41
|
+
|
|
42
|
+
export const supported = true
|
|
43
|
+
|
|
44
|
+
/** WordPress AztecEditor-iOS surface — a UITextView-backed native editor,
|
|
45
|
+
* not a WebView. Content in/out is HTML (`setHTML`/`getHTML`); JSON
|
|
46
|
+
* interchange lives one layer up in @octane-xplat/tiptap. */
|
|
8
47
|
export function RichTextEditor(props: RichTextEditorProps) @{
|
|
9
|
-
<
|
|
48
|
+
const host = useRef<any>(null)
|
|
49
|
+
const lastEmitted = useRef<string | undefined>(undefined)
|
|
50
|
+
const applyingValue = useRef(false)
|
|
51
|
+
const emitTimer = useRef<any>(null)
|
|
52
|
+
const warned = useRef<Set<string>>(new Set())
|
|
53
|
+
|
|
54
|
+
// Stable identity — an inline closure re-fires ref(null)/ref(v) per
|
|
55
|
+
// render, which would bounce host.current around the effects below.
|
|
56
|
+
useImperativeHandle(props.ref, () => handle(editor()), []);
|
|
57
|
+
|
|
58
|
+
const bindHost = useCallback((v: any) => {
|
|
59
|
+
host.current = v
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
const editor = () => host.current?.ios
|
|
63
|
+
|
|
64
|
+
const emitChange = () => {
|
|
65
|
+
const e = editor()
|
|
66
|
+
if (!e || applyingValue.current) return
|
|
67
|
+
clearTimeout(emitTimer.current)
|
|
68
|
+
emitTimer.current = setTimeout(() => {
|
|
69
|
+
const current = editor()
|
|
70
|
+
if (!current || applyingValue.current) return
|
|
71
|
+
const html = current.getHTML()
|
|
72
|
+
lastEmitted.current = html
|
|
73
|
+
props.onChange?.(html)
|
|
74
|
+
}, 150)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const reportUnsupported = (format: RichTextFormat) => {
|
|
78
|
+
if (warned.current.has(format)) return
|
|
79
|
+
warned.current.add(format)
|
|
80
|
+
console.warn(
|
|
81
|
+
`[richtext] apply('${format}') is not supported by the iOS Aztec surface`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handle = (e: any): RichTextEditorHandle => ({
|
|
86
|
+
getHTML: () => e.getHTML(),
|
|
87
|
+
setHTML: (html: string) => {
|
|
88
|
+
applyingValue.current = true
|
|
89
|
+
try {
|
|
90
|
+
e.setHTML(html)
|
|
91
|
+
lastEmitted.current = html
|
|
92
|
+
} finally {
|
|
93
|
+
applyingValue.current = false
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
apply: (format: RichTextFormat) => {
|
|
97
|
+
if (format === 'link') return
|
|
98
|
+
if (UNSUPPORTED.has(format) || !e.applyFormat(format)) {
|
|
99
|
+
reportUnsupported(format)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
emitChange()
|
|
104
|
+
},
|
|
105
|
+
linkTo: (url: string, anchor?: string) => {
|
|
106
|
+
e.linkTo(url, anchor ?? null)
|
|
107
|
+
emitChange()
|
|
108
|
+
},
|
|
109
|
+
removeLink: () => {
|
|
110
|
+
e.removeLink()
|
|
111
|
+
emitChange()
|
|
112
|
+
},
|
|
113
|
+
isActive: (format: RichTextFormat) =>
|
|
114
|
+
!UNSUPPORTED.has(format) && Boolean(e.isFormatActive(format)),
|
|
115
|
+
undo: () => e.undo(),
|
|
116
|
+
redo: () => e.redo(),
|
|
117
|
+
split: () => {
|
|
118
|
+
const ok = Boolean(e.split())
|
|
119
|
+
if (ok) emitChange()
|
|
120
|
+
return ok
|
|
121
|
+
},
|
|
122
|
+
join: () => {
|
|
123
|
+
const ok = Boolean(e.join())
|
|
124
|
+
if (ok) emitChange()
|
|
125
|
+
return ok
|
|
126
|
+
},
|
|
127
|
+
indent: () => {
|
|
128
|
+
const ok = Boolean(e.indent())
|
|
129
|
+
if (ok) emitChange()
|
|
130
|
+
return ok
|
|
131
|
+
},
|
|
132
|
+
outdent: () => {
|
|
133
|
+
const ok = Boolean(e.outdent())
|
|
134
|
+
if (ok) emitChange()
|
|
135
|
+
return ok
|
|
136
|
+
},
|
|
137
|
+
canIndent: () => Boolean(e.canIndent()),
|
|
138
|
+
canOutdent: () => Boolean(e.canOutdent()),
|
|
139
|
+
focus: () => e.focus(),
|
|
140
|
+
blur: () => e.blur(),
|
|
141
|
+
isFocused: () => e.focused(),
|
|
142
|
+
native: e,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
const e = editor()
|
|
147
|
+
if (!e) return
|
|
148
|
+
|
|
149
|
+
e.onTextChange = () => emitChange()
|
|
150
|
+
e.onSelectionChange = (start: number, end: number, active: any) => {
|
|
151
|
+
const formats: RichTextFormat[] = []
|
|
152
|
+
const count = active?.count ?? 0
|
|
153
|
+
for (let i = 0; i < count; i++) {
|
|
154
|
+
formats.push(active.objectAtIndex(i))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
props.onSelectionChange?.({ start, end, active: formats })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
e.onFocus = () => props.onFocus?.()
|
|
161
|
+
e.onBlur = () => props.onBlur?.()
|
|
162
|
+
|
|
163
|
+
if (props.placeholder !== undefined) e.placeholder = props.placeholder
|
|
164
|
+
e.editable = props.editable !== false
|
|
165
|
+
|
|
166
|
+
if (props.value !== undefined) {
|
|
167
|
+
applyingValue.current = true
|
|
168
|
+
try {
|
|
169
|
+
e.setHTML(props.value)
|
|
170
|
+
} finally {
|
|
171
|
+
applyingValue.current = false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (props.autofocus) e.focus()
|
|
176
|
+
|
|
177
|
+
if (props.ios) Object.assign(e, props.ios)
|
|
178
|
+
props.onReady?.()
|
|
179
|
+
|
|
180
|
+
return () => {
|
|
181
|
+
e.onTextChange = null
|
|
182
|
+
e.onSelectionChange = null
|
|
183
|
+
e.onFocus = null
|
|
184
|
+
e.onBlur = null
|
|
185
|
+
}
|
|
186
|
+
}, [])
|
|
187
|
+
|
|
188
|
+
// `value` lands like a controlled prop, minus the loop: text the editor
|
|
189
|
+
// emitted through onChange is never pushed back; any other change is an
|
|
190
|
+
// external reset (Aztec-iOS setHTML is non-undoable, matching Android).
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
const e = editor()
|
|
193
|
+
if (!e || props.value === undefined) return
|
|
194
|
+
if (props.value === lastEmitted.current) return
|
|
195
|
+
applyingValue.current = true
|
|
196
|
+
try {
|
|
197
|
+
e.setHTML(props.value)
|
|
198
|
+
} finally {
|
|
199
|
+
applyingValue.current = false
|
|
200
|
+
}
|
|
201
|
+
}, [props.value])
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
const e = editor()
|
|
205
|
+
if (e) e.editable = props.editable !== false
|
|
206
|
+
}, [props.editable])
|
|
207
|
+
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
const e = editor()
|
|
210
|
+
if (e && props.placeholder !== undefined) e.placeholder = props.placeholder
|
|
211
|
+
}, [props.placeholder])
|
|
212
|
+
|
|
213
|
+
<xplatrichtext
|
|
214
|
+
ref={bindHost}
|
|
10
215
|
id={props.id}
|
|
11
216
|
className={props.className}
|
|
12
217
|
style={props.style}
|
|
13
|
-
|
|
218
|
+
row={props.row}
|
|
219
|
+
col={props.col}
|
|
220
|
+
rowSpan={props.rowSpan}
|
|
221
|
+
colSpan={props.colSpan}
|
|
222
|
+
dock={props.dock}
|
|
223
|
+
left={props.left}
|
|
224
|
+
top={props.top}
|
|
225
|
+
flexGrow={props.flexGrow}
|
|
226
|
+
flexShrink={props.flexShrink}
|
|
227
|
+
alignSelf={props.alignSelf}
|
|
228
|
+
order={props.order}
|
|
14
229
|
/>;
|
|
15
230
|
}
|
|
@@ -49,6 +49,15 @@ export function createEditorSession(
|
|
|
49
49
|
isActive: (format) => active.includes(format),
|
|
50
50
|
undo: () => command('undo'),
|
|
51
51
|
redo: () => command('redo'),
|
|
52
|
+
// Structural commands are Aztec-only for now: the AppKit transport
|
|
53
|
+
// is one-way async dispatch, so a synchronous "applied" result cannot
|
|
54
|
+
// be reported and a fire-and-forget forward would lie to the caller.
|
|
55
|
+
split: () => false,
|
|
56
|
+
join: () => false,
|
|
57
|
+
indent: () => false,
|
|
58
|
+
outdent: () => false,
|
|
59
|
+
canIndent: () => false,
|
|
60
|
+
canOutdent: () => false,
|
|
52
61
|
focus: () => command('focus'),
|
|
53
62
|
blur: () => command('blur'),
|
|
54
63
|
isFocused: () => focused,
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Keep this relative import extensionless so the NativeScript resolver picks
|
|
2
|
-
// the Android (Aztec) or iOS (
|
|
3
|
-
//
|
|
2
|
+
// the Android (Aztec) or iOS (AztecEditor-iOS facade) component for the
|
|
3
|
+
// current build target.
|
|
4
4
|
export { RichTextEditor, supported } from './RichTextEditor'
|
|
5
5
|
export type * from './types'
|