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;
|
return true;
|
||||||
},
|
},
|
||||||
Enter: ctx => {
|
Enter: ctx => {
|
||||||
|
const raw = ctx.get('keyboardState').raw;
|
||||||
|
if (raw.isComposing) return;
|
||||||
|
|
||||||
const { store } = std;
|
const { store } = std;
|
||||||
const text = std.selection.find(TextSelection);
|
const text = std.selection.find(TextSelection);
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -115,7 +118,6 @@ export const ParagraphKeymapExtension = KeymapExtension(
|
|||||||
const inlineRange = inlineEditor?.getInlineRange();
|
const inlineRange = inlineEditor?.getInlineRange();
|
||||||
if (!inlineRange || !inlineEditor) return;
|
if (!inlineRange || !inlineEditor) return;
|
||||||
|
|
||||||
const raw = ctx.get('keyboardState').raw;
|
|
||||||
const isEnd = model.props.text.length === inlineRange.index;
|
const isEnd = model.props.text.length === inlineRange.index;
|
||||||
|
|
||||||
if (model.props.type === 'quote') {
|
if (model.props.type === 'quote') {
|
||||||
|
|||||||
@@ -527,6 +527,9 @@ export class SelectionController implements ReactiveController {
|
|||||||
removeNativeSelection = true
|
removeNativeSelection = true
|
||||||
) {
|
) {
|
||||||
if (selection) {
|
if (selection) {
|
||||||
|
if (this.hasExternalNativeSelection()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const previous = this.getSelected();
|
const previous = this.getSelected();
|
||||||
if (TableSelectionData.equals(previous, selection)) {
|
if (TableSelectionData.equals(previous, selection)) {
|
||||||
return;
|
return;
|
||||||
@@ -551,4 +554,24 @@ export class SelectionController implements ReactiveController {
|
|||||||
);
|
);
|
||||||
return selection?.is(TableSelection) ? selection.data : undefined;
|
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';
|
import { css } from '@emotion/css';
|
||||||
|
|
||||||
|
const externalRangeSelectionSelector =
|
||||||
|
'affine-table[data-external-range-selection]';
|
||||||
|
const hiddenSelectionBackground = '#fff';
|
||||||
|
|
||||||
export const tableContainer = css({
|
export const tableContainer = css({
|
||||||
display: 'block',
|
display: 'block',
|
||||||
padding: '10px 0 18px 10px',
|
padding: '10px 0 18px 10px',
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
overflowY: 'visible',
|
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': {
|
'::-webkit-scrollbar': {
|
||||||
height: '8px',
|
height: '8px',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
|||||||
import { VirtualPaddingController } from '@blocksuite/affine-shared/utils';
|
import { VirtualPaddingController } from '@blocksuite/affine-shared/utils';
|
||||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||||
import type { BlockComponent } from '@blocksuite/std';
|
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 { signal } from '@preact/signals-core';
|
||||||
import { html, nothing } from 'lit';
|
import { html, nothing } from 'lit';
|
||||||
import { ref } from 'lit/directives/ref.js';
|
import { ref } from 'lit/directives/ref.js';
|
||||||
@@ -37,7 +40,80 @@ export class TableBlockComponent extends CaptionedBlockComponent<TableBlockModel
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||||
|
this.setAttribute(RANGE_QUERY_EXCLUDE_ATTR, 'true');
|
||||||
this.style.position = 'relative';
|
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() {
|
override get topContenteditableElement() {
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ export const cellContainerStyle = css({
|
|||||||
isolation: 'auto',
|
isolation: 'auto',
|
||||||
textAlign: 'start',
|
textAlign: 'start',
|
||||||
verticalAlign: 'top',
|
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({
|
export const columnOptionsCellStyle = css({
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
sortByManually,
|
sortByManually,
|
||||||
} from '../../core/group-by/trait.js';
|
} from '../../core/group-by/trait.js';
|
||||||
import { fromJson } from '../../core/property/utils';
|
import { fromJson } from '../../core/property/utils';
|
||||||
|
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
|
||||||
import { PropertyBase } from '../../core/view-manager/property.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 { SingleViewBase } from '../../core/view-manager/single-view.js';
|
||||||
import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
||||||
import type { KanbanViewColumn, KanbanViewData } from './define.js';
|
import type { KanbanViewColumn, KanbanViewData } from './define.js';
|
||||||
@@ -92,6 +94,19 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
|||||||
return this.data$.value?.filter ?? emptyFilterGroup;
|
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(
|
filterTrait = this.traitSet(
|
||||||
filterTraitKey,
|
filterTraitKey,
|
||||||
new FilterTrait(this.filter$, this, {
|
new FilterTrait(this.filter$, this, {
|
||||||
@@ -140,6 +155,7 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
|||||||
return asc === false ? sorted.reverse() : sorted;
|
return asc === false ? sorted.reverse() : sorted;
|
||||||
},
|
},
|
||||||
sortRow: (key, rows) => {
|
sortRow: (key, rows) => {
|
||||||
|
if (this.sortManager.hasSort$.value) return rows;
|
||||||
const property = this.view?.groupProperties.find(v => v.key === key);
|
const property = this.view?.groupProperties.find(v => v.key === key);
|
||||||
return sortByManually(
|
return sortByManually(
|
||||||
rows,
|
rows,
|
||||||
@@ -359,6 +375,10 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override rowsMapping(rows: Row[]): Row[] {
|
||||||
|
return this.sortManager.sort(super.rowsMapping(rows));
|
||||||
|
}
|
||||||
|
|
||||||
propertyGetOrCreate(columnId: string): KanbanColumn {
|
propertyGetOrCreate(columnId: string): KanbanColumn {
|
||||||
return new KanbanColumn(this, columnId);
|
return new KanbanColumn(this, columnId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,20 +213,22 @@ export class RangeBinding {
|
|||||||
return;
|
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);
|
const el = getElement(range.commonAncestorContainer);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const closestExclude = el.closest(`[${RANGE_SYNC_EXCLUDE_ATTR}="true"]`);
|
const closestExclude = el.closest(`[${RANGE_SYNC_EXCLUDE_ATTR}="true"]`);
|
||||||
if (closestExclude) return;
|
if (closestExclude && !hasInlineEndpoint) return;
|
||||||
|
|
||||||
const closestEditable = el.closest('[contenteditable]');
|
const closestEditable = el.closest('[contenteditable]');
|
||||||
if (!closestEditable) return;
|
if (!closestEditable && !hasInlineEndpoint) return;
|
||||||
|
|
||||||
const startElement = getElement(range.startContainer);
|
|
||||||
const endElement = getElement(range.endContainer);
|
|
||||||
|
|
||||||
// if neither start nor end is in a v-text, the range is invalid
|
// 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._prevTextSelection = null;
|
||||||
this.selectionManager.clear(['text']);
|
this.selectionManager.clear(['text']);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user