mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
feat: new CMD-K (#4408)
This commit is contained in:
3
packages/cmdk/README.md
Normal file
3
packages/cmdk/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# copied directly from https://github.com/pacocoursey/cmdk
|
||||
|
||||
will remove after a new CMDK version is published to npm
|
||||
12
packages/cmdk/package.json
Normal file
12
packages/cmdk/package.json
Normal 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"
|
||||
}
|
||||
179
packages/cmdk/src/command-score.ts
Normal file
179
packages/cmdk/src/command-score.ts
Normal 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
1134
packages/cmdk/src/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
9
packages/cmdk/tsconfig.json
Normal file
9
packages/cmdk/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["./src"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"outDir": "lib"
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
5
packages/infra/src/command/README.md
Normal file
5
packages/infra/src/command/README.md
Normal 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)
|
||||
80
packages/infra/src/command/command.ts
Normal file
80
packages/infra/src/command/command.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2
packages/infra/src/command/index.ts
Normal file
2
packages/infra/src/command/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './command';
|
||||
export * from './registry';
|
||||
64
packages/infra/src/command/registry.ts
Normal file
64
packages/infra/src/command/registry.ts
Normal 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);
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user