mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-21 03:03:15 +08:00
fix(editor): improve code language search and prevent stale highlighting (#15593)
## Summary - Search language IDs, display labels, and aliases case-insensitively, preserving prefix matching and the existing result order. - Ignore pending grammar-load results when the selected language has changed, preventing highlighting from returning after switching to Plain Text. Related to #15492: display-label matching will also make “Plain Text” searchable by its full name, while the highlighting fix prevents pending language loads from restoring highlighting after switching to Plain Text. ## Validation Code-block E2E suite (7 passed), scoped TypeScript checks, lint, and formatting checks passed. Both focused regression tests fail on unmodified `canary` and pass with the fixes. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Code block language search now matches display names, IDs, aliases, and labels without regard to letter case. - Search results prioritize matches at the beginning of language names and aliases over label-only matches. - **Bug Fixes** - Prevented outdated syntax highlighting from appearing after a code block language is changed or cleared while loading. - Prevented duplicate language-loading requests during concurrent highlighting updates. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com> Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
co-authored by
DarkSky
DarkSky
parent
fb515c6d79
commit
f26301a9fb
@@ -6,6 +6,7 @@ import {
|
||||
createHighlighterCore,
|
||||
createOnigurumaEngine,
|
||||
type HighlighterCore,
|
||||
type LanguageInput,
|
||||
type MaybeGetter,
|
||||
} from 'shiki';
|
||||
import getWasm from 'shiki/wasm';
|
||||
@@ -22,6 +23,7 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
|
||||
// Singleton highlighter instance
|
||||
private static _sharedHighlighter: HighlighterCore | null = null;
|
||||
private static _highlighterPromise: Promise<HighlighterCore> | null = null;
|
||||
private static readonly _languagePromises = new Map<string, Promise<void>>();
|
||||
private static _refCount = 0;
|
||||
|
||||
private _darkThemeKey: string | undefined;
|
||||
@@ -36,6 +38,20 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
|
||||
: this._lightThemeKey;
|
||||
}
|
||||
|
||||
loadLanguage(lang: string, input: LanguageInput) {
|
||||
const highlighter = this.highlighter$.value;
|
||||
if (!highlighter) return Promise.resolve();
|
||||
|
||||
let promise = CodeBlockHighlighter._languagePromises.get(lang);
|
||||
if (!promise) {
|
||||
promise = highlighter.loadLanguage(input).finally(() => {
|
||||
CodeBlockHighlighter._languagePromises.delete(lang);
|
||||
});
|
||||
CodeBlockHighlighter._languagePromises.set(lang, promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
private readonly _loadTheme = async (
|
||||
highlighter: HighlighterCore
|
||||
): Promise<void> => {
|
||||
|
||||
@@ -40,6 +40,8 @@ import { codeBlockStyles } from './styles.js';
|
||||
export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel> {
|
||||
static override styles = codeBlockStyles;
|
||||
|
||||
private _highlightRequestId = 0;
|
||||
|
||||
private _inlineRangeProvider: InlineRangeProvider | null = null;
|
||||
|
||||
private readonly _localPreview$ = signal<boolean | null>(null);
|
||||
@@ -118,6 +120,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
|
||||
}
|
||||
|
||||
private _updateHighlightTokens() {
|
||||
const requestId = ++this._highlightRequestId;
|
||||
const modelLang = this.model.props.language$.value;
|
||||
if (modelLang === null) {
|
||||
this.highlightTokens$.value = [];
|
||||
@@ -148,9 +151,10 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
|
||||
|
||||
const loadedLanguages = highlighter.getLoadedLanguages();
|
||||
if (!loadedLanguages.includes(lang)) {
|
||||
highlighter
|
||||
.loadLanguage(langImport)
|
||||
this.highlighter
|
||||
.loadLanguage(lang, langImport)
|
||||
.then(() => {
|
||||
if (requestId !== this._highlightRequestId) return;
|
||||
this.highlightTokens$.value = highlighter.codeToTokensBase(code, {
|
||||
lang,
|
||||
theme,
|
||||
|
||||
@@ -43,15 +43,20 @@ export class FilterableListComponent<Props = unknown> extends WithDisposable(
|
||||
}
|
||||
|
||||
private _filterItems() {
|
||||
const searchFilter = !this._filterText
|
||||
const query = this._filterText.toLowerCase();
|
||||
const matchRank = (item: FilterableListItem<Props>) => {
|
||||
if (!query) return 0;
|
||||
if (
|
||||
item.name.toLowerCase().startsWith(query) ||
|
||||
item.aliases?.some(alias => alias.toLowerCase().startsWith(query))
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return item.label?.toLowerCase().startsWith(query) ? 1 : -1;
|
||||
};
|
||||
const searchFilter = !query
|
||||
? this.options.items
|
||||
: this.options.items.filter(
|
||||
item =>
|
||||
item.name.startsWith(this._filterText.toLowerCase()) ||
|
||||
item.aliases?.some(alias =>
|
||||
alias.startsWith(this._filterText.toLowerCase())
|
||||
)
|
||||
);
|
||||
: this.options.items.filter(item => matchRank(item) !== -1);
|
||||
return searchFilter.sort((a, b) => {
|
||||
const isActiveA = this.options.active?.(a);
|
||||
const isActiveB = this.options.active?.(b);
|
||||
@@ -59,6 +64,9 @@ export class FilterableListComponent<Props = unknown> extends WithDisposable(
|
||||
if (isActiveA && !isActiveB) return -1;
|
||||
if (!isActiveA && isActiveB) return 1;
|
||||
|
||||
const rankDiff = matchRank(a) - matchRank(b);
|
||||
if (rankDiff) return rankDiff;
|
||||
|
||||
return this.listFilter?.(a, b) ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,26 +19,6 @@ test.describe('Code Block Autocomplete Operations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Code Block Language Selector', () => {
|
||||
test('switch language and back to plain text', async ({ page }) => {
|
||||
await initCodeBlockByOneStep(page);
|
||||
const code = page.locator('affine-code');
|
||||
|
||||
await code.hover({
|
||||
position: { x: 155, y: 65 },
|
||||
});
|
||||
await page.getByTestId('lang-button').click();
|
||||
await page.getByRole('button', { name: 'Rust' }).click();
|
||||
|
||||
await expect(page.getByTestId('lang-button')).toHaveText('Rust');
|
||||
|
||||
await page.getByTestId('lang-button').click();
|
||||
await page.getByRole('button', { name: 'Plain Text' }).click();
|
||||
|
||||
await expect(page.getByTestId('lang-button')).toHaveText('Plain Text');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Code Block Preview', () => {
|
||||
test('enable html preview', async ({ page }) => {
|
||||
const code = page.locator('affine-code');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CodeBlockComponent } from '@blocksuite/affine/blocks/code';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { updateBlockType } from '../utils/actions/block.js';
|
||||
@@ -160,7 +161,18 @@ test('change code language can work', async ({ page }, testInfo) => {
|
||||
const locator = codeBlockController.langList;
|
||||
await expect(locator).toBeVisible();
|
||||
|
||||
await type(page, 'rust');
|
||||
for (const [query, label] of [
|
||||
['AsSeMbLy', 'Assembly'],
|
||||
['TS-TAGS', 'TypeScript with Tags'],
|
||||
['rS', 'Rust'],
|
||||
]) {
|
||||
await codeBlockController.langFilterInput.fill(query);
|
||||
await expect(
|
||||
locator.getByRole('button', { name: label, exact: true })
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
await codeBlockController.langFilterInput.fill('rust');
|
||||
await page.click(
|
||||
'.affine-filterable-list > .items-container > icon-button:nth-child(1)'
|
||||
);
|
||||
@@ -183,6 +195,68 @@ test('change code language can work', async ({ page }, testInfo) => {
|
||||
await pressEnter(page);
|
||||
await expect(locator).toBeHidden();
|
||||
await expect(codeBlockController.languageButton).toHaveText('TypeScript');
|
||||
|
||||
await codeBlockController.clickLanguageButton();
|
||||
await locator.getByRole('button', { name: 'Plain Text' }).click();
|
||||
await expect(codeBlockController.languageButton).toHaveText('Plain Text');
|
||||
});
|
||||
|
||||
test('ignore a pending highlight after switching to plain text', async ({
|
||||
page,
|
||||
}) => {
|
||||
await enterPlaygroundRoom(page);
|
||||
await initEmptyCodeBlockState(page);
|
||||
await focusRichText(page);
|
||||
await type(page, 'const answer = 42;');
|
||||
|
||||
const code = getCodeBlock(page).codeBlock;
|
||||
await expect
|
||||
.poll(() =>
|
||||
code.evaluate(
|
||||
(block: CodeBlockComponent) => !!block.highlighter.highlighter$.value
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const result = await code.evaluate(async (block: CodeBlockComponent) => {
|
||||
const highlighter = block.highlighter.highlighter$.value!;
|
||||
const rust = block.langs.find(lang => lang.id === 'rust')!;
|
||||
await highlighter.loadLanguage(rust.import);
|
||||
|
||||
const originalGetLoadedLanguages = highlighter.getLoadedLanguages;
|
||||
const originalLoadLanguage = highlighter.loadLanguage;
|
||||
let finishLoading!: () => void;
|
||||
let loadRequests = 0;
|
||||
const pendingLoad = new Promise<void>(resolve => {
|
||||
finishLoading = resolve;
|
||||
});
|
||||
try {
|
||||
highlighter.getLoadedLanguages = () => [];
|
||||
highlighter.loadLanguage = () => {
|
||||
loadRequests++;
|
||||
return pendingLoad;
|
||||
};
|
||||
block.model.props.language$.value = 'rust';
|
||||
block.model.props.language$.value = null;
|
||||
finishLoading();
|
||||
await pendingLoad;
|
||||
await Promise.resolve();
|
||||
|
||||
return {
|
||||
loadRequests,
|
||||
language: block.model.props.language,
|
||||
tokens: block.highlightTokens$.value,
|
||||
};
|
||||
} finally {
|
||||
highlighter.getLoadedLanguages = originalGetLoadedLanguages;
|
||||
highlighter.loadLanguage = originalLoadLanguage;
|
||||
}
|
||||
});
|
||||
expect(result).toEqual({
|
||||
loadRequests: 1,
|
||||
language: null,
|
||||
tokens: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('duplicate code block', async ({ page }, testInfo) => {
|
||||
|
||||
Reference in New Issue
Block a user