@octane-xplat/richtext 0.9.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 +117 -6
- package/dist/macos/browser.d.ts +1 -0
- package/dist/macos/browser.js +2 -0
- package/dist/native/RichTextEditor.android.js +48 -8
- 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 +27 -5
- 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/platforms/macos/include/XplatEditorHost.h +1 -0
- package/platforms/macos/src/XplatEditorHost.swift +63 -0
- package/src/BrowserEditor.web.ts +107 -0
- package/src/Editor.macos.tsx +9 -0
- package/src/NativeEditor.macos.tsx +81 -0
- package/src/RichTextEditor.android.tsrx +42 -2
- package/src/RichTextEditor.ios.tsrx +221 -6
- package/src/RichTextEditor.windows.tsrx +15 -0
- package/src/browser-transport.web.ts +119 -0
- package/src/host-session.macos.ts +142 -0
- package/src/index.macos.ts +3 -9
- package/src/index.ts +2 -2
- package/src/types.ts +54 -9
- package/types/browser-transport.d.ts +2 -0
- package/types/index.macos.d.ts +5 -0
- package/types/macos-host.d.ts +11 -0
- package/types/types.d.ts +53 -9
|
@@ -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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#import <AppKit/AppKit.h>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
// One isolated, local document per editor. No general app/service bridge.
|
|
5
|
+
private final class EditorChannel: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
|
|
6
|
+
var dispatch: ((String) -> Void)?
|
|
7
|
+
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
8
|
+
guard message.frameInfo.isMainFrame, let body = message.body as? String else { return }
|
|
9
|
+
dispatch?(body)
|
|
10
|
+
}
|
|
11
|
+
func webView(_: WKWebView, decidePolicyFor action: WKNavigationAction,
|
|
12
|
+
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
13
|
+
decisionHandler(action.request.url?.absoluteString == "about:blank" ? .allow : .cancel)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@objc(XplatEditorHost)
|
|
18
|
+
public final class XplatEditorHost: NSObject {
|
|
19
|
+
private let webView: WKWebView
|
|
20
|
+
private let channel = EditorChannel()
|
|
21
|
+
private var disposed = false
|
|
22
|
+
@objc public override init() {
|
|
23
|
+
let config = WKWebViewConfiguration()
|
|
24
|
+
config.websiteDataStore = .nonPersistent()
|
|
25
|
+
webView = WKWebView(frame: .zero, configuration: config)
|
|
26
|
+
super.init()
|
|
27
|
+
config.userContentController.add(channel, name: "editor")
|
|
28
|
+
webView.navigationDelegate = channel
|
|
29
|
+
webView.autoresizingMask = [.width, .height]
|
|
30
|
+
}
|
|
31
|
+
@objc public func attach(to parent: NSView) {
|
|
32
|
+
webView.frame = parent.bounds
|
|
33
|
+
parent.addSubview(webView)
|
|
34
|
+
}
|
|
35
|
+
@objc public func installDispatcher(_ dispatch: @escaping (String) -> Void) {
|
|
36
|
+
channel.dispatch = dispatch
|
|
37
|
+
}
|
|
38
|
+
@objc public func load(_ html: String) {
|
|
39
|
+
guard !disposed else { return }
|
|
40
|
+
webView.loadHTMLString(html, baseURL: nil)
|
|
41
|
+
}
|
|
42
|
+
@objc public func deliver(_ packet: String) {
|
|
43
|
+
guard !disposed,
|
|
44
|
+
let data = try? JSONSerialization.data(withJSONObject: [packet]),
|
|
45
|
+
let literal = String(data: data, encoding: .utf8) else { return }
|
|
46
|
+
webView.evaluateJavaScript("window.__xplatEditorReceive?.(\(literal)[0])") { [weak self] _, error in
|
|
47
|
+
if let error {
|
|
48
|
+
let body = ["event": "error", "message": error.localizedDescription]
|
|
49
|
+
if let data = try? JSONSerialization.data(withJSONObject: body),
|
|
50
|
+
let text = String(data: data, encoding: .utf8) { self?.channel.dispatch?(text) }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
@objc public func dispose() {
|
|
55
|
+
guard !disposed else { return }
|
|
56
|
+
disposed = true
|
|
57
|
+
channel.dispatch = nil
|
|
58
|
+
webView.stopLoading()
|
|
59
|
+
webView.configuration.userContentController.removeScriptMessageHandler(forName: "editor")
|
|
60
|
+
webView.navigationDelegate = nil
|
|
61
|
+
webView.removeFromSuperview()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Editor } from '@tiptap/core'
|
|
2
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
3
|
+
import { installEditorDocument } from './browser-transport.web'
|
|
4
|
+
|
|
5
|
+
let editor: Editor | undefined
|
|
6
|
+
let current: any
|
|
7
|
+
let lastHTML: string | undefined
|
|
8
|
+
let lastJSON: string | undefined
|
|
9
|
+
let lastHTMLInput: string | undefined
|
|
10
|
+
const command: Record<string, (chain: any) => any> = {
|
|
11
|
+
bold: (c) => c.toggleBold(),
|
|
12
|
+
italic: (c) => c.toggleItalic(),
|
|
13
|
+
underline: (c) => c.toggleUnderline(),
|
|
14
|
+
strikethrough: (c) => c.toggleStrike(),
|
|
15
|
+
code: (c) => c.toggleCode(),
|
|
16
|
+
blockquote: (c) => c.toggleBlockquote(),
|
|
17
|
+
bulletList: (c) => c.toggleBulletList(),
|
|
18
|
+
orderedList: (c) => c.toggleOrderedList(),
|
|
19
|
+
codeBlock: (c) => c.toggleCodeBlock(),
|
|
20
|
+
horizontalRule: (c) => c.setHorizontalRule(),
|
|
21
|
+
paragraph: (c) => c.setParagraph(),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (let level = 1; level <= 6; level++) {
|
|
25
|
+
command['heading' + level] = (c) => c.toggleHeading({ level })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
installEditorDocument((props) => {
|
|
29
|
+
current = props
|
|
30
|
+
if (!editor) {
|
|
31
|
+
const element = document.createElement('div')
|
|
32
|
+
document.body.append(element)
|
|
33
|
+
editor = new Editor({
|
|
34
|
+
element,
|
|
35
|
+
extensions: [StarterKit],
|
|
36
|
+
content: props.value ?? '',
|
|
37
|
+
editable: props.editable !== false,
|
|
38
|
+
autofocus: props.autofocus ?? false,
|
|
39
|
+
onUpdate: ({ editor: e }) => {
|
|
40
|
+
lastHTML = e.getHTML()
|
|
41
|
+
current.onChange?.(lastHTML)
|
|
42
|
+
},
|
|
43
|
+
onSelectionUpdate: ({ editor: e }) =>
|
|
44
|
+
current.onSelectionChange?.({
|
|
45
|
+
start: e.state.selection.from,
|
|
46
|
+
end: e.state.selection.to,
|
|
47
|
+
active: [],
|
|
48
|
+
}),
|
|
49
|
+
onFocus: () => current.onFocus?.(),
|
|
50
|
+
onBlur: () => current.onBlur?.(),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
lastHTMLInput = JSON.stringify([props.json !== undefined, props.value])
|
|
54
|
+
const e = editor
|
|
55
|
+
props.ref({
|
|
56
|
+
getHTML: () => e.getHTML(),
|
|
57
|
+
setHTML: (html: string) => e.commands.setContent(html),
|
|
58
|
+
getJSON: () => e.getJSON(),
|
|
59
|
+
setJSON: (doc: any) => e.commands.setContent(doc),
|
|
60
|
+
apply: (format: string) => {
|
|
61
|
+
command[format]?.(e.chain().focus()).run()
|
|
62
|
+
},
|
|
63
|
+
linkTo: (url: string, anchor?: string) =>
|
|
64
|
+
anchor
|
|
65
|
+
? e
|
|
66
|
+
.chain()
|
|
67
|
+
.focus()
|
|
68
|
+
.insertContent({
|
|
69
|
+
type: 'text',
|
|
70
|
+
text: anchor,
|
|
71
|
+
marks: [{ type: 'link', attrs: { href: url } }],
|
|
72
|
+
})
|
|
73
|
+
.run()
|
|
74
|
+
: e.chain().focus().extendMarkRange('link').setLink({ href: url }).run(),
|
|
75
|
+
removeLink: () => e.chain().focus().unsetLink().run(),
|
|
76
|
+
isActive: (format: string) =>
|
|
77
|
+
format.startsWith('heading')
|
|
78
|
+
? e.isActive('heading', { level: Number(format.slice(7)) })
|
|
79
|
+
: e.isActive(format === 'strikethrough' ? 'strike' : format),
|
|
80
|
+
undo: () => e.commands.undo(),
|
|
81
|
+
redo: () => e.commands.redo(),
|
|
82
|
+
focus: () => e.commands.focus(),
|
|
83
|
+
blur: () => e.commands.blur(),
|
|
84
|
+
isFocused: () => e.isFocused,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (props.json !== undefined) {
|
|
89
|
+
const stamp = JSON.stringify(props.json)
|
|
90
|
+
if (stamp !== lastJSON && stamp !== JSON.stringify(editor.getJSON())) {
|
|
91
|
+
editor.commands.setContent(props.json)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
lastJSON = stamp
|
|
95
|
+
} else {
|
|
96
|
+
const stamp = JSON.stringify([false, props.value])
|
|
97
|
+
if (stamp !== lastHTMLInput && props.value !== undefined && props.value !== lastHTML) {
|
|
98
|
+
editor.commands.setContent(props.value)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
lastHTMLInput = stamp
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
editor.setEditable(props.editable !== false)
|
|
105
|
+
editor.view.dom.dataset.placeholder = props.placeholder ?? ''
|
|
106
|
+
editor.view.dom.setAttribute('aria-label', props.placeholder ?? 'Rich text editor')
|
|
107
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** @jsxImportSource @octane-xplat/macos-renderer */
|
|
2
|
+
import type { RichTextEditorProps } from './types'
|
|
3
|
+
import browser from '../dist/macos/browser.js'
|
|
4
|
+
import { NativeEditor } from './NativeEditor.macos'
|
|
5
|
+
|
|
6
|
+
/** AppKit mounts the local editor engine in WKWebView. */
|
|
7
|
+
export function RichTextEditor(props: RichTextEditorProps) {
|
|
8
|
+
return <NativeEditor {...props} browser={browser} />
|
|
9
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** @jsxImportSource @octane-xplat/macos-renderer */
|
|
2
|
+
import { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'octane'
|
|
3
|
+
import type { RichTextEditorProps } from './types'
|
|
4
|
+
import { createEditorSession } from './host-session.macos'
|
|
5
|
+
|
|
6
|
+
declare const XplatEditorHost: { alloc(): { init(): any } }
|
|
7
|
+
|
|
8
|
+
type HostProps = RichTextEditorProps & {
|
|
9
|
+
browser: string
|
|
10
|
+
json?: any
|
|
11
|
+
onJSONReady?: (ready: boolean) => void
|
|
12
|
+
onJSONChange?: (doc: any) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Internal shared AppKit/WKWebView surface, owned by the editor leaf. */
|
|
16
|
+
export function NativeEditor(props: HostProps) {
|
|
17
|
+
const latest = useRef(props)
|
|
18
|
+
latest.current = props
|
|
19
|
+
const session = useRef<ReturnType<typeof createEditorSession> | null>(null)
|
|
20
|
+
const [handle, setHandle] = useState<any>(null)
|
|
21
|
+
useImperativeHandle(props.ref, () => handle, [handle])
|
|
22
|
+
const attach = useCallback((parent: any) => {
|
|
23
|
+
session.current?.dispose()
|
|
24
|
+
session.current = null
|
|
25
|
+
if (!parent) {
|
|
26
|
+
setHandle(null)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const native = XplatEditorHost.alloc().init()
|
|
31
|
+
const next = createEditorSession(
|
|
32
|
+
(packet) => native.deliver(packet),
|
|
33
|
+
native,
|
|
34
|
+
() => latest.current,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
session.current = next
|
|
38
|
+
native.installDispatcher((packet: string) => next.receive(packet))
|
|
39
|
+
native.attachTo(parent)
|
|
40
|
+
native.load(props.browser)
|
|
41
|
+
setHandle(next.handle)
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
session.current?.update({
|
|
46
|
+
value: props.value,
|
|
47
|
+
json: props.json,
|
|
48
|
+
placeholder: props.placeholder,
|
|
49
|
+
editable: props.editable,
|
|
50
|
+
autofocus: props.autofocus,
|
|
51
|
+
})
|
|
52
|
+
}, [handle, props.value, props.json, props.placeholder, props.editable, props.autofocus])
|
|
53
|
+
|
|
54
|
+
useEffect(
|
|
55
|
+
() => () => {
|
|
56
|
+
session.current?.dispose()
|
|
57
|
+
session.current = null
|
|
58
|
+
},
|
|
59
|
+
[],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<flexboxlayout
|
|
64
|
+
id={props.id}
|
|
65
|
+
className={props.className}
|
|
66
|
+
style={props.style}
|
|
67
|
+
row={props.row}
|
|
68
|
+
col={props.col}
|
|
69
|
+
rowSpan={props.rowSpan}
|
|
70
|
+
colSpan={props.colSpan}
|
|
71
|
+
dock={props.dock}
|
|
72
|
+
left={props.left}
|
|
73
|
+
top={props.top}
|
|
74
|
+
flexGrow={props.flexGrow}
|
|
75
|
+
flexShrink={props.flexShrink}
|
|
76
|
+
alignSelf={props.alignSelf}
|
|
77
|
+
order={props.order}
|
|
78
|
+
ref={attach}
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
-
import { useCallback, useEffect, useRef } from 'octane'
|
|
2
|
+
import { useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
|
|
3
3
|
import { View } from '@nativescript/core'
|
|
4
4
|
import { registerElement } from '@nativescript-community/octane'
|
|
5
5
|
import type { Attributes } from '@nativescript-community/octane/intrinsics'
|
|
@@ -96,6 +96,8 @@ export function RichTextEditor(props: RichTextEditorProps) @{
|
|
|
96
96
|
|
|
97
97
|
// Stable identity — an inline closure re-fires ref(null)/ref(v) per
|
|
98
98
|
// render, which would bounce host.current around the effects below.
|
|
99
|
+
useImperativeHandle(props.ref, () => handle(editor()), []);
|
|
100
|
+
|
|
99
101
|
const bindHost = useCallback((v: any) => {
|
|
100
102
|
host.current = v
|
|
101
103
|
}, [])
|
|
@@ -176,6 +178,45 @@ export function RichTextEditor(props: RichTextEditorProps) @{
|
|
|
176
178
|
},
|
|
177
179
|
undo: () => e.undo(),
|
|
178
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()),
|
|
179
220
|
focus: () => {
|
|
180
221
|
e.requestFocus()
|
|
181
222
|
showKeyboard(e)
|
|
@@ -233,7 +274,6 @@ export function RichTextEditor(props: RichTextEditorProps) @{
|
|
|
233
274
|
}
|
|
234
275
|
|
|
235
276
|
if (props.android) Object.assign(e, props.android)
|
|
236
|
-
props.bind?.(handle(e))
|
|
237
277
|
props.onReady?.()
|
|
238
278
|
}, [])
|
|
239
279
|
|