@solidrt/core 0.0.50 → 0.0.51
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/AGENTS.md +13 -1
- package/README.md +1 -1
- package/agents/painting.md +61 -0
- package/agents/performance.md +216 -0
- package/examples/README.md +2 -2
- package/examples/{sound.tsx → audio.tsx} +1 -1
- package/examples/gpu-sprites.tsx +102 -0
- package/jsx-runtime.d.ts +15 -14
- package/package.json +7 -6
- package/src/{sound.ts → audio.ts} +68 -15
- package/src/color.ts +17 -18
- package/src/core.ts +21 -2
- package/src/data.ts +99 -0
- package/src/gpu.ts +20 -2
- package/src/index.ts +2 -2
- package/src/renderer.ts +38 -24
- package/src/runtime-modules.d.ts +3 -2
- package/src/scroll.ts +1 -1
- package/src/text-input.ts +297 -60
- package/src/types.d.ts +171 -3
- package/src/window.ts +58 -7
package/src/text-input.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Headless text-input mechanism. These primitives own the objective parts of
|
|
2
|
-
// an editable
|
|
3
|
-
// and the scroll
|
|
4
|
-
// blink, keybindings, placeholder and styling
|
|
5
|
-
// component (the "skin") that composes these.
|
|
2
|
+
// an editable field -- the value buffer (text + caret/selection) and the line
|
|
3
|
+
// and caret geometry with the scroll that keeps the caret in view -- and
|
|
4
|
+
// nothing with a UI opinion. Caret blink, keybindings, placeholder and styling
|
|
5
|
+
// are policy and belong to the component (the "skin") that composes these.
|
|
6
6
|
|
|
7
|
-
import { createSignal, flush } from "@solidjs/signals"
|
|
8
|
-
import { getBoundingBox, measureText } from "./core"
|
|
7
|
+
import { createMemo, createSignal, flush, untrack } from "@solidjs/signals"
|
|
8
|
+
import { getBoundingBox, layoutNextLine, measureText, prepareText, unitInk } from "./core"
|
|
9
|
+
import type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
|
|
9
10
|
import { onLayout } from "./window"
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -31,6 +32,20 @@ export type TextBufferOptions = {
|
|
|
31
32
|
onInput?: (value: string) => void
|
|
32
33
|
/** Max length; inserts past it are clamped. */
|
|
33
34
|
maxLength?: () => number | undefined
|
|
35
|
+
/**
|
|
36
|
+
* The offset one caret step left/right of `offset` in `text`: what a
|
|
37
|
+
* single Left/Right, Backspace or Delete moves over. Defaults to one code
|
|
38
|
+
* unit, which splits surrogate pairs and combining marks; an editor with
|
|
39
|
+
* grapheme geometry (createTextEditorLayout.step) supplies the real one.
|
|
40
|
+
*/
|
|
41
|
+
step?: (text: string, offset: number, direction: "left" | "right") => number
|
|
42
|
+
/**
|
|
43
|
+
* Called before every edit with the range it replaces and the text going
|
|
44
|
+
* in (already clamped to maxLength), for owners that keep parallel state
|
|
45
|
+
* over the text (a rich text document's attributed runs). setValue/clear
|
|
46
|
+
* report a whole-text replace.
|
|
47
|
+
*/
|
|
48
|
+
onReplace?: (start: number, end: number, text: string) => void
|
|
34
49
|
}
|
|
35
50
|
|
|
36
51
|
export type TextBuffer = {
|
|
@@ -71,8 +86,10 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
|
|
|
71
86
|
let initial = options.defaultValue ?? ""
|
|
72
87
|
let [internalValue, setInternalValue] = createSignal(initial)
|
|
73
88
|
// The caret starts at the end of the current text: for a controlled buffer
|
|
74
|
-
// that is the owner's value, which defaultValue does not reflect.
|
|
75
|
-
|
|
89
|
+
// that is the owner's value, which defaultValue does not reflect. A
|
|
90
|
+
// one-shot read by design, so untracked (the buffer is created in a
|
|
91
|
+
// component body, where a bare reactive read is flagged).
|
|
92
|
+
let initialCaret = untrack(() => options.value?.() ?? initial).length
|
|
76
93
|
let [selectionState, setSelectionState] = createSignal<Selection>({
|
|
77
94
|
anchor: initialCaret,
|
|
78
95
|
focus: initialCaret,
|
|
@@ -94,18 +111,26 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
|
|
|
94
111
|
|
|
95
112
|
let setCaret = (offset: number) => setSelectionState({ anchor: offset, focus: offset })
|
|
96
113
|
|
|
97
|
-
|
|
114
|
+
let step = (text: string, offset: number, direction: "left" | "right"): number => {
|
|
115
|
+
if (options.step) return Math.max(0, Math.min(options.step(text, offset, direction), text.length))
|
|
116
|
+
return direction === "left" ? Math.max(0, offset - 1) : Math.min(text.length, offset + 1)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Every edit is a replace of [start, end) by `text`, caret after it. The
|
|
120
|
+
// inserted text is clamped to what maxLength leaves room for. The flush
|
|
98
121
|
// commits the writes (including a controlled owner's from onInput) before
|
|
99
122
|
// returning: edits must observe each other within one task, because event
|
|
100
123
|
// bursts can dispatch several handlers with no microtask between them
|
|
101
124
|
// (Android IME input arrives as backspace+commit bursts; see
|
|
102
125
|
// okf/backlog/event-burst-stale-signal-reads.md).
|
|
103
|
-
let
|
|
126
|
+
let replace = (start: number, end: number, text: string) => {
|
|
127
|
+
let v = value()
|
|
104
128
|
let max = options.maxLength?.()
|
|
105
|
-
if (max != null
|
|
106
|
-
|
|
129
|
+
if (max != null) text = text.slice(0, Math.max(0, max - (v.length - (end - start))))
|
|
130
|
+
options.onReplace?.(start, end, text)
|
|
131
|
+
let next = v.slice(0, start) + text + v.slice(end)
|
|
107
132
|
if (options.value?.() == null) setInternalValue(next)
|
|
108
|
-
setCaret(
|
|
133
|
+
setCaret(start + text.length)
|
|
109
134
|
options.onInput?.(next)
|
|
110
135
|
flush()
|
|
111
136
|
}
|
|
@@ -116,23 +141,21 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
|
|
|
116
141
|
caret: () => selection().focus,
|
|
117
142
|
|
|
118
143
|
insertText: (text) => {
|
|
119
|
-
let v = value()
|
|
120
144
|
let [start, end] = range()
|
|
121
|
-
|
|
145
|
+
replace(start, end, text)
|
|
122
146
|
},
|
|
123
147
|
|
|
124
148
|
deleteBackward: () => {
|
|
125
|
-
let v = value()
|
|
126
149
|
let [start, end] = range()
|
|
127
|
-
if (start !== end)
|
|
128
|
-
else if (start > 0)
|
|
150
|
+
if (start !== end) replace(start, end, "")
|
|
151
|
+
else if (start > 0) replace(step(value(), start, "left"), start, "")
|
|
129
152
|
},
|
|
130
153
|
|
|
131
154
|
deleteForward: () => {
|
|
132
155
|
let v = value()
|
|
133
156
|
let [start, end] = range()
|
|
134
|
-
if (start !== end)
|
|
135
|
-
else if (end < v.length)
|
|
157
|
+
if (start !== end) replace(start, end, "")
|
|
158
|
+
else if (end < v.length) replace(end, step(v, end, "right"), "")
|
|
136
159
|
},
|
|
137
160
|
|
|
138
161
|
move: (direction, opts) => {
|
|
@@ -146,8 +169,8 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
|
|
|
146
169
|
return
|
|
147
170
|
}
|
|
148
171
|
let next = focus
|
|
149
|
-
if (direction === "left") next =
|
|
150
|
-
else if (direction === "right") next =
|
|
172
|
+
if (direction === "left") next = step(value(), focus, "left")
|
|
173
|
+
else if (direction === "right") next = step(value(), focus, "right")
|
|
151
174
|
else if (direction === "start") next = 0
|
|
152
175
|
else if (direction === "end") next = len
|
|
153
176
|
setSelectionState({ anchor: extend ? anchor : next, focus: next })
|
|
@@ -160,62 +183,276 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
|
|
|
160
183
|
flush()
|
|
161
184
|
},
|
|
162
185
|
|
|
163
|
-
setValue: (next) =>
|
|
164
|
-
clear: () =>
|
|
186
|
+
setValue: (next) => replace(0, value().length, next),
|
|
187
|
+
clear: () => replace(0, value().length, ""),
|
|
165
188
|
}
|
|
166
189
|
}
|
|
167
190
|
|
|
168
|
-
export type
|
|
191
|
+
export type TextEditorLayoutInput = {
|
|
169
192
|
text: string
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
|
|
193
|
+
font: MeasureTextOptions
|
|
194
|
+
/** Styled ranges over `text` (prepareText `runs`): the geometry then follows per-run fonts. */
|
|
195
|
+
runs?: TextRunRange[]
|
|
196
|
+
/** Caret offset into `text`. */
|
|
197
|
+
caret: number
|
|
173
198
|
/** Px reserved so the caret stays visible at the viewport edge. Default 0. */
|
|
174
199
|
caretWidth?: number
|
|
200
|
+
/** Break lines at the viewport width; else one line per hard break. */
|
|
201
|
+
wrap: boolean
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** One drawn line of an editor: `text.slice(start, end)` at `y`, `height` tall. */
|
|
205
|
+
export type EditorLine = {
|
|
206
|
+
start: number
|
|
207
|
+
end: number
|
|
208
|
+
y: number
|
|
209
|
+
height: number
|
|
210
|
+
/** Ink width. */
|
|
211
|
+
width: number
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The caret's box in content coordinates (before scroll). */
|
|
215
|
+
export type CaretRect = { x: number; y: number; height: number }
|
|
216
|
+
|
|
217
|
+
export type TextEditorLayout = {
|
|
218
|
+
lines(): EditorLine[]
|
|
219
|
+
caret(): CaretRect
|
|
220
|
+
/** Index into lines() of the line the caret sits on. */
|
|
221
|
+
caretLine(): number
|
|
222
|
+
/** The caret position (grapheme boundary) on line `line` nearest to content x. */
|
|
223
|
+
offsetAtX(line: number, x: number): number
|
|
224
|
+
/** Index of the line at content y (clamped to the first/last line). */
|
|
225
|
+
lineAtY(y: number): number
|
|
226
|
+
/** The caret position one grapheme left/right of `offset`; a break sequence is one step. For createTextBuffer's `step`. */
|
|
227
|
+
step(offset: number, direction: "left" | "right"): number
|
|
228
|
+
scrollX(): number
|
|
229
|
+
scrollY(): number
|
|
175
230
|
}
|
|
176
231
|
|
|
177
232
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
233
|
+
* The line and caret geometry of an editable text, plus the scroll offsets
|
|
234
|
+
* that keep the caret within the viewport node. Lines come from prepareText
|
|
235
|
+
* (with caret stops) + layoutNextLine at the viewport width (or unbounded
|
|
236
|
+
* when not wrapping) and are drawn by the caller, one d-text per line. A unit
|
|
237
|
+
* wider than the wrap width is split into its graphemes first, so long
|
|
238
|
+
* unbroken text wraps instead of overflowing. An empty text, or one ending in
|
|
239
|
+
* a hard break, still gets a (blank) last line to sit the caret on. Caret
|
|
240
|
+
* positions are the units' grapheme stops: the caret x, the nearest position
|
|
241
|
+
* to an x, and a caret step all come from the same shaping that is drawn.
|
|
242
|
+
*
|
|
243
|
+
* The scroll offsets are retained between frames and only adjusted when the
|
|
244
|
+
* caret would fall outside the visible range, so stationary text does not
|
|
245
|
+
* jump. The viewport size is read in onLayout and the synchronous flush
|
|
246
|
+
* drains the update before paint, so lines and scroll track a caret, text or
|
|
247
|
+
* size change in the same frame. Pure geometry: no caret rendering and no
|
|
185
248
|
* placeholder/visual policy.
|
|
186
249
|
*/
|
|
187
|
-
export function
|
|
250
|
+
export function createTextEditorLayout(
|
|
188
251
|
viewport: () => { id: number } | undefined,
|
|
189
|
-
input: () =>
|
|
190
|
-
):
|
|
252
|
+
input: () => TextEditorLayoutInput,
|
|
253
|
+
): TextEditorLayout {
|
|
254
|
+
let [viewportSize, setViewportSize] = createSignal({ width: 0, height: 0 }, { equals: (a, b) => a.width === b.width && a.height === b.height })
|
|
191
255
|
let [scrollX, setScrollX] = createSignal(0)
|
|
256
|
+
let [scrollY, setScrollY] = createSignal(0)
|
|
257
|
+
|
|
258
|
+
let prepared = createMemo(() => {
|
|
259
|
+
let { text, font, runs } = input()
|
|
260
|
+
return prepareText(text, { ...font, runs, carets: true })
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// Lines carry their unit range so the caret math walks only their units.
|
|
264
|
+
type PlacedLine = EditorLine & { from: number; to: number }
|
|
265
|
+
let placed = createMemo((): { units: TextUnit[]; lines: PlacedLine[] } => {
|
|
266
|
+
let { text, font, wrap, caretWidth = 0 } = input()
|
|
267
|
+
// Wrapped lines leave room for the caret at the end of a full line, so a
|
|
268
|
+
// wrapping editor never scrolls horizontally.
|
|
269
|
+
let width = wrap ? Math.max(0, viewportSize().width - caretWidth) : Infinity
|
|
270
|
+
let units = wrap ? splitWide(prepared(), width) : prepared()
|
|
271
|
+
let out: PlacedLine[] = []
|
|
272
|
+
let y = 0
|
|
273
|
+
let cursor = 0
|
|
274
|
+
let line = layoutNextLine(units, cursor, width)
|
|
275
|
+
let hardBreak = false
|
|
276
|
+
while (line) {
|
|
277
|
+
out.push({ start: line.start, end: line.end, y, height: line.height, width: line.width, from: line.from, to: line.to })
|
|
278
|
+
y += line.height
|
|
279
|
+
hardBreak = line.hardBreak
|
|
280
|
+
line = layoutNextLine(units, line.cursor, width)
|
|
281
|
+
}
|
|
282
|
+
if (out.length === 0 || hardBreak) {
|
|
283
|
+
let height = measureText(" ", font).height
|
|
284
|
+
let n = units.units.length
|
|
285
|
+
out.push({ start: text.length, end: text.length, y, height, width: 0, from: n, to: n })
|
|
286
|
+
}
|
|
287
|
+
return { units: units.units, lines: out }
|
|
288
|
+
})
|
|
289
|
+
let lines = createMemo((): EditorLine[] => placed().lines)
|
|
290
|
+
|
|
291
|
+
// The caret stops of a line, left to right, with the pen advanced per unit;
|
|
292
|
+
// duplicates at unit seams (a unit's end is the next one's start) skipped.
|
|
293
|
+
let lineStops = (index: number): { offset: number; x: number }[] => {
|
|
294
|
+
let { units, lines } = placed()
|
|
295
|
+
let line = lines[index]
|
|
296
|
+
if (!line) return []
|
|
297
|
+
let stops: { offset: number; x: number }[] = []
|
|
298
|
+
let pen = 0
|
|
299
|
+
for (let u = line.from; u < line.to; u++) {
|
|
300
|
+
let unit = units[u]!
|
|
301
|
+
for (let stop of unit.carets ?? []) {
|
|
302
|
+
let x = pen + stop.x
|
|
303
|
+
if (stops.length && stops[stops.length - 1]!.offset === stop.offset) continue
|
|
304
|
+
stops.push({ offset: stop.offset, x })
|
|
305
|
+
}
|
|
306
|
+
pen += unit.advance
|
|
307
|
+
}
|
|
308
|
+
if (stops.length === 0) stops.push({ offset: line.start, x: 0 })
|
|
309
|
+
return stops
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The line an offset sits on: the first whose range extends past it, so an
|
|
313
|
+
// offset on a soft-wrap boundary is the start of the next line (one offset
|
|
314
|
+
// is one position; a caret does not hang after the wrap space, which would
|
|
315
|
+
// take an affinity flag). The very end of the text is on the last line.
|
|
316
|
+
let lineOf = (offset: number): number => {
|
|
317
|
+
let ls = lines()
|
|
318
|
+
for (let i = 0; i < ls.length; i++) {
|
|
319
|
+
if (offset < ls[i]!.end) return i
|
|
320
|
+
}
|
|
321
|
+
return ls.length - 1
|
|
322
|
+
}
|
|
323
|
+
let caretLine = createMemo(() => lineOf(input().caret))
|
|
324
|
+
|
|
325
|
+
// The caret sits at the last stop at or before its offset (an offset inside
|
|
326
|
+
// a grapheme, e.g. from a controlled value, snaps back).
|
|
327
|
+
let caret = createMemo((): CaretRect => {
|
|
328
|
+
let offset = input().caret
|
|
329
|
+
let index = caretLine()
|
|
330
|
+
let line = lines()[index]!
|
|
331
|
+
let x = 0
|
|
332
|
+
for (let stop of lineStops(index)) {
|
|
333
|
+
if (stop.offset > offset) break
|
|
334
|
+
x = stop.x
|
|
335
|
+
}
|
|
336
|
+
return { x, y: line.y, height: line.height }
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
// Only positions that show on this line are candidates: a boundary offset
|
|
340
|
+
// that displays on the next line is that line's start, not this one's end.
|
|
341
|
+
let offsetAtX = (index: number, x: number): number => {
|
|
342
|
+
let best = lines()[index]?.start ?? 0
|
|
343
|
+
let bestDistance = Infinity
|
|
344
|
+
for (let stop of lineStops(index)) {
|
|
345
|
+
if (lineOf(stop.offset) !== index) continue
|
|
346
|
+
let d = Math.abs(stop.x - x)
|
|
347
|
+
if (d < bestDistance) {
|
|
348
|
+
best = stop.offset
|
|
349
|
+
bestDistance = d
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return best
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let lineAtY = (y: number): number => {
|
|
356
|
+
let ls = lines()
|
|
357
|
+
let index = 0
|
|
358
|
+
while (index + 1 < ls.length && ls[index + 1]!.y <= y) index++
|
|
359
|
+
return index
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let step = (offset: number, direction: "left" | "right"): number => {
|
|
363
|
+
let { units } = placed()
|
|
364
|
+
let text = input().text
|
|
365
|
+
if (direction === "right") {
|
|
366
|
+
for (let unit of units) {
|
|
367
|
+
if (unit.end <= offset) continue
|
|
368
|
+
for (let stop of unit.carets ?? []) if (stop.offset > offset) return stop.offset
|
|
369
|
+
// Past the unit's shaped text: over its break characters in one step.
|
|
370
|
+
return unit.end
|
|
371
|
+
}
|
|
372
|
+
return text.length
|
|
373
|
+
}
|
|
374
|
+
for (let u = units.length - 1; u >= 0; u--) {
|
|
375
|
+
let unit = units[u]!
|
|
376
|
+
if (unit.start >= offset) continue
|
|
377
|
+
let stops = unit.carets ?? []
|
|
378
|
+
for (let i = stops.length - 1; i >= 0; i--) if (stops[i]!.offset < offset) return stops[i]!.offset
|
|
379
|
+
return unit.start
|
|
380
|
+
}
|
|
381
|
+
return 0
|
|
382
|
+
}
|
|
192
383
|
|
|
193
384
|
onLayout(() => {
|
|
194
385
|
let node = viewport()
|
|
195
386
|
if (!node) return
|
|
196
|
-
let
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
let
|
|
200
|
-
|
|
201
|
-
let
|
|
202
|
-
let
|
|
203
|
-
let
|
|
204
|
-
|
|
205
|
-
let
|
|
206
|
-
let next = cur
|
|
207
|
-
if (vw <= 0) {
|
|
208
|
-
next = 0
|
|
209
|
-
} else if (caretX < cur) {
|
|
210
|
-
next = caretX
|
|
211
|
-
} else if (caretX + caretWidth > cur + vw) {
|
|
212
|
-
next = caretX + caretWidth - vw
|
|
213
|
-
}
|
|
214
|
-
next = Math.max(0, Math.min(next, maxScroll))
|
|
387
|
+
let box = getBoundingBox(node)
|
|
388
|
+
setViewportSize({ width: box?.width ?? 0, height: box?.height ?? 0 })
|
|
389
|
+
flush()
|
|
390
|
+
let { width: vw, height: vh } = viewportSize()
|
|
391
|
+
let { caretWidth = 0, wrap } = input()
|
|
392
|
+
let ls = lines()
|
|
393
|
+
let contentWidth = ls.reduce((w, l) => Math.max(w, l.width), 0)
|
|
394
|
+
let last = ls[ls.length - 1]!
|
|
395
|
+
let contentHeight = last.y + last.height
|
|
396
|
+
let c = caret()
|
|
215
397
|
|
|
216
|
-
|
|
398
|
+
setScrollX(wrap ? 0 : follow(scrollX(), c.x, caretWidth, vw, contentWidth + caretWidth))
|
|
399
|
+
setScrollY(follow(scrollY(), c.y, c.height, vh, contentHeight))
|
|
217
400
|
flush()
|
|
218
401
|
})
|
|
219
402
|
|
|
220
|
-
return scrollX
|
|
221
|
-
}
|
|
403
|
+
return { lines, caret, caretLine, offsetAtX, lineAtY, step, scrollX, scrollY }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Wrap units wider than `width` (through their glued pieces) split into one
|
|
407
|
+
// unit per grapheme (from their caret stops), so the greedy breaker wraps
|
|
408
|
+
// them like `<text>`'s overflowWrap "anywhere". Everything else is passed
|
|
409
|
+
// through as is.
|
|
410
|
+
function splitWide(prepared: PreparedText, width: number): PreparedText {
|
|
411
|
+
let all = prepared.units
|
|
412
|
+
if (!all.some((u, i) => !u.glue && unitInk(all, i) > width)) return prepared
|
|
413
|
+
let wide = false
|
|
414
|
+
let units: TextUnit[] = []
|
|
415
|
+
for (let u = 0; u < all.length; u++) {
|
|
416
|
+
let unit = all[u]!
|
|
417
|
+
if (!unit.glue) wide = unitInk(all, u) > width
|
|
418
|
+
let stops = unit.carets
|
|
419
|
+
if (!wide || !stops || stops.length <= 2) {
|
|
420
|
+
units.push(unit)
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
for (let i = 1; i < stops.length; i++) {
|
|
424
|
+
let a = stops[i - 1]!
|
|
425
|
+
let b = stops[i]!
|
|
426
|
+
let last = i === stops.length - 1
|
|
427
|
+
let advance = last ? unit.advance - a.x : b.x - a.x
|
|
428
|
+
units.push({
|
|
429
|
+
text: prepared.text.slice(a.offset, b.offset),
|
|
430
|
+
start: a.offset,
|
|
431
|
+
end: last ? unit.end : b.offset,
|
|
432
|
+
advance,
|
|
433
|
+
width: Math.max(0, Math.min(b.x, unit.width) - a.x),
|
|
434
|
+
ascent: unit.ascent,
|
|
435
|
+
descent: unit.descent,
|
|
436
|
+
hardBreak: last && unit.hardBreak,
|
|
437
|
+
glue: i === 1 && unit.glue,
|
|
438
|
+
run: unit.run,
|
|
439
|
+
carets: [
|
|
440
|
+
{ offset: a.offset, x: 0 },
|
|
441
|
+
{ offset: b.offset, x: b.x - a.x },
|
|
442
|
+
],
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return { text: prepared.text, units }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// The scroll offset along one axis that keeps [pos, pos + size] within a
|
|
450
|
+
// viewport of `extent`, moved only when it is out of view and clamped to the
|
|
451
|
+
// content.
|
|
452
|
+
function follow(current: number, pos: number, size: number, extent: number, content: number): number {
|
|
453
|
+
if (extent <= 0) return 0
|
|
454
|
+
let next = current
|
|
455
|
+
if (pos < current) next = pos
|
|
456
|
+
else if (pos + size > current + extent) next = pos + size - extent
|
|
457
|
+
return Math.max(0, Math.min(next, Math.max(0, content - extent)))
|
|
458
|
+
}
|
package/src/types.d.ts
CHANGED
|
@@ -236,6 +236,14 @@ export interface PointerEvent {
|
|
|
236
236
|
*/
|
|
237
237
|
parentX: number
|
|
238
238
|
parentY: number
|
|
239
|
+
/**
|
|
240
|
+
* Pointer movement since the previous move event, in logical pixels. Mouse
|
|
241
|
+
* reports hardware deltas (summed, never lost, and the only motion signal
|
|
242
|
+
* while the pointer is locked); touch reports position diffs. 0 on
|
|
243
|
+
* non-move events.
|
|
244
|
+
*/
|
|
245
|
+
movementX: number
|
|
246
|
+
movementY: number
|
|
239
247
|
/** Node id whose handler is currently running (bubbling changes it per call). */
|
|
240
248
|
currentTarget: number
|
|
241
249
|
/** Deepest node id of the event's path (the hit leaf). */
|
|
@@ -366,6 +374,154 @@ export interface LineGeometryProps {
|
|
|
366
374
|
y2?: number
|
|
367
375
|
}
|
|
368
376
|
|
|
377
|
+
// Native transitions (okf/done/native-transitions.md): declared once on
|
|
378
|
+
// the element, applied by the runtime to every later write of the covered
|
|
379
|
+
// properties. JS hands over targets; Rust interpolates every frame, so a
|
|
380
|
+
// running animation costs no JS per frame.
|
|
381
|
+
|
|
382
|
+
/** A cubic-bezier timing curve: a CSS name or [x1, y1, x2, y2] control values. */
|
|
383
|
+
export type TransitionCurve = "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" | [number, number, number, number]
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* A perceptual spring - the default kind: a bare `{ duration }` is a
|
|
387
|
+
* critically damped spring. `duration` (ms) is the perceptual settling
|
|
388
|
+
* time, `bounce` in (-1, 1] the springiness - 0 (the default) settles
|
|
389
|
+
* without overshoot, positive values overshoot, negative values settle
|
|
390
|
+
* sluggishly. A new target while the spring runs keeps position and
|
|
391
|
+
* velocity, so the motion stays continuous - use springs for anything
|
|
392
|
+
* retargeted while moving.
|
|
393
|
+
*/
|
|
394
|
+
export interface TransitionSpring {
|
|
395
|
+
duration: number
|
|
396
|
+
bounce?: number
|
|
397
|
+
/** Hold each write for this long (ms) before it applies; a newer write during the hold replaces it and restarts the delay. */
|
|
398
|
+
delay?: number
|
|
399
|
+
/**
|
|
400
|
+
* Mount-time enter animation: at the element's first attach the property
|
|
401
|
+
* snaps to this value and animates to the value it mounted with. Numbers
|
|
402
|
+
* for the scalar properties; the color property takes a CSS color string
|
|
403
|
+
* or packed number. Per-property entries only (not under `all`); a later
|
|
404
|
+
* move or reorder re-runs nothing.
|
|
405
|
+
*/
|
|
406
|
+
from?: number | string
|
|
407
|
+
/**
|
|
408
|
+
* Removal exit animation: an unmounted element stays visible, animates
|
|
409
|
+
* the property to this value (honoring `delay`), and is freed when its
|
|
410
|
+
* exit animations settle. Same value forms as `from`, per-property only.
|
|
411
|
+
* A move never plays it, the exiting element is hit-test invisible, its
|
|
412
|
+
* whole subtree stays painted with it, and no onTransitionEnd fires (the
|
|
413
|
+
* component is already disposed). An attached element keeps its layout
|
|
414
|
+
* slot until the exit finishes.
|
|
415
|
+
*/
|
|
416
|
+
exit?: number | string
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* A duration/curve tween, opted into by naming the curve (a tween is
|
|
421
|
+
* always a specific curve; without one the spec reads as a spring).
|
|
422
|
+
* Duration in ms. A new target while the tween runs restarts it from the
|
|
423
|
+
* current value with the full duration (CSS semantics) - designer-timed,
|
|
424
|
+
* one-shot motion.
|
|
425
|
+
*/
|
|
426
|
+
export interface TransitionTween {
|
|
427
|
+
duration: number
|
|
428
|
+
curve: TransitionCurve
|
|
429
|
+
/** Hold each write for this long (ms) before it applies; a newer write during the hold replaces it and restarts the delay. */
|
|
430
|
+
delay?: number
|
|
431
|
+
/**
|
|
432
|
+
* Mount-time enter animation: at the element's first attach the property
|
|
433
|
+
* snaps to this value and animates to the value it mounted with. Numbers
|
|
434
|
+
* for the scalar properties; the color property takes a CSS color string
|
|
435
|
+
* or packed number. Per-property entries only (not under `all`); a later
|
|
436
|
+
* move or reorder re-runs nothing.
|
|
437
|
+
*/
|
|
438
|
+
from?: number | string
|
|
439
|
+
/**
|
|
440
|
+
* Removal exit animation: an unmounted element stays visible, animates
|
|
441
|
+
* the property to this value (honoring `delay`), and is freed when its
|
|
442
|
+
* exit animations settle. Same value forms as `from`, per-property only.
|
|
443
|
+
* A move never plays it, the exiting element is hit-test invisible, its
|
|
444
|
+
* whole subtree stays painted with it, and no onTransitionEnd fires (the
|
|
445
|
+
* component is already disposed). An attached element keeps its layout
|
|
446
|
+
* slot until the exit finishes.
|
|
447
|
+
*/
|
|
448
|
+
exit?: number | string
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The shorthand string: `"<duration>ms [curve] [<delay>ms]"` - `"300ms"` is
|
|
453
|
+
* a bounce-0 spring, `"300ms ease-out"` a tween, `"300ms ease-out 100ms"`
|
|
454
|
+
* delayed (first time value the duration, second the delay; ms only).
|
|
455
|
+
* Bounce, bezier control values and `from` need the object form.
|
|
456
|
+
*/
|
|
457
|
+
export type TransitionShorthand = string
|
|
458
|
+
|
|
459
|
+
export type Transition = TransitionSpring | TransitionTween | TransitionShorthand
|
|
460
|
+
|
|
461
|
+
/** The property names a transition can cover (numeric scalars). */
|
|
462
|
+
export type TransitionPropName =
|
|
463
|
+
| "x"
|
|
464
|
+
| "y"
|
|
465
|
+
| "w"
|
|
466
|
+
| "h"
|
|
467
|
+
| "x1"
|
|
468
|
+
| "y1"
|
|
469
|
+
| "x2"
|
|
470
|
+
| "y2"
|
|
471
|
+
| "opacity"
|
|
472
|
+
| "rotate"
|
|
473
|
+
| "rotateX"
|
|
474
|
+
| "rotateY"
|
|
475
|
+
| "scale"
|
|
476
|
+
| "scaleX"
|
|
477
|
+
| "scaleY"
|
|
478
|
+
| "strokeWidth"
|
|
479
|
+
| "radius"
|
|
480
|
+
| "color"
|
|
481
|
+
|
|
482
|
+
/** Payload of onTransitionEnd: which animated property finished. */
|
|
483
|
+
export interface TransitionEndEvent {
|
|
484
|
+
property: TransitionPropName
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export interface TransitionProps {
|
|
488
|
+
/**
|
|
489
|
+
* A runtime-side transition of one of this element's properties reached
|
|
490
|
+
* its target (natural settles only; a cancelled or retargeted animation
|
|
491
|
+
* does not fire until it finally settles). Delivered to this element
|
|
492
|
+
* only, no bubbling.
|
|
493
|
+
*/
|
|
494
|
+
onTransitionEnd?: (event: TransitionEndEvent) => void
|
|
495
|
+
/**
|
|
496
|
+
* Animate later writes of the listed properties instead of snapping:
|
|
497
|
+
* `transition={{ x: { duration: 400, bounce: 0.2 }, opacity: "200ms ease-out" }}`.
|
|
498
|
+
* `all` covers every animatable property the element has, and a bare
|
|
499
|
+
* string is shorthand for it: `transition="300ms ease-out"`. Only
|
|
500
|
+
* properties the element carries animate (a d-rect has x, a view's x is
|
|
501
|
+
* its transform); the initial value never animates unless the entry sets
|
|
502
|
+
* `from` (an enter animation), and a non-numeric write (e.g. null)
|
|
503
|
+
* cancels the running animation and snaps. `null` clears the
|
|
504
|
+
* declaration; already-running animations finish.
|
|
505
|
+
*/
|
|
506
|
+
transition?:
|
|
507
|
+
| ({
|
|
508
|
+
all?: Omit<TransitionSpring, "from" | "exit"> | Omit<TransitionTween, "from" | "exit"> | TransitionShorthand
|
|
509
|
+
/**
|
|
510
|
+
* Group stagger (ms): every descendant enter (`from`) or exit that
|
|
511
|
+
* begins in the same frame under this element gets `index * stagger`
|
|
512
|
+
* of extra delay, in occurrence order (enters and exits cascade
|
|
513
|
+
* separately). Nearest declaring ancestor wins; it orchestrates
|
|
514
|
+
* descendants only - ordinary writes and this element's own
|
|
515
|
+
* lifecycle are unaffected. Adds on top of a per-entry `delay`.
|
|
516
|
+
*/
|
|
517
|
+
stagger?: number
|
|
518
|
+
} & {
|
|
519
|
+
[P in TransitionPropName]?: Transition
|
|
520
|
+
})
|
|
521
|
+
| TransitionShorthand
|
|
522
|
+
| null
|
|
523
|
+
}
|
|
524
|
+
|
|
369
525
|
// Primitives
|
|
370
526
|
|
|
371
527
|
export interface WindowProps extends LayoutProps, PointerProps {
|
|
@@ -462,9 +618,10 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
|
|
|
462
618
|
repaintBoundary?: boolean | "snapshot" | "snapshot-no-aa"
|
|
463
619
|
/**
|
|
464
620
|
* Run this view's rasterized subtree through a GPU program and composite
|
|
465
|
-
* the result in its place. Requires
|
|
466
|
-
*
|
|
467
|
-
*
|
|
621
|
+
* the result in its place. Requires a snapshot boundary
|
|
622
|
+
* (repaintBoundary="snapshot" or "snapshot-no-aa"; the cost is snapshot
|
|
623
|
+
* semantics, kept explicit; declared without one the shader is ignored
|
|
624
|
+
* with a warning). The pass is region-sized and split from content
|
|
468
625
|
* invalidation: a params-only change re-runs just the pass against the
|
|
469
626
|
* cached snapshot, so animating an effect over a static subtree never
|
|
470
627
|
* re-rasterizes it.
|
|
@@ -576,6 +733,17 @@ export interface TextRunProps {
|
|
|
576
733
|
lineHeight?: number
|
|
577
734
|
fontStyle?: "normal" | "italic"
|
|
578
735
|
fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
|
|
736
|
+
/**
|
|
737
|
+
* Underline in the run's own color, drawn straight through descenders
|
|
738
|
+
* (no skip-ink). Position and thickness come from the font's own metrics
|
|
739
|
+
* unless overridden; a font Impeller resolves through the system fallback
|
|
740
|
+
* gets the shipped Noto values.
|
|
741
|
+
*/
|
|
742
|
+
textDecoration?: "none" | "underline"
|
|
743
|
+
/** Pixels from the baseline to the top of the underline. */
|
|
744
|
+
textUnderlineOffset?: number
|
|
745
|
+
/** Underline thickness in pixels. */
|
|
746
|
+
textDecorationThickness?: number
|
|
579
747
|
}
|
|
580
748
|
|
|
581
749
|
/**
|