mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-01 17:50:50 +08:00
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:
@@ -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']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user