@elefunc/send 0.1.17 → 0.1.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elefunc/send",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/tui/app.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { resolve } from "node:path"
2
2
  import { BACKEND_RAW_WRITE_MARKER, rgb, ui, type BackendRawWrite, type BadgeVariant, type TextStyle, type UiEvent, type VNode } from "@rezi-ui/core"
3
3
  import { createNodeApp } from "@rezi-ui/node"
4
- import { applyInputEditEvent } from "../../node_modules/@rezi-ui/core/dist/runtime/inputEditor.js"
5
4
  import { inspectLocalFile } from "../core/files"
6
5
  import { isSessionAbortedError, SendSession, type PeerSnapshot, type SessionConfig, type SessionSnapshot, type TransferSnapshot } from "../core/session"
7
6
  import { cleanLocalId, cleanName, cleanRoom, displayPeerName, fallbackName, formatBytes, type LogEntry, peerDefaultsToken, type PeerProfile, uid } from "../core/protocol"
8
7
  import { FILE_SEARCH_VISIBLE_ROWS, type FileSearchEvent, type FileSearchMatch, type FileSearchRequest } from "./file-search-protocol"
9
8
  import { deriveFileSearchScope, formatFileSearchDisplayPath, normalizeSearchQuery, offsetFileSearchMatchIndices } from "./file-search"
9
+ import { applyInputEditEvent } from "./input-editor"
10
10
  import { installCheckboxClickPatch } from "../../runtime/rezi-checkbox-click"
11
11
 
12
12
  type Notice = { text: string; variant: "info" | "success" | "warning" | "error" }
@@ -0,0 +1,262 @@
1
+ import type { ZrevEvent } from "@rezi-ui/core"
2
+
3
+ const ZR_KEY_LEFT = 22
4
+ const ZR_KEY_RIGHT = 23
5
+ const ZR_KEY_UP = 20
6
+ const ZR_KEY_DOWN = 21
7
+ const ZR_KEY_HOME = 12
8
+ const ZR_KEY_END = 13
9
+ const ZR_KEY_ENTER = 2
10
+ const ZR_KEY_A = 65
11
+ const ZR_KEY_BACKSPACE = 4
12
+ const ZR_KEY_DELETE = 11
13
+ const ZR_MOD_SHIFT = 1 << 0
14
+ const ZR_MOD_CTRL = 1 << 1
15
+
16
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
17
+ const wordClusterRe = /[\p{L}\p{N}_]/u
18
+ const spaceClusterRe = /\s/u
19
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
20
+
21
+ type InputSelection = Readonly<{ start: number; end: number }>
22
+
23
+ export type InputEditAction = Readonly<{
24
+ id: string
25
+ action: "input"
26
+ value: string
27
+ cursor: number
28
+ }>
29
+
30
+ export type InputEditResult = Readonly<{
31
+ nextValue: string
32
+ nextCursor: number
33
+ nextSelectionStart: number | null
34
+ nextSelectionEnd: number | null
35
+ action?: InputEditAction
36
+ }>
37
+
38
+ type InputEditorContext = Readonly<{
39
+ id: string
40
+ value: string
41
+ cursor: number
42
+ selectionStart?: number | null
43
+ selectionEnd?: number | null
44
+ multiline?: boolean
45
+ }>
46
+
47
+ type Grapheme = Readonly<{
48
+ text: string
49
+ start: number
50
+ end: number
51
+ }>
52
+
53
+ const graphemesOf = (value: string): Grapheme[] => {
54
+ const graphemes: Grapheme[] = []
55
+ for (const { index, segment } of graphemeSegmenter.segment(value)) {
56
+ graphemes.push({ text: segment, start: index, end: index + segment.length })
57
+ }
58
+ return graphemes
59
+ }
60
+
61
+ const clampCursor = (value: string, cursor: number) =>
62
+ !Number.isFinite(cursor) ? 0 : Math.max(0, Math.min(value.length, Math.trunc(cursor)))
63
+
64
+ export const normalizeInputCursor = (value: string, cursor: number) => {
65
+ const clamped = clampCursor(value, cursor)
66
+ if (clamped === 0 || clamped === value.length) return clamped
67
+ let last = 0
68
+ for (const grapheme of graphemesOf(value)) {
69
+ if (grapheme.end === clamped) return clamped
70
+ if (grapheme.end > clamped) return last
71
+ last = grapheme.end
72
+ }
73
+ return value.length
74
+ }
75
+
76
+ const normalizeInputCursorForward = (value: string, cursor: number) => {
77
+ const clamped = clampCursor(value, cursor)
78
+ if (clamped === 0 || clamped === value.length) return clamped
79
+ for (const grapheme of graphemesOf(value)) {
80
+ if (grapheme.end >= clamped) return grapheme.end
81
+ }
82
+ return value.length
83
+ }
84
+
85
+ export const normalizeInputSelection = (value: string, selectionStart: number | null | undefined, selectionEnd: number | null | undefined): InputSelection | null => {
86
+ if (selectionStart == null || selectionEnd == null) return null
87
+ const start = normalizeInputCursor(value, selectionStart)
88
+ const end = normalizeInputCursor(value, selectionEnd)
89
+ return start === end ? null : { start, end }
90
+ }
91
+
92
+ export const getInputSelectionText = (value: string, selectionStart: number | null | undefined, selectionEnd: number | null | undefined) => {
93
+ const selection = normalizeInputSelection(value, selectionStart, selectionEnd)
94
+ if (!selection) return null
95
+ const [start, end] = selection.start <= selection.end ? [selection.start, selection.end] : [selection.end, selection.start]
96
+ return start < end ? value.slice(start, end) : null
97
+ }
98
+
99
+ const normalizeSelectionRange = (selection: InputSelection) =>
100
+ selection.start <= selection.end ? [selection.start, selection.end] as const : [selection.end, selection.start] as const
101
+
102
+ const resolveSelectionAnchor = (selection: InputSelection, cursor: number) =>
103
+ cursor === selection.start ? selection.end : cursor === selection.end ? selection.start : selection.start
104
+
105
+ const prevBoundary = (value: string, cursor: number) => {
106
+ const normalized = normalizeInputCursor(value, cursor)
107
+ if (normalized <= 0) return 0
108
+ let last = 0
109
+ for (const grapheme of graphemesOf(value)) {
110
+ if (grapheme.end >= normalized) return last
111
+ last = grapheme.end
112
+ }
113
+ return last
114
+ }
115
+
116
+ const nextBoundary = (value: string, cursor: number) => {
117
+ const normalized = normalizeInputCursor(value, cursor)
118
+ if (normalized >= value.length) return value.length
119
+ for (const grapheme of graphemesOf(value)) {
120
+ if (grapheme.end > normalized) return grapheme.end
121
+ }
122
+ return value.length
123
+ }
124
+
125
+ const classifyCluster = (cluster: string) =>
126
+ cluster.length === 0 ? "other"
127
+ : wordClusterRe.test(cluster) ? "word"
128
+ : spaceClusterRe.test(cluster) ? "space"
129
+ : "other"
130
+
131
+ const nextWordBoundary = (value: string, cursor: number) => {
132
+ if (cursor >= value.length) return value.length
133
+ const graphemes = graphemesOf(value)
134
+ let index = graphemes.findIndex(grapheme => grapheme.end > cursor)
135
+ if (index < 0) return value.length
136
+ if (classifyCluster(graphemes[index]!.text) === "word") {
137
+ while (index < graphemes.length && classifyCluster(graphemes[index]!.text) === "word") index += 1
138
+ return index < graphemes.length ? graphemes[index]!.start : value.length
139
+ }
140
+ while (index < graphemes.length && classifyCluster(graphemes[index]!.text) !== "word") index += 1
141
+ while (index < graphemes.length && classifyCluster(graphemes[index]!.text) === "word") index += 1
142
+ return index < graphemes.length ? graphemes[index]!.start : value.length
143
+ }
144
+
145
+ const prevWordBoundary = (value: string, cursor: number) => {
146
+ if (cursor <= 0) return 0
147
+ const graphemes = graphemesOf(value)
148
+ const clamped = clampCursor(value, cursor)
149
+ let previousClass: "word" | "space" | "other" | null = null
150
+ let currentWordRunStart = 0
151
+ let lastCompletedWordRunStart = -1
152
+ for (const grapheme of graphemes) {
153
+ if (grapheme.end > clamped) break
154
+ const clusterClass = classifyCluster(grapheme.text)
155
+ if (clusterClass === "word") {
156
+ if (previousClass !== "word") currentWordRunStart = grapheme.start
157
+ } else if (previousClass === "word") {
158
+ lastCompletedWordRunStart = currentWordRunStart
159
+ }
160
+ previousClass = clusterClass
161
+ if (grapheme.end === clamped) break
162
+ }
163
+ return previousClass === "word" ? currentWordRunStart : lastCompletedWordRunStart >= 0 ? lastCompletedWordRunStart : 0
164
+ }
165
+
166
+ const asUnicodeScalarString = (codepoint: number) => {
167
+ if (!Number.isFinite(codepoint)) return "\ufffd"
168
+ const scalar = Math.trunc(codepoint)
169
+ return scalar < 0 || scalar > 0x10ffff || scalar >= 0xd800 && scalar <= 0xdfff ? "\ufffd" : String.fromCodePoint(scalar)
170
+ }
171
+
172
+ const removeCrLf = (value: string) => value.replaceAll(/[\r\n]/g, "")
173
+
174
+ const normalizeLineBreaks = (value: string) => value.replaceAll(/\r\n?/g, "\n")
175
+
176
+ export const applyInputEditEvent = (event: ZrevEvent, ctx: InputEditorContext): InputEditResult | null => {
177
+ const { id, value } = ctx
178
+ const multiline = ctx.multiline === true
179
+ const selection = normalizeInputSelection(value, ctx.selectionStart, ctx.selectionEnd)
180
+ const cursor = normalizeInputCursor(value, ctx.cursor)
181
+ const [selectionMin, selectionMax] = selection ? normalizeSelectionRange(selection) : [cursor, cursor]
182
+
183
+ const result = (nextValue: string, nextCursor: number, nextSelectionStart: number | null, nextSelectionEnd: number | null): InputEditResult =>
184
+ nextValue === value
185
+ ? { nextValue, nextCursor, nextSelectionStart, nextSelectionEnd }
186
+ : { nextValue, nextCursor, nextSelectionStart, nextSelectionEnd, action: { id, action: "input", value: nextValue, cursor: nextCursor } }
187
+
188
+ if (event.kind === "key") {
189
+ if (event.action !== "down" && event.action !== "repeat") return null
190
+ const hasShift = (event.mods & ZR_MOD_SHIFT) !== 0
191
+ const hasCtrl = (event.mods & ZR_MOD_CTRL) !== 0
192
+ if (event.key === ZR_KEY_A && hasCtrl && !hasShift) {
193
+ return value.length === 0
194
+ ? { nextValue: value, nextCursor: 0, nextSelectionStart: null, nextSelectionEnd: null }
195
+ : { nextValue: value, nextCursor: value.length, nextSelectionStart: 0, nextSelectionEnd: value.length }
196
+ }
197
+ if (event.key === ZR_KEY_LEFT || event.key === ZR_KEY_RIGHT || event.key === ZR_KEY_HOME || event.key === ZR_KEY_END || event.key === ZR_KEY_UP || event.key === ZR_KEY_DOWN) {
198
+ if (event.key === ZR_KEY_UP || event.key === ZR_KEY_DOWN) return multiline ? { nextValue: value, nextCursor: cursor, nextSelectionStart: null, nextSelectionEnd: null } : null
199
+ const moveCursor = (active: number) =>
200
+ event.key === ZR_KEY_LEFT ? hasCtrl ? prevWordBoundary(value, active) : prevBoundary(value, active)
201
+ : event.key === ZR_KEY_RIGHT ? hasCtrl ? nextWordBoundary(value, active) : nextBoundary(value, active)
202
+ : event.key === ZR_KEY_HOME ? 0
203
+ : value.length
204
+ if (hasShift) {
205
+ const anchor = selection ? resolveSelectionAnchor(selection, cursor) : cursor
206
+ const moved = moveCursor(cursor)
207
+ return moved === anchor
208
+ ? { nextValue: value, nextCursor: moved, nextSelectionStart: null, nextSelectionEnd: null }
209
+ : { nextValue: value, nextCursor: moved, nextSelectionStart: anchor, nextSelectionEnd: moved }
210
+ }
211
+ if (selection) {
212
+ const collapsed = event.key === ZR_KEY_LEFT || event.key === ZR_KEY_HOME ? selectionMin : selectionMax
213
+ return { nextValue: value, nextCursor: collapsed, nextSelectionStart: null, nextSelectionEnd: null }
214
+ }
215
+ return { nextValue: value, nextCursor: moveCursor(cursor), nextSelectionStart: null, nextSelectionEnd: null }
216
+ }
217
+ if (event.key === ZR_KEY_BACKSPACE) {
218
+ if (selection) {
219
+ const nextValue = value.slice(0, selectionMin) + value.slice(selectionMax)
220
+ const nextCursor = normalizeInputCursor(nextValue, selectionMin)
221
+ return result(nextValue, nextCursor, null, null)
222
+ }
223
+ if (cursor === 0) return { nextValue: value, nextCursor: cursor, nextSelectionStart: null, nextSelectionEnd: null }
224
+ const start = prevBoundary(value, cursor)
225
+ const nextValue = value.slice(0, start) + value.slice(cursor)
226
+ return result(nextValue, normalizeInputCursor(nextValue, start), null, null)
227
+ }
228
+ if (event.key === ZR_KEY_DELETE) {
229
+ if (selection) {
230
+ const nextValue = value.slice(0, selectionMin) + value.slice(selectionMax)
231
+ const nextCursor = normalizeInputCursor(nextValue, selectionMin)
232
+ return result(nextValue, nextCursor, null, null)
233
+ }
234
+ if (cursor === value.length) return { nextValue: value, nextCursor: cursor, nextSelectionStart: null, nextSelectionEnd: null }
235
+ const end = nextBoundary(value, cursor)
236
+ const nextValue = value.slice(0, cursor) + value.slice(end)
237
+ return result(nextValue, normalizeInputCursor(nextValue, cursor), null, null)
238
+ }
239
+ if (event.key === ZR_KEY_ENTER && multiline) {
240
+ const nextValue = `${value.slice(0, selectionMin)}\n${value.slice(selectionMax)}`
241
+ return result(nextValue, normalizeInputCursorForward(nextValue, selectionMin + 1), null, null)
242
+ }
243
+ return null
244
+ }
245
+
246
+ if (event.kind === "text") {
247
+ let inserted = asUnicodeScalarString(event.codepoint)
248
+ if ((inserted === "\n" || inserted === "\r") && !multiline) return null
249
+ if (inserted === "\n" || inserted === "\r") inserted = "\n"
250
+ const nextValue = value.slice(0, selectionMin) + inserted + value.slice(selectionMax)
251
+ return result(nextValue, normalizeInputCursorForward(nextValue, selectionMin + inserted.length), null, null)
252
+ }
253
+
254
+ if (event.kind === "paste") {
255
+ const inserted = multiline ? normalizeLineBreaks(utf8Decoder.decode(event.bytes)) : removeCrLf(utf8Decoder.decode(event.bytes))
256
+ if (!inserted) return null
257
+ const nextValue = value.slice(0, selectionMin) + inserted + value.slice(selectionMax)
258
+ return result(nextValue, normalizeInputCursorForward(nextValue, selectionMin + inserted.length), null, null)
259
+ }
260
+
261
+ return null
262
+ }