feat(editor): improve database & table behavior (#15100)

fix #14982
fix #15028
fix #15099

#### PR Dependency Tree


* **PR #15100** 👈

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**
* Prevented Enter handling during IME composition to avoid unintended
input.
* Avoided overwriting external native selections when interacting with
tables.
* Improved validation of inline text selection ranges for more reliable
behavior.

* **Enhancements**
* Scoped and refined text-selection styling and editability within
tables and cells.
  * Added managed sorting for Kanban views to control card ordering.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-06-11 13:50:23 +08:00
committed by GitHub
parent 07a08e6d4d
commit 6a2b73e76f
7 changed files with 165 additions and 8 deletions
@@ -101,6 +101,9 @@ export const ParagraphKeymapExtension = KeymapExtension(
return true;
},
Enter: ctx => {
const raw = ctx.get('keyboardState').raw;
if (raw.isComposing) return;
const { store } = std;
const text = std.selection.find(TextSelection);
if (!text) return;
@@ -115,7 +118,6 @@ export const ParagraphKeymapExtension = KeymapExtension(
const inlineRange = inlineEditor?.getInlineRange();
if (!inlineRange || !inlineEditor) return;
const raw = ctx.get('keyboardState').raw;
const isEnd = model.props.text.length === inlineRange.index;
if (model.props.type === 'quote') {
@@ -527,6 +527,9 @@ export class SelectionController implements ReactiveController {
removeNativeSelection = true
) {
if (selection) {
if (this.hasExternalNativeSelection()) {
return;
}
const previous = this.getSelected();
if (TableSelectionData.equals(previous, selection)) {
return;
@@ -551,4 +554,24 @@ export class SelectionController implements ReactiveController {
);
return selection?.is(TableSelection) ? selection.data : undefined;
}
private hasExternalNativeSelection() {
const selection = getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return false;
}
const range = selection.getRangeAt(0);
if (!range.intersectsNode(this.host)) {
return false;
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
return (
!!anchorNode &&
!!focusNode &&
(!this.host.contains(anchorNode) || !this.host.contains(focusNode))
);
}
}
@@ -1,10 +1,32 @@
import { css } from '@emotion/css';
const externalRangeSelectionSelector =
'affine-table[data-external-range-selection]';
const hiddenSelectionBackground = '#fff';
export const tableContainer = css({
display: 'block',
padding: '10px 0 18px 10px',
overflowX: 'auto',
overflowY: 'visible',
userSelect: 'none',
WebkitUserSelect: 'none',
'& *': {
userSelect: 'none',
WebkitUserSelect: 'none',
},
[`${externalRangeSelectionSelector} &::selection`]: {
backgroundColor: hiddenSelectionBackground,
},
[`${externalRangeSelectionSelector} & *::selection`]: {
backgroundColor: hiddenSelectionBackground,
},
[`${externalRangeSelectionSelector} & rich-text::selection`]: {
backgroundColor: hiddenSelectionBackground,
},
[`${externalRangeSelectionSelector} & rich-text *::selection`]: {
backgroundColor: hiddenSelectionBackground,
},
'::-webkit-scrollbar': {
height: '8px',
},
@@ -5,7 +5,10 @@ import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { VirtualPaddingController } from '@blocksuite/affine-shared/utils';
import { IS_MOBILE } from '@blocksuite/global/env';
import type { BlockComponent } from '@blocksuite/std';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
import {
RANGE_QUERY_EXCLUDE_ATTR,
RANGE_SYNC_EXCLUDE_ATTR,
} from '@blocksuite/std/inline';
import { signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { ref } from 'lit/directives/ref.js';
@@ -37,7 +40,80 @@ export class TableBlockComponent extends CaptionedBlockComponent<TableBlockModel
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
this.setAttribute(RANGE_QUERY_EXCLUDE_ATTR, 'true');
this.style.position = 'relative';
const doc = this.ownerDocument;
this.disposables.addFromEvent(doc, 'selectionchange', () => {
const hasExternalNativeSelection = this.hasExternalNativeSelection();
this.toggleAttribute(
'data-external-range-selection',
hasExternalNativeSelection
);
if (hasExternalNativeSelection) {
delete this.dataset.internalRangeSelection;
}
this.setInternalEditablesEnabled(!hasExternalNativeSelection);
});
this.disposables.addFromEvent(
doc,
'pointerdown',
event => {
const target = event.target;
const NodeConstructor = this.ownerDocument.defaultView?.Node;
if (
NodeConstructor &&
target instanceof NodeConstructor &&
this.contains(target)
) {
this.setInternalEditablesEnabled(true);
if (this.hasExternalNativeSelection()) {
this.ownerDocument.getSelection()?.removeAllRanges();
}
delete this.dataset.externalRangeSelection;
this.dataset.internalRangeSelection = 'true';
} else {
delete this.dataset.internalRangeSelection;
}
},
{ capture: true }
);
}
private setInternalEditablesEnabled(enabled: boolean) {
this.querySelectorAll<HTMLElement>('.inline-editor').forEach(editor => {
if (enabled) {
if (editor.dataset.tableExternalSelectionDisabled === 'true') {
editor.contentEditable = 'true';
delete editor.dataset.tableExternalSelectionDisabled;
}
return;
}
if (editor.contentEditable === 'true') {
editor.contentEditable = 'false';
editor.dataset.tableExternalSelectionDisabled = 'true';
}
});
}
private hasExternalNativeSelection() {
const selection = this.ownerDocument.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return false;
}
const range = selection.getRangeAt(0);
if (!range.intersectsNode(this)) {
return false;
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
return (
!!anchorNode &&
!!focusNode &&
(!this.contains(anchorNode) || !this.contains(focusNode))
);
}
override get topContenteditableElement() {
@@ -10,6 +10,18 @@ export const cellContainerStyle = css({
isolation: 'auto',
textAlign: 'start',
verticalAlign: 'top',
'affine-table[data-internal-range-selection="true"] &': {
userSelect: 'text',
WebkitUserSelect: 'text',
},
'affine-table[data-internal-range-selection="true"] & rich-text': {
userSelect: 'text',
WebkitUserSelect: 'text',
},
'affine-table[data-internal-range-selection="true"] & rich-text *': {
userSelect: 'text',
WebkitUserSelect: 'text',
},
});
export const columnOptionsCellStyle = css({
@@ -15,7 +15,9 @@ import {
sortByManually,
} from '../../core/group-by/trait.js';
import { fromJson } from '../../core/property/utils';
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
import { PropertyBase } from '../../core/view-manager/property.js';
import type { Row } from '../../core/view-manager/row.js';
import { SingleViewBase } from '../../core/view-manager/single-view.js';
import type { ViewManager } from '../../core/view-manager/view-manager.js';
import type { KanbanViewColumn, KanbanViewData } from './define.js';
@@ -92,6 +94,19 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
return this.data$.value?.filter ?? emptyFilterGroup;
});
private readonly sortList$ = computed(() => {
return this.data$.value?.sort;
});
private readonly sortManager = this.traitSet(
sortTraitKey,
new SortManager(this.sortList$, this, {
setSortList: sortList => {
this.dataUpdate(data => ({ sort: { ...data.sort, ...sortList } }));
},
})
);
filterTrait = this.traitSet(
filterTraitKey,
new FilterTrait(this.filter$, this, {
@@ -140,6 +155,7 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
return asc === false ? sorted.reverse() : sorted;
},
sortRow: (key, rows) => {
if (this.sortManager.hasSort$.value) return rows;
const property = this.view?.groupProperties.find(v => v.key === key);
return sortByManually(
rows,
@@ -359,6 +375,10 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
return true;
}
protected override rowsMapping(rows: Row[]): Row[] {
return this.sortManager.sort(super.rowsMapping(rows));
}
propertyGetOrCreate(columnId: string): KanbanColumn {
return new KanbanColumn(this, columnId);
}
@@ -213,20 +213,22 @@ export class RangeBinding {
return;
}
const startElement = getElement(range.startContainer);
const endElement = getElement(range.endContainer);
const hasInlineEndpoint =
!!startElement?.closest('v-text') || !!endElement?.closest('v-text');
const el = getElement(range.commonAncestorContainer);
if (!el) return;
const closestExclude = el.closest(`[${RANGE_SYNC_EXCLUDE_ATTR}="true"]`);
if (closestExclude) return;
if (closestExclude && !hasInlineEndpoint) return;
const closestEditable = el.closest('[contenteditable]');
if (!closestEditable) return;
const startElement = getElement(range.startContainer);
const endElement = getElement(range.endContainer);
if (!closestEditable && !hasInlineEndpoint) return;
// if neither start nor end is in a v-text, the range is invalid
if (!startElement?.closest('v-text') && !endElement?.closest('v-text')) {
if (!hasInlineEndpoint) {
this._prevTextSelection = null;
this.selectionManager.clear(['text']);