@solidrt/core 0.0.49 → 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 +23 -0
- 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} +23 -3
- package/examples/gpu-sprites.tsx +102 -0
- package/jsx-runtime.d.ts +19 -14
- package/package.json +7 -6
- package/src/{sound.ts → audio.ts} +99 -18
- package/src/color.ts +17 -18
- package/src/core.ts +88 -0
- package/src/data.ts +99 -0
- package/src/gpu.ts +32 -10
- package/src/index.ts +4 -4
- package/src/renderer.ts +43 -27
- 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 +233 -5
- package/src/window.ts +58 -7
package/src/runtime-modules.d.ts
CHANGED
|
@@ -88,8 +88,9 @@ declare module "srt:dev" {
|
|
|
88
88
|
/**
|
|
89
89
|
* Register a named debug command, listable and callable from the dev server
|
|
90
90
|
* (the list_debug / call_debug MCP tools). `args` arrives JSON-parsed; the
|
|
91
|
-
* return value must be JSON-serializable and synchronous (
|
|
92
|
-
* awaited). Re-registering a name replaces
|
|
91
|
+
* return value must be JSON-serializable and synchronous (an async command's
|
|
92
|
+
* Promise is not awaited - the call errors). Re-registering a name replaces
|
|
93
|
+
* it; registrations reset on hot
|
|
93
94
|
* reload, so register at module init. Callable in every build, but only dev
|
|
94
95
|
* clients ever invoke commands.
|
|
95
96
|
*/
|
package/src/scroll.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// scrollable region -- the offset and its clamping against the measured content
|
|
3
3
|
// and viewport sizes -- and nothing with a UI opinion. Wheel/drag input,
|
|
4
4
|
// momentum, scrollbars and styling are policy and belong to the component (the
|
|
5
|
-
// "skin") that composes this, the same way
|
|
5
|
+
// "skin") that composes this, the same way createTextEditorLayout backs TextInput.
|
|
6
6
|
|
|
7
7
|
import { createSignal, flush } from "@solidjs/signals"
|
|
8
8
|
import { getBoundingBox } from "./core"
|
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
|
+
}
|