From 6a2b73e76fe5c5ef5fb44c917beb0808bb6f4815 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:50:23 +0800 Subject: [PATCH] feat(editor): improve database & table behavior (#15100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix #14982 fix #15028 fix #15099 #### PR Dependency Tree * **PR #15100** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## 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. --- .../blocks/paragraph/src/paragraph-keymap.ts | 4 +- .../blocks/table/src/selection-controller.ts | 23 ++++++ .../blocks/table/src/table-block-css.ts | 22 ++++++ .../affine/blocks/table/src/table-block.ts | 78 ++++++++++++++++++- .../affine/blocks/table/src/table-cell-css.ts | 12 +++ .../kanban/kanban-view-manager.ts | 20 +++++ .../std/src/inline/range/range-binding.ts | 14 ++-- 7 files changed, 165 insertions(+), 8 deletions(-) diff --git a/blocksuite/affine/blocks/paragraph/src/paragraph-keymap.ts b/blocksuite/affine/blocks/paragraph/src/paragraph-keymap.ts index 6ad5bbe387..bccbdc1357 100644 --- a/blocksuite/affine/blocks/paragraph/src/paragraph-keymap.ts +++ b/blocksuite/affine/blocks/paragraph/src/paragraph-keymap.ts @@ -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') { diff --git a/blocksuite/affine/blocks/table/src/selection-controller.ts b/blocksuite/affine/blocks/table/src/selection-controller.ts index 73b7ca43f3..e976bc1f4d 100644 --- a/blocksuite/affine/blocks/table/src/selection-controller.ts +++ b/blocksuite/affine/blocks/table/src/selection-controller.ts @@ -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)) + ); + } } diff --git a/blocksuite/affine/blocks/table/src/table-block-css.ts b/blocksuite/affine/blocks/table/src/table-block-css.ts index 7e39f270b6..959c26881b 100644 --- a/blocksuite/affine/blocks/table/src/table-block-css.ts +++ b/blocksuite/affine/blocks/table/src/table-block-css.ts @@ -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', }, diff --git a/blocksuite/affine/blocks/table/src/table-block.ts b/blocksuite/affine/blocks/table/src/table-block.ts index de9063e051..eae7d3cddd 100644 --- a/blocksuite/affine/blocks/table/src/table-block.ts +++ b/blocksuite/affine/blocks/table/src/table-block.ts @@ -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 { + 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('.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() { diff --git a/blocksuite/affine/blocks/table/src/table-cell-css.ts b/blocksuite/affine/blocks/table/src/table-cell-css.ts index 56811c0e8c..392a209c3d 100644 --- a/blocksuite/affine/blocks/table/src/table-cell-css.ts +++ b/blocksuite/affine/blocks/table/src/table-cell-css.ts @@ -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({ diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/kanban-view-manager.ts b/blocksuite/affine/data-view/src/view-presets/kanban/kanban-view-manager.ts index 4758fb2816..3358a12c9a 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/kanban-view-manager.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/kanban-view-manager.ts @@ -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 { 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 { 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 { return true; } + protected override rowsMapping(rows: Row[]): Row[] { + return this.sortManager.sort(super.rowsMapping(rows)); + } + propertyGetOrCreate(columnId: string): KanbanColumn { return new KanbanColumn(this, columnId); } diff --git a/blocksuite/framework/std/src/inline/range/range-binding.ts b/blocksuite/framework/std/src/inline/range/range-binding.ts index 568f3195d8..38eff5916f 100644 --- a/blocksuite/framework/std/src/inline/range/range-binding.ts +++ b/blocksuite/framework/std/src/inline/range/range-binding.ts @@ -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']);