Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: upgrade cmdk to 1.0.0 #6401

Merged
merged 1 commit into from
Mar 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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/info@cue.org.uk', '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