@orz/ui-hex 0.0.0-stage → 0.0.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.
Files changed (84) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +536 -2
  3. package/dist/column-fit.d.ts +16 -0
  4. package/dist/column-fit.d.ts.map +1 -0
  5. package/dist/column-fit.js +22 -0
  6. package/dist/column-fit.js.map +1 -0
  7. package/dist/decode.d.ts +11 -0
  8. package/dist/decode.d.ts.map +1 -0
  9. package/dist/decode.js +63 -0
  10. package/dist/decode.js.map +1 -0
  11. package/dist/document.d.ts +75 -0
  12. package/dist/document.d.ts.map +1 -0
  13. package/dist/document.js +523 -0
  14. package/dist/document.js.map +1 -0
  15. package/dist/editing.d.ts +66 -0
  16. package/dist/editing.d.ts.map +1 -0
  17. package/dist/editing.js +202 -0
  18. package/dist/editing.js.map +1 -0
  19. package/dist/encoding-picker.d.ts +6 -0
  20. package/dist/encoding-picker.d.ts.map +1 -0
  21. package/dist/encoding-picker.js +84 -0
  22. package/dist/encoding-picker.js.map +1 -0
  23. package/dist/encodings.d.ts +19 -0
  24. package/dist/encodings.d.ts.map +1 -0
  25. package/dist/encodings.js +106 -0
  26. package/dist/encodings.js.map +1 -0
  27. package/dist/format.d.ts +29 -0
  28. package/dist/format.d.ts.map +1 -0
  29. package/dist/format.js +73 -0
  30. package/dist/format.js.map +1 -0
  31. package/dist/hex-editor.d.ts +27 -0
  32. package/dist/hex-editor.d.ts.map +1 -0
  33. package/dist/hex-editor.js +270 -0
  34. package/dist/hex-editor.js.map +1 -0
  35. package/dist/hex-grid.d.ts +102 -0
  36. package/dist/hex-grid.d.ts.map +1 -0
  37. package/dist/hex-grid.js +860 -0
  38. package/dist/hex-grid.js.map +1 -0
  39. package/dist/hex-inspector.d.ts +12 -0
  40. package/dist/hex-inspector.d.ts.map +1 -0
  41. package/dist/hex-inspector.js +68 -0
  42. package/dist/hex-inspector.js.map +1 -0
  43. package/dist/hex-paste.d.ts +26 -0
  44. package/dist/hex-paste.d.ts.map +1 -0
  45. package/dist/hex-paste.js +42 -0
  46. package/dist/hex-paste.js.map +1 -0
  47. package/dist/hex-view.d.ts +4 -0
  48. package/dist/hex-view.d.ts.map +1 -0
  49. package/dist/hex-view.js +6 -0
  50. package/dist/hex-view.js.map +1 -0
  51. package/dist/index.d.ts +11 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +10 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/navigation.d.ts +25 -0
  56. package/dist/navigation.d.ts.map +1 -0
  57. package/dist/navigation.js +100 -0
  58. package/dist/navigation.js.map +1 -0
  59. package/dist/page-cache.d.ts +45 -0
  60. package/dist/page-cache.d.ts.map +1 -0
  61. package/dist/page-cache.js +334 -0
  62. package/dist/page-cache.js.map +1 -0
  63. package/dist/source.d.ts +22 -0
  64. package/dist/source.d.ts.map +1 -0
  65. package/dist/source.js +31 -0
  66. package/dist/source.js.map +1 -0
  67. package/dist/text-decoding-multibyte.d.ts +17 -0
  68. package/dist/text-decoding-multibyte.d.ts.map +1 -0
  69. package/dist/text-decoding-multibyte.js +448 -0
  70. package/dist/text-decoding-multibyte.js.map +1 -0
  71. package/dist/text-decoding.d.ts +45 -0
  72. package/dist/text-decoding.d.ts.map +1 -0
  73. package/dist/text-decoding.js +106 -0
  74. package/dist/text-decoding.js.map +1 -0
  75. package/dist/types.d.ts +15 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +10 -0
  78. package/dist/types.js.map +1 -0
  79. package/dist/use-hex-document-state.d.ts +10 -0
  80. package/dist/use-hex-document-state.d.ts.map +1 -0
  81. package/dist/use-hex-document-state.js +22 -0
  82. package/dist/use-hex-document-state.js.map +1 -0
  83. package/package.json +53 -3
  84. package/styles.css +456 -0
package/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright 2026 the orzui team
4
+ Copyright 2026 the todou team
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md CHANGED
@@ -1,3 +1,537 @@
1
- # Temporary Holding Version
1
+ # @orz/ui-hex
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A virtualised hex view over any byte source, in memory or paged; an editor over the same sources
4
+ that keeps its edits in memory and exports the result; and a data inspector that interprets the
5
+ bytes at the selection. Colours come from `@orz/ui`'s theme tokens, so every theme gets a matching
6
+ selection.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pnpm add @orz/ui-hex @orz/ui react react-dom radix-ui lucide-react sonner
12
+ ```
13
+
14
+ `@orz/ui`, `radix-ui` and React are peer dependencies; `lucide-react` and `sonner` are there because
15
+ `@orz/ui` declares them as its own peers. The encoding picker in the view's and the editor's header
16
+ is `@orz/ui`'s `FilterMenu`, and the inspector's endianness switch its `Switch`; both have to share
17
+ the app's radix copy.
18
+
19
+ ## CSS
20
+
21
+ ```css
22
+ @import "tailwindcss";
23
+ @import "@orz/ui/styles.css";
24
+ @import "@orz/ui-hex/styles.css";
25
+ ```
26
+
27
+ Import it after `@orz/ui/styles.css`. The stylesheet is plain CSS, scoped under `.orz-hex` and
28
+ `.orz-hex-inspector`, and every colour in it is derived from the theme tokens through these
29
+ variables:
30
+
31
+ | Variable | Default |
32
+ |---|---|
33
+ | `--hex-font` | `var(--font-mono)`, then the system monospace fonts |
34
+ | `--hex-row-height` | `1.375rem` |
35
+ | `--hex-fill` | `color-mix(in oklch, var(--primary) 32%, var(--card))` |
36
+ | `--hex-edge` | `var(--primary)` |
37
+ | `--hex-twin-fill` | `color-mix(in oklch, var(--foreground) 6%, var(--card))` |
38
+ | `--hex-twin-edge` | `var(--muted-foreground)` |
39
+ | `--hex-hover` | `var(--accent)` |
40
+ | `--hex-label` | `var(--muted-foreground)` |
41
+ | `--hex-faded` | `color-mix(in oklch, var(--muted-foreground) 70%, transparent)` |
42
+ | `--hex-modified` | `var(--destructive)` |
43
+
44
+ The defaults are declared on `.orz-hex` and `.orz-hex-inspector` themselves, so they resolve
45
+ against whichever theme is active above them. To override one, target those elements:
46
+
47
+ ```css
48
+ .my-dump :is(.orz-hex, .orz-hex-inspector) {
49
+ --hex-row-height: 1.5rem;
50
+ --hex-hover: var(--secondary);
51
+ }
52
+ ```
53
+
54
+ The strong pair (`--hex-fill`, `--hex-edge`) marks the selection in the pane it was made in while
55
+ the view has focus; the twin pair marks it in the other pane, and in both once focus leaves.
56
+ `--hex-modified` colours the glyphs of bytes that came from editing, in `HexEditor` only; selection
57
+ and hover draw over it as usual.
58
+
59
+ ### Font
60
+
61
+ The stylesheet loads no font. Any monospace font works. Control codes are drawn as two small
62
+ stacked letters (`NU`, `LF`, `CR`, …) rather than the U+2400 control pictures, so the grid stays
63
+ aligned in fonts that lack them, which is every webfont subset on fontsource. To use JetBrains Mono:
64
+
65
+ ```ts
66
+ import "@fontsource-variable/jetbrains-mono";
67
+ ```
68
+
69
+ ```css
70
+ /* after the @orz/ui-hex/styles.css import */
71
+ .orz-hex,
72
+ .orz-hex-inspector {
73
+ --hex-font: "JetBrains Mono Variable", monospace;
74
+ }
75
+ ```
76
+
77
+ ## Byte sources
78
+
79
+ Every component reads through a `ByteSource`, the editor through a document that is one itself:
80
+
81
+ ```ts
82
+ interface ByteSource {
83
+ /** Total length in bytes; read again after every change notification. */
84
+ readonly size: number;
85
+ /** Exactly min(length, size - offset) bytes, sync or as a promise. */
86
+ read(offset: number, length: number, signal?: AbortSignal): Uint8Array | Promise<Uint8Array>;
87
+ /** The bytes when they are at hand with no I/O, else undefined; never starts a read. */
88
+ peek?(offset: number, length: number): Uint8Array | undefined;
89
+ /** Content or size changed: the view drops its cache and re-reads what is visible. */
90
+ subscribe?(onChange: () => void): () => void;
91
+ }
92
+ ```
93
+
94
+ The size must be known up front, because it sets the scroll extent. A source that holds bytes in
95
+ memory can answer `peek`, and a view then redraws from it at once after a change instead of showing
96
+ placeholders while it re-reads. `createHexDocument` learns whether a source is sync from its first
97
+ real read, and never reads zero bytes.
98
+
99
+ In memory, reads are subarrays and nothing is copied or cached; `peek` answers the same way:
100
+
101
+ ```ts
102
+ import { bytesSource } from "@orz/ui-hex";
103
+
104
+ const source = bytesSource(new Uint8Array(await file.arrayBuffer()));
105
+ ```
106
+
107
+ A `File` or any other `Blob` is read on demand, in 64 KiB pages:
108
+
109
+ ```ts
110
+ import { blobSource } from "@orz/ui-hex";
111
+
112
+ const source = blobSource(file);
113
+ ```
114
+
115
+ Anything else is a source of your own. Over HTTP range requests:
116
+
117
+ ```ts
118
+ import type { ByteSource } from "@orz/ui-hex";
119
+
120
+ async function httpSource(url: string): Promise<ByteSource> {
121
+ const head = await fetch(url, { method: "HEAD" });
122
+ const size = Number(head.headers.get("content-length"));
123
+ return {
124
+ size,
125
+ async read(offset, length, signal) {
126
+ if (length <= 0 || offset >= size) return new Uint8Array(0);
127
+ const end = Math.min(size, offset + length) - 1;
128
+ const response = await fetch(url, {
129
+ headers: { Range: `bytes=${offset}-${end}` },
130
+ signal,
131
+ });
132
+ if (response.status !== 206) throw new Error(`HTTP ${response.status}`);
133
+ return new Uint8Array(await response.arrayBuffer());
134
+ },
135
+ };
136
+ }
137
+
138
+ const source = await httpSource("https://app.example/dumps/core.bin");
139
+ ```
140
+
141
+ The view asks for whole pages, shares one request between the rows that need a page, keeps up to
142
+ 256 pages (16 MiB), and aborts the `signal` of a page that has scrolled more than a page out of
143
+ view. A failed page renders `??`, is not cached, and is asked for again when it next comes into
144
+ view; `onReadError(error, offset)` reports each failure. A `HexDocument` over an async source keeps
145
+ a page cache of the same size of its own, which survives edits, so an editor over a paged source
146
+ can hold up to twice that.
147
+
148
+ ## `HexView` and `HexInspector`
149
+
150
+ ```tsx
151
+ import { bytesSource, HexInspector, type HexSelection, HexView } from "@orz/ui-hex";
152
+
153
+ function Dump({ bytes }: { bytes: Uint8Array }) {
154
+ const source = useMemo(() => bytesSource(bytes), [bytes]);
155
+ const [selection, setSelection] = useState<HexSelection | null>(null);
156
+ return (
157
+ <div style={{ display: "flex", gap: "1.5rem", height: 480 }}>
158
+ <HexView source={source} selection={selection} onSelectionChange={setSelection} />
159
+ <HexInspector source={source} selection={selection} />
160
+ </div>
161
+ );
162
+ }
163
+ ```
164
+
165
+ Keep the source's identity stable: a new source object drops the view's cache.
166
+
167
+ A selection is `{ anchor, focus, pane }`, inclusive at both ends, so `anchor === focus` is one
168
+ selected byte; `null` is nothing. `pane` is `"hex"` or `"text"`, the pane the selection was made
169
+ in.
170
+
171
+ ### `HexView` props
172
+
173
+ | Prop | Default | |
174
+ |---|---|---|
175
+ | `source` | | The bytes |
176
+ | `selection`, `onSelectionChange` | | Controlled selection |
177
+ | `defaultSelection` | `null` | Uncontrolled selection |
178
+ | `columns` | `16` | Bytes per row, or `"auto"` to fit the width (below) |
179
+ | `maxColumns` | `16` | The most columns `"auto"` picks |
180
+ | `groupSize` | `8` | Bytes between group gaps; `0` for none |
181
+ | `baseAddress` | `0` | Added to displayed addresses, never to offsets |
182
+ | `rows` | | A fixed viewport height in rows; omitted, the view fills its parent's height |
183
+ | `resizable` | `false` | A resize handle in the bottom corner: `true` for both ways, `"horizontal"` or `"vertical"` |
184
+ | `showAddresses`, `showColumnLabels`, `showText`, `showStatusBar` | `true` | |
185
+ | `uppercase` | `true` | Hex digit case |
186
+ | `fadeZeroBytes` | `true` | Fades `00` |
187
+ | `controlCodes` | `"abbreviations"` | Or `"dots"` |
188
+ | `encoding`, `onEncodingChange` | | Controlled text-pane encoding (below) |
189
+ | `defaultEncoding` | `"ascii"` | Uncontrolled text-pane encoding |
190
+ | `showEncodingPicker` | `true` | The encoding picker in the text pane's header |
191
+ | `onCopy` | | Reports each copy (below) |
192
+ | `onReadError` | | `(error, offset)` per failed page |
193
+ | `ref` | | `{ scrollToOffset(offset, align?), focus() }`; `align` is `"nearest"`, `"start"` or `"center"` |
194
+ | `className`, `aria-label` | `"Hex view"` | |
195
+
196
+ Without `rows`, give the parent a definite height. The view scrolls by whole rows; without `rows`,
197
+ the strip under the last whole row stays empty. Above roughly 13 MiB at 16 columns the content
198
+ is taller than browsers let an element be; the view then maps the scrollbar onto rows and moves a
199
+ few rows per wheel notch, so dragging the scrollbar becomes coarse but the wheel and keyboard stay
200
+ precise.
201
+
202
+ The view is as wide as its columns, and never wider than its container. With a fixed `columns`, a
203
+ narrower container scrolls it sideways. With `columns="auto"` it takes the width `maxColumns` need
204
+ and shows the widest of `maxColumns` and the powers of two below it, down to 4, that fits the width
205
+ it gets, so a narrow container or a narrow drag of the handle gets 8 or 4 columns instead of a
206
+ scrollbar; below 4 it scrolls. A resize keeps the byte at the start of the top row in the top row.
207
+ To cap the width in pixels rather than columns, put the view in a container with a `max-width`: the
208
+ stylesheet is unlayered, so its `max-width: 100%` on `.orz-hex` wins over a Tailwind class passed
209
+ through `className`.
210
+
211
+ `resizable` lets the user drag the view's size. A dragged height adds rows beyond `rows`, and a
212
+ dragged width overrides the content width, so a fixed `columns` scrolls or leaves space beside the
213
+ text pane while `"auto"` refits.
214
+
215
+ ### `HexInspector` props
216
+
217
+ | Prop | Default | |
218
+ |---|---|---|
219
+ | `source`, `selection` | | Usually the view's |
220
+ | `littleEndian`, `onLittleEndianChange` | | Controlled endianness |
221
+ | `defaultLittleEndian` | `true` | Uncontrolled endianness |
222
+ | `className` | | |
223
+
224
+ It reads up to 8 bytes at the selection start and shows them as binary, Int8 to Int64, Uint8 to
225
+ Uint64, Float16, Float32 and Float64. A row wider than the selection is dimmed but still shows the
226
+ value read from the bytes after it; `—` marks a value that would run past the end of the source.
227
+
228
+ ## Keyboard and copy
229
+
230
+ | Key | Moves the caret |
231
+ |---|---|
232
+ | ← / → | one byte |
233
+ | ↑ / ↓ | one row |
234
+ | PageUp / PageDown | one viewport |
235
+ | Home / End | start / end of the row |
236
+ | Ctrl/⌘ + Home / End | start / end of the source |
237
+ | Ctrl/⌘ + A | selects everything |
238
+ | Escape | clears the selection |
239
+
240
+ Shift extends the selection from its anchor. Tab leaves the view; clicking in the other pane
241
+ switches panes, and a drag may cross from one pane into the other.
242
+
243
+ Copy (Ctrl/⌘ + C, or the browser menu) writes the selection as space-separated hex pairs from the
244
+ hex pane, or as the text pane shows it from the text pane, up to 1 MiB. When
245
+ the bytes are not all loaded, the view reads them and writes through `navigator.clipboard`.
246
+ `onCopy` receives `{ ok: true, bytes }`, or `{ ok: false, reason }` with `"too-large"`,
247
+ `"read-failed"` or `"clipboard-unavailable"`; the view shows no toast itself. From the text pane,
248
+ a copy also writes the exact bytes as hex digits under `application/x-orz-hex` (`HEX_BYTES_TYPE`),
249
+ because its text shows an undecodable byte as a dot, which pastes back as `2E`; `navigator.clipboard`
250
+ writes text only, so a copy through it carries no exact bytes. `copyText`,
251
+ `hexByte`, `formatSize` and the other formatters are exported for copy actions of your own;
252
+ `copyText` takes the view's `encoding` (ASCII by default), and `copyRange` gives the byte range to
253
+ read for it.
254
+
255
+ ## Encodings
256
+
257
+ The text pane decodes its bytes in one of these encodings, ASCII by default:
258
+
259
+ | Group | Encodings |
260
+ |---|---|
261
+ | Western | ASCII, ISO-8859-1, Windows-1252 |
262
+ | Unicode | UTF-8, UTF-16LE, UTF-16BE |
263
+ | East Asian | Shift_JIS, EUC-JP, GB18030 (which covers GBK), Big5, EUC-KR |
264
+ | Other | Windows-1250, 1251, 1253–1258; ISO-8859-2 to -8, -10, -13 to -16; KOI8-R, KOI8-U; IBM866; Macintosh |
265
+
266
+ The ids are the [Encoding Standard](https://encoding.spec.whatwg.org/) names (`"utf-8"`,
267
+ `"shift_jis"`, `"windows-1251"`, …), typed as `HexEncoding`. Which of them a runtime supports depends
268
+ on its `TextDecoder`: `HEX_ENCODINGS` lists the supported ones, each with its `id`, picker `label` and
269
+ `group`, in picker order. Node, for one, lacks ISO-8859-16. An id the runtime lacks renders as ASCII
270
+ and logs one warning.
271
+
272
+ The picker sits in the text pane's header and filters as you type: a query matches an encoding's
273
+ label, id or a common alias (`cp1252`, `sjis`, `latin2`, …), ignoring case and separators, so `utf8`
274
+ finds UTF-8. A row found by an alias shows that alias beside its label.
275
+
276
+ Hide it with `showEncodingPicker={false}`, and the header shows the encoding's name instead; with no
277
+ header (`showColumnLabels={false}`) or no text pane, the prop alone sets the encoding. A controlled
278
+ `encoding` without `onEncodingChange` makes the picker change nothing. To pass a detected charset
279
+ through:
280
+
281
+ ```tsx
282
+ import { HEX_ENCODINGS, type HexEncoding, HexView } from "@orz/ui-hex";
283
+
284
+ function encodingFor(charset: string | undefined): HexEncoding {
285
+ const id = charset?.toLowerCase();
286
+ return HEX_ENCODINGS.find((info) => info.id === id)?.id ?? "utf-8";
287
+ }
288
+
289
+ <HexView source={source} defaultEncoding={encodingFor(detectedCharset)} />;
290
+ ```
291
+
292
+ A multi-byte character is drawn once, centred over its bytes; one that crosses the end of a row is
293
+ drawn on its starting row, squeezed into the cells left there. Bytes that do not decode, control
294
+ codes and characters with no glyph of their own (format characters, combining marks, private use)
295
+ show as faded dots, as in ASCII.
296
+
297
+ - **Selection** stays per byte in both panes, so a selection may end inside a character.
298
+ - **Hover** lights up every byte of the character under the pointer, in both panes.
299
+ - **Copy** from the text pane writes what is shown: a character whose first byte is selected is
300
+ copied whole, one whose first byte lies before the selection is left out, and every dot is `.`.
301
+
302
+ Wide characters fall back to the platform's CJK fonts when `--hex-font` lacks them; how well they
303
+ look depends on the fonts the reader has installed.
304
+
305
+ ## Editing
306
+
307
+ `HexEditor` edits a `HexDocument`: a piece table over a `ByteSource` that never writes to the
308
+ source. Edits stay in the document until the app saves them.
309
+
310
+ ```tsx
311
+ import {
312
+ blobSource,
313
+ createHexDocument,
314
+ type HexDocument,
315
+ HexEditor,
316
+ useHexDocumentState,
317
+ } from "@orz/ui-hex";
318
+ import { useMemo } from "react";
319
+
320
+ function Editor({ file }: { file: File }) {
321
+ const doc = useMemo(() => createHexDocument(blobSource(file)), [file]);
322
+ const { dirty, canUndo, canRedo } = useHexDocumentState(doc);
323
+ return (
324
+ <>
325
+ <button type="button" disabled={!canUndo} onClick={() => doc.undo()}>Undo</button>
326
+ <button type="button" disabled={!canRedo} onClick={() => doc.redo()}>Redo</button>
327
+ <button type="button" disabled={!dirty} onClick={() => download(doc, file.name)}>Save</button>
328
+ <HexEditor document={doc} rows={24} />
329
+ </>
330
+ );
331
+ }
332
+
333
+ async function download(doc: HexDocument, name: string) {
334
+ const url = URL.createObjectURL(await doc.toBlob("application/octet-stream"));
335
+ const link = document.createElement("a");
336
+ link.href = url;
337
+ link.download = name;
338
+ link.click();
339
+ URL.revokeObjectURL(url);
340
+ doc.markSaved();
341
+ }
342
+ ```
343
+
344
+ Keep the document's identity stable, as with a source: a new document starts with no edits and no
345
+ history. A document is itself a `ByteSource`, so a `HexInspector` given the same document and
346
+ selection follows the edits, and a `HexView` can show it read-only, without the modified colour.
347
+
348
+ ### `HexDocument`
349
+
350
+ `createHexDocument(source, { resizable })` takes `resizable: false` to lock the size: then there is
351
+ no insert mode, Delete, Backspace and cut do nothing, and typing and pasting stop at the last byte.
352
+
353
+ | Member | |
354
+ |---|---|
355
+ | `size`, `read`, `peek`, `subscribe` | The edited bytes, as a `ByteSource` |
356
+ | `source`, `resizable` | What it was created with |
357
+ | `replace(offset, length, bytes)` | Replaces `length` bytes at `offset` |
358
+ | `insert(offset, bytes)`, `delete(offset, length)` | `replace` with length 0, or with no bytes |
359
+ | `overwrite(offset, bytes)` | Writes from `offset`; past the end it extends the document, or stops at the end when the size is locked |
360
+ | `undo()`, `redo()`, `canUndo`, `canRedo` | |
361
+ | `dirty`, `markSaved()` | |
362
+ | `modifiedRanges(offset, length)` | The bytes in the range that came from editing, as `[start, end)` pairs |
363
+ | `pieces()`, `toBlob(type?)`, `stream()` | Export (below) |
364
+
365
+ - **Bounds:** offsets and lengths are clamped to the document. A call left with nothing to remove
366
+ and nothing to write records no undo step; one that writes the bytes already there is still an
367
+ edit, with a step, the dirty state and the modified colour. With the size locked, a `replace`,
368
+ `insert` or `delete` that would change the size throws a `RangeError`; `HexEditor` never makes one.
369
+ - **Undo:** each call is one step, and so is each editor keystroke, except that a run of typing is
370
+ one step until the caret moves, the mode or pane changes, or the user pastes, deletes or saves.
371
+ Depth is unlimited. A step stores no source bytes, so undoing an overwrite far into a paged source
372
+ reads nothing. An edit after an undo drops the redo steps.
373
+ - **Memory:** every byte written is kept for the document's lifetime, undone and dropped edits
374
+ included, so it grows with the total amount edited. A new document over the saved bytes starts
375
+ empty.
376
+ - **Selection:** a step made in the editor records the selection before and after it, and the
377
+ editor restores it on undo and redo, including when the app calls `doc.undo()` from a toolbar.
378
+ After the app's own calls the editor only clamps its selection to the new size.
379
+ - **Dirty:** `markSaved()` records the current undo position; the document is dirty while its
380
+ position differs, so undoing back to the save point makes it clean again. A save point lost to a
381
+ dropped redo branch keeps the document dirty until the next `markSaved()`.
382
+ - **Modified colour:** it marks bytes that were written, so a deletion leaves no mark, and a
383
+ document whose only edits are deletions is dirty with nothing coloured. `markSaved()` keeps it; a
384
+ byte loses it only when its edit is undone. For a clean slate after saving, create a new document
385
+ over the saved bytes.
386
+ - **Source changes:** the source must not change while a document is open over it. A change it
387
+ notifies is passed on, so views re-read, but the document's pieces still point at the old
388
+ offsets. Create a new document over the changed source instead.
389
+
390
+ Export takes a snapshot of the document when called, so edits made while it runs do not reach it:
391
+
392
+ - **`toBlob(type?)`** slices the source's `blob` when it has one, as a `blobSource` does, and takes
393
+ subarrays of its `bytes`, as a `bytesSource`'s, reading nothing; so a multi-gigabyte `File` with a
394
+ few edits exports at once. A source of your own gets the same path by exposing a `blob` or `bytes`
395
+ property holding exactly its content. Any other source is read in 1 MiB chunks, all held in
396
+ memory until the `Blob` is built, and a failed read rejects; for a large one, use `stream()`.
397
+ - **`stream()`** is a pull-based `ReadableStream`, for a `FileSystemWritableFileStream` or an
398
+ upload. Each pull yields one run of edited bytes or the next 1 MiB of the source, so a slow
399
+ consumer never holds more than a chunk. Cancelling it aborts the read in flight.
400
+ - **`pieces()`** lists the document in order, as `{ kind: "source", offset, length }` ranges of the
401
+ source and `{ kind: "bytes", bytes }` runs of edited bytes. To write back only what changed, check
402
+ that every `source` piece's `offset` equals the document offset where it starts, which always
403
+ holds with the size locked; an insert and a delete elsewhere keep the size but move the bytes
404
+ between them, and then the whole document has to be written. The `bytes` are views onto the
405
+ document's own buffer, so do not write to them.
406
+
407
+ `useHexDocumentState(doc)` returns `{ size, dirty, canUndo, canRedo }` for a toolbar or a title, as
408
+ a new object only after the document changes.
409
+
410
+ ### `HexEditor` props
411
+
412
+ Every `HexView` prop except `source`, and:
413
+
414
+ | Prop | Default | |
415
+ |---|---|---|
416
+ | `document` | | From `createHexDocument`; anything else throws a `TypeError` |
417
+ | `mode`, `onModeChange` | | Controlled mode, `"overwrite"` or `"insert"` |
418
+ | `defaultMode` | `"overwrite"` | Uncontrolled mode |
419
+ | `readOnly` | `false` | Refuses every edit from the keyboard, paste, cut and the mode button; navigation and copy still work, and edits the app makes to the document still show |
420
+ | `onPaste` | | Reports each paste (below) |
421
+ | `aria-label` | `"Hex editor"` | |
422
+
423
+ The `ref` is the same handle as `HexView`'s. On a document with a locked size the mode is always
424
+ overwrite.
425
+
426
+ The status bar shows an `OVR`/`INS` button before the size. It toggles the mode, as the Insert key
427
+ does, and is disabled when the size is locked or the editor is `readOnly`. Mac laptops have no
428
+ Insert key, so an app that hides the status bar puts its own toggle on `mode`.
429
+
430
+ While the editor has focus, the caret in the hex pane underlines the digit the next keystroke
431
+ writes. In insert mode it is a bar before the byte, where the next byte goes, except after a new
432
+ byte's first digit, when it underlines the second. In the text pane it underlines the whole cell,
433
+ or is a bar in insert mode.
434
+
435
+ A resizable document gets one empty cell after its last byte, `readOnly` or not. The caret reaches
436
+ it like any other cell, by click or by key, and typing there appends; copy, cut, Delete and
437
+ Ctrl/⌘ + A leave it out. So in a resizable document a selection's `focus` can equal `size`: clamp
438
+ to `size - 1` before counting bytes. An empty document shows only that cell, and an empty one with
439
+ a locked size shows nothing.
440
+
441
+ A hex-pane selection in the editor also carries `nibble`, `0` or `1`, the digit the next keystroke
442
+ writes. A controlled selection has to pass it back unchanged, or the caret jumps back to the high
443
+ digit in the middle of a byte.
444
+
445
+ ### Editing keys
446
+
447
+ Navigation, selection and copy work as in [Keyboard and copy](#keyboard-and-copy). On top of them:
448
+
449
+ | Key | Overwrite mode | Insert mode |
450
+ |---|---|---|
451
+ | Hex digit, hex pane | Sets the digit under the caret, keeping the other; the caret moves to the next digit | The first digit inserts a byte before the caret, its low digit 0; the second sets that digit |
452
+ | Character, text pane | Writes its bytes from the caret | Inserts its bytes before the caret |
453
+ | Delete | Deletes the selection, or the byte under the caret | Same |
454
+ | Backspace | Deletes a selection of several bytes; at a byte's second hex digit, moves back to its first; else deletes the byte before the caret | Same |
455
+ | Insert | Toggles the mode | Same |
456
+ | Ctrl/⌘ + Z | Undo | Same |
457
+ | Ctrl/⌘ + Shift + Z, Ctrl/⌘ + Y | Redo | Same |
458
+ | Ctrl/⌘ + X | Cut (below) | Same |
459
+ | Ctrl/⌘ + V | Paste (below) | Same |
460
+
461
+ - **The mode governs typing and paste only.** Delete, Backspace and cut remove bytes in both modes,
462
+ so overwrite mode still has a way to remove a byte from the keyboard.
463
+ - **Delete and Backspace work on bytes**, in the text pane too: one Backspace after a multi-byte
464
+ character removes only its last byte, while undo takes back whole keystrokes.
465
+ - **Typing over a selection of several bytes** replaces it in insert mode, as one undo step; in
466
+ overwrite mode it writes from the selection's start. With the size locked, a character typed too
467
+ near the end is cut off there, so only part of a multi-byte character may be written.
468
+ - **A byte still loading or failed to read** counts as 0 when a typed digit keeps its other digit.
469
+ Nothing waits for the read.
470
+ - **Cut** copies the selection as a copy does and deletes it only once the copy succeeds, so a
471
+ selection over 1 MiB, or a copy that fails, deletes nothing and reports through `onCopy`. The
472
+ copy carries the exact bytes, so a cut and paste puts back what was cut, dots included. When
473
+ only text can go, through `navigator.clipboard` because the selection is not loaded, the cut
474
+ deletes only if that text pastes back into the same pane as the same bytes; otherwise it copies,
475
+ keeps the bytes and announces "Copied, not cut", while `onCopy` still reports `{ ok: true }`.
476
+ With the size locked or `readOnly`, cut neither copies nor reports.
477
+ - **Keys that do nothing:** typing, Delete, Backspace and cut while nothing is selected (undo, redo
478
+ and Insert still work); a character that is not a hex digit, in the hex pane; any editing key
479
+ when `readOnly`.
480
+ - **Modifiers:** keys held with Ctrl, ⌘ or Alt never type, though AltGr does. On macOS, ⌥ types
481
+ the characters it composes (ø, ¢…); ⌘⌥ and ⌃⌥ chords are left to shortcuts.
482
+ - **Input methods:** typing comes from `keydown`, so IME composition, for CJK text say, is not
483
+ supported. Paste such text instead.
484
+
485
+ ### Typed and pasted text
486
+
487
+ The text pane's `encoding` (see [Encodings](#encodings)) also chooses what typed and pasted text is
488
+ written as. The default is ASCII, so until the encoding changes, a non-ASCII character typed into
489
+ the text pane does nothing and a paste containing one is refused as `"unencodable"`. The same holds
490
+ for any character the encoding cannot represent, and for a lone surrogate in every encoding. A paste
491
+ is all or nothing: one unencodable character refuses the whole text.
492
+
493
+ The East Asian encoders build their tables in idle time whenever an editor becomes editable (on
494
+ mount without `readOnly`, or when `readOnly` turns off) or its encoding changes, so the first
495
+ keystroke does not wait for them.
496
+
497
+ ### Paste
498
+
499
+ Paste runs on the native `paste` event while the editor has focus, so Ctrl/⌘ + V, Shift + Insert
500
+ and a menu bar's Paste reach it; a right-click menu usually offers no Paste over the grid. In
501
+ either pane it writes the exact bytes from `application/x-orz-hex` when a copy put them there (see
502
+ [Keyboard and copy](#keyboard-and-copy)); when that type is missing or does not parse, it reads the
503
+ clipboard's plain text as below. `clipboardBytes` is the same logic, and `hexDigits` writes the
504
+ type's format, for copy and paste commands of your own. With nothing selected a paste does nothing
505
+ and does not call `onPaste`, and a paste into the encoding picker stays native.
506
+
507
+ - **Hex pane:** the text is read as a hex dump. Whitespace, commas, semicolons, brackets, braces,
508
+ parentheses and every `0x` / `\x` are dropped, so `89 50 4E 47`, `0x89, 0x50`, `{ 0x89, 0x50 }`,
509
+ `\x89\x50` and `89504e47` all paste. The remaining digits are joined and read two at a time, so
510
+ each byte needs both its digits: `0x5, 0x1` pastes the one byte `51`. `parseHexText` is the same
511
+ parser, for a paste command of your own.
512
+ - **Text pane:** the text is encoded, as above.
513
+ - **Insert mode:** the bytes replace a selection of several bytes, or go in before the caret.
514
+ - **Overwrite mode:** the bytes overwrite from the selection's start. Past the end they extend the
515
+ document; with the size locked they are cut off at the end.
516
+
517
+ The caret lands after the last pasted byte, or on the last byte when the size is locked and the
518
+ paste reaches the end, and the paste is one undo step. `onPaste` receives
519
+ `{ ok: true, bytes, truncated }`, where `bytes` counts what was written and `truncated` says the end
520
+ was cut off, or `{ ok: false, reason }`; the editor shows no toast itself.
521
+
522
+ | Reason | |
523
+ |---|---|
524
+ | `"not-hex"` | The hex pane's text has something besides hex digits and separators, or an odd number of digits |
525
+ | `"unencodable"` | The text pane's encoding cannot represent a character in it |
526
+ | `"empty"` | The clipboard has no plain text, or only separators |
527
+ | `"read-only"` | The editor is `readOnly` |
528
+
529
+ An app that wants separate paste-insert and paste-overwrite commands calls the document's `insert`
530
+ or `overwrite` itself, because the `paste` event carries no modifier keys.
531
+
532
+ ## Entry points
533
+
534
+ | Import | Contents |
535
+ |---|---|
536
+ | `@orz/ui-hex` | `HexView`, `HexEditor`, `HexInspector`, `createHexDocument`, `useHexDocumentState`, `HEX_ENCODINGS`, `bytesSource`, `blobSource`, `parseHexText`, `clipboardBytes`, `hexDigits`, `HEX_BYTES_TYPE`, `copyText`, `copyRange`, `hexByte`, `formatAddress`, `formatOffset`, `formatSize`, `addressDigits`, `MAX_COPY_BYTES`, types |
537
+ | `@orz/ui-hex/styles.css` | the view, editor and inspector styles |
@@ -0,0 +1,16 @@
1
+ /** Measured widths, in px, that a row's width is built from. */
2
+ export interface ColumnMetrics {
3
+ /** Everything but the columns: the address and the gap before the text pane. */
4
+ fixed: number;
5
+ /** One hex cell and its text-pane twin. */
6
+ column: number;
7
+ /** What a group-end cell adds over a plain one. */
8
+ groupGap: number;
9
+ groupSize: number;
10
+ }
11
+ /** The counts `columns="auto"` picks from, widest first: `max`, then the powers of two below it, down to 4. */
12
+ export declare function columnChoices(max: number): number[];
13
+ export declare function rowWidth(columns: number, m: ColumnMetrics): number;
14
+ /** The widest choice whose row fits `available` px; the narrowest when none does. */
15
+ export declare function fitColumns(available: number, max: number, m: ColumnMetrics): number;
16
+ //# sourceMappingURL=column-fit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"column-fit.d.ts","sourceRoot":"","sources":["../src/column-fit.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAChE,MAAM,WAAW,aAAa;IAC5B,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,+GAA+G;AAC/G,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAQnD;AAED,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,aAAa,UAIzD;AAED,qFAAqF;AACrF,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,CAAC,EAAE,aAAa,UAO1E"}
@@ -0,0 +1,22 @@
1
+ /** The counts `columns="auto"` picks from, widest first: `max`, then the powers of two below it, down to 4. */
2
+ export function columnChoices(max) {
3
+ const out = [max];
4
+ let n = 2 ** Math.ceil(Math.log2(max) - 1);
5
+ while (n >= 4 && n < max) {
6
+ out.push(n);
7
+ n /= 2;
8
+ }
9
+ return out;
10
+ }
11
+ export function rowWidth(columns, m) {
12
+ const groupEnds = m.groupSize > 0 ? Math.floor((columns - 1) / m.groupSize) : 0;
13
+ return m.fixed + columns * m.column + groupEnds * m.groupGap;
14
+ }
15
+ /** The widest choice whose row fits `available` px; the narrowest when none does. */
16
+ export function fitColumns(available, max, m) {
17
+ const choices = columnChoices(max);
18
+ // Half a pixel of slack: the widths come from rounded layout boxes.
19
+ return (choices.find((n) => rowWidth(n, m) <= available + 0.5) ??
20
+ choices.at(-1));
21
+ }
22
+ //# sourceMappingURL=column-fit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"column-fit.js","sourceRoot":"","sources":["../src/column-fit.ts"],"names":[],"mappings":"AAWA,+GAA+G;AAC/G,MAAM,UAAU,aAAa,CAAC,GAAW;IACvC,MAAM,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC;IAClB,IAAI,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IAC3C,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,GAAG,EAAE,CAAC;QACzB,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACZ,CAAC,IAAI,CAAC,CAAC;IACT,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,OAAe,EAAE,CAAgB;IACxD,MAAM,SAAS,GACb,CAAC,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAChE,OAAO,CAAC,CAAC,KAAK,GAAG,OAAO,GAAG,CAAC,CAAC,MAAM,GAAG,SAAS,GAAG,CAAC,CAAC,QAAQ,CAAC;AAC/D,CAAC;AAED,qFAAqF;AACrF,MAAM,UAAU,UAAU,CAAC,SAAiB,EAAE,GAAW,EAAE,CAAgB;IACzE,MAAM,OAAO,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;IACnC,oEAAoE;IACpE,OAAO,CACL,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,SAAS,GAAG,GAAG,CAAC;QACrD,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAY,CAC3B,CAAC;AACJ,CAAC"}
@@ -0,0 +1,11 @@
1
+ export interface DecodedValue {
2
+ label: string;
3
+ /** Bytes the value spans. */
4
+ width: number;
5
+ /** `null` when the value would run past the end of the source. */
6
+ value: string | null;
7
+ }
8
+ export declare function float16(bits: number): number;
9
+ /** One entry per inspector row, read from the first `available` bytes of `bytes`. */
10
+ export declare function decodeAll(bytes: Uint8Array, available: number, littleEndian: boolean): DecodedValue[];
11
+ //# sourceMappingURL=decode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decode.d.ts","sourceRoot":"","sources":["../src/decode.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,6BAA6B;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAGD,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAO5C;AA+CD,qFAAqF;AACrF,wBAAgB,SAAS,CACvB,KAAK,EAAE,UAAU,EACjB,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,OAAO,GACpB,YAAY,EAAE,CAQhB"}