fix(editor): editor behavior and styles (#14498)

fix #14269 
fix #13920
fix #13977
fix #13953
fix #13895
fix #13905
fix #14136
fix #14357
fix #14491

#### PR Dependency Tree


* **PR #14498** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
  * Callout and toolbar defaults now reliably show grey backgrounds
  * Keyboard shortcuts behave better across layouts and non-ASCII input
  * Deleted workspaces no longer appear in local listings

* **New Features**
  * Cell editing now respects pre-entry validation hooks
* Scrollbars use themeable variables and include Chromium compatibility
fixes

* **Style**
  * Minor UI color adjustment for hidden properties

* **Tests**
  * Added unit tests for table column handling and keymap behavior
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-23 06:37:16 +08:00
committed by GitHub
parent ad988dbd1e
commit ef6717e59a
13 changed files with 309 additions and 13 deletions

View File

@@ -216,9 +216,13 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
override renderBlock() { override renderBlock() {
const icon = this.model.props.icon$.value; const icon = this.model.props.icon$.value;
const backgroundColorName = this.model.props.backgroundColorName$.value; const backgroundColorName = this.model.props.backgroundColorName$.value;
const normalizedBackgroundName =
backgroundColorName === 'default' || backgroundColorName === ''
? 'grey'
: backgroundColorName;
const backgroundColor = ( const backgroundColor = (
cssVarV2.block.callout.background as Record<string, string> cssVarV2.block.callout.background as Record<string, string>
)[backgroundColorName ?? '']; )[normalizedBackgroundName ?? 'grey'];
const iconContent = getIcon(icon); const iconContent = getIcon(icon);

View File

@@ -68,14 +68,14 @@ const backgroundColorAction = {
${repeat(colors, color => { ${repeat(colors, color => {
const isDefault = color === 'default'; const isDefault = color === 'default';
const value = isDefault const value = isDefault
? null ? cssVarV2.block.callout.background.grey
: `var(--affine-text-highlight-${color})`; : `var(--affine-text-highlight-${color})`;
const displayName = `${color} Background`; const displayName = `${color} Background`;
return html` return html`
<editor-menu-action <editor-menu-action
data-testid="background-${color}" data-testid="background-${color}"
@click=${() => updateBackground(color)} @click=${() => updateBackground(isDefault ? 'grey' : color)}
> >
<affine-text-duotone-icon <affine-text-duotone-icon
style=${styleMap({ style=${styleMap({

View File

@@ -27,6 +27,16 @@ export const codeBlockStyles = css`
${scrollbarStyle('.affine-code-block-container rich-text')} ${scrollbarStyle('.affine-code-block-container rich-text')}
/* In Chromium 121+, non-auto scrollbar-width/color override ::-webkit-scrollbar styles. */
@supports not selector(::-webkit-scrollbar) {
.affine-code-block-container rich-text {
scrollbar-width: thin;
scrollbar-color: ${unsafeCSSVarV2('icon/secondary', '#b1b1b1')}
transparent;
scrollbar-gutter: stable both-edges;
}
}
.affine-code-block-container .inline-editor { .affine-code-block-container .inline-editor {
font-family: var(--affine-font-code-family); font-family: var(--affine-font-code-family);
font-variant-ligatures: none; font-variant-ligatures: none;

View File

@@ -6,10 +6,12 @@ import {
NumberFormatSchema, NumberFormatSchema,
parseNumber, parseNumber,
} from '../property-presets/number/utils/formatter.js'; } from '../property-presets/number/utils/formatter.js';
import { DEFAULT_COLUMN_WIDTH } from '../view-presets/table/consts.js';
import { mobileEffects } from '../view-presets/table/mobile/effect.js'; import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js'; import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js'; import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js'; import type { TableGroup } from '../view-presets/table/pc/group.js';
import { materializeTableColumns } from '../view-presets/table/table-view-manager.js';
/** @vitest-environment happy-dom */ /** @vitest-environment happy-dom */
@@ -41,6 +43,56 @@ describe('TableGroup', () => {
}); });
}); });
describe('table column materialization', () => {
test('appends missing properties while preserving existing order and state', () => {
const columns = [
{ id: 'status', width: 240, hide: true },
{ id: 'title', width: 320 },
];
const next = materializeTableColumns(columns, ['title', 'status', 'date']);
expect(next).toEqual([
{ id: 'status', width: 240, hide: true },
{ id: 'title', width: 320 },
{ id: 'date', width: DEFAULT_COLUMN_WIDTH },
]);
});
test('drops stale columns that no longer exist in data source', () => {
const columns = [
{ id: 'title', width: 320 },
{ id: 'removed', width: 200, hide: true },
];
const next = materializeTableColumns(columns, ['title']);
expect(next).toEqual([{ id: 'title', width: 320 }]);
});
test('returns original reference when columns are already materialized', () => {
const columns = [
{ id: 'title', width: 320 },
{ id: 'status', width: 240, hide: true },
];
const next = materializeTableColumns(columns, ['title', 'status']);
expect(next).toBe(columns);
});
test('supports type-aware default width when materializing missing columns', () => {
const next = materializeTableColumns([], ['title', 'status'], id =>
id === 'title' ? 260 : DEFAULT_COLUMN_WIDTH
);
expect(next).toEqual([
{ id: 'title', width: 260 },
{ id: 'status', width: DEFAULT_COLUMN_WIDTH },
]);
});
});
describe('number formatter', () => { describe('number formatter', () => {
test('number format menu should expose all schema formats', () => { test('number format menu should expose all schema formats', () => {
const menuFormats = numberFormats.map(format => format.type); const menuFormats = numberFormats.map(format => format.type);

View File

@@ -54,7 +54,9 @@ export class DatabaseCellContainer extends SignalWatcher(
const selectionView = this.selectionView; const selectionView = this.selectionView;
if (selectionView) { if (selectionView) {
const selection = selectionView.selection; const selection = selectionView.selection;
if (selection && this.isSelected(selection) && editing) { const shouldEnterEditMode =
editing && this.cell?.beforeEnterEditMode() !== false;
if (selection && this.isSelected(selection) && shouldEnterEditMode) {
selectionView.selection = TableViewAreaSelection.create({ selectionView.selection = TableViewAreaSelection.create({
groupKey: this.groupKey, groupKey: this.groupKey,
focus: { focus: {

View File

@@ -57,7 +57,9 @@ export class TableViewCellContainer extends SignalWatcher(
const selectionView = this.selectionController; const selectionView = this.selectionController;
if (selectionView) { if (selectionView) {
const selection = selectionView.selection; const selection = selectionView.selection;
if (selection && this.isSelected(selection) && editing) { const shouldEnterEditMode =
editing && this.cell?.beforeEnterEditMode() !== false;
if (selection && this.isSelected(selection) && shouldEnterEditMode) {
selectionView.selection = TableViewAreaSelection.create({ selectionView.selection = TableViewAreaSelection.create({
groupKey: this.groupKey, groupKey: this.groupKey,
focus: { focus: {

View File

@@ -26,6 +26,52 @@ import type { ViewManager } from '../../core/view-manager/view-manager.js';
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_COLUMN_WIDTH } from './consts.js'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_COLUMN_WIDTH } from './consts.js';
import type { TableViewData } from './define.js'; import type { TableViewData } from './define.js';
export const materializeColumnsByPropertyIds = (
columns: TableColumnData[],
propertyIds: string[],
getDefaultWidth: (id: string) => number = () => DEFAULT_COLUMN_WIDTH
) => {
const needShow = new Set(propertyIds);
const orderedColumns: TableColumnData[] = [];
for (const column of columns) {
if (needShow.has(column.id)) {
orderedColumns.push(column);
needShow.delete(column.id);
}
}
for (const id of needShow) {
orderedColumns.push({ id, width: getDefaultWidth(id), hide: undefined });
}
return orderedColumns;
};
export const materializeTableColumns = (
columns: TableColumnData[],
propertyIds: string[],
getDefaultWidth?: (id: string) => number
) => {
const nextColumns = materializeColumnsByPropertyIds(
columns,
propertyIds,
getDefaultWidth
);
const unchanged =
columns.length === nextColumns.length &&
columns.every((column, index) => {
const nextColumn = nextColumns[index];
return (
nextColumn != null &&
column.id === nextColumn.id &&
column.hide === nextColumn.hide
);
});
return unchanged ? columns : nextColumns;
};
export class TableSingleView extends SingleViewBase<TableViewData> { export class TableSingleView extends SingleViewBase<TableViewData> {
propertiesRaw$ = computed(() => { propertiesRaw$ = computed(() => {
const needShow = new Set(this.dataSource.properties$.value); const needShow = new Set(this.dataSource.properties$.value);
@@ -220,10 +266,6 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
return this.data$.value?.mode ?? 'table'; return this.data$.value?.mode ?? 'table';
} }
constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
}
isShow(rowId: string): boolean { isShow(rowId: string): boolean {
if (this.filter$.value?.conditions.length) { if (this.filter$.value?.conditions.length) {
const rowMap = Object.fromEntries( const rowMap = Object.fromEntries(
@@ -290,6 +332,33 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
}); });
} }
); );
private materializeColumns() {
const data = this.data$.value;
if (!data) {
return;
}
const nextColumns = materializeTableColumns(
data.columns,
this.dataSource.properties$.value,
id => this.propertyGetOrCreate(id).width$.value
);
if (nextColumns === data.columns) {
return;
}
this.dataUpdate(() => ({ columns: nextColumns }));
}
constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
// Materialize view columns on view activation so newly added properties
// can participate in hide/order operations in table.
queueMicrotask(() => {
this.materializeColumns();
});
}
} }
type TableColumnData = TableViewData['columns'][number]; type TableColumnData = TableViewData['columns'][number];

View File

@@ -1,5 +1,7 @@
import { css, unsafeCSS } from 'lit'; import { css, unsafeCSS } from 'lit';
import { unsafeCSSVarV2 } from '../theme/css-variables';
/** /**
* You should add a container before the scrollbar style to prevent the style pollution of the whole doc. * You should add a container before the scrollbar style to prevent the style pollution of the whole doc.
*/ */
@@ -28,7 +30,7 @@ export const scrollbarStyle = (container: string) => {
} }
${unsafeCSS(container)}::-webkit-scrollbar-thumb { ${unsafeCSS(container)}::-webkit-scrollbar-thumb {
border-radius: 2px; border-radius: 2px;
background-color: #b1b1b1; background-color: ${unsafeCSSVarV2('icon/secondary', '#b1b1b1')};
} }
${unsafeCSS(container)}::-webkit-scrollbar-corner { ${unsafeCSS(container)}::-webkit-scrollbar-corner {
display: none; display: none;

View File

@@ -0,0 +1,119 @@
import { describe, expect, test } from 'vitest';
import { bindKeymap } from '../event/keymap.js';
const createKeyboardEvent = (options: {
key: string;
keyCode: number;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
}): KeyboardEvent => {
const event = new KeyboardEvent('keydown', {
key: options.key,
altKey: options.altKey ?? false,
ctrlKey: options.ctrlKey ?? false,
metaKey: options.metaKey ?? false,
shiftKey: options.shiftKey ?? false,
});
Object.defineProperty(event, 'keyCode', {
configurable: true,
get: () => options.keyCode,
});
Object.defineProperty(event, 'which', {
configurable: true,
get: () => options.keyCode,
});
return event;
};
const createCtx = (event: KeyboardEvent) => {
return {
get(name: string) {
if (name === 'keyboardState') {
return { raw: event };
}
return undefined;
},
} as any;
};
describe('bindKeymap', () => {
test('falls back to physical key for ctrl shortcuts on non-US layouts', () => {
let handled = false;
const handler = bindKeymap({
'Ctrl-f': () => {
handled = true;
return true;
},
});
const event = createKeyboardEvent({
key: 'а',
keyCode: 70,
ctrlKey: true,
});
expect(handler(createCtx(event))).toBe(true);
expect(handled).toBe(true);
});
test('does not fallback for Alt+locale-character letter input', () => {
let handled = false;
const handler = bindKeymap({
'Alt-s': () => {
handled = true;
return true;
},
});
const event = createKeyboardEvent({
key: 'ś',
keyCode: 83,
altKey: true,
});
expect(handler(createCtx(event))).toBe(false);
expect(handled).toBe(false);
});
test('keeps Alt+digit fallback for non-ASCII key outputs', () => {
let handled = false;
const handler = bindKeymap({
'Alt-0': () => {
handled = true;
return true;
},
});
const event = createKeyboardEvent({
key: 'º',
keyCode: 48,
altKey: true,
});
expect(handler(createCtx(event))).toBe(true);
expect(handled).toBe(true);
});
test('does not fallback on non-ASCII input without modifiers', () => {
let handled = false;
const handler = bindKeymap({
'[': () => {
handled = true;
return true;
},
});
const event = createKeyboardEvent({
key: 'х',
keyCode: 219,
});
expect(handler(createCtx(event))).toBe(false);
expect(handled).toBe(false);
});
});

View File

@@ -90,9 +90,21 @@ export function bindKeymap(
// Do NOT fallback when the key produces a non-ASCII character (e.g., Cyrillic 'х' on Russian keyboard), // Do NOT fallback when the key produces a non-ASCII character (e.g., Cyrillic 'х' on Russian keyboard),
// because the user intends to type that character, not trigger a shortcut bound to the physical key. // because the user intends to type that character, not trigger a shortcut bound to the physical key.
// See: https://github.com/toeverything/AFFiNE/issues/14059 // See: https://github.com/toeverything/AFFiNE/issues/14059
const hasModifier = event.shiftKey || event.altKey || event.metaKey; const hasModifier =
event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
const baseName = base[event.keyCode]; const baseName = base[event.keyCode];
if (hasModifier && baseName && baseName !== name) { const isSingleAscii = name.length === 1 && name.charCodeAt(0) <= 0x7e;
const isAltInputChar = event.altKey && !event.ctrlKey && !isSingleAscii;
// Keep supporting existing Alt+digit shortcuts (e.g. Alt-0/1/2 in edgeless)
// while preventing Alt-based locale input characters from triggering letter shortcuts.
const isDigitBaseKey =
baseName != null && baseName.length === 1 && /[0-9]/.test(baseName);
if (
hasModifier &&
baseName &&
baseName !== name &&
!(isAltInputChar && !isDigitBaseKey)
) {
const fromCode = map[modifiers(baseName, event)]; const fromCode = map[modifiers(baseName, event)];
if (fromCode && fromCode(ctx)) { if (fromCode && fromCode(ctx)) {
return true; return true;

View File

@@ -106,9 +106,17 @@ export async function listLocalWorkspaceIds(): Promise<string[]> {
return []; return [];
} }
const deletedWorkspaceBasePath = await getDeletedWorkspacesBasePath();
const deletedWorkspaceIds = new Set<string>(
(await fs.readdir(deletedWorkspaceBasePath).catch(() => [])).filter(Boolean)
);
const entries = await fs.readdir(localWorkspaceBasePath); const entries = await fs.readdir(localWorkspaceBasePath);
const ids = await Promise.all( const ids = await Promise.all(
entries.map(async entry => { entries.map(async entry => {
if (deletedWorkspaceIds.has(entry)) {
return null;
}
const workspacePath = path.join(localWorkspaceBasePath, entry); const workspacePath = path.join(localWorkspaceBasePath, entry);
const stat = await fs.stat(workspacePath).catch(() => null); const stat = await fs.stat(workspacePath).catch(() => null);
if (!stat?.isDirectory()) { if (!stat?.isDirectory()) {

View File

@@ -38,6 +38,7 @@ describe('workspace db management', () => {
await import('@affine/electron/helper/workspace/handlers'); await import('@affine/electron/helper/workspace/handlers');
const validWorkspaceId = v4(); const validWorkspaceId = v4();
const noDbWorkspaceId = v4(); const noDbWorkspaceId = v4();
const deletedWorkspaceId = v4();
const fileEntry = 'README.txt'; const fileEntry = 'README.txt';
const validWorkspacePath = path.join( const validWorkspacePath = path.join(
@@ -52,6 +53,17 @@ describe('workspace db management', () => {
'local', 'local',
noDbWorkspaceId noDbWorkspaceId
); );
const deletedWorkspacePath = path.join(
appDataPath,
'workspaces',
'local',
deletedWorkspaceId
);
const deletedWorkspaceTrashPath = path.join(
appDataPath,
'deleted-workspaces',
deletedWorkspaceId
);
const nonDirectoryPath = path.join( const nonDirectoryPath = path.join(
appDataPath, appDataPath,
'workspaces', 'workspaces',
@@ -62,11 +74,15 @@ describe('workspace db management', () => {
await fs.ensureDir(validWorkspacePath); await fs.ensureDir(validWorkspacePath);
await fs.ensureFile(path.join(validWorkspacePath, 'storage.db')); await fs.ensureFile(path.join(validWorkspacePath, 'storage.db'));
await fs.ensureDir(noDbWorkspacePath); await fs.ensureDir(noDbWorkspacePath);
await fs.ensureDir(deletedWorkspacePath);
await fs.ensureFile(path.join(deletedWorkspacePath, 'storage.db'));
await fs.ensureDir(deletedWorkspaceTrashPath);
await fs.outputFile(nonDirectoryPath, 'not-a-workspace'); await fs.outputFile(nonDirectoryPath, 'not-a-workspace');
const ids = await listLocalWorkspaceIds(); const ids = await listLocalWorkspaceIds();
expect(ids).toContain(validWorkspaceId); expect(ids).toContain(validWorkspaceId);
expect(ids).not.toContain(noDbWorkspaceId); expect(ids).not.toContain(noDbWorkspaceId);
expect(ids).not.toContain(deletedWorkspaceId);
expect(ids).not.toContain(fileEntry); expect(ids).not.toContain(fileEntry);
}); });

View File

@@ -25,7 +25,7 @@ export const property = style({
selectors: { selectors: {
'&[data-show="false"]': { '&[data-show="false"]': {
backgroundColor: cssVarV2.button.emptyIconBackground, backgroundColor: cssVarV2.button.emptyIconBackground,
color: cssVarV2.icon.disable, color: cssVarV2.text.secondary,
}, },
}, },
}); });