@elefunc/send 0.1.16 → 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 +1 -1
- package/src/tui/app.ts +5 -5
- package/src/tui/input-editor.ts +262 -0
package/package.json
CHANGED
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" }
|
|
@@ -130,7 +130,7 @@ const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
|
|
|
130
130
|
const MUTED_TEXT_STYLE = { fg: rgb(159, 166, 178) } as const
|
|
131
131
|
const DEFAULT_WEB_URL = "https://rtme.sh/"
|
|
132
132
|
const DEFAULT_SAVE_DIR = resolve(process.cwd())
|
|
133
|
-
const ABOUT_ELEFUNC_URL = "https://
|
|
133
|
+
const ABOUT_ELEFUNC_URL = "https://rtme.sh/send"
|
|
134
134
|
const ABOUT_TITLE = "About Send"
|
|
135
135
|
const ABOUT_INTRO = "Peer-to-Peer Transfers – Web & CLI"
|
|
136
136
|
const ABOUT_SUMMARY = "Join the same room, see who is there, and offer files directly to selected peers."
|
|
@@ -860,8 +860,8 @@ const renderAboutModal = (_state: TuiState, actions: TuiActions) => {
|
|
|
860
860
|
actions: [
|
|
861
861
|
ui.link({
|
|
862
862
|
id: "about-elefunc-link",
|
|
863
|
-
label: "
|
|
864
|
-
accessibleLabel: "Open
|
|
863
|
+
label: "rtme.sh/send",
|
|
864
|
+
accessibleLabel: "Open rtme.sh Send page",
|
|
865
865
|
url: ABOUT_ELEFUNC_URL,
|
|
866
866
|
}),
|
|
867
867
|
actionButton("close-about", "Close", actions.closeAbout, "primary"),
|
|
@@ -882,8 +882,8 @@ const renderInviteDropdown = (state: TuiState, actions: TuiActions) => ui.dropdo
|
|
|
882
882
|
anchorId: ROOM_INVITE_BUTTON_ID,
|
|
883
883
|
position: "below-end",
|
|
884
884
|
items: [
|
|
885
|
-
{ id: "web", label: "WEB", shortcut: inviteWebLabel(state) },
|
|
886
885
|
{ id: "cli", label: "CLI", shortcut: inviteCliText(state) },
|
|
886
|
+
{ id: "web", label: "WEB", shortcut: inviteWebLabel(state) },
|
|
887
887
|
],
|
|
888
888
|
onSelect: item => { if (item.id === "web") actions.copyWebInvite(); if (item.id === "cli") actions.copyCliInvite() },
|
|
889
889
|
onClose: actions.closeInviteDropdown,
|
|
@@ -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
|
+
}
|