Skip to content

Commit

Permalink
chore: upgrade cmdk to 1.0.0 (#6401)
Browse files Browse the repository at this point in the history
Also include the command score into our own repo for some tweaks.

Might fix #6322
  • Loading branch information
pengx17 committed Mar 30, 2024
1 parent 822bbb5 commit f41d587
Show file tree
Hide file tree
Showing 7 changed files with 304 additions and 232 deletions.
202 changes: 0 additions & 202 deletions .yarn/patches/cmdk-npm-0.2.0-302237a911.patch

This file was deleted.

2 changes: 1 addition & 1 deletion packages/frontend/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"async-call-rpc": "^6.4.0",
"bytes": "^3.1.2",
"clsx": "^2.1.0",
"cmdk": "patch:cmdk@npm%3A0.2.0#~/.yarn/patches/cmdk-npm-0.2.0-302237a911.patch",
"cmdk": "^1.0.0",
"css-spring": "^4.1.0",
"dayjs": "^1.11.10",
"foxact": "^0.2.31",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';

import { commandScore } from '../command-score';

describe('commandScore', function () {
it('should match exact strings exactly', function () {
expect(commandScore('hello', 'hello')).to.equal(1);
});

it('should prefer case-sensitive matches', function () {
expect(commandScore('Hello', 'Hello')).to.be.greaterThan(
commandScore('Hello', 'hello')
);
});

it('should mark down prefixes', function () {
expect(commandScore('hello', 'hello')).to.be.greaterThan(
commandScore('hello', 'he')
);
});

it('should score all prefixes the same', function () {
expect(commandScore('help', 'he')).to.equal(commandScore('hello', 'he'));
});

it('should mark down word jumps', function () {
expect(commandScore('hello world', 'hello')).to.be.greaterThan(
commandScore('hello world', 'hewo')
);
});

it('should score similar word jumps the same', function () {
expect(commandScore('hello world', 'hewo')).to.equal(
commandScore('hey world', 'hewo')
);
});

it('should penalize long word jumps', function () {
expect(commandScore('hello world', 'hewo')).to.be.greaterThan(
commandScore('hello kind world', 'hewo')
);
});

it('should match missing characters', function () {
expect(commandScore('hello', 'hl')).to.be.greaterThan(0);
});

it('should penalize more for more missing characters', function () {
expect(commandScore('hello', 'hllo')).to.be.greaterThan(
commandScore('hello', 'hlo')
);
});

it('should penalize more for missing characters than case', function () {
expect(commandScore('go to Inbox', 'in')).to.be.greaterThan(
commandScore('go to Unversity/Societies/CUE/[email protected]', 'in')
);
});

it('should match transpotisions', function () {
expect(commandScore('hello', 'hle')).to.be.greaterThan(0);
});

it('should not match with a trailing letter', function () {
expect(commandScore('ss', 'sss')).to.equal(0.1);
});

it('should match long jumps', function () {
expect(commandScore('go to @QuickFix', 'fix')).to.be.greaterThan(0);
expect(commandScore('go to Quick Fix', 'fix')).to.be.greaterThan(
commandScore('go to @QuickFix', 'fix')
);
});

it('should work well with the presence of an m-dash', function () {
expect(commandScore('no go — Windows', 'windows')).to.be.greaterThan(0);
});

it('should be robust to duplicated letters', function () {
expect(commandScore('talent', 'tall')).to.be.equal(0.099);
});

it('should not allow letter insertion', function () {
expect(commandScore('talent', 'tadlent')).to.be.equal(0);
});

it('should match - with " " characters', function () {
expect(commandScore('Auto-Advance', 'Auto Advance')).to.be.equal(0.9999);
});

it('should score long strings quickly', function () {
expect(
commandScore(
'go to this is a really long label that is really longthis is a really long label that is really longthis is a really long label that is really longthis is a really long label that is really long',
'this is a'
)
).to.be.equal(0.891);
});
});
195 changes: 195 additions & 0 deletions packages/frontend/core/src/components/pure/cmdk/command-score.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// The scores are arranged so that a continuous match of characters will
// result in a total score of 1.
//
// The best case, this character is a match, and either this is the start
// of the string, or the previous character was also a match.
const SCORE_CONTINUE_MATCH = 1,
// A new match at the start of a word scores better than a new match
// elsewhere as it's more likely that the user will type the starts
// of fragments.
// NOTE: We score word jumps between spaces slightly higher than slashes, brackets
// hyphens, etc.
SCORE_SPACE_WORD_JUMP = 0.9,
SCORE_NON_SPACE_WORD_JUMP = 0.8,
// Any other match isn't ideal, but we include it for completeness.
SCORE_CHARACTER_JUMP = 0.17,
// If the user transposed two letters, it should be significantly penalized.
//
// i.e. "ouch" is more likely than "curtain" when "uc" is typed.
SCORE_TRANSPOSITION = 0.1,
// The goodness of a match should decay slightly with each missing
// character.
//
// i.e. "bad" is more likely than "bard" when "bd" is typed.
//
// This will not change the order of suggestions based on SCORE_* until
// 100 characters are inserted between matches.
PENALTY_SKIPPED = 0.999,
// The goodness of an exact-case match should be higher than a
// case-insensitive match by a small amount.
//
// i.e. "HTML" is more likely than "haml" when "HM" is typed.
//
// This will not change the order of suggestions based on SCORE_* until
// 1000 characters are inserted between matches.
PENALTY_CASE_MISMATCH = 0.9999,
// If the word has more characters than the user typed, it should
// be penalised slightly.
//
// i.e. "html" is more likely than "html5" if I type "html".
//
// However, it may well be the case that there's a sensible secondary
// ordering (like alphabetical) that it makes sense to rely on when
// there are many prefix matches, so we don't make the penalty increase
// with the number of tokens.
PENALTY_NOT_COMPLETE = 0.99;

const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/,
COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g,
IS_SPACE_REGEXP = /[\s-]/,
COUNT_SPACE_REGEXP = /[\s-]/g;

const MAX_RECUR = 1500;

function commandScoreInner(
string: string,
abbreviation: string,
lowerString: string,
lowerAbbreviation: string,
stringIndex: number,
abbreviationIndex: number,
memoizedResults: Record<string, number>,
recur: number = 0
) {
recur += 1;
if (abbreviationIndex === abbreviation.length) {
if (stringIndex === string.length) {
return SCORE_CONTINUE_MATCH;
}
return PENALTY_NOT_COMPLETE;
}

const memoizeKey = `${stringIndex},${abbreviationIndex}`;
if (memoizedResults[memoizeKey] !== undefined) {
return memoizedResults[memoizeKey];
}

const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex);
let index = lowerString.indexOf(abbreviationChar, stringIndex);
let highScore = 0;

let score, transposedScore, wordBreaks, spaceBreaks;

while (index >= 0) {
score = commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 1,
memoizedResults,
recur
);
if (score > highScore) {
if (index === stringIndex) {
score *= SCORE_CONTINUE_MATCH;
} else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) {
score *= SCORE_NON_SPACE_WORD_JUMP;
wordBreaks = string
.slice(stringIndex, index - 1)
.match(COUNT_GAPS_REGEXP);
if (wordBreaks && stringIndex > 0) {
score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length);
}
} else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) {
score *= SCORE_SPACE_WORD_JUMP;
spaceBreaks = string
.slice(stringIndex, index - 1)
.match(COUNT_SPACE_REGEXP);
if (spaceBreaks && stringIndex > 0) {
score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length);
}
} else {
score *= SCORE_CHARACTER_JUMP;
if (stringIndex > 0) {
score *= Math.pow(PENALTY_SKIPPED, index - stringIndex);
}
}

if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) {
score *= PENALTY_CASE_MISMATCH;
}
}

if (
(score < SCORE_TRANSPOSITION &&
lowerString.charAt(index - 1) ===
lowerAbbreviation.charAt(abbreviationIndex + 1)) ||
(lowerAbbreviation.charAt(abbreviationIndex + 1) ===
lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428
lowerString.charAt(index - 1) !==
lowerAbbreviation.charAt(abbreviationIndex))
) {
transposedScore = commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 2,
memoizedResults,
recur
);

if (transposedScore * SCORE_TRANSPOSITION > score) {
score = transposedScore * SCORE_TRANSPOSITION;
}
}

if (score > highScore) {
highScore = score;
}

index = lowerString.indexOf(abbreviationChar, index + 1);

if (recur > MAX_RECUR || score > 0.85) {
break;
}
}

memoizedResults[memoizeKey] = highScore;
return highScore;
}

function formatInput(string: string) {
// convert all valid space characters to space so they match each other
return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ');
}

export function commandScore(
string: string,
abbreviation: string,
aliases?: string[]
): number {
/* NOTE:
* in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase()
* was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster.
*/
string =
aliases && aliases.length > 0
? `${string + ' ' + aliases.join(' ')}`
: string;
const memoizedResults = {};
const result = commandScoreInner(
string,
abbreviation,
formatInput(string),
formatInput(abbreviation),
0,
0,
memoizedResults
);

return result;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CommandCategory } from '@toeverything/infra';
import { commandScore } from 'cmdk';
import { groupBy } from 'lodash-es';

import { commandScore } from './command-score';
import type { CMDKCommand } from './types';
import { highlightTextFragments } from './use-highlight';

Expand Down
2 changes: 1 addition & 1 deletion tests/affine-local/e2e/quick-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const insertInputText = async (page: Page, text: string) => {
const keyboardDownAndSelect = async (page: Page, label: string) => {
await page.keyboard.press('ArrowDown');
const selectedEl = page.locator(
'[cmdk-item][data-selected] [data-testid="cmdk-label"]'
'[cmdk-item][data-selected="true"] [data-testid="cmdk-label"]'
);
if (
!(await selectedEl.isVisible()) ||
Expand Down
34 changes: 7 additions & 27 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ __metadata:
async-call-rpc: "npm:^6.4.0"
bytes: "npm:^3.1.2"
clsx: "npm:^2.1.0"
cmdk: "patch:cmdk@npm%3A0.2.0#~/.yarn/patches/cmdk-npm-0.2.0-302237a911.patch"
cmdk: "npm:^1.0.0"
css-spring: "npm:^4.1.0"
dayjs: "npm:^1.11.10"
express: "npm:^4.18.2"
Expand Down Expand Up @@ -18054,29 +18054,16 @@ __metadata:
languageName: node
linkType: hard

"cmdk@npm:0.2.0":
version: 0.2.0
resolution: "cmdk@npm:0.2.0"
dependencies:
"@radix-ui/react-dialog": "npm:1.0.0"
command-score: "npm:0.1.2"
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
checksum: 10/e178e3d3276e0b5fd158c9c99716c0405427871f48fa97c15c4be2de24be4a478cf0205ffa04244628dbe103dd8573a1bd1aa68f04f8b60633d4ffc04e5eee62
languageName: node
linkType: hard

"cmdk@patch:cmdk@npm%3A0.2.0#~/.yarn/patches/cmdk-npm-0.2.0-302237a911.patch":
version: 0.2.0
resolution: "cmdk@patch:cmdk@npm%3A0.2.0#~/.yarn/patches/cmdk-npm-0.2.0-302237a911.patch::version=0.2.0&hash=640d85"
"cmdk@npm:^1.0.0":
version: 1.0.0
resolution: "cmdk@npm:1.0.0"
dependencies:
"@radix-ui/react-dialog": "npm:1.0.0"
command-score: "npm:0.1.2"
"@radix-ui/react-dialog": "npm:1.0.5"
"@radix-ui/react-primitive": "npm:1.0.3"
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
checksum: 10/758bacb7761a72c6fa03a1b20ea2514ff14ad6b3d00cc1d8bc6781a216b0a719f991eacded9f923ddcf1b58b8efb304209b268c17bd7d6f5671aa3352934b754
checksum: 10/7a0675783d9b12828c30b044993d1ecf0e9230984c04f7a1714025804d34294b2b0f8958f30b26fe3b5be276b3cd874dbe1d0bc27cd25d15daa06adfcd3feb85
languageName: node
linkType: hard

Expand Down Expand Up @@ -18218,13 +18205,6 @@ __metadata:
languageName: node
linkType: hard

"command-score@npm:0.1.2":
version: 0.1.2
resolution: "command-score@npm:0.1.2"
checksum: 10/84f6a69e6b215d3fc8c9ed402d109587f511e4cc84cd5da10a7857b50fb1638953e32dcce8ed8f3549b0bfe499e82601fb7fb6891c9c71b48933d4bb8bac238a
languageName: node
linkType: hard

"commander@npm:11.1.0":
version: 11.1.0
resolution: "commander@npm:11.1.0"
Expand Down

0 comments on commit f41d587

Please sign in to comment.