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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user