feat: new CMD-K (#4408)

This commit is contained in:
Peng Xiao
2023-09-22 22:31:26 +08:00
committed by GitHub
parent 27e4599c94
commit e0063ebc9b
49 changed files with 2936 additions and 965 deletions

3
packages/cmdk/README.md Normal file
View File

@@ -0,0 +1,3 @@
# copied directly from https://github.com/pacocoursey/cmdk
will remove after a new CMDK version is published to npm

View File

@@ -0,0 +1,12 @@
{
"name": "@affine/cmdk",
"private": true,
"type": "module",
"main": "./src/index.tsx",
"module": "./src/index.tsx",
"devDependencies": {
"react": "18.2.0",
"react-dom": "18.2.0"
},
"version": "0.2.1"
}

View File

@@ -0,0 +1,179 @@
/* eslint-disable */
// @ts-nocheck
// 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.
var 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,
// Match higher for letters closer to the beginning of the word
PENALTY_DISTANCE_FROM_START = 0.9,
// 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;
var IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/,
COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g,
IS_SPACE_REGEXP = /[\s-]/,
COUNT_SPACE_REGEXP = /[\s-]/g;
function commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
stringIndex,
abbreviationIndex,
memoizedResults
) {
if (abbreviationIndex === abbreviation.length) {
if (stringIndex === string.length) {
return SCORE_CONTINUE_MATCH;
}
return PENALTY_NOT_COMPLETE;
}
var memoizeKey = `${stringIndex},${abbreviationIndex}`;
if (memoizedResults[memoizeKey] !== undefined) {
return memoizedResults[memoizeKey];
}
var abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex);
var index = lowerString.indexOf(abbreviationChar, stringIndex);
var highScore = 0;
var score, transposedScore, wordBreaks, spaceBreaks;
while (index >= 0) {
score = commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 1,
memoizedResults
);
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
);
if (transposedScore * SCORE_TRANSPOSITION > score) {
score = transposedScore * SCORE_TRANSPOSITION;
}
}
if (score > highScore) {
highScore = score;
}
index = lowerString.indexOf(abbreviationChar, index + 1);
}
memoizedResults[memoizeKey] = highScore;
return highScore;
}
function formatInput(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): 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.
*/
return commandScoreInner(
string,
abbreviation,
formatInput(string),
formatInput(abbreviation),
0,
0,
{}
);
}

1134
packages/cmdk/src/index.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"include": ["./src"],
"compilerOptions": {
"composite": true,
"noEmit": false,
"outDir": "lib"
}
}

View File

@@ -13,6 +13,7 @@ import {
blockSuiteEditorHeaderStyle,
blockSuiteEditorStyle,
} from './index.css';
import { useRegisterBlocksuiteEditorCommands } from './use-register-blocksuite-editor-commands';
export type EditorProps = {
page: Page;
@@ -164,6 +165,7 @@ export const BlockSuiteFallback = memo(function BlockSuiteFallback() {
export const BlockSuiteEditor = memo(function BlockSuiteEditor(
props: EditorProps & ErrorBoundaryProps
): ReactElement {
useRegisterBlocksuiteEditorCommands();
return (
<ErrorBoundary
fallbackRender={useCallback(

View File

@@ -0,0 +1,35 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon } from '@blocksuite/icons';
import { registerAffineCommand } from '@toeverything/infra/command';
import { useEffect } from 'react';
export function useRegisterBlocksuiteEditorCommands() {
const t = useAFFiNEI18N();
useEffect(() => {
const unsubs: Array<() => void> = [];
const getEdgeless = () => {
return document.querySelector('affine-edgeless-page');
};
unsubs.push(
registerAffineCommand({
id: 'editor:edgeless-presentation-start',
preconditionStrategy: () => !!getEdgeless(),
category: 'editor:edgeless',
icon: <EdgelessIcon />,
label: t['com.affine.cmdk.affine.editor.edgeless.presentation-start'](),
run() {
// this is pretty hack and easy to break. need a better way to communicate with blocksuite editor
document
.querySelector<HTMLElement>('edgeless-toolbar')
?.shadowRoot?.querySelector<HTMLElement>(
'.edgeless-toolbar-left-part > edgeless-tool-icon-button:last-child'
)
?.click();
},
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}, [t]);
}

View File

@@ -8,4 +8,5 @@ export * from './operation-menu-items';
export * from './styles';
export * from './type';
export * from './use-collection-manager';
export * from './utils';
export * from './view';

View File

@@ -584,5 +584,30 @@
"com.affine.workspaceList.addWorkspace.create": "Create Workspace",
"com.affine.workspaceList.workspaceListType.local": "Local Storage",
"com.affine.workspaceList.workspaceListType.cloud": "Cloud Sync",
"Local": "Local"
"Local": "Local",
"com.affine.cmdk.placeholder": "Type a command or search anything...",
"com.affine.cmdk.affine.new-page": "New Page",
"com.affine.cmdk.affine.new-edgeless-page": "New Edgeless",
"com.affine.cmdk.affine.new-workspace": "New Workspace",
"com.affine.cmdk.affine.create-new-page-as": "Create New Page as: <1>{{query}}</1>",
"com.affine.cmdk.affine.create-new-edgeless-as": "Create New Edgeless as: <1>{{query}}</1>",
"com.affine.cmdk.affine.color-scheme.to": "Change Colour Scheme to <1>{{colour}}</1>",
"com.affine.cmdk.affine.left-sidebar.expand": "Expand Left Sidebar",
"com.affine.cmdk.affine.left-sidebar.collapse": "Collapse Left Sidebar",
"com.affine.cmdk.affine.navigation.goto-all-pages": "Go to All Pages",
"com.affine.cmdk.affine.navigation.open-settings": "Go to Settings",
"com.affine.cmdk.affine.navigation.goto-trash": "Go to Trash",
"com.affine.cmdk.affine.category.affine.recent": "Recent",
"com.affine.cmdk.affine.category.affine.navigation": "Navigation",
"com.affine.cmdk.affine.category.affine.pages": "Pages",
"com.affine.cmdk.affine.category.affine.creation": "Create",
"com.affine.cmdk.affine.category.affine.settings": "Settings",
"com.affine.cmdk.affine.category.affine.layout": "Layout Controls",
"com.affine.cmdk.affine.category.affine.help": "Help",
"com.affine.cmdk.affine.category.affine.updates": "Updates",
"com.affine.cmdk.affine.category.affine.general": "General",
"com.affine.cmdk.affine.category.editor.insert-object": "Insert Object",
"com.affine.cmdk.affine.category.editor.page": "Page Commands",
"com.affine.cmdk.affine.category.editor.edgeless": "Edgeless Commands",
"com.affine.cmdk.affine.editor.edgeless.presentation-start": "Start Presentation"
}

View File

@@ -15,6 +15,11 @@
"import": "./dist/blocksuite.js",
"require": "./dist/blocksuite.cjs"
},
"./command": {
"types": "./dist/src/command/index.d.ts",
"import": "./dist/command.js",
"require": "./dist/command.cjs"
},
"./core/*": {
"types": "./dist/src/core/*.d.ts",
"import": "./dist/core/*.js",
@@ -54,6 +59,7 @@
"@blocksuite/global": "0.0.0-20230921103931-38d8f07a-nightly",
"@blocksuite/store": "0.0.0-20230921103931-38d8f07a-nightly",
"jotai": "^2.4.1",
"tinykeys": "^2.1.0",
"zod": "^3.22.2"
},
"devDependencies": {

View File

@@ -0,0 +1,5 @@
# AFFiNE Command Abstractions
This package contains the command abstractions for the AFFiNE framework to be used for CMD-K.
The implementation is highly inspired by the [VSCode Command Abstractions](https://github.com/microsoft/vscode)

View File

@@ -0,0 +1,80 @@
import type { ReactNode } from 'react';
// TODO: need better way for composing different precondition strategies
export enum PreconditionStrategy {
Always,
InPaperOrEdgeless,
InPaper,
InEdgeless,
InEdgelessPresentationMode,
NoSearchResult,
Never,
}
export type CommandCategory =
| 'editor:insert-object'
| 'editor:page'
| 'editor:edgeless'
| 'affine:recent'
| 'affine:pages'
| 'affine:navigation'
| 'affine:creation'
| 'affine:settings'
| 'affine:layout'
| 'affine:updates'
| 'affine:help'
| 'affine:general';
export interface KeybindingOptions {
binding: string;
// some keybindings are already registered in blocksuite
// we can skip the registration of these keybindings __FOR NOW__
skipRegister?: boolean;
}
export interface AffineCommandOptions {
id: string;
// a set of predefined precondition strategies, but also allow user to customize their own
preconditionStrategy?: PreconditionStrategy | (() => boolean);
// main text on the left..
// make text a function so that we can do i18n and interpolation when we need to
label?: string | (() => string) | ReactNode | (() => ReactNode);
icon: React.ReactNode; // todo: need a mapping from string -> React element/SVG
category?: CommandCategory;
// we use https://github.com/jamiebuilds/tinykeys so that we can use the same keybinding definition
// for both mac and windows
// todo: render keybinding in command palette
keyBinding?: KeybindingOptions | string;
run: () => void | Promise<void>;
}
export interface AffineCommand {
readonly id: string;
readonly preconditionStrategy: PreconditionStrategy | (() => boolean);
readonly label?: ReactNode | string;
readonly icon?: React.ReactNode; // icon name
readonly category: CommandCategory;
readonly keyBinding?: KeybindingOptions;
run(): void | Promise<void>;
}
export function createAffineCommand(
options: AffineCommandOptions
): AffineCommand {
return {
id: options.id,
run: options.run,
icon: options.icon,
preconditionStrategy:
options.preconditionStrategy ?? PreconditionStrategy.Always,
category: options.category ?? 'affine:general',
get label() {
const label = options.label;
return typeof label === 'function' ? label?.() : label;
},
keyBinding:
typeof options.keyBinding === 'string'
? { binding: options.keyBinding }
: options.keyBinding,
};
}

View File

@@ -0,0 +1,2 @@
export * from './command';
export * from './registry';

View File

@@ -0,0 +1,64 @@
// @ts-expect-error upstream type is wrong
import { tinykeys } from 'tinykeys';
import {
type AffineCommand,
type AffineCommandOptions,
createAffineCommand,
} from './command';
export const AffineCommandRegistry = new (class {
readonly commands: Map<string, AffineCommand> = new Map();
register(options: AffineCommandOptions) {
if (this.commands.has(options.id)) {
console.warn(`Command ${options.id} already registered.`);
return () => {};
}
const command = createAffineCommand(options);
this.commands.set(command.id, command);
let unsubKb: (() => void) | undefined;
if (
command.keyBinding &&
!command.keyBinding.skipRegister &&
typeof window !== 'undefined'
) {
const { binding: keybinding } = command.keyBinding;
unsubKb = tinykeys(window, {
[keybinding]: async (e: Event) => {
e.preventDefault();
try {
await command.run();
} catch (e) {
console.error(`Failed to invoke keybinding [${keybinding}]`, e);
}
},
});
}
console.debug(`Registered command ${command.id}`);
return () => {
unsubKb?.();
this.commands.delete(command.id);
console.debug(`Unregistered command ${command.id}`);
};
}
get(id: string): AffineCommand | undefined {
if (!this.commands.has(id)) {
console.warn(`Command ${id} not registered.`);
return undefined;
}
return this.commands.get(id);
}
getAll(): AffineCommand[] {
return Array.from(this.commands.values());
}
})();
export function registerAffineCommand(options: AffineCommandOptions) {
return AffineCommandRegistry.register(options);
}

View File

@@ -14,6 +14,7 @@ export default defineConfig({
blocksuite: resolve(root, 'src/blocksuite/index.ts'),
index: resolve(root, 'src/index.ts'),
atom: resolve(root, 'src/atom.ts'),
command: resolve(root, 'src/command/index.ts'),
type: resolve(root, 'src/type.ts'),
'core/event-emitter': resolve(root, 'src/core/event-emitter.ts'),
'preload/electron': resolve(root, 'src/preload/electron.ts'),