@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.
@@ -1,15 +1,230 @@
1
1
  /** @jsxImportSource @nativescript-community/octane */
2
- import type { RichTextEditorProps } from './types'
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
- export const supported = false
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 bring-up is deferred — the Swift facade over Aztec-iOS lands in a
7
- * follow-up. Renders the placeholder so a shared screen still mounts. */
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
- <label
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
- text="Rich text editing is not supported on iOS yet."
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
  }
@@ -0,0 +1,15 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import type { RichTextEditorProps } from './types'
3
+
4
+ export const supported = false
5
+
6
+ /** Windows bring-up is deferred — renders the placeholder so a shared
7
+ * screen still mounts. */
8
+ export function RichTextEditor(props: RichTextEditorProps) @{
9
+ <label
10
+ id={props.id}
11
+ className={props.className}
12
+ style={props.style}
13
+ text="Rich text editing is not supported on Windows yet."
14
+ />;
15
+ }
@@ -0,0 +1,119 @@
1
+ const formats = [
2
+ 'bold',
3
+ 'italic',
4
+ 'underline',
5
+ 'strikethrough',
6
+ 'code',
7
+ 'blockquote',
8
+ 'bulletList',
9
+ 'orderedList',
10
+ 'taskList',
11
+ 'codeBlock',
12
+ 'horizontalRule',
13
+ 'highlight',
14
+ 'subscript',
15
+ 'superscript',
16
+ 'paragraph',
17
+ 'heading1',
18
+ 'heading2',
19
+ 'heading3',
20
+ 'heading4',
21
+ 'heading5',
22
+ 'heading6',
23
+ 'alignLeft',
24
+ 'alignCenter',
25
+ 'alignRight',
26
+ 'link',
27
+ ]
28
+
29
+ /** Internal document transport. Engine handles never cross the WebKit boundary. */
30
+ export function installEditorDocument(render: (props: any) => void) {
31
+ let handle: any = null
32
+ const pending: { method: string; args: any[] }[] = []
33
+ let selection = { start: 0, end: 0, active: [] as string[] }
34
+ const post = (event: string, extra: object = {}) => {
35
+ const packet = {
36
+ event,
37
+ html: handle?.getHTML() ?? '',
38
+ json: handle?.getJSON?.() ?? null,
39
+ focused: handle?.isFocused() ?? false,
40
+ selection,
41
+ ...extra,
42
+ }
43
+
44
+ ;(window as any).webkit.messageHandlers.editor.postMessage(JSON.stringify(packet))
45
+ }
46
+
47
+ const snapshot = (event: string) => {
48
+ setTimeout(() => {
49
+ if (!handle) {
50
+ return
51
+ }
52
+
53
+ selection = { ...selection, active: formats.filter((f) => handle.isActive(f)) }
54
+ post(event)
55
+ }, 0)
56
+ }
57
+
58
+ const command = (packet: { method: string; args: any[] }) => {
59
+ if (packet.method === 'props') {
60
+ render({ ...packet.args[0], ...callbacks })
61
+ if (handle) {
62
+ snapshot('snapshot')
63
+ }
64
+ } else if (!handle) {
65
+ pending.push(packet)
66
+ } else if (
67
+ [
68
+ 'setHTML',
69
+ 'setJSON',
70
+ 'apply',
71
+ 'linkTo',
72
+ 'removeLink',
73
+ 'undo',
74
+ 'redo',
75
+ 'focus',
76
+ 'blur',
77
+ ].includes(packet.method)
78
+ ) {
79
+ handle[packet.method]?.(...packet.args)
80
+ snapshot('snapshot')
81
+ }
82
+ }
83
+
84
+ const callbacks = {
85
+ ref(next: any) {
86
+ if (!next || next === handle) {
87
+ return
88
+ }
89
+
90
+ handle = next
91
+ snapshot('ready')
92
+ for (const packet of pending.splice(0)) {
93
+ command(packet)
94
+ }
95
+ },
96
+ onChange: () => snapshot('change'),
97
+ onSelectionChange(next: typeof selection) {
98
+ selection = next
99
+ snapshot('selection')
100
+ },
101
+ onFocus: () => snapshot('focus'),
102
+ onBlur: () => snapshot('blur'),
103
+ }
104
+
105
+ ;(window as any).__xplatEditorReceive = (serialized: string) => {
106
+ try {
107
+ command(JSON.parse(serialized))
108
+ } catch (error) {
109
+ post('error', { message: String(error) })
110
+ }
111
+ }
112
+
113
+ window.addEventListener('error', (event) => post('error', { message: event.message }))
114
+ window.addEventListener('unhandledrejection', (event) =>
115
+ post('error', { message: String(event.reason) }),
116
+ )
117
+
118
+ post('boot')
119
+ }
@@ -0,0 +1,142 @@
1
+ import type { RichTextEditorHandle, RichTextEditorProps, RichTextFormat } from './types'
2
+
3
+ export interface EditorPacket {
4
+ event: string
5
+ html?: string
6
+ json?: any
7
+ selection?: { start: number; end: number; active: RichTextFormat[] }
8
+ focused?: boolean
9
+ message?: string
10
+ }
11
+
12
+ /** Internal transport adapter: synchronous getters read the last WebKit snapshot. */
13
+ export function createEditorSession(
14
+ send: (packet: string) => void,
15
+ native: any,
16
+ readProps: () => RichTextEditorProps & {
17
+ onJSONChange?: (doc: any) => void
18
+ onJSONReady?: (ok: boolean) => void
19
+ },
20
+ ) {
21
+ let disposed = false
22
+ let ready = false
23
+ let html = ''
24
+ let json: any = null
25
+ let active: RichTextFormat[] = []
26
+ let focused = false
27
+ let pending: string[] = []
28
+ const command = (method: string, ...args: any[]) => {
29
+ if (disposed) {
30
+ return
31
+ }
32
+
33
+ const packet = JSON.stringify({ method, args })
34
+ if (ready) {
35
+ send(packet)
36
+ } else {
37
+ pending.push(packet)
38
+ }
39
+ }
40
+
41
+ const handle: RichTextEditorHandle & { getJSON(): any; setJSON(doc: any): void } = {
42
+ getHTML: () => html,
43
+ setHTML: (value) => command('setHTML', value),
44
+ getJSON: () => json,
45
+ setJSON: (doc) => command('setJSON', doc),
46
+ apply: (format) => command('apply', format),
47
+ linkTo: (url, anchor) => command('linkTo', url, anchor),
48
+ removeLink: () => command('removeLink'),
49
+ isActive: (format) => active.includes(format),
50
+ undo: () => command('undo'),
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,
61
+ focus: () => command('focus'),
62
+ blur: () => command('blur'),
63
+ isFocused: () => focused,
64
+ native,
65
+ }
66
+
67
+ return {
68
+ handle,
69
+ update(props: object) {
70
+ command('props', props)
71
+ },
72
+ receive(serialized: string) {
73
+ if (disposed) {
74
+ return
75
+ }
76
+
77
+ const packet: EditorPacket = JSON.parse(serialized)
78
+ const props = readProps()
79
+ if (packet.html !== undefined) {
80
+ html = packet.html
81
+ }
82
+
83
+ if ('json' in packet) {
84
+ json = packet.json
85
+ }
86
+
87
+ if (packet.selection) {
88
+ active = packet.selection.active
89
+ }
90
+
91
+ if (packet.focused !== undefined) {
92
+ focused = packet.focused
93
+ }
94
+
95
+ switch (packet.event) {
96
+ case 'boot':
97
+ ready = true
98
+ for (const packet of pending) {
99
+ send(packet)
100
+ }
101
+
102
+ pending = []
103
+ break
104
+ case 'ready':
105
+ props.onJSONReady?.(true)
106
+ props.onReady?.()
107
+ break
108
+ case 'change':
109
+ props.onChange?.(html)
110
+ if (json) {
111
+ props.onJSONChange?.(json)
112
+ }
113
+
114
+ break
115
+ case 'selection':
116
+ if (packet.selection) {
117
+ props.onSelectionChange?.(packet.selection)
118
+ }
119
+
120
+ break
121
+ case 'focus':
122
+ props.onFocus?.()
123
+ break
124
+ case 'blur':
125
+ props.onBlur?.()
126
+ break
127
+ case 'error':
128
+ console.error('[macos-editor] ' + packet.message)
129
+ break
130
+ }
131
+ },
132
+ dispose() {
133
+ if (disposed) {
134
+ return
135
+ }
136
+
137
+ disposed = true
138
+ pending = []
139
+ native.dispose()
140
+ },
141
+ }
142
+ }
@@ -1,10 +1,4 @@
1
- import type { RichTextEditorProps } from './types'
2
-
3
- export const supported = false
4
-
5
- /** The macOS editor is deferred; the tiptap facade covers desktop needs. */
6
- export function RichTextEditor(_props: RichTextEditorProps): null {
7
- return null
8
- }
9
-
1
+ export { RichTextEditor } from './Editor.macos'
2
+ /** The AppKit/WKWebView backend is implemented. */
3
+ export const supported = true
10
4
  export type * from './types'
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 (stub until the Swift facade lands) component
3
- // for the current build target.
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'
package/src/types.ts CHANGED
@@ -1,6 +1,8 @@
1
- /** Supported on Android today (WordPress Aztec); iOS is stubbed until the
2
- * Swift-facade bring-up. Web/macOS/Windows route through the tiptap facade
3
- * instead of this leaf. */
1
+ import type { Octane } from 'octane/jsx-runtime'
2
+
3
+ /** Android uses WordPress Aztec; iOS uses AztecEditor-iOS; AppKit uses
4
+ * StarterKit in WKWebView. Web/Windows use the tiptap facade instead of
5
+ * this leaf. */
4
6
  export type RichTextFormat =
5
7
  | 'bold'
6
8
  | 'italic'
@@ -31,25 +33,68 @@ export type RichTextFormat =
31
33
  export interface RichTextEditorHandle {
32
34
  /** Serialized document HTML (Aztec's "plain" HTML — no contenteditable
33
35
  * markup). */
36
+ /** AppKit: returns the latest WebKit snapshot; commands are asynchronous. */
34
37
  getHTML(): string
35
- /** Replace the document; resets Aztec's undo history. */
38
+ /** Replace the document. Android resets Aztec history; the iOS
39
+ * `setHTML` variant is likewise non-undoable; AppKit uses engine
40
+ * history. */
36
41
  setHTML(html: string): void
37
42
  /** Toggle a format at the current selection. `link` needs
38
- * `linkTo(url, anchor)` instead. */
43
+ * `linkTo(url, anchor)` instead. iOS has no taskList or text-alignment
44
+ * engine support — those calls report a console warning and do not
45
+ * change the document. */
39
46
  apply(format: RichTextFormat): void
40
47
  /** Insert or re-target a link over the current selection. */
41
48
  linkTo(url: string, anchor?: string): void
42
49
  removeLink(): void
43
- /** Formats active at the selection — powers toolbar highlight state. */
50
+ /** Formats active at the selection — powers toolbar highlight state.
51
+ * iOS always reports false for taskList and the align* formats (no
52
+ * engine support). */
44
53
  isActive(format: RichTextFormat): boolean
45
54
  /** Android: Aztec's history batches keyboard input only — format
46
- * toggles and programmatic edits do not register. */
55
+ * toggles and programmatic edits do not register. iOS: the engine
56
+ * undoManager covers format toggles and keyboard input; `setHTML` and
57
+ * split/join edits sit outside it. */
47
58
  undo(): void
48
59
  redo(): void
60
+ /** Split the block at the caret into two siblings — the Enter-key
61
+ * semantic (a non-collapsed selection is deleted first). Android: a
62
+ * real block break through Aztec's watcher pipeline — a caret inside
63
+ * a list item produces a sibling `<li>`. iOS routes through Aztec's
64
+ * `insertText` newline handling for the same semantics. Inside hidden
65
+ * block markup Aztec does not understand (`<div data-*>` wrappers),
66
+ * this is a line break, not a new node — it serializes as `<br>`.
67
+ * Returns false when
68
+ * there is no valid selection. AppKit: returns false — the engine
69
+ * transport is asynchronous and cannot report application. */
70
+ split(): boolean
71
+ /** Merge the block at the caret into the previous block — the
72
+ * Backspace-at-block-start semantic. Returns false unless the
73
+ * selection is a collapsed caret directly after a block boundary. */
74
+ join(): boolean
75
+ /** Demote the block(s) in the selection. Aztec semantics: a list item
76
+ * nests under its previous sibling; a plain paragraph, heading,
77
+ * quote, or preformat line gains a literal `\t` indent. Hidden
78
+ * `<div data-*>` nodes are opaque to Aztec and cannot be demoted.
79
+ * iOS: `increaseIndent` acts on list/quote depth and falls back to a
80
+ * `\t` insert on plain blocks. Returns false when Aztec reports no
81
+ * indentable selection (iOS: when the view is not editable). */
82
+ indent(): boolean
83
+ /** Promote the block(s) in the selection one level — the inverse of
84
+ * `indent()`. iOS has no plain-block outdent, so this applies only to
85
+ * list items and blockquotes. Returns false when Aztec reports no
86
+ * outdentable selection. */
87
+ outdent(): boolean
88
+ /** Whether `indent()` can apply to the current selection. */
89
+ canIndent(): boolean
90
+ /** Whether `outdent()` can apply to the current selection. */
91
+ canOutdent(): boolean
49
92
  focus(): void
50
93
  blur(): void
51
94
  isFocused(): boolean
52
- /** The platform surface — `AztecText` on Android. */
95
+ /** The platform surface — `AztecText` on Android, the
96
+ * `XplatAztecEditorView` facade on iOS. */
97
+ /** AppKit: the XplatEditorHost transport, not the browser engine instance. */
53
98
  native: any
54
99
  }
55
100
 
@@ -77,7 +122,7 @@ export interface RichTextEditorProps {
77
122
  onFocus?: () => void
78
123
  onBlur?: () => void
79
124
  /** Imperative handle — fires once when the native editor exists. */
80
- bind?: (handle: RichTextEditorHandle) => void
125
+ ref?: Octane.Ref<RichTextEditorHandle>
81
126
  /** Per-platform escape bag — properties are assigned onto the native
82
127
  * `AztecText` after the leaf's own props. */
83
128
  android?: Record<string, any>
@@ -0,0 +1,2 @@
1
+ /** Internal WebKit document transport installer. */
2
+ export declare function installEditorDocument(render: (props: any) => void): void
@@ -0,0 +1,5 @@
1
+ import type { UniversalComponent } from 'octane/universal'
2
+ import type { RichTextEditorProps } from '../src/types'
3
+ export declare const RichTextEditor: UniversalComponent<RichTextEditorProps>
4
+ export declare const supported: boolean
5
+ export type * from '../src/types'
@@ -0,0 +1,11 @@
1
+ import type { UniversalComponent } from 'octane/universal'
2
+ import type { RichTextEditorProps } from '../src/types'
3
+ /** Internal shared editor host. */
4
+ export declare const NativeEditor: UniversalComponent<
5
+ RichTextEditorProps & {
6
+ browser: string
7
+ json?: any
8
+ onJSONReady?: (ok: boolean) => void
9
+ onJSONChange?: (doc: any) => void
10
+ }
11
+ >