@herca/rn-kit 0.1.51 → 0.1.53

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.
Files changed (62) hide show
  1. package/lib/module/Icon/Icon.js +15 -1
  2. package/lib/module/Icon/Icon.js.map +1 -1
  3. package/lib/module/Icon/icons/AlignCenter.js +25 -0
  4. package/lib/module/Icon/icons/AlignCenter.js.map +1 -0
  5. package/lib/module/Icon/icons/AlignLeft.js +25 -0
  6. package/lib/module/Icon/icons/AlignLeft.js.map +1 -0
  7. package/lib/module/Icon/icons/AlignRight.js +25 -0
  8. package/lib/module/Icon/icons/AlignRight.js.map +1 -0
  9. package/lib/module/Icon/icons/Bold.js +3 -3
  10. package/lib/module/Icon/icons/Bold.js.map +1 -1
  11. package/lib/module/Icon/icons/Italic.js +3 -3
  12. package/lib/module/Icon/icons/Italic.js.map +1 -1
  13. package/lib/module/Icon/icons/Link.js +25 -0
  14. package/lib/module/Icon/icons/Link.js.map +1 -0
  15. package/lib/module/Icon/icons/ListOrdered.js +34 -0
  16. package/lib/module/Icon/icons/ListOrdered.js.map +1 -0
  17. package/lib/module/Icon/icons/ListUnOrdered.js +25 -0
  18. package/lib/module/Icon/icons/ListUnOrdered.js.map +1 -0
  19. package/lib/module/Icon/icons/StrikeThrough.js +25 -0
  20. package/lib/module/Icon/icons/StrikeThrough.js.map +1 -0
  21. package/lib/module/Icon/icons/UnderLine.js +3 -3
  22. package/lib/module/Icon/icons/UnderLine.js.map +1 -1
  23. package/lib/module/TextEditor/README.md +263 -0
  24. package/lib/module/TextEditor/TextEditor.js +538 -63
  25. package/lib/module/TextEditor/TextEditor.js.map +1 -1
  26. package/lib/typescript/src/Icon/Icon.d.ts.map +1 -1
  27. package/lib/typescript/src/Icon/icons/AlignCenter.d.ts +9 -0
  28. package/lib/typescript/src/Icon/icons/AlignCenter.d.ts.map +1 -0
  29. package/lib/typescript/src/Icon/icons/AlignLeft.d.ts +9 -0
  30. package/lib/typescript/src/Icon/icons/AlignLeft.d.ts.map +1 -0
  31. package/lib/typescript/src/Icon/icons/AlignRight.d.ts +9 -0
  32. package/lib/typescript/src/Icon/icons/AlignRight.d.ts.map +1 -0
  33. package/lib/typescript/src/Icon/icons/Bold.d.ts.map +1 -1
  34. package/lib/typescript/src/Icon/icons/Italic.d.ts.map +1 -1
  35. package/lib/typescript/src/Icon/icons/Link.d.ts +9 -0
  36. package/lib/typescript/src/Icon/icons/Link.d.ts.map +1 -0
  37. package/lib/typescript/src/Icon/icons/ListOrdered.d.ts +9 -0
  38. package/lib/typescript/src/Icon/icons/ListOrdered.d.ts.map +1 -0
  39. package/lib/typescript/src/Icon/icons/ListUnOrdered.d.ts +9 -0
  40. package/lib/typescript/src/Icon/icons/ListUnOrdered.d.ts.map +1 -0
  41. package/lib/typescript/src/Icon/icons/StrikeThrough.d.ts +9 -0
  42. package/lib/typescript/src/Icon/icons/StrikeThrough.d.ts.map +1 -0
  43. package/lib/typescript/src/Icon/icons/UnderLine.d.ts.map +1 -1
  44. package/lib/typescript/src/Icon/type.d.ts +1 -1
  45. package/lib/typescript/src/Icon/type.d.ts.map +1 -1
  46. package/lib/typescript/src/TextEditor/TextEditor.d.ts +20 -1
  47. package/lib/typescript/src/TextEditor/TextEditor.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/Icon/Icon.tsx +14 -0
  50. package/src/Icon/icons/AlignCenter.tsx +22 -0
  51. package/src/Icon/icons/AlignLeft.tsx +28 -0
  52. package/src/Icon/icons/AlignRight.tsx +22 -0
  53. package/src/Icon/icons/Bold.tsx +8 -2
  54. package/src/Icon/icons/Italic.tsx +8 -2
  55. package/src/Icon/icons/Link.tsx +22 -0
  56. package/src/Icon/icons/ListOrdered.tsx +38 -0
  57. package/src/Icon/icons/ListUnOrdered.tsx +32 -0
  58. package/src/Icon/icons/StrikeThrough.tsx +26 -0
  59. package/src/Icon/icons/UnderLine.tsx +8 -2
  60. package/src/Icon/type.tsx +7 -0
  61. package/src/TextEditor/README.md +263 -0
  62. package/src/TextEditor/TextEditor.tsx +685 -133
@@ -1,37 +1,146 @@
1
- import { useRef, useState, useEffect } from 'react';
2
1
  import {
3
- View,
4
- StyleSheet,
5
- Platform,
2
+ forwardRef,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
8
+ import {
9
+ Alert,
10
+ Dimensions,
11
+ Keyboard,
6
12
  KeyboardAvoidingView,
13
+ Platform,
14
+ SafeAreaView,
15
+ ScrollView,
16
+ StyleSheet,
17
+ TextInput,
7
18
  TouchableOpacity,
8
- Keyboard,
19
+ View,
9
20
  type KeyboardEvent,
10
- Dimensions,
11
- SafeAreaView,
12
21
  } from 'react-native';
13
22
  import { WebView } from 'react-native-webview';
23
+ import BottomSheet from '../BottomSheet/BottomSheet';
24
+ import Button from '../Button/Button';
14
25
  import Color from '../Color/Color';
15
- import type { TextEditorType } from './types';
26
+ import Icon, { type IconNameProps } from '../Icon';
27
+ import Input from '../Input/Input';
16
28
  import LabelForm from '../LabelForm/LabelForm';
17
29
  import Typography from '../Typography/Typography';
18
- import Icon from '../Icon';
19
-
20
- export default function TextEditor({
21
- label,
22
- hasError,
23
- hint,
24
- onChange,
25
- height = 300,
26
- }: TextEditorType) {
27
- const [keyboardHeight, setKeyboardHeight] = useState(0);
28
- const [showToolbar, setShowToolbar] = useState<boolean>(false);
29
- const webviewRef = useRef<WebView>(null);
30
- const handleOnChange = (data: string) => {
31
- onChange?.(data);
32
- };
33
-
34
- const htmlEditor = `
30
+ import type { TextEditorType } from './types';
31
+
32
+ export interface TextEditorRef {
33
+ getContent: () => void;
34
+ setContent: (html: string) => void;
35
+ clearContent: () => void;
36
+ }
37
+
38
+ interface ExtendedTextEditorType extends TextEditorType {
39
+ initialValue?: string;
40
+ placeholder?: string;
41
+ maxLength?: number;
42
+ onFocus?: () => void;
43
+ onBlur?: () => void;
44
+ inputLabelLinkText?: string;
45
+ inputLinkTextPlacholder?: string;
46
+ inputLabelLinkUrl?: string;
47
+ inputLinkUrlPlacholder?: string;
48
+ saveLinkButtonText?: string;
49
+ cancelLinkButtonText?: string;
50
+ }
51
+
52
+ type TextCommand =
53
+ | 'bold'
54
+ | 'italic'
55
+ | 'underline'
56
+ | 'strikeThrough'
57
+ | 'link'
58
+ | 'insertUnorderedList'
59
+ | 'insertOrderedList'
60
+ | 'justifyLeft'
61
+ | 'justifyCenter'
62
+ | 'justifyRight';
63
+
64
+ interface ToolbarButton {
65
+ command: TextCommand;
66
+ icon: IconNameProps;
67
+ label: string;
68
+ isSpecial?: boolean;
69
+ }
70
+
71
+ const TextEditor = forwardRef<TextEditorRef, ExtendedTextEditorType>(
72
+ (
73
+ {
74
+ label,
75
+ hasError,
76
+ hint,
77
+ onChange,
78
+ height = 300,
79
+ initialValue = '',
80
+ placeholder = 'Tulis sesuatu di sini...',
81
+ maxLength,
82
+ onFocus,
83
+ onBlur,
84
+ inputLinkTextPlacholder = 'Link URL',
85
+ inputLinkUrlPlacholder = 'https://www.example.com',
86
+ inputLabelLinkText = 'Teks Link',
87
+ inputLabelLinkUrl = 'Link URL',
88
+ saveLinkButtonText = 'Simpan',
89
+ cancelLinkButtonText = 'Batal',
90
+ },
91
+ ref
92
+ ) => {
93
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
94
+ const [showToolbar, setShowToolbar] = useState<boolean>(false);
95
+ const [activeFormats, setActiveFormats] = useState<Set<string>>(new Set());
96
+ const [characterCount, setCharacterCount] = useState(0);
97
+ const [showLinkModal, setShowLinkModal] = useState(false);
98
+ const [linkText, setLinkText] = useState('');
99
+ const [linkUrl, setLinkUrl] = useState('');
100
+ const [isEditingLink, setIsEditingLink] = useState(false);
101
+ const webviewRef = useRef<WebView>(null);
102
+ const inputUrlRef = useRef<TextInput>(null);
103
+
104
+ const handleOnChange = (data: string) => {
105
+ // Remove HTML tags untuk hitung karakter
106
+ const textOnly = data.replace(/<[^>]*>/g, '');
107
+ setCharacterCount(textOnly.length);
108
+
109
+ if (maxLength && textOnly.length > maxLength) {
110
+ return;
111
+ }
112
+
113
+ onChange?.(data);
114
+ };
115
+
116
+ // Expose methods via ref
117
+ useImperativeHandle(ref, () => ({
118
+ getContent: () => {
119
+ webviewRef.current?.postMessage('getContent');
120
+ },
121
+ setContent: (html: string) => {
122
+ const escapedHtml = html.replace(/`/g, '\\`').replace(/\$/g, '\\$');
123
+ webviewRef.current?.injectJavaScript(`
124
+ const editor = document.getElementById('editor');
125
+ editor.innerHTML = \`${escapedHtml}\`;
126
+ if (editor.textContent.trim()) {
127
+ editor.classList.remove('placeholder');
128
+ }
129
+ true;
130
+ `);
131
+ },
132
+ clearContent: () => {
133
+ webviewRef.current?.injectJavaScript(`
134
+ const editor = document.getElementById('editor');
135
+ editor.innerHTML = '';
136
+ editor.classList.add('placeholder');
137
+ window.ReactNativeWebView.postMessage('');
138
+ true;
139
+ `);
140
+ },
141
+ }));
142
+
143
+ const htmlEditor = `
35
144
  <html>
36
145
  <head>
37
146
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
@@ -49,52 +158,225 @@ export default function TextEditor({
49
158
  overflow-x: hidden;
50
159
  }
51
160
  #editor {
52
- min-height: ${height};
161
+ height: 100%;
53
162
  width: 100%;
54
163
  max-width: 100%;
55
- padding: 12px;
56
- font-size: 12px;
57
- border: 1px solid ${Color.gray[200]};
164
+ font-size: 14px;
165
+ line-height: 1.5;
58
166
  border-radius: 6px;
59
167
  outline: none;
60
168
  transition: border 0.2s;
61
169
  overflow-wrap: break-word;
62
- font-weight: 500;
170
+ font-weight: 400;
63
171
  word-wrap: break-word;
64
172
  word-break: break-word;
173
+ color: #333;
65
174
  }
66
- #editor.focused {
67
- border-color: ${Color.gray[200]}
175
+ #editor.placeholder:empty:before {
176
+ content: '${placeholder}';
177
+ color: ${Color.gray[400]};
178
+ font-style: italic;
179
+ }
180
+ #editor:focus {
181
+ outline: none;
182
+ }
183
+ /* Style untuk list */
184
+ #editor ul, #editor ol {
185
+ margin: 8px 0;
186
+ padding-left: 24px;
187
+ }
188
+ #editor li {
189
+ margin: 4px 0;
190
+ }
191
+ /* Style untuk heading */
192
+ #editor h1 { font-size: 24px; font-weight: bold; margin: 12px 0; }
193
+ #editor h2 { font-size: 20px; font-weight: bold; margin: 10px 0; }
194
+ #editor h3 { font-size: 16px; font-weight: bold; margin: 8px 0; }
195
+ /* Style untuk link */
196
+ #editor a {
197
+ color: #3b82f6;
198
+ text-decoration: underline;
199
+ cursor: pointer;
200
+ }
201
+ #editor a:hover {
202
+ color: #2563eb;
68
203
  }
69
204
  </style>
70
205
  </head>
71
206
  <body>
72
- <div id="editor" contenteditable="true">Tulis sesuatu di sini...</div>
207
+ <div id="editor" contenteditable="true" class="placeholder">${initialValue}</div>
73
208
  <script>
74
209
  const editor = document.getElementById("editor");
75
-
210
+
211
+ // Handle placeholder
212
+ if (editor.textContent.trim()) {
213
+ editor.classList.remove('placeholder');
214
+ }
215
+
216
+ editor.addEventListener("input", () => {
217
+ if (editor.textContent.trim()) {
218
+ editor.classList.remove('placeholder');
219
+ } else {
220
+ editor.classList.add('placeholder');
221
+ }
222
+ window.ReactNativeWebView.postMessage(editor.innerHTML);
223
+ });
224
+
76
225
  editor.addEventListener("focus", () => {
77
226
  editor.classList.add("focused");
227
+ window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'focus' }));
78
228
  });
79
229
 
80
230
  editor.addEventListener("blur", () => {
81
231
  editor.classList.remove("focused");
232
+ window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'blur' }));
82
233
  });
234
+
235
+ // Update active formats
236
+ editor.addEventListener("mouseup", updateFormats);
237
+ editor.addEventListener("keyup", updateFormats);
238
+ editor.addEventListener("click", updateFormats);
239
+
240
+ function updateFormats() {
241
+ const formats = [];
242
+ if (document.queryCommandState('bold')) formats.push('bold');
243
+ if (document.queryCommandState('italic')) formats.push('italic');
244
+ if (document.queryCommandState('underline')) formats.push('underline');
245
+ if (document.queryCommandState('strikeThrough')) formats.push('strikeThrough');
246
+
247
+ window.ReactNativeWebView.postMessage(JSON.stringify({
248
+ type: 'formats',
249
+ formats: formats
250
+ }));
251
+ }
252
+
253
+ function getSelectedLinkInfo() {
254
+ const selection = window.getSelection();
255
+ if (!selection || selection.rangeCount === 0) {
256
+ return { text: '', url: '' };
257
+ }
258
+
259
+ const range = selection.getRangeAt(0);
260
+ let linkElement = null;
261
+
262
+ // Check if selection contains or is within a link
263
+ if (range.startContainer.nodeType === Node.ELEMENT_NODE) {
264
+ linkElement = range.startContainer.querySelector('a');
265
+ }
266
+
267
+ // Check parent nodes for link
268
+ let node = range.startContainer;
269
+ while (node && node !== editor) {
270
+ if (node.nodeName === 'A') {
271
+ linkElement = node;
272
+ break;
273
+ }
274
+ node = node.parentNode;
275
+ }
276
+
277
+ // Check if any node in selection is a link
278
+ if (!linkElement && range.commonAncestorContainer) {
279
+ const container = range.commonAncestorContainer;
280
+ if (container.nodeType === Node.ELEMENT_NODE) {
281
+ linkElement = container.querySelector('a');
282
+ }
283
+ }
284
+
285
+ if (linkElement) {
286
+ return {
287
+ text: linkElement.textContent || '',
288
+ url: linkElement.href || '',
289
+ isExisting: true
290
+ };
291
+ }
292
+
293
+ return {
294
+ text: selection.toString(),
295
+ url: '',
296
+ isExisting: false
297
+ };
298
+ }
299
+
83
300
  const handleMessage = (command) => {
84
301
  if (command === 'getContent') {
85
302
  window.ReactNativeWebView.postMessage(editor.innerHTML);
303
+ } else if (command === 'getSelectedText') {
304
+ const linkInfo = getSelectedLinkInfo();
305
+ window.ReactNativeWebView.postMessage(JSON.stringify({
306
+ type: 'selectedText',
307
+ text: linkInfo.text,
308
+ url: linkInfo.url,
309
+ isExisting: linkInfo.isExisting
310
+ }));
311
+ } else if (command.startsWith('insertLink:')) {
312
+ const linkData = command.replace('insertLink:', '');
313
+ const { text, url, isExisting } = JSON.parse(linkData);
314
+ editor.focus();
315
+ const selection = window.getSelection();
316
+
317
+ if (selection && selection.rangeCount > 0) {
318
+ const range = selection.getRangeAt(0);
319
+
320
+ // If editing existing link, find and update it
321
+ if (isExisting) {
322
+ let linkElement = null;
323
+ let node = range.startContainer;
324
+
325
+ while (node && node !== editor) {
326
+ if (node.nodeName === 'A') {
327
+ linkElement = node;
328
+ break;
329
+ }
330
+ node = node.parentNode;
331
+ }
332
+
333
+ if (!linkElement && range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE) {
334
+ linkElement = range.commonAncestorContainer.querySelector('a');
335
+ }
336
+
337
+ if (linkElement) {
338
+ linkElement.href = url;
339
+ linkElement.textContent = text;
340
+ // Move cursor after link
341
+ range.setStartAfter(linkElement);
342
+ range.collapse(true);
343
+ selection.removeAllRanges();
344
+ selection.addRange(range);
345
+ window.ReactNativeWebView.postMessage(editor.innerHTML);
346
+ return;
347
+ }
348
+ }
349
+
350
+ // Create new link
351
+ const link = document.createElement('a');
352
+ link.href = url;
353
+ link.textContent = text;
354
+ link.target = '_blank';
355
+ range.deleteContents();
356
+ range.insertNode(link);
357
+ // Add space after link
358
+ const space = document.createTextNode(' ');
359
+ link.parentNode.insertBefore(space, link.nextSibling);
360
+ // Move cursor after space
361
+ range.setStartAfter(space);
362
+ range.collapse(true);
363
+ selection.removeAllRanges();
364
+ selection.addRange(range);
365
+ }
366
+ window.ReactNativeWebView.postMessage(editor.innerHTML);
86
367
  } else {
87
368
  editor.focus();
88
- document.execCommand(command, false, '');
369
+ document.execCommand(command, false, null);
370
+ updateFormats();
89
371
  }
90
372
  };
91
373
 
92
- // ini untuk Android
374
+ // untuk Android
93
375
  document.addEventListener("message", (event) => {
94
376
  handleMessage(event.data);
95
377
  });
96
378
 
97
- // ini untuk iOS
379
+ // untuk iOS
98
380
  window.addEventListener("message", (event) => {
99
381
  handleMessage(event.data);
100
382
  });
@@ -103,101 +385,336 @@ export default function TextEditor({
103
385
  </html>
104
386
  `;
105
387
 
106
- const formatText = (command: string) => {
107
- if (webviewRef.current) {
108
- if (Platform.OS === 'ios') {
109
- webviewRef.current.injectJavaScript(`
110
- document.execCommand('${command}', false, '');
111
- true; //di ios harus biar berfunsi boldnya
388
+ const formatText = (command: string) => {
389
+ if (webviewRef.current) {
390
+ if (Platform.OS === 'ios') {
391
+ webviewRef.current.injectJavaScript(`
392
+ document.execCommand('${command}', false, null);
393
+ const formats = [];
394
+ if (document.queryCommandState('bold')) formats.push('bold');
395
+ if (document.queryCommandState('italic')) formats.push('italic');
396
+ if (document.queryCommandState('underline')) formats.push('underline');
397
+ if (document.queryCommandState('strikeThrough')) formats.push('strikeThrough');
398
+ window.ReactNativeWebView.postMessage(JSON.stringify({
399
+ type: 'formats',
400
+ formats: formats
401
+ }));
402
+ true;
403
+ `);
404
+ } else {
405
+ webviewRef.current.postMessage(command);
406
+ }
407
+ }
408
+ };
409
+
410
+ const openLinkModal = () => {
411
+ // Get selected text first
412
+ if (webviewRef.current) {
413
+ webviewRef.current.postMessage('getSelectedText');
414
+ }
415
+ setShowLinkModal(true);
416
+ setTimeout(() => {
417
+ inputUrlRef.current?.focus();
418
+ }, 300);
419
+ };
420
+
421
+ console.log(showLinkModal);
422
+
423
+ const insertLink = () => {
424
+ if (!linkUrl.trim()) {
425
+ Alert.alert('Error', 'URL tidak boleh kosong');
426
+ return;
427
+ }
428
+
429
+ let finalUrl = linkUrl.trim();
430
+ // Add https:// if no protocol
431
+ if (!finalUrl.match(/^https?:\/\//i)) {
432
+ finalUrl = 'https://' + finalUrl;
433
+ }
434
+
435
+ const linkData = JSON.stringify({
436
+ text: linkText.trim() || finalUrl,
437
+ url: finalUrl,
438
+ isExisting: isEditingLink,
439
+ });
440
+
441
+ if (webviewRef.current) {
442
+ if (Platform.OS === 'ios') {
443
+ webviewRef.current.injectJavaScript(`
444
+ const linkData = ${linkData};
445
+ const editor = document.getElementById('editor');
446
+ editor.focus();
447
+ const selection = window.getSelection();
448
+
449
+ if (selection && selection.rangeCount > 0) {
450
+ const range = selection.getRangeAt(0);
451
+
452
+ // If editing existing link
453
+ if (linkData.isExisting) {
454
+ let linkElement = null;
455
+ let node = range.startContainer;
456
+
457
+ while (node && node !== editor) {
458
+ if (node.nodeName === 'A') {
459
+ linkElement = node;
460
+ break;
461
+ }
462
+ node = node.parentNode;
463
+ }
464
+
465
+ if (!linkElement && range.commonAncestorContainer.nodeType === 1) {
466
+ linkElement = range.commonAncestorContainer.querySelector('a');
467
+ }
468
+
469
+ if (linkElement) {
470
+ linkElement.href = linkData.url;
471
+ linkElement.textContent = linkData.text;
472
+ range.setStartAfter(linkElement);
473
+ range.collapse(true);
474
+ selection.removeAllRanges();
475
+ selection.addRange(range);
476
+ window.ReactNativeWebView.postMessage(editor.innerHTML);
477
+ true;
478
+ return;
479
+ }
480
+ }
481
+
482
+ // Create new link
483
+ const link = document.createElement('a');
484
+ link.href = linkData.url;
485
+ link.textContent = linkData.text;
486
+ link.target = '_blank';
487
+ range.deleteContents();
488
+ range.insertNode(link);
489
+ const space = document.createTextNode(' ');
490
+ link.parentNode.insertBefore(space, link.nextSibling);
491
+ range.setStartAfter(space);
492
+ range.collapse(true);
493
+ selection.removeAllRanges();
494
+ selection.addRange(range);
495
+ }
496
+ window.ReactNativeWebView.postMessage(editor.innerHTML);
497
+ true;
112
498
  `);
113
- } else {
114
- webviewRef.current.postMessage(command);
499
+ } else {
500
+ webviewRef.current.postMessage('insertLink:' + linkData);
501
+ }
115
502
  }
116
- }
117
- };
118
-
119
- useEffect(() => {
120
- const showSub = Keyboard.addListener(
121
- 'keyboardDidShow',
122
- (e: KeyboardEvent) => {
123
- setKeyboardHeight(e.endCoordinates.height - 35);
124
- console.log(e.endCoordinates.height);
125
- setShowToolbar(true);
503
+
504
+ setShowLinkModal(false);
505
+ setLinkText('');
506
+ setLinkUrl('');
507
+ setIsEditingLink(false);
508
+ };
509
+
510
+ const closeLinkModal = () => {
511
+ setShowLinkModal(false);
512
+ setLinkText('');
513
+ setLinkUrl('');
514
+ setIsEditingLink(false);
515
+ };
516
+
517
+ const handleWebViewMessage = (event: any) => {
518
+ const data = event.nativeEvent.data;
519
+
520
+ try {
521
+ const parsed = JSON.parse(data);
522
+
523
+ if (parsed.type === 'formats') {
524
+ setActiveFormats(new Set(parsed.formats));
525
+ } else if (parsed.type === 'focus') {
526
+ onFocus?.();
527
+ } else if (parsed.type === 'blur') {
528
+ onBlur?.();
529
+ } else if (parsed.type === 'selectedText') {
530
+ setLinkText(parsed.text);
531
+ // Clean URL (remove protocol prefix for editing)
532
+ const cleanUrl = parsed.url.replace(/^https?:\/\//, '');
533
+ setLinkUrl(cleanUrl);
534
+ setIsEditingLink(parsed.isExisting || false);
535
+ }
536
+ } catch {
537
+ // Jika bukan JSON, berarti itu HTML content
538
+ handleOnChange(data);
126
539
  }
127
- );
128
- const hideSub = Keyboard.addListener('keyboardDidHide', () => {
129
- setKeyboardHeight(0);
130
- setShowToolbar(false);
131
- });
132
-
133
- return () => {
134
- showSub.remove();
135
- hideSub.remove();
136
540
  };
137
- }, []);
138
-
139
- return (
140
- <View style={styles.container}>
141
- {label ? <LabelForm title={label} /> : null}
142
- <KeyboardAvoidingView
143
- style={(styles.editorWrapper, [{ minHeight: height }])}
144
- behavior={Platform.OS === 'ios' ? 'padding' : undefined}
145
- >
146
- <WebView
147
- ref={webviewRef}
148
- originWhitelist={['*']}
149
- source={{ html: htmlEditor }}
150
- onMessage={(event) => {
151
- handleOnChange(event.nativeEvent.data);
152
- }}
153
- style={styles.webview}
154
- javaScriptEnabled={true}
155
- domStorageEnabled={true}
156
- mixedContentMode="always"
157
- allowUniversalAccessFromFileURLs={true}
158
- allowFileAccess={true}
159
- />
160
- </KeyboardAvoidingView>
161
-
162
- {hint ? (
163
- <Typography
164
- color={hasError ? Color.danger[500] : Color.gray[700]}
165
- variant="t3"
166
- weight="medium"
167
- >
168
- {hint}
169
- </Typography>
170
- ) : null}
171
- {showToolbar ? (
172
- <SafeAreaView
173
- style={[
174
- styles.toolbar,
175
- { top: Dimensions.get('screen').height - keyboardHeight - 200 },
176
- ]}
541
+
542
+ useEffect(() => {
543
+ const showSub = Keyboard.addListener(
544
+ 'keyboardDidShow',
545
+ (e: KeyboardEvent) => {
546
+ setKeyboardHeight(e.endCoordinates.height - 35);
547
+ setShowToolbar(true);
548
+ }
549
+ );
550
+ const hideSub = Keyboard.addListener('keyboardDidHide', () => {
551
+ setKeyboardHeight(0);
552
+ setShowToolbar(false);
553
+ });
554
+
555
+ return () => {
556
+ showSub.remove();
557
+ hideSub.remove();
558
+ };
559
+ }, []);
560
+
561
+ const gap = Platform.OS === 'ios' ? 200 : 185;
562
+
563
+ const isFormatActive = (format: string) => activeFormats.has(format);
564
+
565
+ const toolbarButtons: ToolbarButton[] = [
566
+ { command: 'bold', icon: 'Bold', label: 'Bold' },
567
+ { command: 'italic', icon: 'Italic', label: 'Italic' },
568
+ { command: 'underline', icon: 'UnderLine', label: 'Underline' },
569
+ { command: 'strikeThrough', icon: 'strike-through', label: 'Strike' },
570
+ { command: 'link', icon: 'Link', label: 'Link', isSpecial: true },
571
+ {
572
+ command: 'insertUnorderedList',
573
+ icon: 'list-un-ordered',
574
+ label: 'Bullet',
575
+ },
576
+ { command: 'insertOrderedList', icon: 'list-ordered', label: 'Number' },
577
+ { command: 'justifyLeft', icon: 'align-left', label: 'Left' },
578
+ { command: 'justifyCenter', icon: 'align-center', label: 'Center' },
579
+ { command: 'justifyRight', icon: 'align-right', label: 'Right' },
580
+ ];
581
+
582
+ return (
583
+ <View style={styles.container}>
584
+ {label ? <LabelForm title={label} /> : null}
585
+ <KeyboardAvoidingView
586
+ style={[styles.editorWrapper, { minHeight: height, height: height }]}
587
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
177
588
  >
178
- <TouchableOpacity
179
- onPress={() => formatText('bold')}
180
- style={styles.toolButton}
181
- >
182
- <Icon name="Bold" />
183
- </TouchableOpacity>
184
- <TouchableOpacity
185
- onPress={() => formatText('italic')}
186
- style={styles.toolButton}
187
- >
188
- <Icon name="Italic" />
189
- </TouchableOpacity>
190
- <TouchableOpacity
191
- onPress={() => formatText('underline')}
192
- style={styles.toolButton}
589
+ <WebView
590
+ ref={webviewRef}
591
+ originWhitelist={['*']}
592
+ source={{ html: htmlEditor }}
593
+ onMessage={handleWebViewMessage}
594
+ style={styles.webview}
595
+ javaScriptEnabled={true}
596
+ domStorageEnabled={true}
597
+ mixedContentMode="always"
598
+ allowUniversalAccessFromFileURLs={true}
599
+ allowFileAccess={true}
600
+ scrollEnabled={true}
601
+ />
602
+ </KeyboardAvoidingView>
603
+
604
+ {hint || maxLength ? (
605
+ <View style={styles.hintContainer}>
606
+ {hint ? (
607
+ <Typography
608
+ color={hasError ? Color.danger[500] : Color.gray[700]}
609
+ variant="t3"
610
+ weight="medium"
611
+ >
612
+ {hint}
613
+ </Typography>
614
+ ) : null}
615
+ {maxLength && (
616
+ <Typography
617
+ color={
618
+ characterCount > maxLength
619
+ ? Color.danger[500]
620
+ : Color.gray[600]
621
+ }
622
+ variant="t3"
623
+ weight="medium"
624
+ >
625
+ {characterCount}/{maxLength}
626
+ </Typography>
627
+ )}
628
+ </View>
629
+ ) : null}
630
+
631
+ {showToolbar ? (
632
+ <SafeAreaView
633
+ style={[
634
+ styles.toolbar,
635
+ { top: Dimensions.get('screen').height - keyboardHeight - gap },
636
+ ]}
193
637
  >
194
- <Icon name="UnderLine" />
195
- </TouchableOpacity>
196
- </SafeAreaView>
197
- ) : null}
198
- </View>
199
- );
200
- }
638
+ <ScrollView
639
+ horizontal
640
+ showsHorizontalScrollIndicator={false}
641
+ contentContainerStyle={styles.toolbarContent}
642
+ >
643
+ {toolbarButtons.map((button) => (
644
+ <TouchableOpacity
645
+ key={button.command}
646
+ onPress={() =>
647
+ button.isSpecial
648
+ ? openLinkModal()
649
+ : formatText(button.command)
650
+ }
651
+ style={[
652
+ styles.toolButton,
653
+ isFormatActive(button.command) && styles.toolButtonActive,
654
+ ]}
655
+ >
656
+ <Icon
657
+ name={button.icon}
658
+ size={20}
659
+ color={
660
+ isFormatActive(button.command) ? '#fff' : Color.gray[900]
661
+ }
662
+ />
663
+ </TouchableOpacity>
664
+ ))}
665
+ </ScrollView>
666
+ </SafeAreaView>
667
+ ) : null}
668
+
669
+ {/* Link Modal */}
670
+ <BottomSheet
671
+ isOpen={showLinkModal}
672
+ onClose={() => setShowLinkModal(false)}
673
+ >
674
+ <View style={styles.modalBody}>
675
+ <Input
676
+ label={inputLabelLinkText}
677
+ value={linkText}
678
+ onChangeText={setLinkText}
679
+ placeholder={inputLinkTextPlacholder}
680
+ autoCapitalize="none"
681
+ />
682
+ <Input
683
+ ref={inputUrlRef}
684
+ keyboardType="url"
685
+ label={inputLabelLinkUrl}
686
+ value={linkUrl}
687
+ onChangeText={setLinkUrl}
688
+ placeholder={inputLinkUrlPlacholder}
689
+ autoCapitalize="none"
690
+ />
691
+ </View>
692
+ <View style={styles.modalActions}>
693
+ <View style={styles.flex1}>
694
+ <Button
695
+ title={cancelLinkButtonText}
696
+ color="primary"
697
+ onPress={closeLinkModal}
698
+ variant="outline"
699
+ />
700
+ </View>
701
+ <View style={styles.flex1}>
702
+ <Button
703
+ title={saveLinkButtonText}
704
+ color="primary"
705
+ onPress={insertLink}
706
+ />
707
+ </View>
708
+ </View>
709
+ </BottomSheet>
710
+ </View>
711
+ );
712
+ }
713
+ );
714
+
715
+ TextEditor.displayName = 'TextEditor';
716
+
717
+ export default TextEditor;
201
718
 
202
719
  const styles = StyleSheet.create({
203
720
  container: {
@@ -205,25 +722,60 @@ const styles = StyleSheet.create({
205
722
  gap: 4,
206
723
  position: 'relative',
207
724
  },
208
- editorWrapper: { flex: 1 },
725
+ editorWrapper: {
726
+ flex: 1,
727
+ borderWidth: 1,
728
+ borderColor: Color.gray[200],
729
+ borderRadius: 8,
730
+ overflow: 'hidden',
731
+ paddingVertical: 10,
732
+ paddingHorizontal: 12,
733
+ },
734
+ hintContainer: {
735
+ flexDirection: 'row',
736
+ justifyContent: 'space-between',
737
+ alignItems: 'center',
738
+ paddingHorizontal: 2,
739
+ },
209
740
  toolbar: {
210
741
  flexDirection: 'row',
211
742
  borderTopWidth: 1,
212
743
  borderColor: Color.gray[200],
744
+ backgroundColor: '#fff',
213
745
  paddingVertical: 8,
214
- paddingHorizontal: 10,
215
746
  position: 'absolute',
216
- left: 0,
217
- width: '100%',
747
+ left: -24,
748
+ width: Dimensions.get('screen').width,
218
749
  alignItems: 'center',
219
750
  },
751
+ toolbarContent: {
752
+ paddingHorizontal: 8,
753
+ gap: 4,
754
+ },
220
755
  toolButton: {
221
756
  padding: 8,
222
757
  marginHorizontal: 4,
223
758
  borderRadius: 6,
759
+ minWidth: 36,
760
+ alignItems: 'center',
761
+ justifyContent: 'center',
762
+ },
763
+ toolButtonActive: {
764
+ backgroundColor: Color.primary?.[1000],
765
+ },
766
+ webview: {
767
+ flex: 1,
768
+ backgroundColor: 'transparent',
769
+ },
770
+ modalBody: {
771
+ gap: 16,
772
+ },
773
+ modalActions: {
774
+ flexDirection: 'row',
775
+ gap: 12,
776
+ marginTop: 8,
224
777
  },
225
- toolText: {
226
- fontWeight: 'bold',
778
+ flex1: {
779
+ flex: 1,
227
780
  },
228
- webview: { flex: 1 },
229
781
  });