@galfus/engine 0.0.0-stage → 0.31.0-alpha
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/README.md +190 -2
- package/package.json +59 -4
- package/src/anchors/cache.ts +101 -0
- package/src/anchors/component.ts +123 -0
- package/src/anchors/hierarchy.ts +192 -0
- package/src/anchors/index.ts +8 -0
- package/src/anchors/solver.ts +247 -0
- package/src/anchors/strategy.ts +142 -0
- package/src/animation/blending.ts +163 -0
- package/src/animation/clip.ts +132 -0
- package/src/animation/coordinator.ts +568 -0
- package/src/animation/index.ts +7 -0
- package/src/animation/native-targets.ts +37 -0
- package/src/animation/player.ts +287 -0
- package/src/audio/index.ts +4 -0
- package/src/audio/manager.ts +371 -0
- package/src/collision/components.ts +211 -0
- package/src/collision/index.ts +16 -0
- package/src/collision/query2d.ts +167 -0
- package/src/collision/query3d.ts +170 -0
- package/src/ecs/component.ts +130 -0
- package/src/ecs/entity.ts +75 -0
- package/src/ecs/index.ts +9 -0
- package/src/ecs/intent.ts +162 -0
- package/src/ecs/metadata.ts +44 -0
- package/src/ecs/system.ts +224 -0
- package/src/ecs/world.ts +291 -0
- package/src/index.ts +19 -0
- package/src/interaction/focus.ts +327 -0
- package/src/interaction/index.ts +9 -0
- package/src/interaction/listener.ts +58 -0
- package/src/interaction/snapshot2d.ts +210 -0
- package/src/interaction/snapshot3d.ts +214 -0
- package/src/interaction/system.ts +544 -0
- package/src/interaction/types.ts +27 -0
- package/src/math/common.ts +67 -0
- package/src/math/index.ts +17 -0
- package/src/math/mat2d.ts +320 -0
- package/src/math/mat4.ts +576 -0
- package/src/math/quat.ts +438 -0
- package/src/math/vec2.ts +161 -0
- package/src/math/vec3.ts +242 -0
- package/src/math/vec4.ts +171 -0
- package/src/multiverse/coordinator.ts +540 -0
- package/src/multiverse/index.ts +13 -0
- package/src/multiverse/presentation-graph.ts +306 -0
- package/src/multiverse/presentation-links.ts +134 -0
- package/src/multiverse/realm.ts +49 -0
- package/src/platform/index.ts +2 -0
- package/src/platform/platform.ts +149 -0
- package/src/platform/window.ts +181 -0
- package/src/realm2d/components.ts +254 -0
- package/src/realm2d/index.ts +12 -0
- package/src/realm2d/presets/index.ts +36 -0
- package/src/realm2d/realm.ts +522 -0
- package/src/realm2d/sync.ts +284 -0
- package/src/realm2d/utils/index.ts +32 -0
- package/src/realm3d/components.ts +341 -0
- package/src/realm3d/geometry.ts +224 -0
- package/src/realm3d/index.ts +12 -0
- package/src/realm3d/presets/index.ts +67 -0
- package/src/realm3d/realm.ts +588 -0
- package/src/realm3d/resource-ids.ts +62 -0
- package/src/realm3d/skinning.ts +128 -0
- package/src/realm3d/sync.ts +442 -0
- package/src/realm3d/utils/index.ts +26 -0
- package/src/resources/index.ts +4 -0
- package/src/resources/manager.ts +92 -0
- package/src/runtime.ts +588 -0
- package/src/spatial/dirty_registry.ts +217 -0
- package/src/spatial/flags.ts +14 -0
- package/src/spatial/index.ts +6 -0
- package/src/spatial/metrics.ts +124 -0
- package/src/text/editor.ts +224 -0
- package/src/text/encoding.ts +38 -0
- package/src/text/index.ts +13 -0
- package/src/text/input.ts +160 -0
- package/src/text/keyboard.ts +50 -0
- package/src/text/svg-decorations.ts +147 -0
- package/src/types.ts +85 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DirtyRegistry manages mutations, dirty revision tracking, and transaction coalescence.
|
|
3
|
+
* Prevents redundant ABI uploads and cancels create+destroy cycles within uncommitted frames.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type DirtyOp = 'create' | 'update' | 'destroy';
|
|
7
|
+
|
|
8
|
+
export interface DirtyRecord<T = unknown> {
|
|
9
|
+
readonly scopeId: number;
|
|
10
|
+
readonly objectKey: number | string;
|
|
11
|
+
op: DirtyOp;
|
|
12
|
+
currentRevision: number;
|
|
13
|
+
uploadedRevision: number;
|
|
14
|
+
payload?: T;
|
|
15
|
+
/** Indicates that the object has never been successfully confirmed to core yet. */
|
|
16
|
+
isNew: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class DirtyRegistry<T = unknown> {
|
|
20
|
+
private readonly _records = new Map<string, DirtyRecord<T>>();
|
|
21
|
+
|
|
22
|
+
private _makeKey(scopeId: number, objectKey: number | string): string {
|
|
23
|
+
return `${scopeId}:${objectKey}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Total number of tracked records in the registry. */
|
|
27
|
+
get size(): number {
|
|
28
|
+
return this._records.size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Number of records currently needing upload to core. */
|
|
32
|
+
get pendingCount(): number {
|
|
33
|
+
let count = 0;
|
|
34
|
+
for (const record of this._records.values()) {
|
|
35
|
+
if (record.currentRevision > record.uploadedRevision) {
|
|
36
|
+
count++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return count;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Registers a new object creation in this scope.
|
|
44
|
+
*/
|
|
45
|
+
recordCreate(scopeId: number, objectKey: number | string, payload?: T): DirtyRecord<T> {
|
|
46
|
+
const key = this._makeKey(scopeId, objectKey);
|
|
47
|
+
const existing = this._records.get(key);
|
|
48
|
+
|
|
49
|
+
if (existing) {
|
|
50
|
+
if (existing.op === 'destroy') {
|
|
51
|
+
existing.op = existing.uploadedRevision > 0 ? 'update' : 'create';
|
|
52
|
+
}
|
|
53
|
+
existing.currentRevision++;
|
|
54
|
+
if (payload !== undefined) {
|
|
55
|
+
existing.payload = payload;
|
|
56
|
+
}
|
|
57
|
+
return existing;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const record: DirtyRecord<T> = {
|
|
61
|
+
scopeId,
|
|
62
|
+
objectKey,
|
|
63
|
+
op: 'create',
|
|
64
|
+
currentRevision: 1,
|
|
65
|
+
uploadedRevision: 0,
|
|
66
|
+
payload,
|
|
67
|
+
isNew: true
|
|
68
|
+
};
|
|
69
|
+
this._records.set(key, record);
|
|
70
|
+
return record;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Records a mutation on an object, incrementing its revision.
|
|
75
|
+
*/
|
|
76
|
+
recordMutation(scopeId: number, objectKey: number | string, payload?: T): DirtyRecord<T> {
|
|
77
|
+
const key = this._makeKey(scopeId, objectKey);
|
|
78
|
+
const existing = this._records.get(key);
|
|
79
|
+
|
|
80
|
+
if (existing) {
|
|
81
|
+
existing.currentRevision++;
|
|
82
|
+
if (payload !== undefined) {
|
|
83
|
+
existing.payload = payload;
|
|
84
|
+
}
|
|
85
|
+
return existing;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Object was already known to core prior to tracking
|
|
89
|
+
const record: DirtyRecord<T> = {
|
|
90
|
+
scopeId,
|
|
91
|
+
objectKey,
|
|
92
|
+
op: 'update',
|
|
93
|
+
currentRevision: 1,
|
|
94
|
+
uploadedRevision: 0,
|
|
95
|
+
payload,
|
|
96
|
+
isNew: false
|
|
97
|
+
};
|
|
98
|
+
this._records.set(key, record);
|
|
99
|
+
return record;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Records an object destruction.
|
|
104
|
+
* If the object was created and destroyed prior to any core upload,
|
|
105
|
+
* it is completely coalesced and removed from the registry (0 ABI calls).
|
|
106
|
+
*
|
|
107
|
+
* @returns `true` if a core destroy call is pending; `false` if coalesced to zero.
|
|
108
|
+
*/
|
|
109
|
+
recordDestroy(scopeId: number, objectKey: number | string): boolean {
|
|
110
|
+
const key = this._makeKey(scopeId, objectKey);
|
|
111
|
+
const existing = this._records.get(key);
|
|
112
|
+
|
|
113
|
+
if (existing) {
|
|
114
|
+
// Coalescence rule: created and destroyed before first upload
|
|
115
|
+
if (existing.isNew && existing.uploadedRevision === 0) {
|
|
116
|
+
this._records.delete(key);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
existing.op = 'destroy';
|
|
121
|
+
existing.currentRevision++;
|
|
122
|
+
existing.payload = undefined;
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Object was previously uploaded and tracked by core
|
|
127
|
+
const record: DirtyRecord<T> = {
|
|
128
|
+
scopeId,
|
|
129
|
+
objectKey,
|
|
130
|
+
op: 'destroy',
|
|
131
|
+
currentRevision: 1,
|
|
132
|
+
uploadedRevision: 0,
|
|
133
|
+
isNew: false
|
|
134
|
+
};
|
|
135
|
+
this._records.set(key, record);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Returns all records whose currentRevision > uploadedRevision.
|
|
141
|
+
*/
|
|
142
|
+
getPendingDirty(): DirtyRecord<T>[] {
|
|
143
|
+
const pending: DirtyRecord<T>[] = [];
|
|
144
|
+
for (const record of this._records.values()) {
|
|
145
|
+
if (record.currentRevision > record.uploadedRevision) {
|
|
146
|
+
pending.push(record);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return pending;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Iterates through pending dirty records without allocating an intermediate array.
|
|
154
|
+
*/
|
|
155
|
+
forEachPending(callback: (record: DirtyRecord<T>) => void): void {
|
|
156
|
+
for (const record of this._records.values()) {
|
|
157
|
+
if (record.currentRevision > record.uploadedRevision) {
|
|
158
|
+
callback(record);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Confirms successful synchronization of an object to the core.
|
|
165
|
+
*/
|
|
166
|
+
confirmSync(scopeId: number, objectKey: number | string, revision?: number): void {
|
|
167
|
+
const key = this._makeKey(scopeId, objectKey);
|
|
168
|
+
const existing = this._records.get(key);
|
|
169
|
+
if (!existing) return;
|
|
170
|
+
|
|
171
|
+
if (existing.op === 'destroy') {
|
|
172
|
+
this._records.delete(key);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
existing.uploadedRevision = revision ?? existing.currentRevision;
|
|
177
|
+
existing.isNew = false;
|
|
178
|
+
if (existing.op === 'create') {
|
|
179
|
+
existing.op = 'update';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Indicates sync failed for an object, retaining its dirty status for retry on the next frame.
|
|
185
|
+
*/
|
|
186
|
+
failSync(_scopeId: number, _objectKey: number | string): void {
|
|
187
|
+
// uploadedRevision is left unchanged so currentRevision > uploadedRevision persists
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Retrieves a record by scope and object key.
|
|
192
|
+
*/
|
|
193
|
+
get(scopeId: number, objectKey: number | string): DirtyRecord<T> | undefined {
|
|
194
|
+
return this._records.get(this._makeKey(scopeId, objectKey));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Checks whether a record is tracked.
|
|
199
|
+
*/
|
|
200
|
+
has(scopeId: number, objectKey: number | string): boolean {
|
|
201
|
+
return this._records.has(this._makeKey(scopeId, objectKey));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Deletes a record from the registry.
|
|
206
|
+
*/
|
|
207
|
+
delete(scopeId: number, objectKey: number | string): boolean {
|
|
208
|
+
return this._records.delete(this._makeKey(scopeId, objectKey));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Clears all records.
|
|
213
|
+
*/
|
|
214
|
+
clear(): void {
|
|
215
|
+
this._records.clear();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spatial dirty flags bitmask for 2D and 3D spatial models.
|
|
3
|
+
* Controls the two-stage lifecycle: local change -> anchor solve -> world change -> core sync.
|
|
4
|
+
*/
|
|
5
|
+
export const enum SpatialDirtyFlags {
|
|
6
|
+
/** Clean state: no updates needed in solver or core */
|
|
7
|
+
Clean = 0,
|
|
8
|
+
/** Local transform changed: Anchor solver needs to update worldMatrix */
|
|
9
|
+
LocalDirty = 1 << 0,
|
|
10
|
+
/** World matrix changed: Realm sync needs to upload worldMatrix to core */
|
|
11
|
+
WorldDirty = 1 << 1,
|
|
12
|
+
/** Descriptor changed: Realm sync needs to upload descriptor/material/flags to core */
|
|
13
|
+
DescDirty = 1 << 2
|
|
14
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ABI Call Metrics Tracker for monitoring, diagnostics, and zero-ABI regression testing.
|
|
3
|
+
* Intercepts GalfusEngine calls and categorizes them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { GalfusEngine } from '@galfus/transport-types';
|
|
7
|
+
|
|
8
|
+
export type AbiCallCategory =
|
|
9
|
+
'create' | 'updateTransform' | 'updateDescriptor' | 'destroy' | 'query' | 'dispatch' | 'other';
|
|
10
|
+
|
|
11
|
+
export interface AbiMetricsSnapshot {
|
|
12
|
+
readonly total: number;
|
|
13
|
+
readonly byCategory: Record<AbiCallCategory, number>;
|
|
14
|
+
readonly byMethod: Record<string, number>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function categorizeMethod(name: string): AbiCallCategory {
|
|
18
|
+
if (name.includes('Dispatch') || name.includes('PumpEvents')) {
|
|
19
|
+
return 'dispatch';
|
|
20
|
+
}
|
|
21
|
+
if (
|
|
22
|
+
name.startsWith('realm3dUpdateModelTransform') ||
|
|
23
|
+
name.startsWith('realm2dUpdate') ||
|
|
24
|
+
name.includes('Transform') ||
|
|
25
|
+
name.includes('SetPosition') ||
|
|
26
|
+
name.includes('SetRotation')
|
|
27
|
+
) {
|
|
28
|
+
return 'updateTransform';
|
|
29
|
+
}
|
|
30
|
+
if (
|
|
31
|
+
name.includes('Update') ||
|
|
32
|
+
name.includes('Set') ||
|
|
33
|
+
name.includes('Attribute') ||
|
|
34
|
+
name.includes('Morph') ||
|
|
35
|
+
name.includes('Bones')
|
|
36
|
+
) {
|
|
37
|
+
return 'updateDescriptor';
|
|
38
|
+
}
|
|
39
|
+
if (
|
|
40
|
+
name.includes('Create') ||
|
|
41
|
+
name.includes('Load') ||
|
|
42
|
+
name.includes('Register') ||
|
|
43
|
+
name.includes('Attach')
|
|
44
|
+
) {
|
|
45
|
+
return 'create';
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
name.includes('Destroy') ||
|
|
49
|
+
name.includes('Close') ||
|
|
50
|
+
name.includes('Unregister') ||
|
|
51
|
+
name.includes('Unload') ||
|
|
52
|
+
name.includes('Dispose') ||
|
|
53
|
+
name.includes('Clear')
|
|
54
|
+
) {
|
|
55
|
+
return 'destroy';
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
name.includes('Get') ||
|
|
59
|
+
name.includes('Has') ||
|
|
60
|
+
name.includes('Find') ||
|
|
61
|
+
name.includes('Query')
|
|
62
|
+
) {
|
|
63
|
+
return 'query';
|
|
64
|
+
}
|
|
65
|
+
return 'other';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class AbiMetricsTracker {
|
|
69
|
+
private _total = 0;
|
|
70
|
+
private readonly _byCategory: Record<AbiCallCategory, number> = {
|
|
71
|
+
create: 0,
|
|
72
|
+
updateTransform: 0,
|
|
73
|
+
updateDescriptor: 0,
|
|
74
|
+
destroy: 0,
|
|
75
|
+
query: 0,
|
|
76
|
+
dispatch: 0,
|
|
77
|
+
other: 0
|
|
78
|
+
};
|
|
79
|
+
private readonly _byMethod: Record<string, number> = {};
|
|
80
|
+
|
|
81
|
+
recordCall(methodName: string): void {
|
|
82
|
+
this._total++;
|
|
83
|
+
const cat = categorizeMethod(methodName);
|
|
84
|
+
this._byCategory[cat]++;
|
|
85
|
+
this._byMethod[methodName] = (this._byMethod[methodName] ?? 0) + 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
reset(): void {
|
|
89
|
+
this._total = 0;
|
|
90
|
+
for (const key of Object.keys(this._byCategory) as AbiCallCategory[]) {
|
|
91
|
+
this._byCategory[key] = 0;
|
|
92
|
+
}
|
|
93
|
+
for (const key of Object.keys(this._byMethod)) {
|
|
94
|
+
delete this._byMethod[key];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
getSnapshot(): AbiMetricsSnapshot {
|
|
99
|
+
return {
|
|
100
|
+
total: this._total,
|
|
101
|
+
byCategory: { ...this._byCategory },
|
|
102
|
+
byMethod: { ...this._byMethod }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Wraps a GalfusEngine instance with a proxy that tracks every method invocation.
|
|
108
|
+
*/
|
|
109
|
+
wrapEngine(engine: GalfusEngine): GalfusEngine {
|
|
110
|
+
const tracker = this;
|
|
111
|
+
return new Proxy(engine, {
|
|
112
|
+
get(target, prop, receiver) {
|
|
113
|
+
const orig = Reflect.get(target, prop, receiver);
|
|
114
|
+
if (typeof orig === 'function') {
|
|
115
|
+
return function (...args: unknown[]) {
|
|
116
|
+
tracker.recordCall(String(prop));
|
|
117
|
+
return orig.apply(target, args);
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return orig;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { graphemeSegments } from 'unicode-segmenter/grapheme';
|
|
2
|
+
|
|
3
|
+
/** UTF-16 offsets at grapheme boundaries. Anchor and focus preserve selection direction. */
|
|
4
|
+
export interface TextSelection {
|
|
5
|
+
readonly anchor: number;
|
|
6
|
+
readonly focus: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TextComposition {
|
|
10
|
+
readonly text: string;
|
|
11
|
+
/** UTF-16 offsets within the preview, when supplied by the input method. */
|
|
12
|
+
readonly selection?: readonly [number, number];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function charType(grapheme: string): 'space' | 'word' | 'punct' {
|
|
16
|
+
if (/^\s+$/u.test(grapheme)) return 'space';
|
|
17
|
+
if (/^[\p{L}\p{M}\p{N}_]+$/u.test(grapheme)) return 'word';
|
|
18
|
+
return 'punct';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Platform-independent text editing state. Text remains Unicode inside the host. */
|
|
22
|
+
export class TextEditor {
|
|
23
|
+
private value_: string;
|
|
24
|
+
private boundaries: number[] = [];
|
|
25
|
+
private anchor_: number;
|
|
26
|
+
private focus_: number;
|
|
27
|
+
private composition_: TextComposition | undefined;
|
|
28
|
+
|
|
29
|
+
constructor(value = '') {
|
|
30
|
+
this.value_ = value;
|
|
31
|
+
this.rebuildBoundaries();
|
|
32
|
+
this.anchor_ = this.focus_ = value.length;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get value(): string {
|
|
36
|
+
return this.value_;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get selection(): TextSelection {
|
|
40
|
+
return { anchor: this.anchor_, focus: this.focus_ };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get selectedText(): string {
|
|
44
|
+
const [start, end] = this.range();
|
|
45
|
+
return this.value_.slice(start, end);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get composition(): TextComposition | undefined {
|
|
49
|
+
return this.composition_;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Includes the preview in place of the selected text, without committing it. */
|
|
53
|
+
get displayValue(): string {
|
|
54
|
+
if (!this.composition_) return this.value_;
|
|
55
|
+
const [start, end] = this.range();
|
|
56
|
+
return this.value_.slice(0, start) + this.composition_.text + this.value_.slice(end);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setValue(value: string): void {
|
|
60
|
+
this.composition_ = undefined;
|
|
61
|
+
this.value_ = value;
|
|
62
|
+
this.rebuildBoundaries();
|
|
63
|
+
this.anchor_ = this.focus_ = value.length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setSelection(anchor: number, focus = anchor): void {
|
|
67
|
+
const nextAnchor = this.snap(anchor);
|
|
68
|
+
const nextFocus = this.snap(focus);
|
|
69
|
+
this.composition_ = undefined;
|
|
70
|
+
this.anchor_ = nextAnchor;
|
|
71
|
+
this.focus_ = nextFocus;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
selectAll(): void {
|
|
75
|
+
this.setSelection(0, this.value_.length);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
insert(text: string): void {
|
|
79
|
+
this.composition_ = undefined;
|
|
80
|
+
this.replaceSelection(text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
backspace(byWord = false): void {
|
|
84
|
+
this.delete('backward', byWord);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
deleteForward(byWord = false): void {
|
|
88
|
+
this.delete('forward', byWord);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
move(direction: 'left' | 'right', extend = false, byWord = false): void {
|
|
92
|
+
if (this.composition_) return;
|
|
93
|
+
const [start, end] = this.range();
|
|
94
|
+
const position =
|
|
95
|
+
!extend && start !== end
|
|
96
|
+
? direction === 'left'
|
|
97
|
+
? start
|
|
98
|
+
: end
|
|
99
|
+
: direction === 'left'
|
|
100
|
+
? byWord
|
|
101
|
+
? this.previousWord(this.focus_)
|
|
102
|
+
: this.previous(this.focus_)
|
|
103
|
+
: byWord
|
|
104
|
+
? this.nextWord(this.focus_)
|
|
105
|
+
: this.next(this.focus_);
|
|
106
|
+
this.focus_ = position;
|
|
107
|
+
if (!extend) this.anchor_ = position;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
moveToBoundary(boundary: 'start' | 'end', extend = false): void {
|
|
111
|
+
if (this.composition_) return;
|
|
112
|
+
this.focus_ = boundary === 'start' ? 0 : this.value_.length;
|
|
113
|
+
if (!extend) this.anchor_ = this.focus_;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
updateComposition(text: string, selection?: readonly [number, number]): void {
|
|
117
|
+
this.composition_ = selection ? { text, selection: [selection[0], selection[1]] } : { text };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
commitComposition(text: string): void {
|
|
121
|
+
this.composition_ = undefined;
|
|
122
|
+
this.replaceSelection(text);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
cancelComposition(): void {
|
|
126
|
+
this.composition_ = undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private delete(direction: 'backward' | 'forward', byWord: boolean): void {
|
|
130
|
+
if (this.composition_) return;
|
|
131
|
+
if (this.anchor_ === this.focus_) {
|
|
132
|
+
if (direction === 'backward') {
|
|
133
|
+
this.anchor_ = byWord ? this.previousWord(this.focus_) : this.previous(this.focus_);
|
|
134
|
+
} else {
|
|
135
|
+
this.focus_ = byWord ? this.nextWord(this.focus_) : this.next(this.focus_);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
this.replaceSelection('');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private range(): [number, number] {
|
|
142
|
+
return [Math.min(this.anchor_, this.focus_), Math.max(this.anchor_, this.focus_)];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private replaceSelection(text: string): void {
|
|
146
|
+
const [start, end] = this.range();
|
|
147
|
+
if (start === end && text.length === 0) return;
|
|
148
|
+
this.value_ = this.value_.slice(0, start) + text + this.value_.slice(end);
|
|
149
|
+
this.rebuildBoundaries();
|
|
150
|
+
// Inserted text can combine with neighboring code points into one grapheme.
|
|
151
|
+
const desired = start + text.length;
|
|
152
|
+
this.anchor_ = this.focus_ =
|
|
153
|
+
this.boundaries.find((boundary) => boundary >= desired) ?? this.value_.length;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private rebuildBoundaries(): void {
|
|
157
|
+
this.boundaries.length = 0;
|
|
158
|
+
for (const segment of graphemeSegments(this.value_)) this.boundaries.push(segment.index);
|
|
159
|
+
this.boundaries.push(this.value_.length);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private snap(offset: number): number {
|
|
163
|
+
if (!Number.isFinite(offset)) throw new RangeError('Text selection offset must be finite.');
|
|
164
|
+
const clamped = Math.max(0, Math.min(this.value_.length, Math.trunc(offset)));
|
|
165
|
+
let result = 0;
|
|
166
|
+
for (const boundary of this.boundaries) {
|
|
167
|
+
if (boundary > clamped) break;
|
|
168
|
+
result = boundary;
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private previous(offset: number): number {
|
|
174
|
+
let result = 0;
|
|
175
|
+
for (const boundary of this.boundaries) {
|
|
176
|
+
if (boundary >= offset) break;
|
|
177
|
+
result = boundary;
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private next(offset: number): number {
|
|
183
|
+
return this.boundaries.find((boundary) => boundary > offset) ?? this.value_.length;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private previousWord(offset: number): number {
|
|
187
|
+
let current = this.snap(offset);
|
|
188
|
+
if (current === 0) return 0;
|
|
189
|
+
while (current > 0) {
|
|
190
|
+
const prev = this.previous(current);
|
|
191
|
+
if (charType(this.value_.slice(prev, current)) !== 'space') break;
|
|
192
|
+
current = prev;
|
|
193
|
+
}
|
|
194
|
+
if (current === 0) return 0;
|
|
195
|
+
const prev = this.previous(current);
|
|
196
|
+
const targetType = charType(this.value_.slice(prev, current));
|
|
197
|
+
while (current > 0) {
|
|
198
|
+
const p = this.previous(current);
|
|
199
|
+
if (charType(this.value_.slice(p, current)) !== targetType) break;
|
|
200
|
+
current = p;
|
|
201
|
+
}
|
|
202
|
+
return current;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private nextWord(offset: number): number {
|
|
206
|
+
let current = this.snap(offset);
|
|
207
|
+
const length = this.value_.length;
|
|
208
|
+
if (current >= length) return length;
|
|
209
|
+
while (current < length) {
|
|
210
|
+
const next = this.next(current);
|
|
211
|
+
if (charType(this.value_.slice(current, next)) !== 'space') break;
|
|
212
|
+
current = next;
|
|
213
|
+
}
|
|
214
|
+
if (current >= length) return length;
|
|
215
|
+
const next = this.next(current);
|
|
216
|
+
const targetType = charType(this.value_.slice(current, next));
|
|
217
|
+
while (current < length) {
|
|
218
|
+
const n = this.next(current);
|
|
219
|
+
if (charType(this.value_.slice(current, n)) !== targetType) break;
|
|
220
|
+
current = n;
|
|
221
|
+
}
|
|
222
|
+
return current;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import * as iconv from 'iconv-lite';
|
|
3
|
+
|
|
4
|
+
/** Configures conversion at byte boundaries; editor strings remain Unicode. */
|
|
5
|
+
export class TextEncoding {
|
|
6
|
+
private charset_: string;
|
|
7
|
+
|
|
8
|
+
constructor(charset = 'utf-8') {
|
|
9
|
+
this.charset_ = TextEncoding.validate(charset);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
get charset(): string {
|
|
13
|
+
return this.charset_;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setCharset(charset: string): void {
|
|
17
|
+
this.charset_ = TextEncoding.validate(charset);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Unrepresentable characters are replaced according to the selected codec. */
|
|
21
|
+
encode(value: string): Uint8Array {
|
|
22
|
+
return Uint8Array.from(iconv.encode(value, this.charset_));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Invalid byte sequences are replaced according to the selected codec. */
|
|
26
|
+
decode(value: Uint8Array): string {
|
|
27
|
+
return iconv.decode(
|
|
28
|
+
Buffer.from(value.buffer, value.byteOffset, value.byteLength),
|
|
29
|
+
this.charset_
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private static validate(charset: string): string {
|
|
34
|
+
if (!iconv.encodingExists(charset))
|
|
35
|
+
throw new RangeError(`Unsupported text charset: ${charset}`);
|
|
36
|
+
return charset;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { TextEditor } from './editor';
|
|
2
|
+
export { TextEncoding } from './encoding';
|
|
3
|
+
export { TextInput } from './input';
|
|
4
|
+
export type {
|
|
5
|
+
TextInputOptions,
|
|
6
|
+
TextInputEvent,
|
|
7
|
+
TextInputEventType,
|
|
8
|
+
TextInputCallback
|
|
9
|
+
} from './input';
|
|
10
|
+
export { SvgTextDecorations } from './svg-decorations';
|
|
11
|
+
export type { SvgTextDecorationOptions, TextDecorationRect } from './svg-decorations';
|
|
12
|
+
export type { TextComposition, TextSelection } from './editor';
|
|
13
|
+
export { applyTextKeyboardEvent } from './keyboard';
|