fix(editor): database behavier (#14394)

fix #13459
fix #13707
fix #13924

#### PR Dependency Tree


* **PR #14394** 👈

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

* **New Features**
* Improved URL paste: text is split into segments, inserted correctly,
and single-URL pastes create linked-page references.

* **UI Improvements**
  * Redesigned layout selector with compact dynamic options.
* Number-format options are always available in table headers and mobile
menus.

* **Bug Fixes**
  * More consistent paste behavior for mixed text+URL content.
  * Prevented recursive selection updates when exiting edit mode.

* **Tests**
* Added tests for URL splitting, paste insertion, number formatting, and
selection behavior.

* **Chores**
* Removed number-formatting feature flag; formatting now applied by
default.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-08 23:31:30 +08:00
committed by GitHub
parent 8192a492d9
commit bb01bb1aef
20 changed files with 665 additions and 296 deletions
@@ -135,14 +135,10 @@ export class DatabaseBlockDataSource extends DataSourceBase {
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
const featureFlagService = this.doc.get(FeatureFlagService);
const enableNumberFormat = featureFlagService.getFlag(
'enable_database_number_formatting'
);
const enableTableVirtualScroll = featureFlagService.getFlag(
'enable_table_virtual_scroll'
);
return {
enable_number_formatting: enableNumberFormat ?? false,
enable_table_virtual_scroll: enableTableVirtualScroll ?? false,
};
});
@@ -0,0 +1,56 @@
import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import {
splitTextByUrl,
type UrlTextSegment,
} from '@blocksuite/affine-shared/utils';
import type { InlineRange } from '@blocksuite/std/inline';
type UrlPasteInlineEditor = Pick<
AffineInlineEditor,
'insertText' | 'setInlineRange'
>;
export function analyzeTextForUrlPaste(text: string) {
const segments = splitTextByUrl(text);
const firstSegment = segments[0];
const singleUrl =
segments.length === 1 && firstSegment?.link && firstSegment.text === text
? firstSegment.link
: undefined;
return {
segments,
singleUrl,
};
}
export function insertUrlTextSegments(
inlineEditor: UrlPasteInlineEditor,
inlineRange: InlineRange,
segments: UrlTextSegment[]
) {
let index = inlineRange.index;
let replacedSelection = false;
segments.forEach(segment => {
if (!segment.text) return;
const attributes: AffineTextAttributes | undefined = segment.link
? { link: segment.link }
: undefined;
inlineEditor.insertText(
{
index,
length: replacedSelection ? 0 : inlineRange.length,
},
segment.text,
attributes
);
replacedSelection = true;
index += segment.text.length;
});
inlineEditor.setInlineRange({
index,
length: 0,
});
}
@@ -8,10 +8,7 @@ import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import {
BaseCellRenderer,
createFromBaseCellRenderer,
@@ -26,6 +23,7 @@ import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
import {
richTextCellStyle,
richTextContainerStyle,
@@ -271,10 +269,13 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
if (isValidUrl(text)) {
if (singleUrl) {
const std = this.std;
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
const result = std
?.getOptional(ParseDocUrlProvider)
?.parseDocUrl(singleUrl);
if (result) {
const text = ' ';
inlineEditor.insertText(inlineRange, text, {
@@ -300,22 +301,10 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
segment: 'database',
parentFlavour: 'affine:database',
});
} else {
inlineEditor.insertText(inlineRange, text, {
link: text,
});
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
return;
}
} else {
inlineEditor.insertText(inlineRange, text);
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
insertUrlTextSegments(inlineEditor, inlineRange, segments);
};
override connectedCallback() {
@@ -4,10 +4,7 @@ import {
ParseDocUrlProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import { BaseCellRenderer } from '@blocksuite/data-view';
import { IS_MAC } from '@blocksuite/global/env';
import { LinkedPageIcon } from '@blocksuite/icons/lit';
@@ -20,6 +17,7 @@ import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
import {
headerAreaIconStyle,
titleCellStyle,
@@ -95,7 +93,9 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
private readonly _onPaste = (e: ClipboardEvent) => {
const inlineEditor = this.inlineEditor;
const inlineRange = inlineEditor?.getInlineRange();
if (!inlineRange) return;
if (!inlineEditor || !inlineRange) return;
e.preventDefault();
e.stopPropagation();
if (e.clipboardData) {
try {
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
@@ -121,14 +121,15 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
e.preventDefault();
e.stopPropagation();
if (isValidUrl(text)) {
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
if (singleUrl) {
const std = this.std;
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
const result = std
?.getOptional(ParseDocUrlProvider)
?.parseDocUrl(singleUrl);
if (result) {
const text = ' ';
inlineEditor?.insertText(inlineRange, text, {
inlineEditor.insertText(inlineRange, text, {
reference: {
type: 'LinkedPage',
pageId: result.docId,
@@ -139,7 +140,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
},
},
});
inlineEditor?.setInlineRange({
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
@@ -151,22 +152,10 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
segment: 'database',
parentFlavour: 'affine:database',
});
} else {
inlineEditor?.insertText(inlineRange, text, {
link: text,
});
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
return;
}
} else {
inlineEditor?.insertText(inlineRange, text);
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
insertUrlTextSegments(inlineEditor, inlineRange, segments);
};
insertDelta = (delta: DeltaInsert) => {
@@ -240,7 +229,8 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
this.disposables.addFromEvent(
this.richText.value,
'paste',
this._onPaste
this._onPaste,
true
);
const inlineEditor = this.inlineEditor;
if (inlineEditor) {