fix: Fractional index validation (#11258)

- Vendored fractional-indexing and converted to TypeScript
- Stricter index format validation in fractional-indexing
- Added format validation to fractional index validation

---
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Márk Tolmács
2026-05-04 11:37:17 +02:00
committed by GitHub
parent 2dfcc6f0ce
commit 3004c642da
18 changed files with 508 additions and 17 deletions
+2 -1
View File
@@ -64,6 +64,7 @@
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0"
"@excalidraw/math": "0.18.0",
"@excalidraw/fractional-indexing": "3.3.0"
}
}
+12 -2
View File
@@ -1,7 +1,10 @@
import { generateNKeysBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common";
import {
validateOrderKey,
generateNKeysBetween,
} from "@excalidraw/fractional-indexing";
import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks";
@@ -382,6 +385,13 @@ const isValidFractionalIndex = (
return false;
}
try {
// Format validation
validateOrderKey(index);
} catch {
return false;
}
if (predecessor && successor) {
return predecessor < index && index < successor;
}
+63 -3
View File
@@ -1,9 +1,8 @@
/* eslint-disable no-lone-blocks */
import { generateKeyBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common";
import {
InvalidFractionalIndexError,
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
@@ -13,13 +12,34 @@ import { deepCopyElement } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import {
generateKeyBetween,
validateOrderKey,
} from "@excalidraw/fractional-indexing";
import type {
ElementsMap,
ExcalidrawElement,
FractionalIndex,
} from "@excalidraw/element/types";
import { InvalidFractionalIndexError } from "../src/fractionalIndex";
describe("fractional index format validation", () => {
it("should reject malformed base62 order keys", () => {
expect(() => validateOrderKey("a!")).toThrow();
expect(() => validateOrderKey("a_")).toThrow();
expect(() => validateOrderKey("a1!")).toThrow();
expect(() => validateOrderKey("a1_")).toThrow();
expect(() => validateOrderKey("zd0032")).toThrow();
});
it("should accept valid base62 order keys", () => {
expect(() => validateOrderKey("Zz")).not.toThrow();
expect(() => validateOrderKey("a0")).not.toThrow();
expect(() => validateOrderKey("a1")).not.toThrow();
expect(() => validateOrderKey("a1V")).not.toThrow();
expect(() => validateOrderKey("z".padEnd(28, "z"))).not.toThrow();
});
});
describe("sync invalid indices with array order", () => {
describe("should NOT sync empty array", () => {
@@ -104,6 +124,46 @@ describe("sync invalid indices with array order", () => {
});
});
describe("should sync when fractional index is malformed", () => {
// "zd0032" has head "z" which requires length 28 per getIntegerLength,
// but the string is far too short, so validateOrderKey throws for it
testInvalidIndicesSync({
elements: [{ id: "A", index: "zd0032" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "zd0032" },
{ id: "C", index: "a3" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
testInvalidIndicesSync({
elements: [{ id: "A", index: "a!" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a!" },
{ id: "C", index: "a2" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
});
describe("should sync when fractional indices are duplicated", () => {
testInvalidIndicesSync({
elements: [
-1
View File
@@ -95,7 +95,6 @@
"clsx": "1.1.1",
"cross-env": "7.0.3",
"es6-promise-pool": "2.5.0",
"fractional-indexing": "3.2.0",
"fuzzy": "0.1.3",
"image-blob-reduce": "3.0.1",
"jotai": "2.11.0",
View File
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@excalidraw/fractional-indexing",
"version": "3.3.0",
"description": "Provides functions for generating ordering strings",
"type": "module",
"types": "./dist/types/fractional-indexing/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"engines": {
"node": "^14.13.1 || >=16.0.0"
},
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"keywords": [
"fractional",
"indexing",
"ordering",
"order"
],
"homepage": "https://github.com/rocicorp/fractional-indexing#readme",
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"author": "arv@rocicorp.dev",
"license": "CC0-1.0",
"devDependencies": {
"prettier": "^2.6.0",
"typescript": "5.9.3"
},
"exports": {
".": {
"types": "./dist/types/fractional-indexing/src/index.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"publishConfig": {
"access": "public"
},
"files": [
"dist/*"
]
}
+322
View File
@@ -0,0 +1,322 @@
// Vendored from https://www.npmjs.com/package/fractional-indexing
// License: CC0 (no rights reserved).
// This is based on https://observablehq.com/@dgreensp/implementing-fractional-indexing
export const BASE_62_DIGITS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// `a` may be empty string, `b` is null or non-empty string.
// `a < b` lexicographically if `b` is non-null.
// no trailing zeros allowed.
// digits is a string such as '0123456789' for base 10. Digits must be in
// ascending character code order!
/**
* @param {string} a
* @param {string | null | undefined} b
* @param {string} digits
* @returns {string}
*/
function midpoint(
a: string,
b: string | null | undefined,
digits: string,
): string {
const zero = digits[0];
if (b != null && a >= b) {
throw new Error(`${a} >= ${b}`);
}
if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) {
throw new Error("trailing zero");
}
if (b) {
// remove longest common prefix. pad `a` with 0s as we
// go. note that we don't need to pad `b`, because it can't
// end before `a` while traversing the common prefix.
let n = 0;
while ((a[n] || zero) === b[n]) {
n++;
}
if (n > 0) {
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits);
}
}
// first digits (or lack of digit) are different
const digitA = a ? digits.indexOf(a[0]) : 0;
const digitB = b != null ? digits.indexOf(b[0]) : digits.length;
if (digitB - digitA > 1) {
const midDigit = Math.round(0.5 * (digitA + digitB));
return digits[midDigit];
}
// first digits are consecutive
if (b && b.length > 1) {
return b.slice(0, 1);
}
// `b` is null or has length 1 (a single digit).
// the first digit of `a` is the previous digit to `b`,
// or 9 if `b` is null.
// given, for example, midpoint('49', '5'), return
// '4' + midpoint('9', null), which will become
// '4' + '9' + midpoint('', null), which is '495'
return digits[digitA] + midpoint(a.slice(1), null, digits);
}
/**
* @param {string} int
* @return {void}
*/
function validateInteger(int: string): void {
if (int.length !== getIntegerLength(int[0])) {
throw new Error(`invalid integer part of order key: ${int}`);
}
}
/**
* @param {string} head
* @return {number}
*/
function getIntegerLength(head: string): number {
if (head >= "a" && head <= "z") {
return head.charCodeAt(0) - "a".charCodeAt(0) + 2;
} else if (head >= "A" && head <= "Z") {
return "Z".charCodeAt(0) - head.charCodeAt(0) + 2;
}
throw new Error(`invalid order key head: ${head}`);
}
/**
* @param {string} key
* @return {string}
*/
function getIntegerPart(key: string): string {
const integerPartLength = getIntegerLength(key[0]);
if (integerPartLength > key.length) {
throw new Error(`invalid order key: ${key}`);
}
return key.slice(0, integerPartLength);
}
/**
* @param {string} key
* @param {string} digits
* @return {void}
*/
export function validateOrderKey(
key: string,
digits: string = BASE_62_DIGITS,
): void {
const validChars = key.split("").every((char) => digits.includes(char));
if (key === `A${digits[0].repeat(26)}` || !validChars) {
throw new Error(`invalid order key: ${key}`);
}
// getIntegerPart will throw if the first character is bad,
// or the key is too short. we'd call it to check these things
// even if we didn't need the result
const i = getIntegerPart(key);
const f = key.slice(i.length);
if (f.slice(-1) === digits[0]) {
throw new Error(`invalid order key: ${key}`);
}
}
// note that this may return null, as there is a largest integer
/**
* @param {string} x
* @param {string} digits
* @return {string | null}
*/
function incrementInteger(x: string, digits: string): string | null {
validateInteger(x);
const [head, ...digs] = x.split("");
let carry = true;
for (let i = digs.length - 1; carry && i >= 0; i--) {
const d = digits.indexOf(digs[i]) + 1;
if (d === digits.length) {
digs[i] = digits[0];
} else {
digs[i] = digits[d];
carry = false;
}
}
if (carry) {
if (head === "Z") {
return `a${digits[0]}`;
}
if (head === "z") {
return null;
}
const h = String.fromCharCode(head.charCodeAt(0) + 1);
if (h > "a") {
digs.push(digits[0]);
} else {
digs.pop();
}
return h + digs.join("");
}
return head + digs.join("");
}
// note that this may return null, as there is a smallest integer
/**
* @param {string} x
* @param {string} digits
* @return {string | null}
*/
function decrementInteger(x: string, digits: string): string | null {
validateInteger(x);
const [head, ...digs] = x.split("");
let borrow = true;
for (let i = digs.length - 1; borrow && i >= 0; i--) {
const d = digits.indexOf(digs[i]) - 1;
if (d === -1) {
digs[i] = digits.slice(-1);
} else {
digs[i] = digits[d];
borrow = false;
}
}
if (borrow) {
if (head === "a") {
return `Z${digits.slice(-1)}`;
}
if (head === "A") {
return null;
}
const h = String.fromCharCode(head.charCodeAt(0) - 1);
if (h < "Z") {
digs.push(digits.slice(-1));
} else {
digs.pop();
}
return h + digs.join("");
}
return head + digs.join("");
}
// `a` is an order key or null (START).
// `b` is an order key or null (END).
// `a < b` lexicographically if both are non-null.
// digits is a string such as '0123456789' for base 10. Digits must be in
// ascending character code order!
/**
* @param {string | null | undefined} a
* @param {string | null | undefined} b
* @param {string=} digits
* @return {string}
*/
export function generateKeyBetween(
a: string | null | undefined,
b: string | null | undefined,
digits = BASE_62_DIGITS,
): string {
if (a != null) {
validateOrderKey(a, digits);
}
if (b != null) {
validateOrderKey(b, digits);
}
if (a != null && b != null && a >= b) {
throw new Error(`${a} >= ${b}`);
}
if (a == null) {
if (b == null) {
return `a${digits[0]}`;
}
const ib = getIntegerPart(b);
const fb = b.slice(ib.length);
if (ib === `A${digits[0].repeat(26)}`) {
return ib + midpoint("", fb, digits);
}
if (ib < b) {
return ib;
}
const res = decrementInteger(ib, digits);
if (res == null) {
throw new Error("cannot decrement any more");
}
return res;
}
if (b == null) {
const ia = getIntegerPart(a);
const fa = a.slice(ia.length);
const i = incrementInteger(ia, digits);
return i == null ? ia + midpoint(fa, null, digits) : i;
}
const ia = getIntegerPart(a);
const fa = a.slice(ia.length);
const ib = getIntegerPart(b);
const fb = b.slice(ib.length);
if (ia === ib) {
return ia + midpoint(fa, fb, digits);
}
const i = incrementInteger(ia, digits);
if (i == null) {
throw new Error("cannot increment any more");
}
if (i < b) {
return i;
}
return ia + midpoint(fa, null, digits);
}
/**
* same preconditions as generateKeysBetween.
* n >= 0.
* Returns an array of n distinct keys in sorted order.
* If a and b are both null, returns [a0, a1, ...]
* If one or the other is null, returns consecutive "integer"
* keys. Otherwise, returns relatively short keys between
* a and b.
* @param {string | null | undefined} a
* @param {string | null | undefined} b
* @param {number} n
* @param {string} digits
* @return {string[]}
*/
export function generateNKeysBetween(
a: string | null | undefined,
b: string | null | undefined,
n: number,
digits = BASE_62_DIGITS,
): string[] {
if (n === 0) {
return [];
}
if (n === 1) {
return [generateKeyBetween(a, b, digits)];
}
if (b == null) {
let c = generateKeyBetween(a, b, digits);
const result = [c];
for (let i = 0; i < n - 1; i++) {
c = generateKeyBetween(c, b, digits);
result.push(c);
}
return result;
}
if (a == null) {
let c = generateKeyBetween(a, b, digits);
const result = [c];
for (let i = 0; i < n - 1; i++) {
c = generateKeyBetween(a, c, digits);
result.push(c);
}
result.reverse();
return result;
}
const mid = Math.floor(n / 2);
const c = generateKeyBetween(a, b, digits);
return [
...generateNKeysBetween(a, c, mid, digits),
c,
...generateNKeysBetween(c, b, n - mid - 1, digits),
];
}
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
}
+3 -1
View File
@@ -20,7 +20,9 @@
"@excalidraw/math": ["./math/src/index.ts"],
"@excalidraw/math/*": ["./math/src/*"],
"@excalidraw/utils": ["./utils/src/index.ts"],
"@excalidraw/utils/*": ["./utils/src/*"]
"@excalidraw/utils/*": ["./utils/src/*"],
"@excalidraw/fractional-indexing": ["./fractional-indexing/src/index.ts"],
"@excalidraw/fractional-indexing/*": ["./fractional-indexing/src/*"]
}
}
}