@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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/reordering.ts"],
4
- "sourcesContent": ["import { generateKeyBetween, generateNKeysBetween } from 'jittered-fractional-indexing'\n\nconst generateNKeysBetweenWithNoJitter = (a: string | null, b: string | null, n: number) => {\n\treturn generateNKeysBetween(a, b, n, { jitterBits: 0 })\n}\n\nconst generateKeysFn =\n\tprocess.env.NODE_ENV === 'test' ? generateNKeysBetweenWithNoJitter : generateNKeysBetween\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\tgenerateKeyBetween(index, null)\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,SAAS,oBAAoB,4BAA4B;AAEzD,MAAM,mCAAmC,CAAC,GAAkB,GAAkB,MAAc;AAC3F,SAAO,qBAAqB,GAAG,GAAG,GAAG,EAAE,YAAY,EAAE,CAAC;AACvD;AAEA,MAAM,iBACL,QAAQ,IAAI,aAAa,SAAS,mCAAmC;AAe/D,MAAM,iBAAiB;AAQvB,SAAS,iBAAiB,OAA0C;AAC1E,MAAI;AACH,uBAAmB,OAAO,IAAI;AAAA,EAC/B,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;",
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-next.ee0fa4d6244f",
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": "^3.2.4"
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: ReturnType<typeof vi.fn>
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.fn()
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
+ }
@@ -1,11 +1,11 @@
1
- import { generateKeyBetween, generateNKeysBetween } from 'jittered-fractional-indexing'
2
-
3
- const generateNKeysBetweenWithNoJitter = (a: string | null, b: string | null, n: number) => {
4
- return generateNKeysBetween(a, b, n, { jitterBits: 0 })
5
- }
1
+ import {
2
+ generateNJitteredKeysBetween,
3
+ generateNKeysBetween,
4
+ validateOrderKey,
5
+ } from './fractionalIndexing'
6
6
 
7
7
  const generateKeysFn =
8
- process.env.NODE_ENV === 'test' ? generateNKeysBetweenWithNoJitter : generateNKeysBetween
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
- generateKeyBetween(index, null)
33
+ validateOrderKey(index)
34
34
  } catch {
35
35
  throw new Error('invalid index: ' + index)
36
36
  }
@@ -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()