@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.
@@ -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