@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,150 @@
1
+ /**
2
+ * Text Operations -- invertible, composable text mutations.
3
+ *
4
+ * Every text change produces a TextOp that can be inverted (for undo)
5
+ * and composed (for merging consecutive typing). Foundation for
6
+ * operations-based undo/redo without document snapshots.
7
+ *
8
+ * Architecture layer 0 -- no state, no hooks, no components.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { applyTextOp, invertTextOp, mergeTextOps } from '@silvery/react'
13
+ *
14
+ * const op: TextOp = { type: "insert", offset: 5, text: "hello" }
15
+ * const result = applyTextOp("world", op) // "worlhellod"
16
+ * const inv = invertTextOp(op) // { type: "delete", offset: 5, text: "hello" }
17
+ * ```
18
+ */
19
+
20
+ // =============================================================================
21
+ // Types
22
+ // =============================================================================
23
+
24
+ /**
25
+ * An invertible text operation.
26
+ *
27
+ * - `insert`: text was inserted at `offset`
28
+ * - `delete`: text was deleted starting at `offset` (the deleted content is
29
+ * stored in `text` so the operation can be inverted)
30
+ * - `replace`: text at `offset` was replaced — `deleted` holds the old text,
31
+ * `text` holds the new text. The inverse swaps them.
32
+ */
33
+ export type TextOp =
34
+ | { type: "insert"; offset: number; text: string }
35
+ | { type: "delete"; offset: number; text: string }
36
+ | { type: "replace"; offset: number; text: string; deleted: string }
37
+
38
+ // =============================================================================
39
+ // Core Functions
40
+ // =============================================================================
41
+
42
+ /**
43
+ * Apply a text operation to a string, returning the modified string.
44
+ *
45
+ * Throws if the offset is out of bounds or if a delete operation's stored
46
+ * text doesn't match what's actually in the string at that position.
47
+ */
48
+ export function applyTextOp(text: string, op: TextOp): string {
49
+ if (op.offset < 0 || op.offset > text.length) {
50
+ throw new RangeError(`TextOp offset ${op.offset} out of bounds for text of length ${text.length}`)
51
+ }
52
+
53
+ if (op.type === "insert") {
54
+ return text.slice(0, op.offset) + op.text + text.slice(op.offset)
55
+ }
56
+
57
+ if (op.type === "replace") {
58
+ const end = op.offset + op.deleted.length
59
+ if (end > text.length) {
60
+ throw new RangeError(
61
+ `TextOp replace extends past end: offset=${op.offset}, deleteLen=${op.deleted.length}, textLen=${text.length}`,
62
+ )
63
+ }
64
+ const actual = text.slice(op.offset, end)
65
+ if (actual !== op.deleted) {
66
+ throw new Error(
67
+ `TextOp replace mismatch at offset ${op.offset}: expected ${JSON.stringify(op.deleted)}, got ${JSON.stringify(actual)}`,
68
+ )
69
+ }
70
+ return text.slice(0, op.offset) + op.text + text.slice(end)
71
+ }
72
+
73
+ // delete
74
+ const end = op.offset + op.text.length
75
+ if (end > text.length) {
76
+ throw new RangeError(
77
+ `TextOp delete extends past end: offset=${op.offset}, deleteLen=${op.text.length}, textLen=${text.length}`,
78
+ )
79
+ }
80
+ const actual = text.slice(op.offset, end)
81
+ if (actual !== op.text) {
82
+ throw new Error(
83
+ `TextOp delete mismatch at offset ${op.offset}: expected ${JSON.stringify(op.text)}, got ${JSON.stringify(actual)}`,
84
+ )
85
+ }
86
+ return text.slice(0, op.offset) + text.slice(end)
87
+ }
88
+
89
+ /**
90
+ * Invert a text operation (insert becomes delete and vice versa).
91
+ *
92
+ * The inverse of an insert at offset N is a delete of the same text at
93
+ * offset N; the inverse of a delete is an insert.
94
+ */
95
+ export function invertTextOp(op: TextOp): TextOp {
96
+ if (op.type === "insert") {
97
+ return { type: "delete", offset: op.offset, text: op.text }
98
+ }
99
+ if (op.type === "replace") {
100
+ return { type: "replace", offset: op.offset, text: op.deleted, deleted: op.text }
101
+ }
102
+ return { type: "insert", offset: op.offset, text: op.text }
103
+ }
104
+
105
+ /**
106
+ * Attempt to merge two consecutive text operations into one.
107
+ *
108
+ * Returns the merged operation, or `null` if the operations can't be merged.
109
+ *
110
+ * Merge rules:
111
+ * - Two inserts where `b` starts exactly where `a` ends -> single insert
112
+ * - Two deletes where `b` ends exactly where `a` starts (backspace sequence)
113
+ * -> single delete covering both ranges
114
+ * - Two deletes where `b` starts at `a`'s offset (forward-delete sequence)
115
+ * -> single delete covering both ranges
116
+ * - Otherwise -> null (can't merge)
117
+ */
118
+ export function mergeTextOps(a: TextOp, b: TextOp): TextOp | null {
119
+ // insert + insert: b inserts right after a's inserted text
120
+ if (a.type === "insert" && b.type === "insert") {
121
+ if (b.offset === a.offset + a.text.length) {
122
+ return { type: "insert", offset: a.offset, text: a.text + b.text }
123
+ }
124
+ return null
125
+ }
126
+
127
+ // delete + delete
128
+ if (a.type === "delete" && b.type === "delete") {
129
+ // Backspace sequence: b deletes the character just before a's range.
130
+ // After a deletes at offset X, the next backspace deletes at offset X-1.
131
+ if (b.offset + b.text.length === a.offset) {
132
+ return { type: "delete", offset: b.offset, text: b.text + a.text }
133
+ }
134
+ // Forward-delete sequence: b deletes at the same position as a (because
135
+ // after a removed its text, the next character slid into the same offset).
136
+ if (b.offset === a.offset) {
137
+ return { type: "delete", offset: a.offset, text: a.text + b.text }
138
+ }
139
+ return null
140
+ }
141
+
142
+ // insert + delete that exactly cancels the insert
143
+ if (a.type === "insert" && b.type === "delete") {
144
+ if (b.offset === a.offset && b.text === a.text) {
145
+ return null // operations cancel out
146
+ }
147
+ }
148
+
149
+ return null
150
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared tree utilities for silvery event systems.
3
+ *
4
+ * Functions used by both focus-events.ts and mouse-events.ts.
5
+ */
6
+
7
+ import type { TeaNode, Rect } from "./types.js"
8
+
9
+ /**
10
+ * Collect the ancestor path from target to root (inclusive).
11
+ */
12
+ export function getAncestorPath(node: TeaNode): TeaNode[] {
13
+ const path: TeaNode[] = []
14
+ let current: TeaNode | null = node
15
+ while (current) {
16
+ path.push(current)
17
+ current = current.parent
18
+ }
19
+ return path
20
+ }
21
+
22
+ /**
23
+ * Check if a point is inside a rect.
24
+ */
25
+ export function pointInRect(x: number, y: number, rect: Rect): boolean {
26
+ return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
27
+ }