@solidrt/core 0.0.60 → 0.0.61

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.
@@ -0,0 +1,137 @@
1
+ // The long-press recognizer: a pointer held LONG_PRESS_MS without leaving
2
+ // TAP_SLOP of its down fires while still down, then streams moves until
3
+ // the lift (drag-after-long-press is the reorder idiom: the finger that
4
+ // held a row now carries it).
5
+ //
6
+ // Arena: the down arms silently, no claim. A claim at the down would be
7
+ // refused wherever a press already holds the pointer (the same node, or
8
+ // an inner pressable), and the wanted behaviour there is the press
9
+ // showing pressed and the long-press winning at the timer, so the steal
10
+ // happens at the timer instead: the press is cancelled (its feedback
11
+ // retracts, it never fires), an ancestor pan can no longer take the
12
+ // finger, moves stream to the consumer. A pan that crossed its slop first
13
+ // resolved the arena, so the timer's steal is refused and the recognizer
14
+ // stands down: scrolling a list of long-pressable rows scrolls. Nested
15
+ // long-presses resolve innermost-first for free: the inner timer was
16
+ // registered first in the leaf-to-root walk. Options are read at event
17
+ // time; cancel() retracts without an end; an unmount clears the timer and
18
+ // the claim. Mouse and touch alike (a held button long-presses).
19
+ //
20
+ // The hold timer itself (createHoldTimer) is shared with the pointer
21
+ // feed's longPress source, which pulses instead of stealing: the feed is
22
+ // one arena claimant (its transform) and must stay so.
23
+
24
+ import { onSettled } from "@solidjs/signals"
25
+ import type { PointerEvent } from "./types"
26
+ import { arena, type ArenaOwner } from "./arena"
27
+ import { pastSlop, pointAt, type PointerPoint } from "./gesture"
28
+
29
+ // Hold before a long-press fires: Flutter, iOS and Android's upper value.
30
+ const LONG_PRESS_MS = 500
31
+
32
+ export interface HoldTimerOptions {
33
+ /** Read when the timer is armed. */
34
+ ms?: () => number | undefined
35
+ onFire: (pointerId: number, at: PointerPoint, event: { shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; button?: number }) => void
36
+ }
37
+
38
+ /** The arm / slop / timer machine under a long-press: one pointer at a
39
+ * time, disarmed by travel, by a lift, by a second pointer or by cancel(). */
40
+ export function createHoldTimer(options: HoldTimerOptions) {
41
+ let armed: { id: number; at: PointerPoint; mods: { shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; button?: number } } | null = null
42
+ let timer: ReturnType<typeof setTimeout> | null = null
43
+ let disarm = () => {
44
+ if (timer !== null) clearTimeout(timer)
45
+ timer = null
46
+ armed = null
47
+ }
48
+ return {
49
+ /** The pointer armed right now, if any. */
50
+ armed: () => armed?.id ?? null,
51
+ down(e: PointerEvent) {
52
+ if (armed) {
53
+ // A second finger is not a long-press.
54
+ disarm()
55
+ return
56
+ }
57
+ let ms = options.ms?.() ?? LONG_PRESS_MS
58
+ armed = { id: e.pointerId, at: pointAt(e), mods: { shiftKey: e.shiftKey, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey, button: e.button } }
59
+ timer = setTimeout(() => {
60
+ timer = null
61
+ let a = armed!
62
+ armed = null
63
+ options.onFire(a.id, a.at, a.mods)
64
+ }, ms)
65
+ },
66
+ move(e: PointerEvent) {
67
+ if (armed && armed.id === e.pointerId && pastSlop(e, armed.at)) disarm()
68
+ },
69
+ up(e: PointerEvent) {
70
+ if (armed && armed.id === e.pointerId) disarm()
71
+ },
72
+ cancel: disarm,
73
+ }
74
+ }
75
+
76
+ export interface LongPressOptions {
77
+ /** Hold before the fire, default 500 ms. */
78
+ ms?: number
79
+ /** Fired at the timer while the pointer is still down, with the down's
80
+ * position. */
81
+ onLongPress?: (at: PointerPoint) => void
82
+ /** Movement since the previous event after the fire, in the handler
83
+ * node's parent frame (createPan's frame rule). */
84
+ onLongPressMove?: (dx: number, dy: number) => void
85
+ /** The lift after a fire. */
86
+ onLongPressEnd?: () => void
87
+ }
88
+
89
+ export function createLongPress(options: LongPressOptions) {
90
+ // The pointer the long-press won, and its last delivered parent position.
91
+ let fired: number | null = null
92
+ let last: { x: number; y: number } | null = null
93
+ let hold = createHoldTimer({
94
+ ms: () => options.ms,
95
+ onFire: (id, at) => {
96
+ if (!arena.steal(id, owner)) return
97
+ fired = id
98
+ last = { x: at.parentX, y: at.parentY }
99
+ options.onLongPress?.(at)
100
+ },
101
+ })
102
+ let cancel = () => {
103
+ hold.cancel()
104
+ if (fired !== null) {
105
+ arena.release(fired, owner)
106
+ fired = null
107
+ last = null
108
+ }
109
+ }
110
+ let owner: ArenaOwner = { cancel }
111
+ onSettled(() => cancel)
112
+
113
+ let handlers = {
114
+ onPointerDown: (e: PointerEvent) => {
115
+ if (e.button != null && e.button !== 0) return
116
+ if (fired !== null) return
117
+ hold.down(e)
118
+ },
119
+ onPointerMove: (e: PointerEvent) => {
120
+ if (fired === e.pointerId && last) {
121
+ options.onLongPressMove?.(e.parentX - last.x, e.parentY - last.y)
122
+ last = { x: e.parentX, y: e.parentY }
123
+ return
124
+ }
125
+ hold.move(e)
126
+ },
127
+ onPointerUp: (e: PointerEvent) => {
128
+ if (fired === e.pointerId) {
129
+ cancel()
130
+ options.onLongPressEnd?.()
131
+ return
132
+ }
133
+ hold.up(e)
134
+ },
135
+ }
136
+ return { handlers, cancel }
137
+ }
package/src/pan.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { onSettled } from "@solidjs/signals"
2
2
  import type { PointerEvent } from "./types"
3
3
  import { arena } from "./arena"
4
+ import { createVelocityTracker, flingVelocity } from "./velocity"
5
+ import type { Velocity } from "./velocity"
4
6
 
5
7
  // Movement in logical pixels before a pan activates. Below this a drag still
6
8
  // reads as a press (tap wiggle); crossing it is the positive evidence that the
@@ -26,7 +28,13 @@ export interface PanOptions {
26
28
  * Positive dx is rightward, positive dy downward.
27
29
  */
28
30
  onPanMove?: (dx: number, dy: number) => void
29
- onPanEnd?: () => void
31
+ /**
32
+ * The lift, with the pointer's velocity at it in the same parent-frame
33
+ * pixels per second (a least-squares fit over the last 100 ms, see
34
+ * velocity.ts): the fling fact for whoever animates on. Zero when the
35
+ * finger rested before lifting or moved under FLING_MIN_VELOCITY.
36
+ */
37
+ onPanEnd?: (velocity: Velocity) => void
30
38
  }
31
39
 
32
40
  // The pan recognizer: turns a drag into a movement-delta stream. On a down it
@@ -36,8 +44,11 @@ export interface PanOptions {
36
44
  // resolving it so no other recognizer can take the drag over. If the arena is
37
45
  // already resolved (an inner pan won first) the recognizer disarms and stays
38
46
  // out. The slop distance itself is swallowed: deltas stream from the
39
- // activation point on. Moves and the up arrive on the frozen down path, so an
40
- // active pan keeps streaming when the pointer leaves the node or the window.
47
+ // activation point on, and the up carries the lift velocity (velocity.ts: a
48
+ // fit over the last 100 ms of positions, zero after a rest or under the
49
+ // fling minimum) for momentum. Moves and the up arrive on the frozen down
50
+ // path, so an active pan keeps streaming when the pointer leaves the node
51
+ // or the window.
41
52
  // cancel() is the external-cancel hook; it ends an active pan without
42
53
  // onPanEnd. Options are read at event time. Single-pointer by design; for
43
54
  // multi-pointer pinch/rotate compose createTransform instead.
@@ -55,6 +66,8 @@ export function createPan(options: PanOptions) {
55
66
  let origin: { x: number; y: number } | null = null
56
67
  let active: number | null = null
57
68
  let armed: number | null = null
69
+ // Parent-frame positions of the active pointer, for the lift's velocity.
70
+ let tracker = createVelocityTracker()
58
71
 
59
72
  let past = (e: PointerEvent) => {
60
73
  if (!origin) return false
@@ -94,6 +107,8 @@ export function createPan(options: PanOptions) {
94
107
  active = e.pointerId
95
108
  armed = null
96
109
  origin = { x: e.parentX, y: e.parentY }
110
+ tracker.reset()
111
+ tracker.push(e.parentX, e.parentY)
97
112
  options.onPanStart?.()
98
113
  } else {
99
114
  // The arena is resolved against us; the drag belongs elsewhere.
@@ -102,14 +117,18 @@ export function createPan(options: PanOptions) {
102
117
  return
103
118
  }
104
119
  if (active === e.pointerId && origin) {
120
+ tracker.push(e.parentX, e.parentY)
105
121
  options.onPanMove?.(e.parentX - origin.x, e.parentY - origin.y)
106
122
  origin = { x: e.parentX, y: e.parentY }
107
123
  }
108
124
  },
109
125
  onPointerUp: (e: PointerEvent) => {
110
126
  if (active === e.pointerId) {
127
+ // The up is not a sample: it sits where the last move left the
128
+ // pointer, and its age is what tells a rest from a flick.
129
+ let velocity = flingVelocity(tracker.velocity())
111
130
  reset()
112
- options.onPanEnd?.()
131
+ options.onPanEnd?.(velocity)
113
132
  } else if (armed === e.pointerId) {
114
133
  reset()
115
134
  }
package/src/renderer.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
1
+ // createErrorBoundary comes from signals, not solid-js: solid-js marks it
2
+ // @internal (an app writes <Errored>, a renderer reaches for the primitive)
3
+ // and strips it from its .d.ts, so the solid-js re-export types as any.
4
+ import { createErrorBoundary, createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
2
5
  import { createRenderer } from "@solidjs/universal"
3
- import { createErrorBoundary } from "solid-js"
4
6
  import type { Element } from "solid-js"
5
7
  import * as tree from "flux:rendertree"
6
8
  import { attachWindow, setWindowRoot } from "./window"
@@ -116,6 +118,10 @@ function removeNode(parent: ProxyNode, node: ProxyNode): void {
116
118
  const SENTINEL_INTERVAL_MS = 5000
117
119
  let sentinelDue = 0
118
120
  let warnedLeakTypes = new Set<string>()
121
+ // The orphan total's order of magnitude (floor of log10) at the last
122
+ // warning: a leak that keeps growing at a stable set of types warns again
123
+ // each time the total crosses the next power of ten.
124
+ let warnedMagnitude = -1
119
125
 
120
126
  export function scanForOrphans(now: number): void {
121
127
  if (!import.meta.env.DEV) return
@@ -129,18 +135,22 @@ export function scanForOrphans(now: number): void {
129
135
  counts.set(node.elementType, (counts.get(node.elementType) ?? 0) + 1)
130
136
  }
131
137
  if (total === 0) return
132
- // Warn only when a new element type joins the orphans, but list every type
133
- // with its count so the breakdown always adds up to the total.
138
+ // Warn when a new element type joins the orphans or the total crosses the
139
+ // next order of magnitude, but list every type with its count so the
140
+ // breakdown always adds up to the total.
134
141
  let fresh = [...counts].filter(([type]) => !warnedLeakTypes.has(type))
135
- if (fresh.length === 0) return
142
+ let magnitude = Math.floor(Math.log10(total))
143
+ if (fresh.length === 0 && magnitude <= warnedMagnitude) return
136
144
  for (let [type] of fresh) warnedLeakTypes.add(type)
145
+ warnedMagnitude = magnitude
137
146
  let list = [...counts].map(([type, n]) => `<${type}> x${n}`).join(", ")
138
147
  console.warn(
139
148
  `Leak sentinel: ${total} nodes are unreachable and will never be freed: ${list}. ` +
140
149
  `The usual cause is reading an element-valued prop more than once (every read ` +
141
150
  `builds a new subtree); read it once where it mounts, or resolve it with ` +
142
151
  `children(). If these nodes are intentionally kept for later mounting, ignore ` +
143
- `this. The next warning comes when a new element type joins the list.`,
152
+ `this. The next warning comes when a new element type joins the list or the ` +
153
+ `total passes ${10 ** (magnitude + 1)}.`,
144
154
  )
145
155
  }
146
156
 
@@ -51,6 +51,10 @@ declare module "*.glb" {
51
51
  const bytes: Uint8Array
52
52
  export default bytes
53
53
  }
54
+ declare module "*.srtm" {
55
+ const bytes: Uint8Array
56
+ export default bytes
57
+ }
54
58
 
55
59
  // UI event bus (lattice), provided by the runtime as a builtin module.
56
60
  // on/once return an unsubscribe function. Notable events: the routed pointer
package/src/svg.ts CHANGED
@@ -27,6 +27,7 @@ export type SvgDraw = {
27
27
  color: string | Gradient
28
28
  drawStyle: "fill" | "stroke"
29
29
  fillRule?: "nonzero" | "evenodd"
30
+ /** Absent when the source omits it: the d-path default of 1 applies, SVG's own. */
30
31
  strokeWidth?: number
31
32
  strokeCap?: StrokeCap
32
33
  strokeJoin?: StrokeJoin
package/src/swipe.ts ADDED
@@ -0,0 +1,130 @@
1
+ // The swipe recognizer: a one-pointer drag that, at the lift, has
2
+ // travelled SWIPE_MIN_DISTANCE from its down at SWIPE_MIN_VELOCITY or
3
+ // more, within SWIPE_ANGLE_TOLERANCE of one of the four axes. Discrete:
4
+ // onSwipe fires once, at the lift, with the direction and the velocity.
5
+ // Direction is the dominant axis of the VELOCITY, not of the travel (a
6
+ // finger that wandered then flicked swipes where it flicked). A drag
7
+ // that ends without qualifying gets onSwipeEnd alone, so a consumer that
8
+ // followed the finger snaps back.
9
+ //
10
+ // Built on createPan with the pan's axis derived from the allowed
11
+ // directions: a horizontal-only swipe inside a vertical ScrollView takes
12
+ // only horizontal drags, exactly as nested scrollers share drags today,
13
+ // and it steals at the pan's slop, so a pressable under it retracts. The
14
+ // pan measures the parent-frame velocity it hands the consumer; the
15
+ // thresholds are finger travel, so the swipe keeps a second tracker in
16
+ // window pixels over the events it wraps. The classifier is shared with
17
+ // the pointer feed's swipe sources. Mouse and touch alike.
18
+
19
+ import { createPan } from "./pan"
20
+ import type { PointerEvent } from "./types"
21
+ import { createVelocityTracker } from "./velocity"
22
+ import type { Velocity } from "./velocity"
23
+
24
+ export type SwipeDirection = "Left" | "Right" | "Up" | "Down"
25
+
26
+ export const SWIPE_DIRECTIONS: readonly SwipeDirection[] = ["Left", "Right", "Up", "Down"]
27
+
28
+ // Least finger travel (window px) from the down to the lift.
29
+ const SWIPE_MIN_DISTANCE = 24
30
+ // Least finger speed at the lift (window px/s): Android's usual
31
+ // minimumFlingVelocity of 50 dp/s at the density it assumes.
32
+ const SWIPE_MIN_VELOCITY = 300
33
+ // How far off an axis (degrees) the velocity may point.
34
+ const SWIPE_ANGLE_TOLERANCE = 30
35
+ const OFF_AXIS_RATIO = Math.tan((SWIPE_ANGLE_TOLERANCE * Math.PI) / 180)
36
+
37
+ /**
38
+ * The direction a lift swipes in, or null: `finger` is the velocity in
39
+ * window px/s, `travel` the window-px offset from the down.
40
+ */
41
+ export function classifySwipe(finger: Velocity, travel: { dx: number; dy: number }, directions: readonly SwipeDirection[] = SWIPE_DIRECTIONS): SwipeDirection | null {
42
+ if (Math.hypot(travel.dx, travel.dy) < SWIPE_MIN_DISTANCE) return null
43
+ let ax = Math.abs(finger.vx)
44
+ let ay = Math.abs(finger.vy)
45
+ if (Math.hypot(ax, ay) < SWIPE_MIN_VELOCITY) return null
46
+ let direction: SwipeDirection
47
+ if (ax >= ay) {
48
+ if (ay > ax * OFF_AXIS_RATIO) return null
49
+ direction = finger.vx < 0 ? "Left" : "Right"
50
+ } else {
51
+ if (ax > ay * OFF_AXIS_RATIO) return null
52
+ direction = finger.vy < 0 ? "Up" : "Down"
53
+ }
54
+ return directions.includes(direction) ? direction : null
55
+ }
56
+
57
+ export interface SwipeOptions {
58
+ /** Which directions count; default all four. Decides the pan's axis:
59
+ * only Left/Right takes horizontal drags, only Up/Down vertical ones. */
60
+ directions?: SwipeDirection[]
61
+ onSwipeStart?: () => void
62
+ /** Movement since the previous event in the handler node's parent frame,
63
+ * so a card follows the finger (createPan's frame rule). */
64
+ onSwipeMove?: (dx: number, dy: number) => void
65
+ /** The lift qualified: fired once, before onSwipeEnd. `velocity` is the
66
+ * pan's, parent-frame px/s. */
67
+ onSwipe?: (direction: SwipeDirection, velocity: Velocity) => void
68
+ /** Every lift of an active swipe, qualified or not. */
69
+ onSwipeEnd?: (velocity: Velocity) => void
70
+ }
71
+
72
+ export function createSwipe(options: SwipeOptions) {
73
+ let directions = () => options.directions ?? SWIPE_DIRECTIONS
74
+ let axisOf = (): "horizontal" | "vertical" | "both" => {
75
+ let d = directions()
76
+ let h = d.includes("Left") || d.includes("Right")
77
+ let v = d.includes("Up") || d.includes("Down")
78
+ return h && !v ? "horizontal" : v && !h ? "vertical" : "both"
79
+ }
80
+ // The down's window position and the finger's window-px velocity, over
81
+ // every event the wrapped pan sees.
82
+ let down: { id: number; x: number; y: number } | null = null
83
+ // The lift's window position, set just before the pan's up runs.
84
+ let lift: { x: number; y: number } | null = null
85
+ let finger = createVelocityTracker()
86
+ let pan = createPan({
87
+ get axis() {
88
+ return axisOf()
89
+ },
90
+ onPanStart: () => options.onSwipeStart?.(),
91
+ onPanMove: (dx, dy) => options.onSwipeMove?.(dx, dy),
92
+ onPanEnd: velocity => {
93
+ let direction: SwipeDirection | null = null
94
+ if (down && lift) {
95
+ direction = classifySwipe(finger.velocity(), { dx: lift.x - down.x, dy: lift.y - down.y }, directions())
96
+ }
97
+ if (direction) options.onSwipe?.(direction, velocity)
98
+ options.onSwipeEnd?.(velocity)
99
+ },
100
+ })
101
+
102
+ let handlers = {
103
+ onPointerDown: (e: PointerEvent) => {
104
+ if (down === null && (e.button == null || e.button === 0)) {
105
+ down = { id: e.pointerId, x: e.clientX, y: e.clientY }
106
+ finger.reset()
107
+ finger.push(e.clientX, e.clientY)
108
+ }
109
+ pan.handlers.onPointerDown(e)
110
+ },
111
+ onPointerMove: (e: PointerEvent) => {
112
+ if (down && down.id === e.pointerId) finger.push(e.clientX, e.clientY)
113
+ pan.handlers.onPointerMove(e)
114
+ },
115
+ onPointerUp: (e: PointerEvent) => {
116
+ if (down && down.id === e.pointerId) lift = { x: e.clientX, y: e.clientY }
117
+ pan.handlers.onPointerUp(e)
118
+ if (down && down.id === e.pointerId) {
119
+ down = null
120
+ lift = null
121
+ }
122
+ },
123
+ }
124
+ let cancel = () => {
125
+ pan.cancel()
126
+ down = null
127
+ lift = null
128
+ }
129
+ return { handlers, cancel }
130
+ }
package/src/text-input.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // are policy and belong to the component (the "skin") that composes these.
6
6
 
7
7
  import { createMemo, createSignal, flush, untrack } from "@solidjs/signals"
8
- import { getBoundingBox, layoutNextLine, measureText, prepareText, unitInk } from "./core"
8
+ import { getLayoutBox, layoutNextLine, measureText, prepareText, unitInk } from "./core"
9
9
  import type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
10
10
  import { onLayout } from "./window"
11
11
 
@@ -247,50 +247,82 @@ export type TextEditorLayout = {
247
247
  *
248
248
  * The scroll offsets are retained between frames and only adjusted when the
249
249
  * caret would fall outside the visible range, so stationary text does not
250
- * jump. The viewport size is read in onLayout and the synchronous flush
251
- * drains the update before paint, so lines and scroll track a caret, text or
252
- * size change in the same frame. Pure geometry: no caret rendering and no
253
- * placeholder/visual policy.
250
+ * jump. They derive from the caret, the lines and the viewport size, so a
251
+ * frame that changes none of them does no scroll work. The viewport size is
252
+ * the node's layout box (the untransformed solved box: the lines are drawn in
253
+ * the node's own frame, so an ancestor's scale must not change the wrap
254
+ * width), read in onLayout; the post-layout flush drains the update before
255
+ * paint, so lines and scroll track a caret, text or size change in the same
256
+ * frame. A size change that re-breaks nothing (an empty field, lines that
257
+ * still fit, a height-only change) stops at the line placement: the lines,
258
+ * the caret and everything the caller derives from them keep their values
259
+ * and re-run nothing, so a field inside a resizing box costs one placement
260
+ * per frame, not a flush of its whole graph. Pure geometry: no caret
261
+ * rendering and no placeholder/visual policy.
254
262
  */
255
263
  export function createTextEditorLayout(
256
264
  viewport: () => { id: number } | undefined,
257
265
  input: () => TextEditorLayoutInput,
258
266
  ): TextEditorLayout {
259
- let [viewportSize, setViewportSize] = createSignal({ width: 0, height: 0 }, { equals: (a, b) => a.width === b.width && a.height === b.height })
260
- let [scrollX, setScrollX] = createSignal(0)
261
- let [scrollY, setScrollY] = createSignal(0)
267
+ // Width and height apart: wrapping depends on the width alone, so a
268
+ // height-only change (a field growing with its content) re-breaks nothing.
269
+ let [viewportWidth, setViewportWidth] = createSignal(0)
270
+ let [viewportHeight, setViewportHeight] = createSignal(0)
271
+
272
+ // The font as a value: input() builds a fresh options object per read, so
273
+ // field equality is what lets the metrics below key on the font itself.
274
+ let font = createMemo(() => input().font, { equals: sameOptions })
275
+
276
+ // One space in the font: its height sits the synthesized blank line, its
277
+ // width is the selection's break sliver. Measured once per font, not once
278
+ // per placement.
279
+ let space = createMemo(() => measureText(" ", font()))
262
280
 
263
281
  let prepared = createMemo(() => {
264
- let { text, font, runs } = input()
265
- return prepareText(text, { ...font, runs, carets: true })
282
+ let { text, runs } = input()
283
+ return prepareText(text, { ...font(), runs, carets: true })
266
284
  })
267
285
 
268
286
  // Lines carry their unit range so the caret math walks only their units.
287
+ // Equal placements (same units, same breaks and metrics) keep the previous
288
+ // value, so nothing downstream re-runs for a width change that changed
289
+ // no line. Within a changed placement, a line equal to its predecessor
290
+ // keeps the previous object: a row bound to it (a non-keyed <For> item)
291
+ // sees no change and writes nothing, so a moved break costs the lines it
292
+ // touched, not every line of the field.
269
293
  type PlacedLine = EditorLine & { from: number; to: number }
270
- let placed = createMemo((): { units: TextUnit[]; lines: PlacedLine[] } => {
271
- let { text, font, wrap, caretWidth = 0 } = input()
272
- // Wrapped lines leave room for the caret at the end of a full line, so a
273
- // wrapping editor never scrolls horizontally.
274
- let width = wrap ? Math.max(0, viewportSize().width - caretWidth) : Infinity
275
- let units = wrap ? splitWide(prepared(), width) : prepared()
276
- let out: PlacedLine[] = []
277
- let y = 0
278
- let cursor = 0
279
- let line = layoutNextLine(units, cursor, width)
280
- let hardBreak = false
281
- while (line) {
282
- out.push({ start: line.start, end: line.end, y, height: line.height, width: line.width, from: line.from, to: line.to })
283
- y += line.height
284
- hardBreak = line.hardBreak
285
- line = layoutNextLine(units, line.cursor, width)
286
- }
287
- if (out.length === 0 || hardBreak) {
288
- let height = measureText(" ", font).height
289
- let n = units.units.length
290
- out.push({ start: text.length, end: text.length, y, height, width: 0, from: n, to: n })
291
- }
292
- return { units: units.units, lines: out }
293
- })
294
+ type Placement = { units: TextUnit[]; lines: PlacedLine[] }
295
+ let placed = createMemo(
296
+ (prev): Placement => {
297
+ let { text, wrap, caretWidth = 0 } = input()
298
+ // Wrapped lines leave room for the caret at the end of a full line, so a
299
+ // wrapping editor never scrolls horizontally.
300
+ let width = wrap ? Math.max(0, viewportWidth() - caretWidth) : Infinity
301
+ let units = wrap ? splitWide(prepared(), width) : prepared()
302
+ let out: PlacedLine[] = []
303
+ let y = 0
304
+ let cursor = 0
305
+ let line = layoutNextLine(units, cursor, width)
306
+ let hardBreak = false
307
+ while (line) {
308
+ out.push({ start: line.start, end: line.end, y, height: line.height, width: line.width, from: line.from, to: line.to })
309
+ y += line.height
310
+ hardBreak = line.hardBreak
311
+ line = layoutNextLine(units, line.cursor, width)
312
+ }
313
+ if (out.length === 0 || hardBreak) {
314
+ let n = units.units.length
315
+ out.push({ start: text.length, end: text.length, y, height: space().height, width: 0, from: n, to: n })
316
+ }
317
+ if (prev && prev.units === units.units) {
318
+ for (let i = 0; i < out.length && i < prev.lines.length; i++) {
319
+ if (sameLine(prev.lines[i]!, out[i]!)) out[i] = prev.lines[i]!
320
+ }
321
+ }
322
+ return { units: units.units, lines: out }
323
+ },
324
+ { equals: samePlacement },
325
+ )
294
326
  let lines = createMemo((): EditorLine[] => placed().lines)
295
327
 
296
328
  // The caret stops of a line, left to right, with the pen advanced per unit;
@@ -375,7 +407,7 @@ export function createTextEditorLayout(
375
407
  let ls = lines()
376
408
  let first = lineOf(start)
377
409
  let last = lineOf(end)
378
- let breakWidth = measureText(" ", input().font).width
410
+ let breakWidth = space().width
379
411
  let out: SelectionRect[] = []
380
412
  for (let i = first; i <= last; i++) {
381
413
  let line = ls[i]!
@@ -416,22 +448,29 @@ export function createTextEditorLayout(
416
448
  return 0
417
449
  }
418
450
 
419
- onLayout(() => {
420
- let node = viewport()
421
- if (!node) return
422
- let box = getBoundingBox(node)
423
- setViewportSize({ width: box?.width ?? 0, height: box?.height ?? 0 })
424
- flush()
425
- let { width: vw, height: vh } = viewportSize()
451
+ // Each offset follows from its own previous value: `follow` moves it only
452
+ // when the caret has left the visible range, so the memo carries the
453
+ // retained position across recomputes.
454
+ let scrollX = createMemo((prev: number | undefined): number => {
426
455
  let { caretWidth = 0, wrap } = input()
456
+ if (wrap) return 0
457
+ let contentWidth = lines().reduce((w, l) => Math.max(w, l.width), 0)
458
+ let c = caret()
459
+ return follow(prev ?? 0, c.x, caretWidth, viewportWidth(), contentWidth + caretWidth)
460
+ })
461
+ let scrollY = createMemo((prev: number | undefined): number => {
427
462
  let ls = lines()
428
- let contentWidth = ls.reduce((w, l) => Math.max(w, l.width), 0)
429
463
  let last = ls[ls.length - 1]!
430
- let contentHeight = last.y + last.height
431
464
  let c = caret()
465
+ return follow(prev ?? 0, c.y, c.height, viewportHeight(), last.y + last.height)
466
+ })
432
467
 
433
- setScrollX(wrap ? 0 : follow(scrollX(), c.x, caretWidth, vw, contentWidth + caretWidth))
434
- setScrollY(follow(scrollY(), c.y, c.height, vh, contentHeight))
468
+ onLayout(() => {
469
+ let node = viewport()
470
+ if (!node) return
471
+ let box = getLayoutBox(node)
472
+ setViewportWidth(box?.width ?? 0)
473
+ setViewportHeight(box?.height ?? 0)
435
474
  })
436
475
 
437
476
  return { lines, caret, caretLine, offsetAtX, selectionRects, lineAtY, step, scrollX, scrollY }
@@ -480,6 +519,25 @@ function splitWide(prepared: PreparedText, width: number): PreparedText {
480
519
  return { text: prepared.text, units }
481
520
  }
482
521
 
522
+ // Font options compared field by field: the same keys with the same values.
523
+ function sameOptions(a: MeasureTextOptions, b: MeasureTextOptions): boolean {
524
+ let ka = Object.keys(a) as (keyof MeasureTextOptions)[]
525
+ let kb = Object.keys(b) as (keyof MeasureTextOptions)[]
526
+ return ka.length === kb.length && ka.every((k) => a[k] === b[k])
527
+ }
528
+
529
+ // The same break (text range) at the same place with the same metrics.
530
+ function sameLine(a: EditorLine, b: EditorLine): boolean {
531
+ return a.start === b.start && a.end === b.end && a.y === b.y && a.height === b.height && a.width === b.width
532
+ }
533
+
534
+ // Two placements of the same units with the same lines, break for break and
535
+ // metric for metric.
536
+ function samePlacement(a: { units: TextUnit[]; lines: EditorLine[] }, b: { units: TextUnit[]; lines: EditorLine[] }): boolean {
537
+ if (a.units !== b.units || a.lines.length !== b.lines.length) return false
538
+ return a.lines.every((l, i) => sameLine(l, b.lines[i]!))
539
+ }
540
+
483
541
  // The scroll offset along one axis that keeps [pos, pos + size] within a
484
542
  // viewport of `extent`, moved only when it is out of view and clamped to the
485
543
  // content.