@rozenite/storage-plugin 1.8.1 → 1.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/CHANGELOG.md +34 -0
- package/README.md +23 -0
- package/dist/devtools/assets/panel-Bm-SWF7d.js +33 -0
- package/dist/devtools/assets/panel-DIqI4WSp.css +1 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +43 -57
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +253 -191
- package/dist/react-native/index.d.ts +29 -6
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.d.ts +1 -0
- package/package.json +9 -6
- package/src/react-native/__tests__/import.test.ts +182 -0
- package/src/react-native/adapters/__tests__/mmkv.test.ts +436 -0
- package/src/react-native/adapters/mmkv.ts +65 -39
- package/src/react-native/import.ts +67 -0
- package/src/react-native/storage-view.ts +16 -8
- package/src/react-native/useRozeniteStoragePlugin.ts +61 -36
- package/src/shared/__tests__/snapshot.test.ts +361 -0
- package/src/shared/messaging.ts +25 -1
- package/src/shared/snapshot.ts +276 -0
- package/src/shared/types.ts +1 -0
- package/src/ui/__tests__/binary-value-editor-state.test.ts +199 -0
- package/src/ui/__tests__/binary.test.ts +251 -0
- package/src/ui/__tests__/type-conversion.test.ts +123 -0
- package/src/ui/add-entry-dialog.tsx +83 -173
- package/src/ui/binary-value-editor-state.ts +125 -0
- package/src/ui/binary-value-editor.tsx +168 -0
- package/src/ui/binary.ts +123 -0
- package/src/ui/edit-entry-dialog.tsx +64 -161
- package/src/ui/editable-table.tsx +12 -10
- package/src/ui/editor-switcher.tsx +62 -0
- package/src/ui/entry-detail-dialog.tsx +14 -6
- package/src/ui/import-dialog.tsx +261 -0
- package/src/ui/panel.tsx +257 -57
- package/src/ui/type-conversion.ts +105 -0
- package/src/ui/typed-value-editor.tsx +96 -0
- package/src/ui/utils.ts +30 -0
- package/dist/devtools/assets/panel-DMhXYHH4.css +0 -1
- package/dist/devtools/assets/panel-eGOuOVos.js +0 -22
- package/src/react-native/is-garbled.ts +0 -17
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
2
|
import { X } from 'lucide-react';
|
|
3
3
|
import type {
|
|
4
4
|
StorageEntry,
|
|
@@ -6,13 +6,8 @@ import type {
|
|
|
6
6
|
StorageEntryValue,
|
|
7
7
|
} from '../shared/types';
|
|
8
8
|
import { ConfirmDialog } from './confirm-dialog';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
{ value: 'string', label: 'String' },
|
|
12
|
-
{ value: 'number', label: 'Number' },
|
|
13
|
-
{ value: 'boolean', label: 'Boolean' },
|
|
14
|
-
{ value: 'buffer', label: 'Buffer (Array)' },
|
|
15
|
-
];
|
|
9
|
+
import { TypedValueEditor } from './typed-value-editor';
|
|
10
|
+
import { defaultValueForType } from './type-conversion';
|
|
16
11
|
|
|
17
12
|
export type AddEntryDialogProps = {
|
|
18
13
|
isOpen: boolean;
|
|
@@ -22,6 +17,23 @@ export type AddEntryDialogProps = {
|
|
|
22
17
|
supportedTypes: StorageEntryType[];
|
|
23
18
|
};
|
|
24
19
|
|
|
20
|
+
const buildEntry = (
|
|
21
|
+
key: string,
|
|
22
|
+
type: StorageEntryType,
|
|
23
|
+
value: StorageEntryValue,
|
|
24
|
+
): StorageEntry => {
|
|
25
|
+
switch (type) {
|
|
26
|
+
case 'string':
|
|
27
|
+
return { key, type: 'string', value: value as string };
|
|
28
|
+
case 'number':
|
|
29
|
+
return { key, type: 'number', value: value as number };
|
|
30
|
+
case 'boolean':
|
|
31
|
+
return { key, type: 'boolean', value: value as boolean };
|
|
32
|
+
case 'buffer':
|
|
33
|
+
return { key, type: 'buffer', value: value as number[] };
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
25
37
|
export const AddEntryDialog = ({
|
|
26
38
|
isOpen,
|
|
27
39
|
onClose,
|
|
@@ -29,42 +41,48 @@ export const AddEntryDialog = ({
|
|
|
29
41
|
existingKeys,
|
|
30
42
|
supportedTypes,
|
|
31
43
|
}: AddEntryDialogProps) => {
|
|
44
|
+
const initialType: StorageEntryType = supportedTypes.includes('string')
|
|
45
|
+
? 'string'
|
|
46
|
+
: (supportedTypes[0] ?? 'string');
|
|
47
|
+
|
|
32
48
|
const [newEntryKey, setNewEntryKey] = useState('');
|
|
33
|
-
const [
|
|
34
|
-
const [
|
|
49
|
+
const [currentType, setCurrentType] = useState<StorageEntryType>(initialType);
|
|
50
|
+
const [currentValue, setCurrentValue] = useState<StorageEntryValue | null>(
|
|
51
|
+
defaultValueForType(initialType),
|
|
52
|
+
);
|
|
35
53
|
const [confirmDialog, setConfirmDialog] = useState<{
|
|
36
54
|
isOpen: boolean;
|
|
37
55
|
title: string;
|
|
38
56
|
message: string;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
}>({ isOpen: false, title: '', message: '' });
|
|
58
|
+
|
|
59
|
+
// Reset state every time the dialog opens, so a previous session's
|
|
60
|
+
// type / value doesn't bleed in.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (isOpen) {
|
|
63
|
+
setNewEntryKey('');
|
|
64
|
+
setCurrentType(initialType);
|
|
65
|
+
setCurrentValue(defaultValueForType(initialType));
|
|
66
|
+
}
|
|
67
|
+
}, [isOpen, initialType]);
|
|
49
68
|
|
|
50
|
-
const
|
|
69
|
+
const isCurrentTypeSupported = supportedTypes.includes(currentType);
|
|
51
70
|
|
|
52
|
-
const
|
|
71
|
+
const resetAndClose = () => {
|
|
53
72
|
setNewEntryKey('');
|
|
54
|
-
|
|
55
|
-
|
|
73
|
+
setCurrentType(initialType);
|
|
74
|
+
setCurrentValue(defaultValueForType(initialType));
|
|
56
75
|
onClose();
|
|
57
76
|
};
|
|
58
77
|
|
|
59
|
-
const
|
|
60
|
-
if (!newEntryKey.trim()) return;
|
|
78
|
+
const handleAdd = () => {
|
|
79
|
+
if (!newEntryKey.trim() || currentValue === null) return;
|
|
61
80
|
|
|
62
|
-
if (!
|
|
81
|
+
if (!isCurrentTypeSupported) {
|
|
63
82
|
setConfirmDialog({
|
|
64
83
|
isOpen: true,
|
|
65
84
|
title: 'Unsupported Type',
|
|
66
85
|
message: 'Selected type is not supported by this storage.',
|
|
67
|
-
type: 'alert',
|
|
68
86
|
});
|
|
69
87
|
return;
|
|
70
88
|
}
|
|
@@ -74,77 +92,25 @@ export const AddEntryDialog = ({
|
|
|
74
92
|
isOpen: true,
|
|
75
93
|
title: 'Key Already Exists',
|
|
76
94
|
message: 'An entry with this key already exists.',
|
|
77
|
-
type: 'alert',
|
|
78
95
|
});
|
|
79
96
|
return;
|
|
80
97
|
}
|
|
81
98
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
switch (newEntryType) {
|
|
85
|
-
case 'string':
|
|
86
|
-
parsedValue = newEntryValue;
|
|
87
|
-
break;
|
|
88
|
-
case 'number':
|
|
89
|
-
parsedValue = Number(newEntryValue);
|
|
90
|
-
if (Number.isNaN(parsedValue)) {
|
|
91
|
-
throw new Error('Invalid number');
|
|
92
|
-
}
|
|
93
|
-
break;
|
|
94
|
-
case 'boolean':
|
|
95
|
-
if (newEntryValue !== 'true' && newEntryValue !== 'false') {
|
|
96
|
-
throw new Error('Boolean value must be true or false');
|
|
97
|
-
}
|
|
98
|
-
parsedValue = newEntryValue === 'true';
|
|
99
|
-
break;
|
|
100
|
-
case 'buffer':
|
|
101
|
-
parsedValue = JSON.parse(newEntryValue);
|
|
102
|
-
if (
|
|
103
|
-
!Array.isArray(parsedValue) ||
|
|
104
|
-
!parsedValue.every((value) => typeof value === 'number')
|
|
105
|
-
) {
|
|
106
|
-
throw new Error('Buffer must be an array of numbers');
|
|
107
|
-
}
|
|
108
|
-
break;
|
|
109
|
-
default:
|
|
110
|
-
throw new Error('Invalid type');
|
|
111
|
-
}
|
|
112
|
-
} catch (error) {
|
|
113
|
-
setConfirmDialog({
|
|
114
|
-
isOpen: true,
|
|
115
|
-
title: 'Invalid Value',
|
|
116
|
-
message: `Invalid value for ${newEntryType}: ${
|
|
117
|
-
error instanceof Error ? error.message : 'Unknown error'
|
|
118
|
-
}`,
|
|
119
|
-
type: 'alert',
|
|
120
|
-
});
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
let entry: StorageEntry;
|
|
125
|
-
if (newEntryType === 'string') {
|
|
126
|
-
entry = { key: newEntryKey, type: 'string', value: parsedValue as string };
|
|
127
|
-
} else if (newEntryType === 'number') {
|
|
128
|
-
entry = { key: newEntryKey, type: 'number', value: parsedValue as number };
|
|
129
|
-
} else if (newEntryType === 'boolean') {
|
|
130
|
-
entry = { key: newEntryKey, type: 'boolean', value: parsedValue as boolean };
|
|
131
|
-
} else {
|
|
132
|
-
entry = { key: newEntryKey, type: 'buffer', value: parsedValue as number[] };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
onAddEntry(entry);
|
|
136
|
-
|
|
137
|
-
resetForm();
|
|
99
|
+
onAddEntry(buildEntry(newEntryKey, currentType, currentValue));
|
|
100
|
+
resetAndClose();
|
|
138
101
|
};
|
|
139
102
|
|
|
140
103
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
141
104
|
if (event.key === 'Escape') {
|
|
142
|
-
|
|
105
|
+
resetAndClose();
|
|
143
106
|
return;
|
|
144
107
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
108
|
+
if (
|
|
109
|
+
event.key === 'Enter' &&
|
|
110
|
+
newEntryKey.trim() &&
|
|
111
|
+
currentType !== 'buffer'
|
|
112
|
+
) {
|
|
113
|
+
handleAdd();
|
|
148
114
|
}
|
|
149
115
|
};
|
|
150
116
|
|
|
@@ -152,20 +118,25 @@ export const AddEntryDialog = ({
|
|
|
152
118
|
return null;
|
|
153
119
|
}
|
|
154
120
|
|
|
121
|
+
// Unsavable when no key, no supported type, or when the value is
|
|
122
|
+
// null — the hex editor signals invalid / empty hex via null.
|
|
123
|
+
const isAddDisabled =
|
|
124
|
+
!newEntryKey.trim() || !isCurrentTypeSupported || currentValue === null;
|
|
125
|
+
|
|
155
126
|
return (
|
|
156
127
|
<div
|
|
157
128
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
|
158
|
-
onClick={
|
|
129
|
+
onClick={resetAndClose}
|
|
159
130
|
>
|
|
160
131
|
<div
|
|
161
|
-
className="bg-gray-800 rounded-lg p-6 w-
|
|
132
|
+
className="bg-gray-800 rounded-lg p-6 w-[32rem] max-w-full mx-4"
|
|
162
133
|
onClick={(event) => event.stopPropagation()}
|
|
163
134
|
onKeyDown={handleKeyDown}
|
|
164
135
|
>
|
|
165
136
|
<div className="flex items-center justify-between mb-4">
|
|
166
137
|
<h2 className="text-lg font-semibold text-gray-100">Add New Entry</h2>
|
|
167
138
|
<button
|
|
168
|
-
onClick={
|
|
139
|
+
onClick={resetAndClose}
|
|
169
140
|
className="p-1 text-gray-400 hover:text-gray-200 hover:bg-gray-700 rounded transition-colors"
|
|
170
141
|
title="Close dialog"
|
|
171
142
|
>
|
|
@@ -194,98 +165,39 @@ export const AddEntryDialog = ({
|
|
|
194
165
|
|
|
195
166
|
<div>
|
|
196
167
|
<label
|
|
197
|
-
htmlFor="new-entry-
|
|
168
|
+
htmlFor="new-entry-value"
|
|
198
169
|
className="block text-sm font-medium text-gray-200 mb-1"
|
|
199
170
|
>
|
|
200
|
-
|
|
171
|
+
Value
|
|
201
172
|
</label>
|
|
202
|
-
<
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
173
|
+
<TypedValueEditor
|
|
174
|
+
supportedTypes={supportedTypes}
|
|
175
|
+
type={currentType}
|
|
176
|
+
value={currentValue}
|
|
177
|
+
onChange={(nextType, nextValue) => {
|
|
178
|
+
setCurrentType(nextType);
|
|
179
|
+
setCurrentValue(nextValue);
|
|
208
180
|
}}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const isSupported = supportedTypes.includes(option.value);
|
|
213
|
-
return (
|
|
214
|
-
<option
|
|
215
|
-
key={option.value}
|
|
216
|
-
value={option.value}
|
|
217
|
-
disabled={!isSupported}
|
|
218
|
-
title={
|
|
219
|
-
isSupported ? option.label : 'Not supported by this storage'
|
|
220
|
-
}
|
|
221
|
-
>
|
|
222
|
-
{option.label}
|
|
223
|
-
{!isSupported ? ' (Not supported)' : ''}
|
|
224
|
-
</option>
|
|
225
|
-
);
|
|
226
|
-
})}
|
|
227
|
-
</select>
|
|
228
|
-
{!selectedTypeSupported && (
|
|
181
|
+
inputId="new-entry-value"
|
|
182
|
+
/>
|
|
183
|
+
{!isCurrentTypeSupported && (
|
|
229
184
|
<p className="text-xs text-amber-400 mt-1">
|
|
230
185
|
Selected type is not supported by this storage.
|
|
231
186
|
</p>
|
|
232
187
|
)}
|
|
233
188
|
</div>
|
|
234
|
-
|
|
235
|
-
<div>
|
|
236
|
-
<label
|
|
237
|
-
htmlFor="new-entry-value"
|
|
238
|
-
className="block text-sm font-medium text-gray-200 mb-1"
|
|
239
|
-
>
|
|
240
|
-
Value
|
|
241
|
-
</label>
|
|
242
|
-
{newEntryType === 'boolean' ? (
|
|
243
|
-
<select
|
|
244
|
-
id="new-entry-value"
|
|
245
|
-
value={newEntryValue}
|
|
246
|
-
onChange={(event) => setNewEntryValue(event.target.value)}
|
|
247
|
-
className="w-full px-3 py-2 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
248
|
-
>
|
|
249
|
-
<option value="">Select value</option>
|
|
250
|
-
<option value="true">true</option>
|
|
251
|
-
<option value="false">false</option>
|
|
252
|
-
</select>
|
|
253
|
-
) : (
|
|
254
|
-
<input
|
|
255
|
-
id="new-entry-value"
|
|
256
|
-
type={newEntryType === 'number' ? 'number' : 'text'}
|
|
257
|
-
value={newEntryValue}
|
|
258
|
-
onChange={(event) => setNewEntryValue(event.target.value)}
|
|
259
|
-
placeholder={
|
|
260
|
-
newEntryType === 'string'
|
|
261
|
-
? 'Enter string value'
|
|
262
|
-
: newEntryType === 'number'
|
|
263
|
-
? 'Enter number value'
|
|
264
|
-
: newEntryType === 'buffer'
|
|
265
|
-
? 'Enter array as JSON, e.g., [1, 2, 3]'
|
|
266
|
-
: 'Enter value'
|
|
267
|
-
}
|
|
268
|
-
className="w-full px-3 py-2 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
269
|
-
/>
|
|
270
|
-
)}
|
|
271
|
-
{newEntryType === 'buffer' && (
|
|
272
|
-
<p className="text-xs text-gray-400 mt-1">
|
|
273
|
-
Enter as JSON array of numbers, e.g., [1, 2, 3, 255]
|
|
274
|
-
</p>
|
|
275
|
-
)}
|
|
276
|
-
</div>
|
|
277
189
|
</div>
|
|
278
190
|
|
|
279
191
|
<div className="flex items-center justify-end gap-2 mt-6">
|
|
280
192
|
<button
|
|
281
|
-
onClick={
|
|
193
|
+
onClick={resetAndClose}
|
|
282
194
|
className="px-4 py-2 text-sm text-gray-300 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
283
195
|
>
|
|
284
196
|
Cancel
|
|
285
197
|
</button>
|
|
286
198
|
<button
|
|
287
|
-
onClick={
|
|
288
|
-
disabled={
|
|
199
|
+
onClick={handleAdd}
|
|
200
|
+
disabled={isAddDisabled}
|
|
289
201
|
className="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded transition-colors"
|
|
290
202
|
>
|
|
291
203
|
Add Entry
|
|
@@ -295,15 +207,13 @@ export const AddEntryDialog = ({
|
|
|
295
207
|
|
|
296
208
|
<ConfirmDialog
|
|
297
209
|
isOpen={confirmDialog.isOpen}
|
|
298
|
-
onClose={() =>
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
}}
|
|
210
|
+
onClose={() =>
|
|
211
|
+
setConfirmDialog((previous) => ({ ...previous, isOpen: false }))
|
|
212
|
+
}
|
|
213
|
+
onConfirm={() => {}}
|
|
304
214
|
title={confirmDialog.title}
|
|
305
215
|
message={confirmDialog.message}
|
|
306
|
-
type=
|
|
216
|
+
type="alert"
|
|
307
217
|
/>
|
|
308
218
|
</div>
|
|
309
219
|
);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
base64ToBytes,
|
|
3
|
+
bytesToBase64,
|
|
4
|
+
bytesToGroupedHex,
|
|
5
|
+
hexInputToBytes,
|
|
6
|
+
} from './binary';
|
|
7
|
+
|
|
8
|
+
export type EditorMode = 'hex' | 'base64';
|
|
9
|
+
|
|
10
|
+
export type EditorState = {
|
|
11
|
+
mode: EditorMode;
|
|
12
|
+
text: string;
|
|
13
|
+
bytes: number[] | null;
|
|
14
|
+
error: string | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type EditorAction =
|
|
18
|
+
| { type: 'set-text'; text: string }
|
|
19
|
+
| { type: 'normalize-paste'; text: string }
|
|
20
|
+
| { type: 'switch-mode'; mode: EditorMode };
|
|
21
|
+
|
|
22
|
+
export type Validation =
|
|
23
|
+
| { ok: true; bytes: number[] }
|
|
24
|
+
| { ok: false; reason: string };
|
|
25
|
+
|
|
26
|
+
const encode = (bytes: readonly number[], mode: EditorMode): string =>
|
|
27
|
+
mode === 'hex' ? bytesToGroupedHex(bytes) : bytesToBase64(bytes);
|
|
28
|
+
|
|
29
|
+
const parse = (text: string, mode: EditorMode) =>
|
|
30
|
+
mode === 'hex' ? hexInputToBytes(text) : base64ToBytes(text);
|
|
31
|
+
|
|
32
|
+
const parsedToState = (
|
|
33
|
+
text: string,
|
|
34
|
+
mode: EditorMode,
|
|
35
|
+
): Pick<EditorState, 'bytes' | 'error'> => {
|
|
36
|
+
const result = parse(text, mode);
|
|
37
|
+
return result.ok
|
|
38
|
+
? { bytes: result.value, error: null }
|
|
39
|
+
: { bytes: null, error: result.error };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const initialState = (args: {
|
|
43
|
+
initialBytes?: number[];
|
|
44
|
+
mode?: EditorMode;
|
|
45
|
+
}): EditorState => {
|
|
46
|
+
const mode = args.mode ?? 'hex';
|
|
47
|
+
if (args.initialBytes && args.initialBytes.length > 0) {
|
|
48
|
+
return {
|
|
49
|
+
mode,
|
|
50
|
+
text: encode(args.initialBytes, mode),
|
|
51
|
+
bytes: args.initialBytes,
|
|
52
|
+
error: null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
mode,
|
|
57
|
+
text: '',
|
|
58
|
+
bytes: null,
|
|
59
|
+
error: null,
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const reduce = (
|
|
64
|
+
state: EditorState,
|
|
65
|
+
action: EditorAction,
|
|
66
|
+
): EditorState => {
|
|
67
|
+
switch (action.type) {
|
|
68
|
+
case 'set-text': {
|
|
69
|
+
return {
|
|
70
|
+
...state,
|
|
71
|
+
text: action.text,
|
|
72
|
+
...parsedToState(action.text, state.mode),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
case 'normalize-paste': {
|
|
76
|
+
const result = parse(action.text, state.mode);
|
|
77
|
+
if (result.ok) {
|
|
78
|
+
return {
|
|
79
|
+
...state,
|
|
80
|
+
text: encode(result.value, state.mode),
|
|
81
|
+
bytes: result.value,
|
|
82
|
+
error: null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
...state,
|
|
87
|
+
text: action.text,
|
|
88
|
+
bytes: null,
|
|
89
|
+
error: result.error,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
case 'switch-mode': {
|
|
93
|
+
if (state.mode === action.mode) {
|
|
94
|
+
return state;
|
|
95
|
+
}
|
|
96
|
+
if (state.bytes !== null) {
|
|
97
|
+
return {
|
|
98
|
+
mode: action.mode,
|
|
99
|
+
text: encode(state.bytes, action.mode),
|
|
100
|
+
bytes: state.bytes,
|
|
101
|
+
error: null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
mode: action.mode,
|
|
106
|
+
text: '',
|
|
107
|
+
bytes: null,
|
|
108
|
+
error: null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const validate = (state: EditorState): Validation => {
|
|
115
|
+
if (state.bytes === null) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
reason: state.error ?? 'Enter at least one byte.',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (state.bytes.length === 0) {
|
|
122
|
+
return { ok: false, reason: 'Enter at least one byte.' };
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, bytes: state.bytes };
|
|
125
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
|
2
|
+
import { EditorState as CMEditorState } from '@codemirror/state';
|
|
3
|
+
import { EditorView, keymap } from '@codemirror/view';
|
|
4
|
+
import { useEffect, useReducer, useRef } from 'react';
|
|
5
|
+
import { bytesToAsciiPreview } from './binary';
|
|
6
|
+
import {
|
|
7
|
+
initialState,
|
|
8
|
+
reduce,
|
|
9
|
+
type EditorMode,
|
|
10
|
+
} from './binary-value-editor-state';
|
|
11
|
+
|
|
12
|
+
export type BinaryValueEditorProps = {
|
|
13
|
+
initialBytes?: number[];
|
|
14
|
+
onChange: (bytes: number[] | null) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const darkTheme = EditorView.theme(
|
|
18
|
+
{
|
|
19
|
+
'&': {
|
|
20
|
+
color: '#e5e7eb',
|
|
21
|
+
backgroundColor: '#111827',
|
|
22
|
+
fontSize: '12px',
|
|
23
|
+
},
|
|
24
|
+
'.cm-content': {
|
|
25
|
+
caretColor: '#60a5fa',
|
|
26
|
+
fontFamily:
|
|
27
|
+
'ui-monospace, "Cascadia Mono", "Fira Code", Menlo, monospace',
|
|
28
|
+
padding: '8px',
|
|
29
|
+
},
|
|
30
|
+
'.cm-focused': { outline: 'none' },
|
|
31
|
+
'.cm-gutters': { display: 'none' },
|
|
32
|
+
'.cm-scroller': { overflow: 'auto' },
|
|
33
|
+
},
|
|
34
|
+
{ dark: true },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const ModeButton = ({
|
|
38
|
+
label,
|
|
39
|
+
active,
|
|
40
|
+
onClick,
|
|
41
|
+
}: {
|
|
42
|
+
label: string;
|
|
43
|
+
active: boolean;
|
|
44
|
+
onClick: () => void;
|
|
45
|
+
}) => (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
onClick={onClick}
|
|
49
|
+
className={`rounded px-2 py-1 text-xs transition-colors ${
|
|
50
|
+
active
|
|
51
|
+
? 'bg-blue-600 text-white'
|
|
52
|
+
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
|
53
|
+
}`}
|
|
54
|
+
>
|
|
55
|
+
{label}
|
|
56
|
+
</button>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
export const BinaryValueEditor = ({
|
|
60
|
+
initialBytes,
|
|
61
|
+
onChange,
|
|
62
|
+
}: BinaryValueEditorProps) => {
|
|
63
|
+
const [state, dispatch] = useReducer(reduce, undefined, () =>
|
|
64
|
+
initialState({ initialBytes }),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
68
|
+
const viewRef = useRef<EditorView | null>(null);
|
|
69
|
+
const stateRef = useRef(state);
|
|
70
|
+
stateRef.current = state;
|
|
71
|
+
const onChangeRef = useRef(onChange);
|
|
72
|
+
onChangeRef.current = onChange;
|
|
73
|
+
|
|
74
|
+
// Mount the CodeMirror view once. The update listener compares the
|
|
75
|
+
// doc to the latest reducer text (via stateRef) so paste-or-type
|
|
76
|
+
// events round-trip through the reducer instead of looping back.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!hostRef.current) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const view = new EditorView({
|
|
82
|
+
state: CMEditorState.create({
|
|
83
|
+
doc: stateRef.current.text,
|
|
84
|
+
extensions: [
|
|
85
|
+
history(),
|
|
86
|
+
keymap.of([...defaultKeymap, ...historyKeymap]),
|
|
87
|
+
darkTheme,
|
|
88
|
+
EditorView.lineWrapping,
|
|
89
|
+
EditorView.updateListener.of((update) => {
|
|
90
|
+
if (!update.docChanged) return;
|
|
91
|
+
const newText = update.state.doc.toString();
|
|
92
|
+
if (newText === stateRef.current.text) return;
|
|
93
|
+
const isPaste = update.transactions.some((tr) =>
|
|
94
|
+
tr.isUserEvent('input.paste'),
|
|
95
|
+
);
|
|
96
|
+
dispatch({
|
|
97
|
+
type: isPaste ? 'normalize-paste' : 'set-text',
|
|
98
|
+
text: newText,
|
|
99
|
+
});
|
|
100
|
+
}),
|
|
101
|
+
],
|
|
102
|
+
}),
|
|
103
|
+
parent: hostRef.current,
|
|
104
|
+
});
|
|
105
|
+
viewRef.current = view;
|
|
106
|
+
return () => {
|
|
107
|
+
view.destroy();
|
|
108
|
+
viewRef.current = null;
|
|
109
|
+
};
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
// Push reducer text into CodeMirror only when they diverge.
|
|
113
|
+
// This fires for paste normalization and mode switches.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const view = viewRef.current;
|
|
116
|
+
if (!view) return;
|
|
117
|
+
const current = view.state.doc.toString();
|
|
118
|
+
if (current === state.text) return;
|
|
119
|
+
view.dispatch({
|
|
120
|
+
changes: { from: 0, to: current.length, insert: state.text },
|
|
121
|
+
});
|
|
122
|
+
}, [state.text]);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
onChangeRef.current(state.bytes);
|
|
126
|
+
}, [state.bytes]);
|
|
127
|
+
|
|
128
|
+
const byteCount = state.bytes?.length ?? 0;
|
|
129
|
+
const asciiPreview = state.bytes ? bytesToAsciiPreview(state.bytes) : '';
|
|
130
|
+
|
|
131
|
+
const handleModeChange = (mode: EditorMode) => {
|
|
132
|
+
if (state.mode === mode) return;
|
|
133
|
+
dispatch({ type: 'switch-mode', mode });
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="flex flex-col gap-2">
|
|
138
|
+
<div className="flex items-center gap-1">
|
|
139
|
+
<ModeButton
|
|
140
|
+
label="Hex"
|
|
141
|
+
active={state.mode === 'hex'}
|
|
142
|
+
onClick={() => handleModeChange('hex')}
|
|
143
|
+
/>
|
|
144
|
+
<ModeButton
|
|
145
|
+
label="Base64"
|
|
146
|
+
active={state.mode === 'base64'}
|
|
147
|
+
onClick={() => handleModeChange('base64')}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div
|
|
152
|
+
ref={hostRef}
|
|
153
|
+
className="min-h-[120px] max-h-[300px] overflow-auto rounded border border-gray-700 bg-gray-900"
|
|
154
|
+
/>
|
|
155
|
+
|
|
156
|
+
<div className="flex flex-col gap-1 text-xs">
|
|
157
|
+
<div className="text-gray-400">{byteCount} bytes</div>
|
|
158
|
+
{asciiPreview && (
|
|
159
|
+
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-gray-400">
|
|
160
|
+
ASCII:{' '}
|
|
161
|
+
<span className="font-mono text-gray-300">{asciiPreview}</span>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
{state.error && <div className="text-red-400">{state.error}</div>}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
};
|