@silvery/tea 0.3.0

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,174 @@
1
+ /**
2
+ * silvery/tea — Zustand middleware for TEA (The Elm Architecture) effects.
3
+ *
4
+ * A ~30-line middleware that extends Zustand reducers to optionally return
5
+ * [state, effects]. Gradual adoption: return plain state (Level 3) or
6
+ * [state, effects] (Level 4) on a per-case basis.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { createStore } from "zustand"
11
+ * import { tea, collect } from "@silvery/tea/tea"
12
+ *
13
+ * // Define effects as plain data
14
+ * const log = (msg: string) => ({ type: "log" as const, msg })
15
+ * const httpPost = (url: string, body: unknown) => ({ type: "http" as const, url, body })
16
+ *
17
+ * type MyEffect = ReturnType<typeof log> | ReturnType<typeof httpPost>
18
+ *
19
+ * interface State {
20
+ * count: number
21
+ * }
22
+ *
23
+ * type Op = { type: "increment" } | { type: "save" }
24
+ *
25
+ * // Reducer: return state (no effects) or [state, effects]
26
+ * function reducer(state: State, op: Op): TeaResult<State, MyEffect> {
27
+ * switch (op.type) {
28
+ * case "increment":
29
+ * return { ...state, count: state.count + 1 } // Level 3: plain state
30
+ * case "save":
31
+ * return [{ ...state }, [httpPost("/api", state), log("saved")]] // Level 4: [state, effects]
32
+ * }
33
+ * }
34
+ *
35
+ * // Effect runners (swappable: production, test, replay)
36
+ * const runners: EffectRunners<MyEffect, Op> = {
37
+ * log: (e) => console.log(e.msg),
38
+ * http: async (e, dispatch) => {
39
+ * const res = await fetch(e.url, { method: "POST", body: JSON.stringify(e.body) })
40
+ * dispatch({ type: "loaded", data: await res.json() })
41
+ * },
42
+ * }
43
+ *
44
+ * // Wire up
45
+ * const store = createStore(tea({ count: 0 }, reducer, { runners }))
46
+ * store.getState().dispatch({ type: "increment" })
47
+ *
48
+ * // Test: collect() normalizes output for assertions
49
+ * const [state, effects] = collect(reducer(initial, { type: "save" }))
50
+ * expect(effects).toContainEqual(httpPost("/api", initial))
51
+ * ```
52
+ *
53
+ * @packageDocumentation
54
+ */
55
+
56
+ import type { StateCreator } from "zustand"
57
+
58
+ // =============================================================================
59
+ // Types
60
+ // =============================================================================
61
+
62
+ /** An effect is a plain object with a `type` discriminant. */
63
+ export type EffectLike = { type: string }
64
+
65
+ /** Reducer result: plain state (no effects) or [state, effects]. */
66
+ export type TeaResult<S, E extends EffectLike = EffectLike> = S | readonly [S, E[]]
67
+
68
+ /** A reducer that takes state + operation and returns TeaResult. */
69
+ export type TeaReducer<S, Op, E extends EffectLike = EffectLike> = (state: S, op: Op) => TeaResult<S, E>
70
+
71
+ /**
72
+ * Effect runners keyed by effect `type`.
73
+ *
74
+ * Each runner receives the effect and a dispatch function for round-trip
75
+ * communication (Elm's Cmd Msg pattern).
76
+ */
77
+ export type EffectRunners<E extends EffectLike, Op = unknown> = {
78
+ [K in E["type"]]?: (effect: Extract<E, { type: K }>, dispatch: (op: Op) => void) => void | Promise<void>
79
+ }
80
+
81
+ /** Options for the tea() middleware. */
82
+ export interface TeaOptions<E extends EffectLike, Op> {
83
+ /** Effect runners. Keyed by effect type. Unmatched effects are silently dropped. */
84
+ runners?: EffectRunners<E, Op>
85
+ }
86
+
87
+ /**
88
+ * The store shape produced by the tea() middleware.
89
+ *
90
+ * `dispatch(op)` runs the reducer, updates state, and executes effects.
91
+ * All domain state fields from S are spread at the top level alongside dispatch.
92
+ */
93
+ export type TeaSlice<S, Op> = S & {
94
+ /** Dispatch an operation through the reducer. */
95
+ dispatch: (op: Op) => void
96
+ }
97
+
98
+ // =============================================================================
99
+ // Core: tea() middleware
100
+ // =============================================================================
101
+
102
+ /**
103
+ * Zustand state creator that adds TEA-style dispatch + effects.
104
+ *
105
+ * The reducer can return plain state (Level 3) or `[state, effects]` (Level 4).
106
+ * Array.isArray detects which — safe because Zustand state is always an object.
107
+ *
108
+ * Effects are executed after state update. Each effect is routed to a runner
109
+ * by its `type` field. Runners receive a `dispatch` callback for round-trip
110
+ * communication (Elm's Cmd Msg pattern).
111
+ */
112
+ export function tea<S extends object, Op, E extends EffectLike = EffectLike>(
113
+ initialState: S,
114
+ reducer: TeaReducer<S, Op, E>,
115
+ options?: TeaOptions<E, Op>,
116
+ ): StateCreator<TeaSlice<S, Op>> {
117
+ return (set, get) => {
118
+ const dispatch = (op: Op): void => {
119
+ // Extract domain state (everything except dispatch)
120
+ const { dispatch: _, ...currentState } = get()
121
+ const result = reducer(currentState as unknown as S, op)
122
+
123
+ // Detect: plain state vs [state, effects]
124
+ const [newState, effects] = Array.isArray(result) ? (result as [S, E[]]) : [result as S, [] as E[]]
125
+
126
+ // Update Zustand store (spread domain state, keep dispatch)
127
+ set(newState as Partial<TeaSlice<S, Op>>)
128
+
129
+ // Execute effects
130
+ if (effects.length > 0 && options?.runners) {
131
+ for (const effect of effects) {
132
+ const runner = options.runners[effect.type as E["type"]]
133
+ if (runner) {
134
+ ;(runner as (e: E, d: (op: Op) => void) => void)(effect, dispatch)
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ return {
141
+ ...initialState,
142
+ dispatch,
143
+ } as TeaSlice<S, Op>
144
+ }
145
+ }
146
+
147
+ // =============================================================================
148
+ // Test helper: collect()
149
+ // =============================================================================
150
+
151
+ /**
152
+ * Normalize a reducer result to `[state, effects]` tuple.
153
+ *
154
+ * Use in tests to uniformly assert on both state and effects regardless of
155
+ * whether the reducer returned plain state or a tuple.
156
+ *
157
+ * @example
158
+ * ```ts
159
+ * const [state, effects] = collect(reducer(initial, { type: "save" }))
160
+ * expect(state.saving).toBe(true)
161
+ * expect(effects).toContainEqual(httpPost("/api", initial))
162
+ *
163
+ * // Also works for Level 3 (no effects):
164
+ * const [state2, effects2] = collect(reducer(initial, { type: "increment" }))
165
+ * expect(state2.count).toBe(1)
166
+ * expect(effects2).toEqual([])
167
+ * ```
168
+ */
169
+ export function collect<S, E extends EffectLike = EffectLike>(result: TeaResult<S, E>): [S, E[]] {
170
+ if (Array.isArray(result)) {
171
+ return result as [S, E[]]
172
+ }
173
+ return [result as S, []]
174
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Text Cursor Utilities
3
+ *
4
+ * Pure functions for mapping between flat character offsets and visual
5
+ * (row, col) positions in word-wrapped text. Uses the same wrapText()
6
+ * function as the rendering pipeline, guaranteeing cursor positions
7
+ * match what's displayed on screen.
8
+ *
9
+ * Architecture layer 0 — no state, no hooks, no components.
10
+ * Used by: TextArea (layer 3), useTextEdit (layer 1), and apps
11
+ * that need cursor math without the full component stack.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { cursorToRowCol, cursorMoveDown } from '@silvery/react'
16
+ *
17
+ * const { row, col } = cursorToRowCol("hello world", 5, 8)
18
+ * // row=0, col=5 (fits in 8-wide line)
19
+ *
20
+ * const next = cursorMoveDown("hello world\nfoo", 3, 8)
21
+ * // next = 12 (moved to row 1, col 3 → "foo"[3] = end)
22
+ * ```
23
+ */
24
+ import { type Measurer, wrapText } from "@silvery/term/unicode"
25
+
26
+ // =============================================================================
27
+ // Types
28
+ // =============================================================================
29
+
30
+ export interface WrappedLine {
31
+ /** The text content of this visual line */
32
+ line: string
33
+ /** Character offset in the original text where this line starts */
34
+ startOffset: number
35
+ }
36
+
37
+ // =============================================================================
38
+ // Core Functions
39
+ // =============================================================================
40
+
41
+ /**
42
+ * Convert a flat cursor offset to visual (row, col) in word-wrapped text.
43
+ *
44
+ * Uses wrapText() from unicode.ts — the same function the render pipeline
45
+ * uses — so cursor positions always match what's displayed on screen.
46
+ */
47
+ export function cursorToRowCol(
48
+ text: string,
49
+ cursor: number,
50
+ wrapWidth: number,
51
+ measurer?: Measurer,
52
+ ): { row: number; col: number } {
53
+ if (wrapWidth <= 0) return { row: 0, col: 0 }
54
+ return cursorToRowColFromLines(getWrappedLines(text, wrapWidth, measurer), cursor)
55
+ }
56
+
57
+ /** Internal: compute row/col from pre-computed wrapped lines. */
58
+ function cursorToRowColFromLines(lines: WrappedLine[], cursor: number): { row: number; col: number } {
59
+ for (let i = 0; i < lines.length; i++) {
60
+ const line = lines[i]!
61
+ const lineEnd = line.startOffset + line.line.length
62
+ const isLast = i === lines.length - 1
63
+
64
+ if (cursor <= lineEnd || isLast) {
65
+ const col = Math.max(0, Math.min(cursor - line.startOffset, line.line.length))
66
+ return { row: i, col }
67
+ }
68
+ }
69
+
70
+ return { row: Math.max(0, lines.length - 1), col: 0 }
71
+ }
72
+
73
+ /**
74
+ * Get all wrapped display lines with their starting character offsets.
75
+ *
76
+ * Each entry represents one visual line on screen. The startOffset can be
77
+ * used to convert a (row, col) back to a flat cursor position:
78
+ * `flatOffset = lines[row].startOffset + col`
79
+ */
80
+ export function getWrappedLines(text: string, wrapWidth: number, measurer?: Measurer): WrappedLine[] {
81
+ if (wrapWidth <= 0) return [{ line: "", startOffset: 0 }]
82
+
83
+ const logicalLines = text.split("\n")
84
+ const result: WrappedLine[] = []
85
+ let offset = 0
86
+ // Use explicit measurer when available, fall back to module-level convenience function
87
+ const wt = measurer ? measurer.wrapText.bind(measurer) : wrapText
88
+
89
+ for (let li = 0; li < logicalLines.length; li++) {
90
+ const line = logicalLines[li]!
91
+ // Use trim=true to match the renderer's wrapping behavior.
92
+ // The renderer uses wrapText(text, width, true, true), so cursor math
93
+ // must produce the same visual lines to keep positions synchronized.
94
+ const wrapped = wt(line, wrapWidth, false, true)
95
+ const lines = wrapped.length === 0 ? [""] : wrapped
96
+
97
+ for (const wLine of lines) {
98
+ // Skip whitespace in the original text that was trimmed:
99
+ // - Leading spaces on continuation lines (trimmed by renderer)
100
+ // - Trailing space at break point (consumed as separator by renderer)
101
+ while (offset < text.length && text[offset] === " " && wLine.length > 0 && text[offset] !== wLine[0]) {
102
+ offset++
103
+ }
104
+ result.push({ line: wLine, startOffset: offset })
105
+ offset += wLine.length
106
+ }
107
+ // Skip any remaining trailing spaces before the newline
108
+ while (offset < text.length && text[offset] === " ") {
109
+ offset++
110
+ }
111
+ offset++ // for \n
112
+ }
113
+
114
+ return result
115
+ }
116
+
117
+ /**
118
+ * Convert visual (row, col) to a flat cursor offset.
119
+ *
120
+ * Clamps col to the line length if the target column exceeds it
121
+ * (important for stickyX behavior on short lines).
122
+ */
123
+ export function rowColToCursor(text: string, row: number, col: number, wrapWidth: number, measurer?: Measurer): number {
124
+ const lines = getWrappedLines(text, wrapWidth, measurer)
125
+ if (row < 0) return 0
126
+ if (row >= lines.length) return text.length
127
+ const line = lines[row]!
128
+ return line.startOffset + Math.min(col, line.line.length)
129
+ }
130
+
131
+ /**
132
+ * Move cursor up one visual line.
133
+ *
134
+ * Returns the new cursor offset, or null if already on the first visual line
135
+ * (indicating a boundary — the caller should handle cross-block navigation).
136
+ *
137
+ * @param stickyX - Preferred column position for vertical movement.
138
+ * When moving through lines of different lengths, the cursor tries to
139
+ * stay at this column. Pass the col from the original position before
140
+ * the first vertical move in a sequence.
141
+ */
142
+ export function cursorMoveUp(
143
+ text: string,
144
+ cursor: number,
145
+ wrapWidth: number,
146
+ stickyX?: number,
147
+ measurer?: Measurer,
148
+ ): number | null {
149
+ if (wrapWidth <= 0) return cursor > 0 ? 0 : null
150
+
151
+ const lines = getWrappedLines(text, wrapWidth, measurer)
152
+ const { row, col } = cursorToRowColFromLines(lines, cursor)
153
+
154
+ if (row === 0) return null // at first visual line — boundary
155
+
156
+ const targetX = stickyX ?? col
157
+ // Try successive lines upward: if the target position equals the current cursor
158
+ // (happens at wrap boundaries), keep going up to make real progress.
159
+ for (let prevRow = row - 1; prevRow >= 0; prevRow--) {
160
+ const targetLine = lines[prevRow]!
161
+ const next = targetLine.startOffset + Math.min(targetX, targetLine.line.length)
162
+ if (next !== cursor) return next
163
+ }
164
+ return null // all preceding lines map to same position — boundary
165
+ }
166
+
167
+ /**
168
+ * Move cursor down one visual line.
169
+ *
170
+ * Returns the new cursor offset, or null if already on the last visual line
171
+ * (indicating a boundary — the caller should handle cross-block navigation).
172
+ *
173
+ * @param stickyX - Preferred column position for vertical movement.
174
+ */
175
+ export function cursorMoveDown(
176
+ text: string,
177
+ cursor: number,
178
+ wrapWidth: number,
179
+ stickyX?: number,
180
+ measurer?: Measurer,
181
+ ): number | null {
182
+ if (wrapWidth <= 0) return cursor < text.length ? text.length : null
183
+
184
+ const lines = getWrappedLines(text, wrapWidth, measurer)
185
+ const { row, col } = cursorToRowColFromLines(lines, cursor)
186
+
187
+ if (row >= lines.length - 1) return null // at last visual line — boundary
188
+
189
+ const targetX = stickyX ?? col
190
+ // Try successive lines: if the target position equals the current cursor
191
+ // (happens at wrap boundaries where end-of-line-N == start-of-line-N+1),
192
+ // advance to the next line to make real progress.
193
+ for (let nextRow = row + 1; nextRow < lines.length; nextRow++) {
194
+ const targetLine = lines[nextRow]!
195
+ const next = targetLine.startOffset + Math.min(targetX, targetLine.line.length)
196
+ if (next !== cursor) return next
197
+ }
198
+ return null // all remaining lines map to same position — boundary
199
+ }
200
+
201
+ /**
202
+ * Count total visual lines after word wrapping.
203
+ */
204
+ export function countVisualLines(text: string, wrapWidth: number, measurer?: Measurer): number {
205
+ return getWrappedLines(text, wrapWidth, measurer).length
206
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Text Decorations — SlateJS-style overlay ranges for styled text.
3
+ *
4
+ * Decorations overlay visual styles on text without modifying the underlying
5
+ * content. Use cases: search highlighting, syntax coloring, spell-check
6
+ * underlines, diff markers, collaborative cursors.
7
+ *
8
+ * Architecture layer 0 — no state, no hooks, no components. Pure functions.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { splitIntoSegments, type Decoration } from '@silvery/tea/text-decorations'
13
+ *
14
+ * const decorations: Decoration[] = [
15
+ * { from: 0, to: 5, style: { backgroundColor: "yellow" } },
16
+ * { from: 10, to: 15, style: { bold: true } },
17
+ * ]
18
+ *
19
+ * // Split a line range into styled segments
20
+ * const segments = splitIntoSegments(0, 20, decorations, null)
21
+ * // => [
22
+ * // { from: 0, to: 5, style: { backgroundColor: "yellow" } },
23
+ * // { from: 5, to: 10, style: {} },
24
+ * // { from: 10, to: 15, style: { bold: true } },
25
+ * // { from: 15, to: 20, style: {} },
26
+ * // ]
27
+ * ```
28
+ */
29
+
30
+ // =============================================================================
31
+ // Types
32
+ // =============================================================================
33
+
34
+ /**
35
+ * Style properties for a decoration. Matches silvery Text component props.
36
+ * All properties are optional — only specified properties are applied.
37
+ */
38
+ export interface DecorationStyle {
39
+ /** Foreground color (hex, named, or $token) */
40
+ color?: string
41
+ /** Background color (hex, named, or $token) */
42
+ backgroundColor?: string
43
+ /** Bold text */
44
+ bold?: boolean
45
+ /** Italic text */
46
+ italic?: boolean
47
+ /** Underline text */
48
+ underline?: boolean
49
+ /** Strikethrough text */
50
+ strikethrough?: boolean
51
+ /** Dim (reduced intensity) */
52
+ dimColor?: boolean
53
+ /** Inverse (swap fg/bg) */
54
+ inverse?: boolean
55
+ }
56
+
57
+ /**
58
+ * A decoration range that overlays styles on text.
59
+ *
60
+ * Ranges are half-open: [from, to) — `from` is inclusive, `to` is exclusive.
61
+ * Ranges refer to character offsets in the full text value.
62
+ */
63
+ export interface Decoration {
64
+ /** Start offset in the text (inclusive) */
65
+ from: number
66
+ /** End offset in the text (exclusive) */
67
+ to: number
68
+ /** Style properties to apply to this range */
69
+ style: DecorationStyle
70
+ }
71
+
72
+ /**
73
+ * A resolved segment with merged styles. Produced by splitIntoSegments().
74
+ * Segments are non-overlapping and sorted by position.
75
+ */
76
+ export interface StyledSegment {
77
+ /** Start offset (inclusive) */
78
+ from: number
79
+ /** End offset (exclusive) */
80
+ to: number
81
+ /** Merged style from all overlapping decorations */
82
+ style: DecorationStyle
83
+ /** Whether this segment is within the selection */
84
+ selected?: boolean
85
+ }
86
+
87
+ /** Selection range as [start, end) character offsets */
88
+ export interface SelectionRange {
89
+ start: number
90
+ end: number
91
+ }
92
+
93
+ // =============================================================================
94
+ // Segment Splitting
95
+ // =============================================================================
96
+
97
+ /**
98
+ * Split a line range into non-overlapping styled segments.
99
+ *
100
+ * Takes a range [lineStart, lineEnd), an array of decorations, and an optional
101
+ * selection range. Returns sorted, non-overlapping segments that cover the
102
+ * entire line range.
103
+ *
104
+ * Rules:
105
+ * - Selection takes precedence over decorations (selected text ignores decoration styles)
106
+ * - Later decorations override earlier ones for overlapping style properties
107
+ * - Empty segments (from === to) are omitted
108
+ * - Decorations outside [lineStart, lineEnd) are clipped
109
+ *
110
+ * @param lineStart - Start of the line range (inclusive, character offset in full text)
111
+ * @param lineEnd - End of the line range (exclusive)
112
+ * @param decorations - Array of decoration ranges (may be empty)
113
+ * @param selection - Optional selection range, or null
114
+ * @returns Array of non-overlapping StyledSegment objects covering [lineStart, lineEnd)
115
+ */
116
+ export function splitIntoSegments(
117
+ lineStart: number,
118
+ lineEnd: number,
119
+ decorations: readonly Decoration[],
120
+ selection: SelectionRange | null,
121
+ ): StyledSegment[] {
122
+ if (lineStart >= lineEnd) return []
123
+
124
+ // Collect all boundary points within the line range
125
+ const boundaries = new Set<number>()
126
+ boundaries.add(lineStart)
127
+ boundaries.add(lineEnd)
128
+
129
+ // Add decoration boundaries (clipped to line range)
130
+ for (const dec of decorations) {
131
+ if (dec.to <= lineStart || dec.from >= lineEnd) continue
132
+ boundaries.add(Math.max(dec.from, lineStart))
133
+ boundaries.add(Math.min(dec.to, lineEnd))
134
+ }
135
+
136
+ // Add selection boundaries (clipped to line range)
137
+ if (selection && selection.start < lineEnd && selection.end > lineStart) {
138
+ boundaries.add(Math.max(selection.start, lineStart))
139
+ boundaries.add(Math.min(selection.end, lineEnd))
140
+ }
141
+
142
+ // Sort boundaries
143
+ const sorted = Array.from(boundaries).sort((a, b) => a - b)
144
+
145
+ // Build segments
146
+ const segments: StyledSegment[] = []
147
+ for (let i = 0; i < sorted.length - 1; i++) {
148
+ const from = sorted[i]!
149
+ const to = sorted[i + 1]!
150
+ if (from >= to) continue
151
+
152
+ // Check if this segment is within the selection
153
+ const isSelected = selection !== null && from >= selection.start && to <= selection.end
154
+
155
+ // Merge styles from all overlapping decorations (later wins for conflicts)
156
+ const mergedStyle: DecorationStyle = {}
157
+ for (const dec of decorations) {
158
+ if (dec.from >= to || dec.to <= from) continue
159
+ // Merge: later decoration properties override earlier ones
160
+ Object.assign(mergedStyle, dec.style)
161
+ }
162
+
163
+ segments.push({
164
+ from,
165
+ to,
166
+ style: mergedStyle,
167
+ ...(isSelected ? { selected: true } : {}),
168
+ })
169
+ }
170
+
171
+ return segments
172
+ }
173
+
174
+ // =============================================================================
175
+ // Decoration Utilities
176
+ // =============================================================================
177
+
178
+ /**
179
+ * Create decorations for all occurrences of a search string in text.
180
+ *
181
+ * @param text - The full text to search in
182
+ * @param query - The search string (case-insensitive)
183
+ * @param style - Style to apply to matches
184
+ * @returns Array of Decoration objects for all matches
185
+ */
186
+ export function createSearchDecorations(
187
+ text: string,
188
+ query: string,
189
+ style: DecorationStyle = { backgroundColor: "yellow", color: "black" },
190
+ ): Decoration[] {
191
+ if (!query || !text) return []
192
+
193
+ const decorations: Decoration[] = []
194
+ const lowerText = text.toLowerCase()
195
+ const lowerQuery = query.toLowerCase()
196
+ let pos = 0
197
+
198
+ while (pos < lowerText.length) {
199
+ const idx = lowerText.indexOf(lowerQuery, pos)
200
+ if (idx === -1) break
201
+ decorations.push({
202
+ from: idx,
203
+ to: idx + query.length,
204
+ style,
205
+ })
206
+ pos = idx + 1 // Allow overlapping matches
207
+ }
208
+
209
+ return decorations
210
+ }
211
+
212
+ /**
213
+ * Adjust decoration positions after a text edit operation.
214
+ *
215
+ * When text is inserted or deleted, decoration ranges need to shift
216
+ * to maintain their association with the correct text content.
217
+ *
218
+ * @param decorations - Existing decorations
219
+ * @param editStart - Position where the edit occurred
220
+ * @param deletedLength - Number of characters deleted (0 for pure insert)
221
+ * @param insertedLength - Number of characters inserted (0 for pure delete)
222
+ * @returns New array of adjusted decorations (removed decorations are filtered out)
223
+ */
224
+ export function adjustDecorations(
225
+ decorations: readonly Decoration[],
226
+ editStart: number,
227
+ deletedLength: number,
228
+ insertedLength: number,
229
+ ): Decoration[] {
230
+ const delta = insertedLength - deletedLength
231
+ const editEnd = editStart + deletedLength
232
+
233
+ return decorations
234
+ .map((dec) => {
235
+ // Decoration is entirely before the edit — no change
236
+ if (dec.to <= editStart) return dec
237
+
238
+ // Decoration is entirely after the edit — shift by delta
239
+ if (dec.from >= editEnd) {
240
+ return { ...dec, from: dec.from + delta, to: dec.to + delta }
241
+ }
242
+
243
+ // Decoration overlaps the edit region — adjust boundaries
244
+ const newFrom = dec.from < editStart ? dec.from : editStart + insertedLength
245
+ const newTo = dec.to <= editEnd ? editStart + insertedLength : dec.to + delta
246
+
247
+ // If the decoration collapses to zero width, remove it
248
+ if (newFrom >= newTo) return null
249
+
250
+ return { ...dec, from: newFrom, to: newTo }
251
+ })
252
+ .filter((dec): dec is Decoration => dec !== null)
253
+ }