From bb01bb1aefa6411de1c200730efc7a1ce18b8cf3 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:31:30 +0800 Subject: [PATCH] fix(editor): database behavier (#14394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix #13459 fix #13707 fix #13924 #### PR Dependency Tree * **PR #14394** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## 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. --- .../__tests__/database/paste-url.unit.spec.ts | 95 +++++++++++++++ .../affine/blocks/database/src/data-source.ts | 4 - .../database/src/properties/paste-url.ts | 56 +++++++++ .../src/properties/rich-text/cell-renderer.ts | 29 ++--- .../database/src/properties/title/text.ts | 42 +++---- .../src/__tests__/kanban.unit.spec.ts | 58 +++++++++ .../src/__tests__/table-group.unit.spec.ts | 36 ------ .../src/__tests__/table.unit.spec.ts | 101 ++++++++++++++++ .../affine/data-view/src/core/data-view.ts | 35 +++++- .../data-view/src/core/detail/selection.ts | 47 ++++++-- blocksuite/affine/data-view/src/core/types.ts | 1 - .../property-presets/number/cell-renderer.ts | 12 +- .../src/property-presets/number/define.ts | 3 +- .../table/mobile/column-header.ts | 64 +++++----- .../group/top/header/single-column-header.ts | 60 ++++------ .../table/pc/header/database-header-column.ts | 60 ++++------ .../presets/view-options/view-options.ts | 91 +++++--------- .../src/__tests__/utils/url.unit.spec.ts | 54 ++++++++- .../src/services/feature-flag-service.ts | 2 - blocksuite/affine/shared/src/utils/url.ts | 111 +++++++++++++++--- 20 files changed, 665 insertions(+), 296 deletions(-) create mode 100644 blocksuite/affine/all/src/__tests__/database/paste-url.unit.spec.ts create mode 100644 blocksuite/affine/blocks/database/src/properties/paste-url.ts delete mode 100644 blocksuite/affine/data-view/src/__tests__/table-group.unit.spec.ts create mode 100644 blocksuite/affine/data-view/src/__tests__/table.unit.spec.ts diff --git a/blocksuite/affine/all/src/__tests__/database/paste-url.unit.spec.ts b/blocksuite/affine/all/src/__tests__/database/paste-url.unit.spec.ts new file mode 100644 index 000000000..abfeb2eb5 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/database/paste-url.unit.spec.ts @@ -0,0 +1,95 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { describe, expect, test } from 'vitest'; + +import { insertUrlTextSegments } from '../../../../blocks/database/src/properties/paste-url.js'; + +type InsertCall = { + range: { + index: number; + length: number; + }; + text: string; + attributes?: AffineTextAttributes; +}; + +describe('insertUrlTextSegments', () => { + test('should replace selected text on first insert and append remaining segments', () => { + const insertCalls: InsertCall[] = []; + const selectionCalls: Array<{ index: number; length: number } | null> = []; + const inlineEditor = { + insertText: ( + range: { index: number; length: number }, + text: string, + attributes?: AffineTextAttributes + ) => { + insertCalls.push({ range, text, attributes }); + }, + setInlineRange: (range: { index: number; length: number } | null) => { + selectionCalls.push(range); + }, + }; + + const inlineRange = { index: 4, length: 6 }; + const segments = [ + { text: 'hi - ' }, + { text: 'https://google.com', link: 'https://google.com' }, + ]; + + insertUrlTextSegments(inlineEditor, inlineRange, segments); + + expect(insertCalls).toEqual([ + { + range: { index: 4, length: 6 }, + text: 'hi - ', + }, + { + range: { index: 9, length: 0 }, + text: 'https://google.com', + attributes: { + link: 'https://google.com', + }, + }, + ]); + expect(selectionCalls).toEqual([{ index: 27, length: 0 }]); + }); + + test('should keep insertion range length zero when there is no selected text', () => { + const insertCalls: InsertCall[] = []; + const selectionCalls: Array<{ index: number; length: number } | null> = []; + const inlineEditor = { + insertText: ( + range: { index: number; length: number }, + text: string, + attributes?: AffineTextAttributes + ) => { + insertCalls.push({ range, text, attributes }); + }, + setInlineRange: (range: { index: number; length: number } | null) => { + selectionCalls.push(range); + }, + }; + + const inlineRange = { index: 2, length: 0 }; + const segments = [ + { text: 'prefix ' }, + { text: 'https://a.com', link: 'https://a.com' }, + ]; + + insertUrlTextSegments(inlineEditor, inlineRange, segments); + + expect(insertCalls).toEqual([ + { + range: { index: 2, length: 0 }, + text: 'prefix ', + }, + { + range: { index: 9, length: 0 }, + text: 'https://a.com', + attributes: { + link: 'https://a.com', + }, + }, + ]); + expect(selectionCalls).toEqual([{ index: 22, length: 0 }]); + }); +}); diff --git a/blocksuite/affine/blocks/database/src/data-source.ts b/blocksuite/affine/blocks/database/src/data-source.ts index fa06298d4..c12190731 100644 --- a/blocksuite/affine/blocks/database/src/data-source.ts +++ b/blocksuite/affine/blocks/database/src/data-source.ts @@ -135,14 +135,10 @@ export class DatabaseBlockDataSource extends DataSourceBase { override featureFlags$: ReadonlySignal = 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, }; }); diff --git a/blocksuite/affine/blocks/database/src/properties/paste-url.ts b/blocksuite/affine/blocks/database/src/properties/paste-url.ts new file mode 100644 index 000000000..49b3ac217 --- /dev/null +++ b/blocksuite/affine/blocks/database/src/properties/paste-url.ts @@ -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, + }); +} diff --git a/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts b/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts index 27d280a66..51302e611 100644 --- a/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts +++ b/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts @@ -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 { ?.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 { 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() { diff --git a/blocksuite/affine/blocks/database/src/properties/title/text.ts b/blocksuite/affine/blocks/database/src/properties/title/text.ts index e4deda964..605b4f0fb 100644 --- a/blocksuite/affine/blocks/database/src/properties/title/text.ts +++ b/blocksuite/affine/blocks/database/src/properties/title/text.ts @@ -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 { 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 { ?.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 { }, }, }); - inlineEditor?.setInlineRange({ + inlineEditor.setInlineRange({ index: inlineRange.index + text.length, length: 0, }); @@ -151,22 +152,10 @@ export class HeaderAreaTextCell extends BaseCellRenderer { 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 { this.disposables.addFromEvent( this.richText.value, 'paste', - this._onPaste + this._onPaste, + true ); const inlineEditor = this.inlineEditor; if (inlineEditor) { diff --git a/blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts b/blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts index 3ecce8fc4..30447b898 100644 --- a/blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts +++ b/blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts @@ -3,8 +3,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { GroupBy } from '../core/common/types.js'; import type { DataSource } from '../core/data-source/base.js'; +import { DetailSelection } from '../core/detail/selection.js'; import { groupByMatchers } from '../core/group-by/define.js'; import { t } from '../core/logical/type-presets.js'; +import type { DataViewCellLifeCycle } from '../core/property/index.js'; import { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js'; import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js'; import { selectPropertyModelConfig } from '../property-presets/select/define.js'; @@ -456,4 +458,60 @@ describe('kanban', () => { expect(next?.hideEmpty).toBe(true); }); }); + + describe('detail selection', () => { + it('should avoid recursive selection update when exiting select edit mode', () => { + vi.stubGlobal('requestAnimationFrame', ((cb: FrameRequestCallback) => { + cb(0); + return 0; + }) as typeof requestAnimationFrame); + try { + let selection: DetailSelection; + let beforeExitCalls = 0; + + const cell = { + beforeEnterEditMode: () => true, + beforeExitEditingMode: () => { + beforeExitCalls += 1; + selection.selection = { + propertyId: 'status', + isEditing: false, + }; + }, + afterEnterEditingMode: () => {}, + focusCell: () => true, + blurCell: () => true, + forceUpdate: () => {}, + } satisfies DataViewCellLifeCycle; + + const field = { + isFocus$: signal(false), + isEditing$: signal(false), + cell, + focus: () => {}, + blur: () => {}, + }; + + const detail = { + querySelector: () => field, + }; + + selection = new DetailSelection(detail); + selection.selection = { + propertyId: 'status', + isEditing: true, + }; + + selection.selection = { + propertyId: 'status', + isEditing: false, + }; + + expect(beforeExitCalls).toBe(1); + expect(field.isEditing$.value).toBe(false); + } finally { + vi.unstubAllGlobals(); + } + }); + }); }); diff --git a/blocksuite/affine/data-view/src/__tests__/table-group.unit.spec.ts b/blocksuite/affine/data-view/src/__tests__/table-group.unit.spec.ts deleted file mode 100644 index 56ef6f1ad..000000000 --- a/blocksuite/affine/data-view/src/__tests__/table-group.unit.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { mobileEffects } from '../view-presets/table/mobile/effect.js'; -import type { MobileTableGroup } from '../view-presets/table/mobile/group.js'; -import { pcEffects } from '../view-presets/table/pc/effect.js'; -import type { TableGroup } from '../view-presets/table/pc/group.js'; - -/** @vitest-environment happy-dom */ - -describe('TableGroup', () => { - test('toggle collapse on pc', () => { - pcEffects(); - const group = document.createElement( - 'affine-data-view-table-group' - ) as TableGroup; - - expect(group.collapsed$.value).toBe(false); - (group as any)._toggleCollapse(); - expect(group.collapsed$.value).toBe(true); - (group as any)._toggleCollapse(); - expect(group.collapsed$.value).toBe(false); - }); - - test('toggle collapse on mobile', () => { - mobileEffects(); - const group = document.createElement( - 'mobile-table-group' - ) as MobileTableGroup; - - expect(group.collapsed$.value).toBe(false); - (group as any)._toggleCollapse(); - expect(group.collapsed$.value).toBe(true); - (group as any)._toggleCollapse(); - expect(group.collapsed$.value).toBe(false); - }); -}); diff --git a/blocksuite/affine/data-view/src/__tests__/table.unit.spec.ts b/blocksuite/affine/data-view/src/__tests__/table.unit.spec.ts new file mode 100644 index 000000000..1c919e080 --- /dev/null +++ b/blocksuite/affine/data-view/src/__tests__/table.unit.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'vitest'; + +import { numberFormats } from '../property-presets/number/utils/formats.js'; +import { + formatNumber, + NumberFormatSchema, + parseNumber, +} from '../property-presets/number/utils/formatter.js'; +import { mobileEffects } from '../view-presets/table/mobile/effect.js'; +import type { MobileTableGroup } from '../view-presets/table/mobile/group.js'; +import { pcEffects } from '../view-presets/table/pc/effect.js'; +import type { TableGroup } from '../view-presets/table/pc/group.js'; + +/** @vitest-environment happy-dom */ + +describe('TableGroup', () => { + test('toggle collapse on pc', () => { + pcEffects(); + const group = document.createElement( + 'affine-data-view-table-group' + ) as TableGroup; + + expect(group.collapsed$.value).toBe(false); + (group as any)._toggleCollapse(); + expect(group.collapsed$.value).toBe(true); + (group as any)._toggleCollapse(); + expect(group.collapsed$.value).toBe(false); + }); + + test('toggle collapse on mobile', () => { + mobileEffects(); + const group = document.createElement( + 'mobile-table-group' + ) as MobileTableGroup; + + expect(group.collapsed$.value).toBe(false); + (group as any)._toggleCollapse(); + expect(group.collapsed$.value).toBe(true); + (group as any)._toggleCollapse(); + expect(group.collapsed$.value).toBe(false); + }); +}); + +describe('number formatter', () => { + test('number format menu should expose all schema formats', () => { + const menuFormats = numberFormats.map(format => format.type); + const schemaFormats = NumberFormatSchema.options; + + expect(new Set(menuFormats)).toEqual(new Set(schemaFormats)); + expect(menuFormats).toHaveLength(schemaFormats.length); + }); + + test('formats grouped decimal numbers with Intl grouping rules', () => { + const value = 11451.4; + const decimals = 1; + const expected = new Intl.NumberFormat(navigator.language, { + style: 'decimal', + useGrouping: true, + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value); + + expect(formatNumber(value, 'numberWithCommas', decimals)).toBe(expected); + }); + + test('formats percent values with Intl percent rules', () => { + const value = 0.1234; + const decimals = 2; + const expected = new Intl.NumberFormat(navigator.language, { + style: 'percent', + useGrouping: false, + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value); + + expect(formatNumber(value, 'percent', decimals)).toBe(expected); + }); + + test('formats currency values with Intl currency rules', () => { + const value = 11451.4; + const expected = new Intl.NumberFormat(navigator.language, { + style: 'currency', + currency: 'USD', + currencyDisplay: 'symbol', + }).format(value); + + expect(formatNumber(value, 'currencyUSD')).toBe(expected); + }); + + test('parses grouped number string pasted from clipboard', () => { + expect(parseNumber('11,451.4')).toBe(11451.4); + }); + + test('keeps regular decimal parsing', () => { + expect(parseNumber('123.45')).toBe(123.45); + }); + + test('supports comma as decimal separator in locale-specific input', () => { + expect(parseNumber('11451,4', ',')).toBe(11451.4); + }); +}); diff --git a/blocksuite/affine/data-view/src/core/data-view.ts b/blocksuite/affine/data-view/src/core/data-view.ts index 760490efb..3e601c96b 100644 --- a/blocksuite/affine/data-view/src/core/data-view.ts +++ b/blocksuite/affine/data-view/src/core/data-view.ts @@ -22,7 +22,6 @@ import { html } from 'lit/static-html.js'; import { dataViewCommonStyle } from './common/css-variable.js'; import type { DataSource } from './data-source/index.js'; import type { DataViewSelection } from './types.js'; -import { cacheComputed } from './utils/cache.js'; import { renderUniLit } from './utils/uni-component/index.js'; import type { DataViewUILogicBase } from './view/data-view-base.js'; import type { SingleView } from './view-manager/single-view.js'; @@ -75,12 +74,38 @@ export class DataViewRootUILogic { return new (logic(view))(this, view); } - private readonly views$ = cacheComputed(this.viewManager.views$, viewId => - this.createDataViewUILogic(viewId) - ); + private readonly _viewsCache = new Map< + string, + { mode: string; logic: DataViewUILogicBase } + >(); + + private readonly views$ = computed(() => { + const viewDataList = this.dataSource.viewDataList$.value; + const validIds = new Set(viewDataList.map(viewData => viewData.id)); + + for (const cachedId of this._viewsCache.keys()) { + if (!validIds.has(cachedId)) { + this._viewsCache.delete(cachedId); + } + } + + return viewDataList.map(viewData => { + const cached = this._viewsCache.get(viewData.id); + if (cached && cached.mode === viewData.mode) { + return cached.logic; + } + const logic = this.createDataViewUILogic(viewData.id); + this._viewsCache.set(viewData.id, { + mode: viewData.mode, + logic, + }); + return logic; + }); + }); + private readonly viewsMap$ = computed(() => { return Object.fromEntries( - this.views$.list.value.map(logic => [logic.view.id, logic]) + this.views$.value.map(logic => [logic.view.id, logic]) ); }); private readonly _uiRef = signal(); diff --git a/blocksuite/affine/data-view/src/core/detail/selection.ts b/blocksuite/affine/data-view/src/core/detail/selection.ts index 779e46839..929fd7c85 100644 --- a/blocksuite/affine/data-view/src/core/detail/selection.ts +++ b/blocksuite/affine/data-view/src/core/detail/selection.ts @@ -1,7 +1,6 @@ import type { KanbanCardSelection } from '../../view-presets'; import type { KanbanCard } from '../../view-presets/kanban/pc/card.js'; import { KanbanCell } from '../../view-presets/kanban/pc/cell.js'; -import type { RecordDetail } from './detail.js'; import { RecordField } from './field.js'; type DetailViewSelection = { @@ -9,16 +8,39 @@ type DetailViewSelection = { isEditing: boolean; }; +type DetailSelectionHost = { + querySelector: (selector: string) => unknown; +}; + +const isSameDetailSelection = ( + current?: DetailViewSelection, + next?: DetailViewSelection +) => { + if (!current && !next) { + return true; + } + if (!current || !next) { + return false; + } + return ( + current.propertyId === next.propertyId && + current.isEditing === next.isEditing + ); +}; + export class DetailSelection { _selection?: DetailViewSelection; onSelect = (selection?: DetailViewSelection) => { + if (isSameDetailSelection(this._selection, selection)) { + return; + } const old = this._selection; + this._selection = selection; if (old) { this.blur(old); } - this._selection = selection; - if (selection) { + if (selection && isSameDetailSelection(this._selection, selection)) { this.focus(selection); } }; @@ -49,7 +71,7 @@ export class DetailSelection { } } - constructor(private readonly viewEle: RecordDetail) {} + constructor(private readonly viewEle: DetailSelectionHost) {} blur(selection: DetailViewSelection) { const container = this.getFocusCellContainer(selection); @@ -111,8 +133,10 @@ export class DetailSelection { } focusFirstCell() { - const firstId = this.viewEle.querySelector('affine-data-view-record-field') - ?.column.id; + const firstField = this.viewEle.querySelector( + 'affine-data-view-record-field' + ) as RecordField | undefined; + const firstId = firstField?.column.id; if (firstId) { this.selection = { propertyId: firstId, @@ -144,11 +168,12 @@ export class DetailSelection { getSelectCard(selection: KanbanCardSelection) { const { groupKey, cardId } = selection.cards[0]; + const group = this.viewEle.querySelector( + `affine-data-view-kanban-group[data-key="${groupKey}"]` + ) as HTMLElement | undefined; - return this.viewEle - .querySelector(`affine-data-view-kanban-group[data-key="${groupKey}"]`) - ?.querySelector( - `affine-data-view-kanban-card[data-card-id="${cardId}"]` - ) as KanbanCard | undefined; + return group?.querySelector( + `affine-data-view-kanban-card[data-card-id="${cardId}"]` + ) as KanbanCard | undefined; } } diff --git a/blocksuite/affine/data-view/src/core/types.ts b/blocksuite/affine/data-view/src/core/types.ts index c3c60ba40..0fc79d799 100644 --- a/blocksuite/affine/data-view/src/core/types.ts +++ b/blocksuite/affine/data-view/src/core/types.ts @@ -12,6 +12,5 @@ export type PropertyDataUpdater< > = (data: Data) => Partial; export interface DatabaseFlags { - enable_number_formatting: boolean; enable_table_virtual_scroll: boolean; } diff --git a/blocksuite/affine/data-view/src/property-presets/number/cell-renderer.ts b/blocksuite/affine/data-view/src/property-presets/number/cell-renderer.ts index 6c7a4d879..00c9ac120 100644 --- a/blocksuite/affine/data-view/src/property-presets/number/cell-renderer.ts +++ b/blocksuite/affine/data-view/src/property-presets/number/cell-renderer.ts @@ -24,17 +24,11 @@ export class NumberCell extends BaseCellRenderer< private accessor _inputEle!: HTMLInputElement; private _getFormattedString(value: number | undefined = this.value) { - const enableNewFormatting = - this.view.featureFlags$.value.enable_number_formatting; const decimals = this.property.data$.value.decimal ?? 0; const formatMode = (this.property.data$.value.format ?? 'number') as NumberFormat; - return value != undefined - ? enableNewFormatting - ? formatNumber(value, formatMode, decimals) - : value.toString() - : ''; + return value != undefined ? formatNumber(value, formatMode, decimals) : ''; } private readonly _keydown = (e: KeyboardEvent) => { @@ -58,9 +52,7 @@ export class NumberCell extends BaseCellRenderer< return; } - const enableNewFormatting = - this.view.featureFlags$.value.enable_number_formatting; - const value = enableNewFormatting ? parseNumber(str) : parseFloat(str); + const value = parseNumber(str); if (isNaN(value)) { if (this._inputEle) { this._inputEle.value = this.value diff --git a/blocksuite/affine/data-view/src/property-presets/number/define.ts b/blocksuite/affine/data-view/src/property-presets/number/define.ts index b1b1de3bf..a8bf13cfb 100644 --- a/blocksuite/affine/data-view/src/property-presets/number/define.ts +++ b/blocksuite/affine/data-view/src/property-presets/number/define.ts @@ -3,6 +3,7 @@ import zod from 'zod'; import { t } from '../../core/logical/type-presets.js'; import { propertyType } from '../../core/property/property-config.js'; import { NumberPropertySchema } from './types.js'; +import { parseNumber } from './utils/formatter.js'; export const numberPropertyType = propertyType('number'); export const numberPropertyModelConfig = numberPropertyType.modelConfig({ @@ -21,7 +22,7 @@ export const numberPropertyModelConfig = numberPropertyType.modelConfig({ default: () => null, toString: ({ value }) => value?.toString() ?? '', fromString: ({ value }) => { - const num = value ? Number(value) : NaN; + const num = value ? parseNumber(value) : NaN; return { value: isNaN(num) ? null : num }; }, toJson: ({ value }) => value ?? null, diff --git a/blocksuite/affine/data-view/src/view-presets/table/mobile/column-header.ts b/blocksuite/affine/data-view/src/view-presets/table/mobile/column-header.ts index 048b2eb2a..5ae58d211 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/mobile/column-header.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/mobile/column-header.ts @@ -64,9 +64,6 @@ export class MobileTableColumnHeader extends SignalWatcher( }; private popMenu(ele?: HTMLElement) { - const enableNumberFormatting = - this.tableViewManager.featureFlags$.value.enable_number_formatting; - popMenu(popupTargetFromElement(ele ?? this), { options: { title: { @@ -76,41 +73,36 @@ export class MobileTableColumnHeader extends SignalWatcher( inputConfig(this.column), typeConfig(this.column), // Number format begin - ...(enableNumberFormatting - ? [ - menu.subMenu({ - name: 'Number Format', - hide: () => - !this.column.dataUpdate || - this.column.type$.value !== 'number', - options: { - title: { - text: 'Number Format', + menu.subMenu({ + name: 'Number Format', + hide: () => + !this.column.dataUpdate || this.column.type$.value !== 'number', + options: { + title: { + text: 'Number Format', + }, + items: [ + numberFormatConfig(this.column), + ...numberFormats.map(format => { + const data = this.column.data$.value; + return menu.action({ + isSelected: data.format === format.type, + prefix: html`${format.symbol}`, + name: format.label, + select: () => { + if (data.format === format.type) return; + this.column.dataUpdate(() => ({ + format: format.type, + })); }, - items: [ - numberFormatConfig(this.column), - ...numberFormats.map(format => { - const data = this.column.data$.value; - return menu.action({ - isSelected: data.format === format.type, - prefix: html`${format.symbol}`, - name: format.label, - select: () => { - if (data.format === format.type) return; - this.column.dataUpdate(() => ({ - format: format.type, - })); - }, - }); - }), - ], - }, + }); }), - ] - : []), + ], + }, + }), // Number format end menu.group({ items: [ diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/single-column-header.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/single-column-header.ts index 67803e1d8..95135866a 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/single-column-header.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/single-column-header.ts @@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher( } private popMenu(ele?: HTMLElement) { - const enableNumberFormatting = - this.tableViewManager.featureFlags$.value.enable_number_formatting; - popMenu(popupTargetFromElement(ele ?? this), { options: { items: [ inputConfig(this.column), typeConfig(this.column), // Number format begin - ...(enableNumberFormatting - ? [ - menu.subMenu({ - name: 'Number Format', - hide: () => - !this.column.dataUpdate || - this.column.type$.value !== 'number', - options: { - items: [ - numberFormatConfig(this.column), - ...numberFormats.map(format => { - const data = this.column.data$.value; - return menu.action({ - isSelected: data.format === format.type, - prefix: html`${format.symbol}`, - name: format.label, - select: () => { - if (data.format === format.type) return; - this.column.dataUpdate(() => ({ - format: format.type, - })); - }, - }); - }), - ], - }, + menu.subMenu({ + name: 'Number Format', + hide: () => + !this.column.dataUpdate || this.column.type$.value !== 'number', + options: { + items: [ + numberFormatConfig(this.column), + ...numberFormats.map(format => { + const data = this.column.data$.value; + return menu.action({ + isSelected: data.format === format.type, + prefix: html`${format.symbol}`, + name: format.label, + select: () => { + if (data.format === format.type) return; + this.column.dataUpdate(() => ({ + format: format.type, + })); + }, + }); }), - ] - : []), + ], + }, + }), // Number format end menu.group({ items: [ diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/header/database-header-column.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/header/database-header-column.ts index 2efa517cf..abd85509c 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc/header/database-header-column.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/header/database-header-column.ts @@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher( } private popMenu(ele?: HTMLElement) { - const enableNumberFormatting = - this.tableViewManager.featureFlags$.value.enable_number_formatting; - popMenu(popupTargetFromElement(ele ?? this), { options: { items: [ inputConfig(this.column), typeConfig(this.column), // Number format begin - ...(enableNumberFormatting - ? [ - menu.subMenu({ - name: 'Number Format', - hide: () => - !this.column.dataUpdate || - this.column.type$.value !== 'number', - options: { - items: [ - numberFormatConfig(this.column), - ...numberFormats.map(format => { - const data = this.column.data$.value; - return menu.action({ - isSelected: data.format === format.type, - prefix: html`${format.symbol}`, - name: format.label, - select: () => { - if (data.format === format.type) return; - this.column.dataUpdate(() => ({ - format: format.type, - })); - }, - }); - }), - ], - }, + menu.subMenu({ + name: 'Number Format', + hide: () => + !this.column.dataUpdate || this.column.type$.value !== 'number', + options: { + items: [ + numberFormatConfig(this.column), + ...numberFormats.map(format => { + const data = this.column.data$.value; + return menu.action({ + isSelected: data.format === format.type, + prefix: html`${format.symbol}`, + name: format.label, + select: () => { + if (data.format === format.type) return; + this.column.dataUpdate(() => ({ + format: format.type, + })); + }, + }); }), - ] - : []), + ], + }, + }), // Number format end menu.group({ items: [ diff --git a/blocksuite/affine/data-view/src/widget-presets/tools/presets/view-options/view-options.ts b/blocksuite/affine/data-view/src/widget-presets/tools/presets/view-options/view-options.ts index d46c96b2f..79a81d642 100644 --- a/blocksuite/affine/data-view/src/widget-presets/tools/presets/view-options/view-options.ts +++ b/blocksuite/affine/data-view/src/widget-presets/tools/presets/view-options/view-options.ts @@ -337,6 +337,7 @@ export const popViewOptions = ( const reopen = () => { popViewOptions(target, dataViewLogic); }; + let handler: ReturnType; const items: MenuConfig[] = []; items.push( menu.input({ @@ -350,16 +351,9 @@ export const popViewOptions = ( items.push( menu.group({ items: [ - menu.action({ - name: 'Layout', - postfix: html`
- ${view.type} -
- ${ArrowRightSmallIcon()}`, - select: () => { - const viewTypes = view.manager.viewMetas.map(meta => { + menu => { + const viewTypeItems = menu.renderItems( + view.manager.viewMetas.map(meta => { return menu => { if (!menu.search(meta.model.defaultName)) { return; @@ -379,10 +373,10 @@ export const popViewOptions = ( ? 'var(--affine-text-emphasis-color)' : 'var(--affine-text-secondary-color)', }); - const data: MenuButtonData = { + const buttonData: MenuButtonData = { content: () => html`
${renderUniLit(meta.renderer.icon)} @@ -392,7 +386,7 @@ export const popViewOptions = ( `, select: () => { const id = view.manager.currentViewId$.value; - if (!id) { + if (!id || meta.type === view.type) { return; } view.manager.viewChangeType(id, meta.type); @@ -403,55 +397,35 @@ export const popViewOptions = ( const containerStyle = styleMap({ flex: '1', }); - return html` `; }; - }); - const subHandler = popMenu(target, { - options: { - title: { - onBack: reopen, - text: 'Layout', - }, - items: [ - menu => { - const result = menu.renderItems(viewTypes); - if (result.length) { - return html`
${result}
`; - } - return html``; - }, - // menu.toggleSwitch({ - // name: 'Show block icon', - // on: true, - // onChange: value => { - // console.log(value); - // }, - // }), - // menu.toggleSwitch({ - // name: 'Show Vertical lines', - // on: true, - // onChange: value => { - // console.log(value); - // }, - // }), - ], - }, - middleware: [ - autoPlacement({ - allowedPlacements: ['bottom-start', 'top-start'], - }), - offset({ mainAxis: 15, crossAxis: -162 }), - shift({ crossAxis: true }), - ], - }); - subHandler.menu.menuElement.style.minHeight = '550px'; - }, - prefix: LayoutIcon(), - }), + }) + ); + if (!viewTypeItems.length) { + return html``; + } + return html` +
+
+ ${LayoutIcon()} +
+
+ Layout +
+
+
+ ${viewTypeItems} +
+ `; + }, ], }) ); @@ -486,7 +460,6 @@ export const popViewOptions = ( ], }) ); - let handler: ReturnType; handler = popMenu(target, { options: { title: { diff --git a/blocksuite/affine/shared/src/__tests__/utils/url.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/utils/url.unit.spec.ts index 797d14f2c..ca4e5b9b7 100644 --- a/blocksuite/affine/shared/src/__tests__/utils/url.unit.spec.ts +++ b/blocksuite/affine/shared/src/__tests__/utils/url.unit.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { isValidUrl } from '../../utils/url.js'; +import { isValidUrl, splitTextByUrl } from '../../utils/url.js'; describe('isValidUrl: determining whether a URL is valid is very complicated', () => { test('basic case', () => { @@ -85,3 +85,55 @@ describe('isValidUrl: determining whether a URL is valid is very complicated', ( expect(isValidUrl('http://127.0.0.1', 'http://127.0.0.1')).toEqual(true); }); }); + +describe('splitTextByUrl', () => { + test('should split text and keep url part as link segment', () => { + expect(splitTextByUrl('hi - https://google.com')).toEqual([ + { text: 'hi - ' }, + { + text: 'https://google.com', + link: 'https://google.com', + }, + ]); + }); + + test('should support prefixed url token without swallowing prefix text', () => { + expect(splitTextByUrl('-https://google.com')).toEqual([ + { text: '-' }, + { + text: 'https://google.com', + link: 'https://google.com', + }, + ]); + }); + + test('should trim tail punctuations from url token', () => { + expect(splitTextByUrl('visit https://google.com, now')).toEqual([ + { text: 'visit ' }, + { + text: 'https://google.com', + link: 'https://google.com', + }, + { text: ', now' }, + ]); + }); + + test('should convert domain token in plain text', () => { + expect(splitTextByUrl('google.com and text')).toEqual([ + { + text: 'google.com', + link: 'https://google.com', + }, + { text: ' and text' }, + ]); + }); + + test('should normalize www domain token link while preserving display text', () => { + expect(splitTextByUrl('www.google.com')).toEqual([ + { + text: 'www.google.com', + link: 'https://www.google.com', + }, + ]); + }); +}); diff --git a/blocksuite/affine/shared/src/services/feature-flag-service.ts b/blocksuite/affine/shared/src/services/feature-flag-service.ts index adacf4f53..1f12691c7 100644 --- a/blocksuite/affine/shared/src/services/feature-flag-service.ts +++ b/blocksuite/affine/shared/src/services/feature-flag-service.ts @@ -2,7 +2,6 @@ import { type Store, StoreExtension } from '@blocksuite/store'; import { type Signal, signal } from '@preact/signals-core'; export interface BlockSuiteFlags { - enable_database_number_formatting: boolean; enable_database_attachment_note: boolean; enable_database_full_width: boolean; enable_block_query: boolean; @@ -28,7 +27,6 @@ export class FeatureFlagService extends StoreExtension { static override key = 'feature-flag-server'; private readonly _flags: Signal = signal({ - enable_database_number_formatting: false, enable_database_attachment_note: false, enable_database_full_width: false, enable_block_query: false, diff --git a/blocksuite/affine/shared/src/utils/url.ts b/blocksuite/affine/shared/src/utils/url.ts index df96797af..f1a67a2ab 100644 --- a/blocksuite/affine/shared/src/utils/url.ts +++ b/blocksuite/affine/shared/src/utils/url.ts @@ -95,28 +95,107 @@ export function isValidUrl(str: string, baseUrl = location.origin) { return result?.allowed ?? false; } +const URL_SCHEME_IN_TOKEN_REGEXP = + /(?:https?:\/\/|ftp:\/\/|sftp:\/\/|mailto:|tel:|www\.)/i; + +const URL_LEADING_DELIMITER_REGEXP = /^[-([{<'"~]+/; + +const URL_TRAILING_DELIMITER_REGEXP = /[)\]}>.,;:!?'"]+$/; + +export type UrlTextSegment = { + text: string; + link?: string; +}; + +function appendUrlTextSegment( + segments: UrlTextSegment[], + segment: UrlTextSegment +) { + if (!segment.text) return; + const last = segments[segments.length - 1]; + if (last && !last.link && !segment.link) { + last.text += segment.text; + return; + } + segments.push(segment); +} + +function splitTokenByUrl(token: string, baseUrl: string): UrlTextSegment[] { + const schemeMatch = token.match(URL_SCHEME_IN_TOKEN_REGEXP); + const schemeIndex = schemeMatch?.index; + if (typeof schemeIndex === 'number' && schemeIndex > 0) { + return [ + { text: token.slice(0, schemeIndex) }, + ...splitTokenByUrl(token.slice(schemeIndex), baseUrl), + ]; + } + + const leading = token.match(URL_LEADING_DELIMITER_REGEXP)?.[0] ?? ''; + const withoutLeading = token.slice(leading.length); + const trailing = + withoutLeading.match(URL_TRAILING_DELIMITER_REGEXP)?.[0] ?? ''; + const core = trailing + ? withoutLeading.slice(0, withoutLeading.length - trailing.length) + : withoutLeading; + + if (core && isValidUrl(core, baseUrl)) { + const segments: UrlTextSegment[] = []; + appendUrlTextSegment(segments, { text: leading }); + appendUrlTextSegment(segments, { text: core, link: normalizeUrl(core) }); + appendUrlTextSegment(segments, { text: trailing }); + return segments; + } + + return [{ text: token }]; +} + +/** + * Split plain text into mixed segments, where only URL segments carry link metadata. + * This is used by paste handlers so text like `example:https://google.com` keeps + * normal text while only URL parts are linkified. + */ +export function splitTextByUrl(text: string, baseUrl = location.origin) { + const chunks = text.match(/\s+|\S+/g); + if (!chunks) { + return []; + } + + const segments: UrlTextSegment[] = []; + chunks.forEach(chunk => { + if (/^\s+$/.test(chunk)) { + appendUrlTextSegment(segments, { text: chunk }); + return; + } + splitTokenByUrl(chunk, baseUrl).forEach(segment => { + appendUrlTextSegment(segments, segment); + }); + }); + return segments; +} + // https://en.wikipedia.org/wiki/Top-level_domain const COMMON_TLDS = new Set([ - 'com', - 'org', - 'net', - 'edu', - 'gov', - 'co', - 'io', - 'me', - 'moe', - 'mil', - 'top', - 'dev', - 'xyz', - 'info', 'cat', - 'ru', + 'co', + 'com', 'de', + 'dev', + 'edu', + 'eu', + 'gov', + 'info', + 'io', 'jp', - 'uk', + 'me', + 'mil', + 'moe', + 'net', + 'org', 'pro', + 'ru', + 'top', + 'uk', + 'xyz', ]); function isCommonTLD(url: URL) {