@octane-xplat/richtext 0.0.1 → 0.10.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/LICENSE +21 -0
- package/README.md +114 -2
- package/dist/macos/browser.d.ts +1 -0
- package/dist/macos/browser.js +2 -0
- package/dist/native/RichTextEditor.android.js +235 -0
- package/dist/native/RichTextEditor.ios.js +22 -0
- package/dist/native/index.js +2 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/web/index.js +9 -0
- package/dist/web/octane-client-build.json +8 -0
- package/package.json +96 -11
- package/platforms/android/include.gradle +7 -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 +282 -0
- package/src/RichTextEditor.ios.tsrx +15 -0
- package/src/RichTextEditor.windows.tsrx +15 -0
- package/src/browser-transport.web.ts +119 -0
- package/src/host-session.macos.ts +133 -0
- package/src/index.macos.ts +4 -0
- package/src/index.ts +5 -0
- package/src/index.web.ts +11 -0
- package/src/types.ts +100 -0
- package/types/browser-transport.d.ts +2 -0
- package/types/index.d.ts +6 -0
- package/types/index.macos.d.ts +5 -0
- package/types/macos-host.d.ts +11 -0
- package/types/types.d.ts +70 -0
- package/index.d.ts +0 -1
- package/index.js +0 -2
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
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'
|
|
11
|
+
|
|
12
|
+
/** `org.wordpress.aztec` arrives through the leaf's aar — there are no
|
|
13
|
+
* generated typings for it, so every touch goes through `as any` at one
|
|
14
|
+
* boundary instead of `declare const org` per call site. */
|
|
15
|
+
const aztec = (globalThis as any).org?.wordpress?.aztec
|
|
16
|
+
const androidText = (globalThis as any).android?.text
|
|
17
|
+
const androidView = (globalThis as any).android?.view
|
|
18
|
+
|
|
19
|
+
const FORMATS: Record<Exclude<RichTextFormat, 'link'>, () => any> = {
|
|
20
|
+
bold: () => aztec.AztecTextFormat.FORMAT_BOLD,
|
|
21
|
+
italic: () => aztec.AztecTextFormat.FORMAT_ITALIC,
|
|
22
|
+
underline: () => aztec.AztecTextFormat.FORMAT_UNDERLINE,
|
|
23
|
+
strikethrough: () => aztec.AztecTextFormat.FORMAT_STRIKETHROUGH,
|
|
24
|
+
code: () => aztec.AztecTextFormat.FORMAT_CODE,
|
|
25
|
+
blockquote: () => aztec.AztecTextFormat.FORMAT_QUOTE,
|
|
26
|
+
bulletList: () => aztec.AztecTextFormat.FORMAT_UNORDERED_LIST,
|
|
27
|
+
orderedList: () => aztec.AztecTextFormat.FORMAT_ORDERED_LIST,
|
|
28
|
+
taskList: () => aztec.AztecTextFormat.FORMAT_TASK_LIST,
|
|
29
|
+
codeBlock: () => aztec.AztecTextFormat.FORMAT_PREFORMAT,
|
|
30
|
+
horizontalRule: () => aztec.AztecTextFormat.FORMAT_HORIZONTAL_RULE,
|
|
31
|
+
highlight: () => aztec.AztecTextFormat.FORMAT_MARK,
|
|
32
|
+
subscript: () => aztec.AztecTextFormat.FORMAT_SUBSCRIPT,
|
|
33
|
+
superscript: () => aztec.AztecTextFormat.FORMAT_SUPERSCRIPT,
|
|
34
|
+
paragraph: () => aztec.AztecTextFormat.FORMAT_PARAGRAPH,
|
|
35
|
+
heading1: () => aztec.AztecTextFormat.FORMAT_HEADING_1,
|
|
36
|
+
heading2: () => aztec.AztecTextFormat.FORMAT_HEADING_2,
|
|
37
|
+
heading3: () => aztec.AztecTextFormat.FORMAT_HEADING_3,
|
|
38
|
+
heading4: () => aztec.AztecTextFormat.FORMAT_HEADING_4,
|
|
39
|
+
heading5: () => aztec.AztecTextFormat.FORMAT_HEADING_5,
|
|
40
|
+
heading6: () => aztec.AztecTextFormat.FORMAT_HEADING_6,
|
|
41
|
+
alignLeft: () => aztec.AztecTextFormat.FORMAT_ALIGN_LEFT,
|
|
42
|
+
alignCenter: () => aztec.AztecTextFormat.FORMAT_ALIGN_CENTER,
|
|
43
|
+
alignRight: () => aztec.AztecTextFormat.FORMAT_ALIGN_RIGHT,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const AZTEC_OF: [RichTextFormat, () => any][] = Object.entries(FORMATS) as any
|
|
47
|
+
|
|
48
|
+
const formatOf = (format: RichTextFormat) =>
|
|
49
|
+
format === 'link' ? aztec.AztecTextFormat.FORMAT_LINK : FORMATS[format]()
|
|
50
|
+
|
|
51
|
+
class AztecEditor extends View {
|
|
52
|
+
createNativeView() {
|
|
53
|
+
return new aztec.AztecText(this._context)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
declare module '@nativescript-community/octane/intrinsics' {
|
|
58
|
+
interface NativeScriptElements {
|
|
59
|
+
xplatrichtext: Attributes<typeof AztecEditor>
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
registerElement('xplatrichtext', AztecEditor)
|
|
64
|
+
|
|
65
|
+
export const supported = true
|
|
66
|
+
|
|
67
|
+
/** Active formats at the selection, mapped back to facade spellings. */
|
|
68
|
+
const activeFormats = (editor: any): RichTextFormat[] => {
|
|
69
|
+
const styles = editor.getAppliedStyles(
|
|
70
|
+
editor.getSelectionStart(),
|
|
71
|
+
editor.getSelectionEnd(),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const count = styles?.size?.() ?? 0
|
|
75
|
+
const active = new Set<any>()
|
|
76
|
+
for (let i = 0; i < count; i++) active.add(styles.get(i))
|
|
77
|
+
|
|
78
|
+
const out: RichTextFormat[] = []
|
|
79
|
+
for (const [name, get] of AZTEC_OF) {
|
|
80
|
+
if (active.has(get())) out.push(name)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (active.has(aztec.AztecTextFormat.FORMAT_LINK)) out.push('link')
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** WordPress Aztec surface on Android — a true Spannable-backed editor, not
|
|
88
|
+
* a WebView. Content in/out is HTML (`fromHtml`/`toPlainHtml`); JSON
|
|
89
|
+
* interchange lives one layer up in @octane-xplat/tiptap. */
|
|
90
|
+
export function RichTextEditor(props: RichTextEditorProps) @{
|
|
91
|
+
const host = useRef<any>(null)
|
|
92
|
+
const lastEmitted = useRef<string | undefined>(undefined)
|
|
93
|
+
const applyingValue = useRef(false)
|
|
94
|
+
const keyListener = useRef<any>(null)
|
|
95
|
+
const emitTimer = useRef<any>(null)
|
|
96
|
+
|
|
97
|
+
// Stable identity — an inline closure re-fires ref(null)/ref(v) per
|
|
98
|
+
// render, which would bounce host.current around the effects below.
|
|
99
|
+
useImperativeHandle(props.ref, () => handle(editor()), []);
|
|
100
|
+
|
|
101
|
+
const bindHost = useCallback((v: any) => {
|
|
102
|
+
host.current = v
|
|
103
|
+
}, [])
|
|
104
|
+
|
|
105
|
+
const editor = () => host.current?.android
|
|
106
|
+
|
|
107
|
+
const setEditable = (next: boolean) => {
|
|
108
|
+
const e = editor()
|
|
109
|
+
if (!e) return
|
|
110
|
+
if (keyListener.current == null) keyListener.current = e.getKeyListener()
|
|
111
|
+
if (next) {
|
|
112
|
+
e.setKeyListener(keyListener.current)
|
|
113
|
+
e.setFocusable(true)
|
|
114
|
+
e.setCursorVisible(true)
|
|
115
|
+
} else {
|
|
116
|
+
e.setKeyListener(null)
|
|
117
|
+
e.setFocusable(false)
|
|
118
|
+
e.setCursorVisible(false)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const showKeyboard = (e: any) => {
|
|
123
|
+
try {
|
|
124
|
+
const imm = e.getContext().getSystemService(
|
|
125
|
+
(globalThis as any).android.content.Context.INPUT_METHOD_SERVICE,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
imm?.showSoftInput(e, androidView.inputmethod.InputMethodManager.SHOW_IMPLICIT)
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const emitChange = () => {
|
|
133
|
+
const e = editor()
|
|
134
|
+
if (!e || applyingValue.current) return
|
|
135
|
+
clearTimeout(emitTimer.current)
|
|
136
|
+
emitTimer.current = setTimeout(() => {
|
|
137
|
+
const current = editor()
|
|
138
|
+
if (!current || applyingValue.current) return
|
|
139
|
+
const html = current.toPlainHtml(false)
|
|
140
|
+
lastEmitted.current = html
|
|
141
|
+
props.onChange?.(html)
|
|
142
|
+
}, 150)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const handle = (e: any): RichTextEditorHandle => ({
|
|
146
|
+
getHTML: () => e.toPlainHtml(false),
|
|
147
|
+
setHTML: (html: string) => {
|
|
148
|
+
applyingValue.current = true
|
|
149
|
+
try {
|
|
150
|
+
e.fromHtml(html, true)
|
|
151
|
+
lastEmitted.current = html
|
|
152
|
+
} finally {
|
|
153
|
+
applyingValue.current = false
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
apply: (format: RichTextFormat) => {
|
|
157
|
+
if (format === 'link') return
|
|
158
|
+
e.toggleFormatting(formatOf(format))
|
|
159
|
+
emitChange()
|
|
160
|
+
},
|
|
161
|
+
linkTo: (url: string, anchor?: string) => {
|
|
162
|
+
e.link(url, anchor ?? url, false)
|
|
163
|
+
emitChange()
|
|
164
|
+
},
|
|
165
|
+
removeLink: () => {
|
|
166
|
+
e.removeLink()
|
|
167
|
+
emitChange()
|
|
168
|
+
},
|
|
169
|
+
isActive: (format: RichTextFormat) => {
|
|
170
|
+
const applied = e.getAppliedStyles(e.getSelectionStart(), e.getSelectionEnd())
|
|
171
|
+
const count = applied?.size?.() ?? 0
|
|
172
|
+
const target = formatOf(format)
|
|
173
|
+
for (let i = 0; i < count; i++) {
|
|
174
|
+
if (applied.get(i) === target) return true
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return false
|
|
178
|
+
},
|
|
179
|
+
undo: () => e.undo(),
|
|
180
|
+
redo: () => e.redo(),
|
|
181
|
+
focus: () => {
|
|
182
|
+
e.requestFocus()
|
|
183
|
+
showKeyboard(e)
|
|
184
|
+
},
|
|
185
|
+
blur: () => e.clearFocus(),
|
|
186
|
+
isFocused: () => e.isFocused(),
|
|
187
|
+
native: e,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
const e = editor()
|
|
192
|
+
if (!e || !aztec) return
|
|
193
|
+
|
|
194
|
+
e.setOnSelectionChangedListener(
|
|
195
|
+
new aztec.AztecText.OnSelectionChangedListener({
|
|
196
|
+
onSelectionChanged: (start: number, end: number) =>
|
|
197
|
+
props.onSelectionChange?.({
|
|
198
|
+
start,
|
|
199
|
+
end,
|
|
200
|
+
active: activeFormats(e),
|
|
201
|
+
}),
|
|
202
|
+
}),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
e.addTextChangedListener(
|
|
206
|
+
new androidText.TextWatcher({
|
|
207
|
+
beforeTextChanged: () => {},
|
|
208
|
+
onTextChanged: () => {},
|
|
209
|
+
afterTextChanged: () => emitChange(),
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
e.setOnFocusChangeListener(
|
|
214
|
+
new androidView.View.OnFocusChangeListener({
|
|
215
|
+
onFocusChange: (_v: any, focused: boolean) =>
|
|
216
|
+
focused ? props.onFocus?.() : props.onBlur?.(),
|
|
217
|
+
}),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if (props.placeholder !== undefined) e.setHint(props.placeholder)
|
|
221
|
+
setEditable(props.editable !== false)
|
|
222
|
+
|
|
223
|
+
if (props.value !== undefined) {
|
|
224
|
+
applyingValue.current = true
|
|
225
|
+
try {
|
|
226
|
+
e.fromHtml(props.value, true)
|
|
227
|
+
} finally {
|
|
228
|
+
applyingValue.current = false
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (props.autofocus) {
|
|
233
|
+
e.requestFocus()
|
|
234
|
+
showKeyboard(e)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (props.android) Object.assign(e, props.android)
|
|
238
|
+
props.onReady?.()
|
|
239
|
+
}, [])
|
|
240
|
+
|
|
241
|
+
// `value` lands like a controlled prop, minus the loop: text the editor
|
|
242
|
+
// emitted through onChange is never pushed back; any other change is an
|
|
243
|
+
// external reset (and resets Aztec's undo stack, like PM setContent).
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
const e = editor()
|
|
246
|
+
if (!e || props.value === undefined) return
|
|
247
|
+
if (props.value === lastEmitted.current) return
|
|
248
|
+
applyingValue.current = true
|
|
249
|
+
try {
|
|
250
|
+
e.fromHtml(props.value, true)
|
|
251
|
+
} finally {
|
|
252
|
+
applyingValue.current = false
|
|
253
|
+
}
|
|
254
|
+
}, [props.value])
|
|
255
|
+
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
setEditable(props.editable !== false)
|
|
258
|
+
}, [props.editable])
|
|
259
|
+
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
const e = editor()
|
|
262
|
+
if (e && props.placeholder !== undefined) e.setHint(props.placeholder)
|
|
263
|
+
}, [props.placeholder])
|
|
264
|
+
|
|
265
|
+
<xplatrichtext
|
|
266
|
+
ref={bindHost}
|
|
267
|
+
id={props.id}
|
|
268
|
+
className={props.className}
|
|
269
|
+
style={props.style}
|
|
270
|
+
row={props.row}
|
|
271
|
+
col={props.col}
|
|
272
|
+
rowSpan={props.rowSpan}
|
|
273
|
+
colSpan={props.colSpan}
|
|
274
|
+
dock={props.dock}
|
|
275
|
+
left={props.left}
|
|
276
|
+
top={props.top}
|
|
277
|
+
flexGrow={props.flexGrow}
|
|
278
|
+
flexShrink={props.flexShrink}
|
|
279
|
+
alignSelf={props.alignSelf}
|
|
280
|
+
order={props.order}
|
|
281
|
+
/>;
|
|
282
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
+
import type { RichTextEditorProps } from './types'
|
|
3
|
+
|
|
4
|
+
export const supported = false
|
|
5
|
+
|
|
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. */
|
|
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 iOS yet."
|
|
14
|
+
/>;
|
|
15
|
+
}
|
|
@@ -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,133 @@
|
|
|
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
|
+
focus: () => command('focus'),
|
|
53
|
+
blur: () => command('blur'),
|
|
54
|
+
isFocused: () => focused,
|
|
55
|
+
native,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
handle,
|
|
60
|
+
update(props: object) {
|
|
61
|
+
command('props', props)
|
|
62
|
+
},
|
|
63
|
+
receive(serialized: string) {
|
|
64
|
+
if (disposed) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const packet: EditorPacket = JSON.parse(serialized)
|
|
69
|
+
const props = readProps()
|
|
70
|
+
if (packet.html !== undefined) {
|
|
71
|
+
html = packet.html
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if ('json' in packet) {
|
|
75
|
+
json = packet.json
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (packet.selection) {
|
|
79
|
+
active = packet.selection.active
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (packet.focused !== undefined) {
|
|
83
|
+
focused = packet.focused
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
switch (packet.event) {
|
|
87
|
+
case 'boot':
|
|
88
|
+
ready = true
|
|
89
|
+
for (const packet of pending) {
|
|
90
|
+
send(packet)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pending = []
|
|
94
|
+
break
|
|
95
|
+
case 'ready':
|
|
96
|
+
props.onJSONReady?.(true)
|
|
97
|
+
props.onReady?.()
|
|
98
|
+
break
|
|
99
|
+
case 'change':
|
|
100
|
+
props.onChange?.(html)
|
|
101
|
+
if (json) {
|
|
102
|
+
props.onJSONChange?.(json)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
break
|
|
106
|
+
case 'selection':
|
|
107
|
+
if (packet.selection) {
|
|
108
|
+
props.onSelectionChange?.(packet.selection)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
break
|
|
112
|
+
case 'focus':
|
|
113
|
+
props.onFocus?.()
|
|
114
|
+
break
|
|
115
|
+
case 'blur':
|
|
116
|
+
props.onBlur?.()
|
|
117
|
+
break
|
|
118
|
+
case 'error':
|
|
119
|
+
console.error('[macos-editor] ' + packet.message)
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
dispose() {
|
|
124
|
+
if (disposed) {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
disposed = true
|
|
129
|
+
pending = []
|
|
130
|
+
native.dispose()
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
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.
|
|
4
|
+
export { RichTextEditor, supported } from './RichTextEditor'
|
|
5
|
+
export type * from './types'
|
package/src/index.web.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RichTextEditorProps } from './types'
|
|
2
|
+
|
|
3
|
+
export const supported = false
|
|
4
|
+
|
|
5
|
+
/** Web routes through @octane-xplat/tiptap — this leaf only exists for the
|
|
6
|
+
* native (Aztec) surface. */
|
|
7
|
+
export function RichTextEditor(_props: RichTextEditorProps): null {
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type * from './types'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Octane } from 'octane/jsx-runtime'
|
|
2
|
+
|
|
3
|
+
/** Android uses WordPress Aztec; AppKit uses StarterKit in WKWebView.
|
|
4
|
+
* iOS is stubbed. Web/Windows use the tiptap facade instead of this leaf. */
|
|
5
|
+
export type RichTextFormat =
|
|
6
|
+
| 'bold'
|
|
7
|
+
| 'italic'
|
|
8
|
+
| 'underline'
|
|
9
|
+
| 'strikethrough'
|
|
10
|
+
| 'code'
|
|
11
|
+
| 'blockquote'
|
|
12
|
+
| 'bulletList'
|
|
13
|
+
| 'orderedList'
|
|
14
|
+
| 'taskList'
|
|
15
|
+
| 'codeBlock'
|
|
16
|
+
| 'horizontalRule'
|
|
17
|
+
| 'highlight'
|
|
18
|
+
| 'subscript'
|
|
19
|
+
| 'superscript'
|
|
20
|
+
| 'paragraph'
|
|
21
|
+
| 'heading1'
|
|
22
|
+
| 'heading2'
|
|
23
|
+
| 'heading3'
|
|
24
|
+
| 'heading4'
|
|
25
|
+
| 'heading5'
|
|
26
|
+
| 'heading6'
|
|
27
|
+
| 'alignLeft'
|
|
28
|
+
| 'alignCenter'
|
|
29
|
+
| 'alignRight'
|
|
30
|
+
| 'link'
|
|
31
|
+
|
|
32
|
+
export interface RichTextEditorHandle {
|
|
33
|
+
/** Serialized document HTML (Aztec's "plain" HTML — no contenteditable
|
|
34
|
+
* markup). */
|
|
35
|
+
/** AppKit: returns the latest WebKit snapshot; commands are asynchronous. */
|
|
36
|
+
getHTML(): string
|
|
37
|
+
/** Replace the document. Android resets Aztec history; AppKit uses engine history. */
|
|
38
|
+
setHTML(html: string): void
|
|
39
|
+
/** Toggle a format at the current selection. `link` needs
|
|
40
|
+
* `linkTo(url, anchor)` instead. */
|
|
41
|
+
apply(format: RichTextFormat): void
|
|
42
|
+
/** Insert or re-target a link over the current selection. */
|
|
43
|
+
linkTo(url: string, anchor?: string): void
|
|
44
|
+
removeLink(): void
|
|
45
|
+
/** Formats active at the selection — powers toolbar highlight state. */
|
|
46
|
+
isActive(format: RichTextFormat): boolean
|
|
47
|
+
/** Android: Aztec's history batches keyboard input only — format
|
|
48
|
+
* toggles and programmatic edits do not register. */
|
|
49
|
+
undo(): void
|
|
50
|
+
redo(): void
|
|
51
|
+
focus(): void
|
|
52
|
+
blur(): void
|
|
53
|
+
isFocused(): boolean
|
|
54
|
+
/** The platform surface — `AztecText` on Android. */
|
|
55
|
+
/** AppKit: the XplatEditorHost transport, not the browser engine instance. */
|
|
56
|
+
native: any
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RichTextSelectionChange {
|
|
60
|
+
start: number
|
|
61
|
+
end: number
|
|
62
|
+
/** Formats active at the caret/range, in facade spellings. */
|
|
63
|
+
active: RichTextFormat[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RichTextEditorProps {
|
|
67
|
+
id?: string
|
|
68
|
+
className?: string
|
|
69
|
+
style?: any
|
|
70
|
+
/** Initial document HTML. Re-applied only when the string changes
|
|
71
|
+
* externally — text the editor emitted back through `onChange` is never
|
|
72
|
+
* re-pushed, so typing doesn't reparse the document. */
|
|
73
|
+
value?: string
|
|
74
|
+
placeholder?: string
|
|
75
|
+
editable?: boolean
|
|
76
|
+
autofocus?: boolean
|
|
77
|
+
onReady?: () => void
|
|
78
|
+
onChange?: (html: string) => void
|
|
79
|
+
onSelectionChange?: (event: RichTextSelectionChange) => void
|
|
80
|
+
onFocus?: () => void
|
|
81
|
+
onBlur?: () => void
|
|
82
|
+
/** Imperative handle — fires once when the native editor exists. */
|
|
83
|
+
ref?: Octane.Ref<RichTextEditorHandle>
|
|
84
|
+
/** Per-platform escape bag — properties are assigned onto the native
|
|
85
|
+
* `AztecText` after the leaf's own props. */
|
|
86
|
+
android?: Record<string, any>
|
|
87
|
+
ios?: Record<string, any>
|
|
88
|
+
// NativeScript layout-child attributes the driver reads on this element.
|
|
89
|
+
row?: number | string
|
|
90
|
+
col?: number | string
|
|
91
|
+
rowSpan?: number
|
|
92
|
+
colSpan?: number
|
|
93
|
+
dock?: 'left' | 'top' | 'right' | 'bottom'
|
|
94
|
+
left?: number
|
|
95
|
+
top?: number
|
|
96
|
+
flexGrow?: number
|
|
97
|
+
flexShrink?: number
|
|
98
|
+
alignSelf?: 'auto' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline'
|
|
99
|
+
order?: number
|
|
100
|
+
}
|
package/types/index.d.ts
ADDED