@tldraw/utils 5.2.0-next.ee0fa4d6244f → 5.2.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.
- package/DOCS.md +930 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/fractionalIndexing.js +227 -0
- package/dist-cjs/lib/fractionalIndexing.js.map +7 -0
- package/dist-cjs/lib/reordering.js +3 -6
- package/dist-cjs/lib/reordering.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/fractionalIndexing.mjs +207 -0
- package/dist-esm/lib/fractionalIndexing.mjs.map +7 -0
- package/dist-esm/lib/reordering.mjs +7 -6
- package/dist-esm/lib/reordering.mjs.map +2 -2
- package/package.json +7 -4
- package/src/lib/PerformanceTracker.test.ts +3 -4
- package/src/lib/fractionalIndexing.test.ts +138 -0
- package/src/lib/fractionalIndexing.ts +275 -0
- package/src/lib/reordering.ts +7 -7
- package/src/lib/version.test.ts +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/reordering.ts"],
|
|
4
|
-
"sourcesContent": ["import {
|
|
5
|
-
"mappings": "AAAA,
|
|
4
|
+
"sourcesContent": ["import {\n\tgenerateNJitteredKeysBetween,\n\tgenerateNKeysBetween,\n\tvalidateOrderKey,\n} from './fractionalIndexing'\n\nconst generateKeysFn =\n\tprocess.env.NODE_ENV === 'test' ? generateNKeysBetween : generateNJitteredKeysBetween\n\n/**\n * A string made up of an integer part followed by a fraction part. The fraction point consists of\n * zero or more digits with no trailing zeros. Based on\n * {@link https://observablehq.com/@dgreensp/implementing-fractional-indexing}.\n *\n * @public\n */\nexport type IndexKey = string & { __brand: 'indexKey' }\n\n/**\n * The index key for the first index - 'a0'.\n * @public\n */\nexport const ZERO_INDEX_KEY = 'a0' as IndexKey\n\n/**\n * Validates that a string is a valid IndexKey.\n * @param index - The string to validate.\n * @throws Error if the index is invalid.\n * @internal\n */\nexport function validateIndexKey(index: string): asserts index is IndexKey {\n\ttry {\n\t\tvalidateOrderKey(index)\n\t} catch {\n\t\tthrow new Error('invalid index: ' + index)\n\t}\n}\n\n/**\n * Get a number of indices between two indices.\n * @param below - The index below.\n * @param above - The index above.\n * @param n - The number of indices to get.\n * @returns An array of n IndexKey values between below and above.\n * @example\n * ```ts\n * const indices = getIndicesBetween('a0' as IndexKey, 'a2' as IndexKey, 2)\n * console.log(indices) // ['a0V', 'a1']\n * ```\n * @public\n */\nexport function getIndicesBetween(\n\tbelow: IndexKey | null | undefined,\n\tabove: IndexKey | null | undefined,\n\tn: number\n) {\n\treturn generateKeysFn(below ?? null, above ?? null, n) as IndexKey[]\n}\n\n/**\n * Get a number of indices above an index.\n * @param below - The index below.\n * @param n - The number of indices to get.\n * @returns An array of n IndexKey values above the given index.\n * @example\n * ```ts\n * const indices = getIndicesAbove('a0' as IndexKey, 3)\n * console.log(indices) // ['a1', 'a2', 'a3']\n * ```\n * @public\n */\nexport function getIndicesAbove(below: IndexKey | null | undefined, n: number) {\n\treturn generateKeysFn(below ?? null, null, n) as IndexKey[]\n}\n\n/**\n * Get a number of indices below an index.\n * @param above - The index above.\n * @param n - The number of indices to get.\n * @returns An array of n IndexKey values below the given index.\n * @example\n * ```ts\n * const indices = getIndicesBelow('a2' as IndexKey, 2)\n * console.log(indices) // ['a1', 'a0V']\n * ```\n * @public\n */\nexport function getIndicesBelow(above: IndexKey | null | undefined, n: number) {\n\treturn generateKeysFn(null, above ?? null, n) as IndexKey[]\n}\n\n/**\n * Get the index between two indices.\n * @param below - The index below.\n * @param above - The index above.\n * @returns A single IndexKey value between below and above.\n * @example\n * ```ts\n * const index = getIndexBetween('a0' as IndexKey, 'a2' as IndexKey)\n * console.log(index) // 'a1'\n * ```\n * @public\n */\nexport function getIndexBetween(\n\tbelow: IndexKey | null | undefined,\n\tabove: IndexKey | null | undefined\n) {\n\treturn generateKeysFn(below ?? null, above ?? null, 1)[0] as IndexKey\n}\n\n/**\n * Get the index above a given index.\n * @param below - The index below.\n * @returns An IndexKey value above the given index.\n * @example\n * ```ts\n * const index = getIndexAbove('a0' as IndexKey)\n * console.log(index) // 'a1'\n * ```\n * @public\n */\nexport function getIndexAbove(below: IndexKey | null | undefined = null) {\n\treturn generateKeysFn(below, null, 1)[0] as IndexKey\n}\n\n/**\n * Get the index below a given index.\n * @param above - The index above.\n * @returns An IndexKey value below the given index.\n * @example\n * ```ts\n * const index = getIndexBelow('a2' as IndexKey)\n * console.log(index) // 'a1'\n * ```\n * @public\n */\nexport function getIndexBelow(above: IndexKey | null | undefined = null) {\n\treturn generateKeysFn(null, above, 1)[0] as IndexKey\n}\n\n/**\n * Get n number of indices, starting at an index.\n * @param n - The number of indices to get.\n * @param start - The index to start at.\n * @returns An array containing the start index plus n additional IndexKey values.\n * @example\n * ```ts\n * const indices = getIndices(3, 'a1' as IndexKey)\n * console.log(indices) // ['a1', 'a2', 'a3', 'a4']\n * ```\n * @public\n */\nexport function getIndices(n: number, start = 'a1' as IndexKey) {\n\treturn [start, ...generateKeysFn(start, null, n)] as IndexKey[]\n}\n\n/**\n * Sort by index.\n * @param a - An object with an index property.\n * @param b - An object with an index property.\n * @returns A number indicating sort order (-1, 0, or 1).\n * @example\n * ```ts\n * const shapes = [\n * { id: 'b', index: 'a2' as IndexKey },\n * { id: 'a', index: 'a1' as IndexKey }\n * ]\n * const sorted = shapes.sort(sortByIndex)\n * console.log(sorted) // [{ id: 'a', index: 'a1' }, { id: 'b', index: 'a2' }]\n * ```\n * @public\n */\nexport function sortByIndex<T extends { index: IndexKey }>(a: T, b: T) {\n\tif (a.index < b.index) {\n\t\treturn -1\n\t} else if (a.index > b.index) {\n\t\treturn 1\n\t}\n\treturn 0\n}\n\n/**\n * Sort by index, or null.\n * @param a - An object with an index property.\n * @param b - An object with an index property.\n * @public\n */\nexport function sortByMaybeIndex<T extends { index?: IndexKey | null }>(a: T, b: T) {\n\tif (a.index && b.index) {\n\t\treturn a.index < b.index ? -1 : 1\n\t}\n\tif (a.index && b.index == null) {\n\t\treturn -1\n\t}\n\tif (a.index == null && b.index == null) {\n\t\treturn 0\n\t}\n\treturn 1\n}\n"],
|
|
5
|
+
"mappings": "AAAA;AAAA,EACC;AAAA,EACA;AAAA,EACA;AAAA,OACM;AAEP,MAAM,iBACL,QAAQ,IAAI,aAAa,SAAS,uBAAuB;AAenD,MAAM,iBAAiB;AAQvB,SAAS,iBAAiB,OAA0C;AAC1E,MAAI;AACH,qBAAiB,KAAK;AAAA,EACvB,QAAQ;AACP,UAAM,IAAI,MAAM,oBAAoB,KAAK;AAAA,EAC1C;AACD;AAeO,SAAS,kBACf,OACA,OACA,GACC;AACD,SAAO,eAAe,SAAS,MAAM,SAAS,MAAM,CAAC;AACtD;AAcO,SAAS,gBAAgB,OAAoC,GAAW;AAC9E,SAAO,eAAe,SAAS,MAAM,MAAM,CAAC;AAC7C;AAcO,SAAS,gBAAgB,OAAoC,GAAW;AAC9E,SAAO,eAAe,MAAM,SAAS,MAAM,CAAC;AAC7C;AAcO,SAAS,gBACf,OACA,OACC;AACD,SAAO,eAAe,SAAS,MAAM,SAAS,MAAM,CAAC,EAAE,CAAC;AACzD;AAaO,SAAS,cAAc,QAAqC,MAAM;AACxE,SAAO,eAAe,OAAO,MAAM,CAAC,EAAE,CAAC;AACxC;AAaO,SAAS,cAAc,QAAqC,MAAM;AACxE,SAAO,eAAe,MAAM,OAAO,CAAC,EAAE,CAAC;AACxC;AAcO,SAAS,WAAW,GAAW,QAAQ,MAAkB;AAC/D,SAAO,CAAC,OAAO,GAAG,eAAe,OAAO,MAAM,CAAC,CAAC;AACjD;AAkBO,SAAS,YAA2C,GAAM,GAAM;AACtE,MAAI,EAAE,QAAQ,EAAE,OAAO;AACtB,WAAO;AAAA,EACR,WAAW,EAAE,QAAQ,EAAE,OAAO;AAC7B,WAAO;AAAA,EACR;AACA,SAAO;AACR;AAQO,SAAS,iBAAwD,GAAM,GAAM;AACnF,MAAI,EAAE,SAAS,EAAE,OAAO;AACvB,WAAO,EAAE,QAAQ,EAAE,QAAQ,KAAK;AAAA,EACjC;AACA,MAAI,EAAE,SAAS,EAAE,SAAS,MAAM;AAC/B,WAAO;AAAA,EACR;AACA,MAAI,EAAE,SAAS,QAAQ,EAAE,SAAS,MAAM;AACvC,WAAO;AAAA,EACR;AACA,SAAO;AACR;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/utils",
|
|
3
3
|
"description": "tldraw infinite canvas SDK (private utilities).",
|
|
4
|
-
"version": "5.2.0
|
|
4
|
+
"version": "5.2.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw Inc.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"files": [
|
|
30
30
|
"dist-esm",
|
|
31
31
|
"dist-cjs",
|
|
32
|
-
"src"
|
|
32
|
+
"src",
|
|
33
|
+
"DOCS.md"
|
|
33
34
|
],
|
|
34
35
|
"scripts": {
|
|
35
36
|
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
|
@@ -43,7 +44,6 @@
|
|
|
43
44
|
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"jittered-fractional-indexing": "^1.0.0",
|
|
47
47
|
"lodash.isequal": "^4.5.0",
|
|
48
48
|
"lodash.isequalwith": "^4.4.0",
|
|
49
49
|
"lodash.throttle": "^4.1.1",
|
|
@@ -55,7 +55,10 @@
|
|
|
55
55
|
"@types/lodash.throttle": "^4.1.9",
|
|
56
56
|
"@types/lodash.uniq": "^4.5.9",
|
|
57
57
|
"lazyrepo": "0.0.0-alpha.27",
|
|
58
|
-
"vitest": "^
|
|
58
|
+
"vitest": "^4.1.7"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=22.12.0"
|
|
59
62
|
},
|
|
60
63
|
"module": "dist-esm/index.mjs",
|
|
61
64
|
"source": "src/index.ts",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'
|
|
2
2
|
import { PERFORMANCE_COLORS, PERFORMANCE_PREFIX_COLOR } from './perf'
|
|
3
3
|
import { PerformanceTracker } from './PerformanceTracker'
|
|
4
4
|
|
|
@@ -7,7 +7,7 @@ describe('PerformanceTracker', () => {
|
|
|
7
7
|
let mockPerformanceNow: ReturnType<typeof vi.fn>
|
|
8
8
|
let mockRequestAnimationFrame: ReturnType<typeof vi.fn>
|
|
9
9
|
let mockCancelAnimationFrame: ReturnType<typeof vi.fn>
|
|
10
|
-
let mockConsoleDebug:
|
|
10
|
+
let mockConsoleDebug: MockInstance<typeof console.debug>
|
|
11
11
|
let frameId = 1
|
|
12
12
|
|
|
13
13
|
beforeEach(() => {
|
|
@@ -24,8 +24,7 @@ describe('PerformanceTracker', () => {
|
|
|
24
24
|
vi.stubGlobal('cancelAnimationFrame', mockCancelAnimationFrame)
|
|
25
25
|
|
|
26
26
|
// Mock console.debug
|
|
27
|
-
mockConsoleDebug = vi.
|
|
28
|
-
vi.spyOn(console, 'debug').mockImplementation(mockConsoleDebug)
|
|
27
|
+
mockConsoleDebug = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
|
29
28
|
})
|
|
30
29
|
|
|
31
30
|
afterEach(() => {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
generateNJitteredKeysBetween,
|
|
4
|
+
generateNKeysBetween,
|
|
5
|
+
validateOrderKey,
|
|
6
|
+
} from './fractionalIndexing'
|
|
7
|
+
|
|
8
|
+
// Single-key helper: the non-jittered generator with n=1.
|
|
9
|
+
const keyBetween = (a: string | null, b: string | null) => generateNKeysBetween(a, b, 1)[0]
|
|
10
|
+
|
|
11
|
+
describe('fractionalIndexing', () => {
|
|
12
|
+
// Golden vectors from the upstream `fractional-indexing` README. These pin the
|
|
13
|
+
// exact base-62 output, proving the vendored/specialized core stays
|
|
14
|
+
// byte-for-byte compatible with the original library.
|
|
15
|
+
describe('generateKeyBetween (golden vectors)', () => {
|
|
16
|
+
it.each([
|
|
17
|
+
[null, null, 'a0'],
|
|
18
|
+
['a0', null, 'a1'],
|
|
19
|
+
['a1', null, 'a2'],
|
|
20
|
+
[null, 'a0', 'Zz'],
|
|
21
|
+
['a0', 'a1', 'a0V'],
|
|
22
|
+
['a1', 'a2', 'a1V'],
|
|
23
|
+
])('between %o and %o is %o', (a, b, expected) => {
|
|
24
|
+
expect(keyBetween(a, b)).toBe(expected)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('generateNKeysBetween (golden vectors)', () => {
|
|
29
|
+
it.each<[string | null, string | null, number, string[]]>([
|
|
30
|
+
[null, null, 2, ['a0', 'a1']],
|
|
31
|
+
['a1', null, 2, ['a2', 'a3']],
|
|
32
|
+
[null, 'a0', 2, ['Zy', 'Zz']],
|
|
33
|
+
['a0', 'a1', 2, ['a0G', 'a0V']],
|
|
34
|
+
[null, null, 5, ['a0', 'a1', 'a2', 'a3', 'a4']],
|
|
35
|
+
])('%o keys between %o and %o', (a, b, n, expected) => {
|
|
36
|
+
expect(generateNKeysBetween(a, b, n)).toEqual(expected)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns an empty array for n = 0', () => {
|
|
40
|
+
expect(generateNKeysBetween(null, null, 0)).toEqual([])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('errors', () => {
|
|
45
|
+
it('throws when a >= b', () => {
|
|
46
|
+
expect(() => keyBetween('a1', 'a0')).toThrow()
|
|
47
|
+
expect(() => keyBetween('a0', 'a0')).toThrow()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('throws on invalid input keys', () => {
|
|
51
|
+
expect(() => keyBetween('a00', null)).toThrow() // trailing zero
|
|
52
|
+
expect(() => keyBetween('', null)).toThrow()
|
|
53
|
+
expect(() => keyBetween('foo', null)).toThrow()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('validateOrderKey', () => {
|
|
58
|
+
it.each(['a0', 'a1', 'a0V', 'Zz', 'a0G', 'b127'])('accepts %s', (key) => {
|
|
59
|
+
expect(() => validateOrderKey(key)).not.toThrow()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it.each([
|
|
63
|
+
'', // empty
|
|
64
|
+
'a', // integer part too short
|
|
65
|
+
'1', // invalid head
|
|
66
|
+
'a00', // trailing zero in fraction
|
|
67
|
+
'foo', // invalid head
|
|
68
|
+
'A' + '0'.repeat(26), // the reserved smallest-integer key
|
|
69
|
+
])('rejects %o', (key) => {
|
|
70
|
+
expect(() => validateOrderKey(key)).toThrow()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Invariant tests: independent of exact output, these catch ordering or
|
|
75
|
+
// digit-mapping regressions across the whole alphabet (e.g. a bad charCode
|
|
76
|
+
// offset in digitIndex, or carry/borrow bugs when integers roll over).
|
|
77
|
+
describe('invariants', () => {
|
|
78
|
+
it('generateNKeysBetween yields strictly increasing, valid, in-bounds keys', () => {
|
|
79
|
+
const keys = generateNKeysBetween('a0', 'a1', 200)
|
|
80
|
+
expect(keys).toHaveLength(200)
|
|
81
|
+
let prev = 'a0'
|
|
82
|
+
for (const key of keys) {
|
|
83
|
+
expect(() => validateOrderKey(key)).not.toThrow()
|
|
84
|
+
expect(key > prev).toBe(true)
|
|
85
|
+
expect(key < 'a1').toBe(true)
|
|
86
|
+
prev = key
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('stays ordered while repeatedly inserting after the last key (integer carries)', () => {
|
|
91
|
+
// 200 sequential keys force the integer part to roll over many digits,
|
|
92
|
+
// exercising incrementInteger across the full base-62 alphabet.
|
|
93
|
+
let key = keyBetween(null, null)
|
|
94
|
+
let prev = ''
|
|
95
|
+
for (let i = 0; i < 200; i++) {
|
|
96
|
+
expect(() => validateOrderKey(key)).not.toThrow()
|
|
97
|
+
expect(key > prev).toBe(true)
|
|
98
|
+
prev = key
|
|
99
|
+
key = keyBetween(key, null)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('stays ordered while repeatedly inserting before the first key (integer borrows)', () => {
|
|
104
|
+
let key = keyBetween(null, null)
|
|
105
|
+
let next = 'zzzzz'
|
|
106
|
+
for (let i = 0; i < 200; i++) {
|
|
107
|
+
expect(() => validateOrderKey(key)).not.toThrow()
|
|
108
|
+
expect(key < next).toBe(true)
|
|
109
|
+
next = key
|
|
110
|
+
key = keyBetween(null, key)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('generateNJitteredKeysBetween yields ordered, valid, in-bounds, distinct keys', () => {
|
|
115
|
+
const keys = generateNJitteredKeysBetween('a0', 'a5', 100)
|
|
116
|
+
expect(keys).toHaveLength(100)
|
|
117
|
+
expect(new Set(keys).size).toBe(100)
|
|
118
|
+
let prev = 'a0'
|
|
119
|
+
for (const key of keys) {
|
|
120
|
+
expect(() => validateOrderKey(key)).not.toThrow()
|
|
121
|
+
expect(key > prev).toBe(true)
|
|
122
|
+
expect(key < 'a5').toBe(true)
|
|
123
|
+
prev = key
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('jittered keys land strictly between their neighbours', () => {
|
|
128
|
+
const a = keyBetween(null, null)
|
|
129
|
+
const b = keyBetween(a, null)
|
|
130
|
+
for (let i = 0; i < 50; i++) {
|
|
131
|
+
const mid = generateNJitteredKeysBetween(a, b, 1)[0]
|
|
132
|
+
expect(mid > a).toBe(true)
|
|
133
|
+
expect(mid < b).toBe(true)
|
|
134
|
+
expect(() => validateOrderKey(mid)).not.toThrow()
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Fractional indexing, specialized to tldraw's needs.
|
|
2
|
+
//
|
|
3
|
+
// This is a vendored and trimmed version of the `fractional-indexing` (by
|
|
4
|
+
// arv@rocicorp.dev) and `jittered-fractional-indexing` (by
|
|
5
|
+
// github@nathanhleung.com) packages, both released under CC0-1.0 (public
|
|
6
|
+
// domain). See https://observablehq.com/@dgreensp/implementing-fractional-indexing
|
|
7
|
+
// for the original algorithm.
|
|
8
|
+
//
|
|
9
|
+
// We only ever use the default base-62 alphabet, so the `digits` parameter has
|
|
10
|
+
// been specialized away. This lets us hoist the per-call allocations that the
|
|
11
|
+
// upstream `validateOrderKey` made (notably `digits[0].repeat(26)`) into module
|
|
12
|
+
// constants, and skip redundant re-validation inside the jitter loop, which are
|
|
13
|
+
// both on hot paths (every index generation and every IndexKey validation).
|
|
14
|
+
|
|
15
|
+
// Digits must be in ascending character-code order.
|
|
16
|
+
const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
|
17
|
+
const ZERO = DIGITS[0] // '0'
|
|
18
|
+
const LARGEST_DIGIT = DIGITS[DIGITS.length - 1] // 'z'
|
|
19
|
+
// The reserved "smallest integer" key, which has no valid predecessor.
|
|
20
|
+
const SMALLEST_INTEGER = 'A' + ZERO.repeat(26)
|
|
21
|
+
// Number of random bisections used to spread concurrently-generated keys apart,
|
|
22
|
+
// reducing the chance of collisions when multiple clients insert into the same
|
|
23
|
+
// gap at once. Each bit costs one extra key generation and ~0.17 characters of
|
|
24
|
+
// key length, so this trades collision resistance against compute and key size.
|
|
25
|
+
// 16 keeps collisions below ~0.1% even for ~10 simultaneous same-position
|
|
26
|
+
// inserts, which is ample headroom for real multiplayer use.
|
|
27
|
+
const JITTER_BITS = 16
|
|
28
|
+
|
|
29
|
+
function getIntegerLength(head: string): number {
|
|
30
|
+
if (head >= 'a' && head <= 'z') {
|
|
31
|
+
return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2
|
|
32
|
+
} else if (head >= 'A' && head <= 'Z') {
|
|
33
|
+
return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2
|
|
34
|
+
}
|
|
35
|
+
throw new Error('invalid order key head: ' + head)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getIntegerPart(key: string): string {
|
|
39
|
+
const integerPartLength = getIntegerLength(key[0])
|
|
40
|
+
if (integerPartLength > key.length) {
|
|
41
|
+
throw new Error('invalid order key: ' + key)
|
|
42
|
+
}
|
|
43
|
+
return key.slice(0, integerPartLength)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Map a base-62 digit to its value. Faster than DIGITS.indexOf() (which scans
|
|
47
|
+
// the alphabet) on the hot key-generation path. DIGITS is in char-code order:
|
|
48
|
+
// '0'-'9' -> 0-9, 'A'-'Z' -> 10-35, 'a'-'z' -> 36-61.
|
|
49
|
+
function digitIndex(char: string): number {
|
|
50
|
+
const code = char.charCodeAt(0)
|
|
51
|
+
if (code <= 57) return code - 48
|
|
52
|
+
if (code <= 90) return code - 55
|
|
53
|
+
return code - 61
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Throws if `key` is not a canonical order key. Allocation-free in the common
|
|
58
|
+
* case: no `repeat`/`slice` per call.
|
|
59
|
+
*/
|
|
60
|
+
export function validateOrderKey(key: string): void {
|
|
61
|
+
if (key === SMALLEST_INTEGER) {
|
|
62
|
+
throw new Error('invalid order key: ' + key)
|
|
63
|
+
}
|
|
64
|
+
// getIntegerPart throws if the first character is bad or the key is too short.
|
|
65
|
+
const i = getIntegerPart(key)
|
|
66
|
+
// The fractional part (everything after the integer part) must not end in the
|
|
67
|
+
// smallest digit, as a trailing zero is a non-canonical representation.
|
|
68
|
+
if (key.length > i.length && key[key.length - 1] === ZERO) {
|
|
69
|
+
throw new Error('invalid order key: ' + key)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// `a` may be empty string, `b` is null or non-empty string.
|
|
74
|
+
// `a < b` lexicographically if `b` is non-null. No trailing zeros allowed.
|
|
75
|
+
function midpoint(a: string, b: string | null): string {
|
|
76
|
+
if (b != null && a >= b) {
|
|
77
|
+
throw new Error(a + ' >= ' + b)
|
|
78
|
+
}
|
|
79
|
+
if (a.slice(-1) === ZERO || (b && b.slice(-1) === ZERO)) {
|
|
80
|
+
throw new Error('trailing zero')
|
|
81
|
+
}
|
|
82
|
+
if (b) {
|
|
83
|
+
// Remove the longest common prefix, padding `a` with zeros as we go. We
|
|
84
|
+
// don't need to pad `b`, because it can't end before `a` while traversing
|
|
85
|
+
// the common prefix.
|
|
86
|
+
let n = 0
|
|
87
|
+
while ((a[n] || ZERO) === b[n]) {
|
|
88
|
+
n++
|
|
89
|
+
}
|
|
90
|
+
if (n > 0) {
|
|
91
|
+
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// First digits (or lack of digit) are different.
|
|
95
|
+
const digitA = a ? digitIndex(a[0]) : 0
|
|
96
|
+
const digitB = b != null ? digitIndex(b[0]) : DIGITS.length
|
|
97
|
+
if (digitB - digitA > 1) {
|
|
98
|
+
const midDigit = Math.round(0.5 * (digitA + digitB))
|
|
99
|
+
return DIGITS[midDigit]
|
|
100
|
+
}
|
|
101
|
+
// First digits are consecutive.
|
|
102
|
+
if (b && b.length > 1) {
|
|
103
|
+
return b.slice(0, 1)
|
|
104
|
+
}
|
|
105
|
+
// `b` is null or has length 1 (a single digit). The first digit of `a` is the
|
|
106
|
+
// previous digit to `b`, or the largest digit if `b` is null.
|
|
107
|
+
return DIGITS[digitA] + midpoint(a.slice(1), null)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// May return null, as there is a largest integer.
|
|
111
|
+
function incrementInteger(x: string): string | null {
|
|
112
|
+
const [head, ...digs] = x.split('')
|
|
113
|
+
let carry = true
|
|
114
|
+
for (let i = digs.length - 1; carry && i >= 0; i--) {
|
|
115
|
+
const d = digitIndex(digs[i]) + 1
|
|
116
|
+
if (d === DIGITS.length) {
|
|
117
|
+
digs[i] = ZERO
|
|
118
|
+
} else {
|
|
119
|
+
digs[i] = DIGITS[d]
|
|
120
|
+
carry = false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (carry) {
|
|
124
|
+
if (head === 'Z') return 'a' + ZERO
|
|
125
|
+
if (head === 'z') return null
|
|
126
|
+
const h = String.fromCharCode(head.charCodeAt(0) + 1)
|
|
127
|
+
if (h > 'a') {
|
|
128
|
+
digs.push(ZERO)
|
|
129
|
+
} else {
|
|
130
|
+
digs.pop()
|
|
131
|
+
}
|
|
132
|
+
return h + digs.join('')
|
|
133
|
+
}
|
|
134
|
+
return head + digs.join('')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// May return null, as there is a smallest integer.
|
|
138
|
+
function decrementInteger(x: string): string | null {
|
|
139
|
+
const [head, ...digs] = x.split('')
|
|
140
|
+
let borrow = true
|
|
141
|
+
for (let i = digs.length - 1; borrow && i >= 0; i--) {
|
|
142
|
+
const d = digitIndex(digs[i]) - 1
|
|
143
|
+
if (d === -1) {
|
|
144
|
+
digs[i] = LARGEST_DIGIT
|
|
145
|
+
} else {
|
|
146
|
+
digs[i] = DIGITS[d]
|
|
147
|
+
borrow = false
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (borrow) {
|
|
151
|
+
if (head === 'a') return 'Z' + LARGEST_DIGIT
|
|
152
|
+
if (head === 'A') return null
|
|
153
|
+
const h = String.fromCharCode(head.charCodeAt(0) - 1)
|
|
154
|
+
if (h < 'Z') {
|
|
155
|
+
digs.push(LARGEST_DIGIT)
|
|
156
|
+
} else {
|
|
157
|
+
digs.pop()
|
|
158
|
+
}
|
|
159
|
+
return h + digs.join('')
|
|
160
|
+
}
|
|
161
|
+
return head + digs.join('')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Generate a key strictly between `a` and `b`, without validating the inputs.
|
|
165
|
+
// `a`/`b` are order keys or null (START/END). `a < b` if both are non-null.
|
|
166
|
+
function keyBetweenUnchecked(a: string | null, b: string | null): string {
|
|
167
|
+
if (a != null && b != null && a >= b) {
|
|
168
|
+
throw new Error(a + ' >= ' + b)
|
|
169
|
+
}
|
|
170
|
+
if (a == null) {
|
|
171
|
+
if (b == null) return 'a' + ZERO
|
|
172
|
+
const ib = getIntegerPart(b)
|
|
173
|
+
const fb = b.slice(ib.length)
|
|
174
|
+
if (ib === SMALLEST_INTEGER) {
|
|
175
|
+
return ib + midpoint('', fb)
|
|
176
|
+
}
|
|
177
|
+
if (ib < b) return ib
|
|
178
|
+
const res = decrementInteger(ib)
|
|
179
|
+
if (res == null) throw new Error('cannot decrement any more')
|
|
180
|
+
return res
|
|
181
|
+
}
|
|
182
|
+
if (b == null) {
|
|
183
|
+
const ia = getIntegerPart(a)
|
|
184
|
+
const fa = a.slice(ia.length)
|
|
185
|
+
const i = incrementInteger(ia)
|
|
186
|
+
return i == null ? ia + midpoint(fa, null) : i
|
|
187
|
+
}
|
|
188
|
+
const ia = getIntegerPart(a)
|
|
189
|
+
const fa = a.slice(ia.length)
|
|
190
|
+
const ib = getIntegerPart(b)
|
|
191
|
+
const fb = b.slice(ib.length)
|
|
192
|
+
if (ia === ib) {
|
|
193
|
+
return ia + midpoint(fa, fb)
|
|
194
|
+
}
|
|
195
|
+
const i = incrementInteger(ia)
|
|
196
|
+
if (i == null) throw new Error('cannot increment any more')
|
|
197
|
+
if (i < b) return i
|
|
198
|
+
return ia + midpoint(fa, null)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Generate `n` keys between `a` and `b`, without validating the inputs.
|
|
202
|
+
// Intermediate keys are valid by construction, so they're never re-validated.
|
|
203
|
+
function nKeysBetweenUnchecked(a: string | null, b: string | null, n: number): string[] {
|
|
204
|
+
if (n === 0) return []
|
|
205
|
+
if (n === 1) return [keyBetweenUnchecked(a, b)]
|
|
206
|
+
if (b == null) {
|
|
207
|
+
let c = keyBetweenUnchecked(a, b)
|
|
208
|
+
const result = [c]
|
|
209
|
+
for (let i = 0; i < n - 1; i++) {
|
|
210
|
+
c = keyBetweenUnchecked(c, b)
|
|
211
|
+
result.push(c)
|
|
212
|
+
}
|
|
213
|
+
return result
|
|
214
|
+
}
|
|
215
|
+
if (a == null) {
|
|
216
|
+
let c = keyBetweenUnchecked(a, b)
|
|
217
|
+
const result = [c]
|
|
218
|
+
for (let i = 0; i < n - 1; i++) {
|
|
219
|
+
c = keyBetweenUnchecked(a, c)
|
|
220
|
+
result.push(c)
|
|
221
|
+
}
|
|
222
|
+
result.reverse()
|
|
223
|
+
return result
|
|
224
|
+
}
|
|
225
|
+
const mid = Math.floor(n / 2)
|
|
226
|
+
const c = keyBetweenUnchecked(a, b)
|
|
227
|
+
return [...nKeysBetweenUnchecked(a, c, mid), c, ...nKeysBetweenUnchecked(c, b, n - mid - 1)]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Bisect `[low, high]` JITTER_BITS times, following a random walk, so that keys
|
|
231
|
+
// generated concurrently land in different sub-ranges. `low`/`high` must already
|
|
232
|
+
// be valid (or null); the intermediate midpoints are valid by construction.
|
|
233
|
+
function jitterBetween(low: string | null, high: string | null): string {
|
|
234
|
+
let mid = keyBetweenUnchecked(low, high)
|
|
235
|
+
for (let i = 0; i < JITTER_BITS; i++) {
|
|
236
|
+
if (Math.random() < 0.5) {
|
|
237
|
+
low = mid
|
|
238
|
+
} else {
|
|
239
|
+
high = mid
|
|
240
|
+
}
|
|
241
|
+
mid = keyBetweenUnchecked(low, high)
|
|
242
|
+
}
|
|
243
|
+
return mid
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Generate `n` jittered keys evenly spaced between `a` and `b`. Inputs are
|
|
248
|
+
* validated once; everything downstream is generated, hence trusted.
|
|
249
|
+
*/
|
|
250
|
+
export function generateNJitteredKeysBetween(
|
|
251
|
+
a: string | null,
|
|
252
|
+
b: string | null,
|
|
253
|
+
n: number
|
|
254
|
+
): string[] {
|
|
255
|
+
if (n === 0) return []
|
|
256
|
+
if (a != null) validateOrderKey(a)
|
|
257
|
+
if (b != null) validateOrderKey(b)
|
|
258
|
+
const keys = nKeysBetweenUnchecked(a, b, n + 1)
|
|
259
|
+
const result = new Array<string>(n)
|
|
260
|
+
for (let i = 0; i < n; i++) {
|
|
261
|
+
result[i] = jitterBetween(keys[i], keys[i + 1])
|
|
262
|
+
}
|
|
263
|
+
return result
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Generate `n` keys evenly spaced between `a` and `b`, without jitter. Used in
|
|
268
|
+
* tests for deterministic output.
|
|
269
|
+
*/
|
|
270
|
+
export function generateNKeysBetween(a: string | null, b: string | null, n: number): string[] {
|
|
271
|
+
if (n === 0) return []
|
|
272
|
+
if (a != null) validateOrderKey(a)
|
|
273
|
+
if (b != null) validateOrderKey(b)
|
|
274
|
+
return nKeysBetweenUnchecked(a, b, n)
|
|
275
|
+
}
|
package/src/lib/reordering.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
generateNJitteredKeysBetween,
|
|
3
|
+
generateNKeysBetween,
|
|
4
|
+
validateOrderKey,
|
|
5
|
+
} from './fractionalIndexing'
|
|
6
6
|
|
|
7
7
|
const generateKeysFn =
|
|
8
|
-
process.env.NODE_ENV === 'test' ?
|
|
8
|
+
process.env.NODE_ENV === 'test' ? generateNKeysBetween : generateNJitteredKeysBetween
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* A string made up of an integer part followed by a fraction part. The fraction point consists of
|
|
@@ -30,7 +30,7 @@ export const ZERO_INDEX_KEY = 'a0' as IndexKey
|
|
|
30
30
|
*/
|
|
31
31
|
export function validateIndexKey(index: string): asserts index is IndexKey {
|
|
32
32
|
try {
|
|
33
|
-
|
|
33
|
+
validateOrderKey(index)
|
|
34
34
|
} catch {
|
|
35
35
|
throw new Error('invalid index: ' + index)
|
|
36
36
|
}
|
package/src/lib/version.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
2
2
|
import { clearRegisteredVersionsForTests, registerTldrawLibraryVersion } from './version'
|
|
3
3
|
|
|
4
4
|
describe('version utilities', () => {
|
|
5
|
-
let mockConsoleLog: ReturnType<typeof vi.fn
|
|
5
|
+
let mockConsoleLog: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
|
6
6
|
|
|
7
7
|
beforeEach(() => {
|
|
8
8
|
mockConsoleLog = vi.fn()
|