mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-02 09:59:55 +08:00
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:
@@ -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 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -135,14 +135,10 @@ export class DatabaseBlockDataSource extends DataSourceBase {
|
|||||||
|
|
||||||
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
|
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
|
||||||
const featureFlagService = this.doc.get(FeatureFlagService);
|
const featureFlagService = this.doc.get(FeatureFlagService);
|
||||||
const enableNumberFormat = featureFlagService.getFlag(
|
|
||||||
'enable_database_number_formatting'
|
|
||||||
);
|
|
||||||
const enableTableVirtualScroll = featureFlagService.getFlag(
|
const enableTableVirtualScroll = featureFlagService.getFlag(
|
||||||
'enable_table_virtual_scroll'
|
'enable_table_virtual_scroll'
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
enable_number_formatting: enableNumberFormat ?? false,
|
|
||||||
enable_table_virtual_scroll: enableTableVirtualScroll ?? 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,
|
AffineInlineEditor,
|
||||||
AffineTextAttributes,
|
AffineTextAttributes,
|
||||||
} from '@blocksuite/affine-shared/types';
|
} from '@blocksuite/affine-shared/types';
|
||||||
import {
|
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||||
getViewportElement,
|
|
||||||
isValidUrl,
|
|
||||||
} from '@blocksuite/affine-shared/utils';
|
|
||||||
import {
|
import {
|
||||||
BaseCellRenderer,
|
BaseCellRenderer,
|
||||||
createFromBaseCellRenderer,
|
createFromBaseCellRenderer,
|
||||||
@@ -26,6 +23,7 @@ import { html } from 'lit/static-html.js';
|
|||||||
|
|
||||||
import { EditorHostKey } from '../../context/host-context.js';
|
import { EditorHostKey } from '../../context/host-context.js';
|
||||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||||
|
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
|
||||||
import {
|
import {
|
||||||
richTextCellStyle,
|
richTextCellStyle,
|
||||||
richTextContainerStyle,
|
richTextContainerStyle,
|
||||||
@@ -271,10 +269,13 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
?.getData('text/plain')
|
?.getData('text/plain')
|
||||||
?.replace(/\r?\n|\r/g, '\n');
|
?.replace(/\r?\n|\r/g, '\n');
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
|
||||||
|
|
||||||
if (isValidUrl(text)) {
|
if (singleUrl) {
|
||||||
const std = this.std;
|
const std = this.std;
|
||||||
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
|
const result = std
|
||||||
|
?.getOptional(ParseDocUrlProvider)
|
||||||
|
?.parseDocUrl(singleUrl);
|
||||||
if (result) {
|
if (result) {
|
||||||
const text = ' ';
|
const text = ' ';
|
||||||
inlineEditor.insertText(inlineRange, text, {
|
inlineEditor.insertText(inlineRange, text, {
|
||||||
@@ -300,22 +301,10 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
segment: 'database',
|
segment: 'database',
|
||||||
parentFlavour: 'affine:database',
|
parentFlavour: 'affine:database',
|
||||||
});
|
});
|
||||||
} else {
|
return;
|
||||||
inlineEditor.insertText(inlineRange, text, {
|
|
||||||
link: text,
|
|
||||||
});
|
|
||||||
inlineEditor.setInlineRange({
|
|
||||||
index: inlineRange.index + text.length,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
inlineEditor.insertText(inlineRange, text);
|
|
||||||
inlineEditor.setInlineRange({
|
|
||||||
index: inlineRange.index + text.length,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
insertUrlTextSegments(inlineEditor, inlineRange, segments);
|
||||||
};
|
};
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import {
|
|||||||
ParseDocUrlProvider,
|
ParseDocUrlProvider,
|
||||||
TelemetryProvider,
|
TelemetryProvider,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
import {
|
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||||
getViewportElement,
|
|
||||||
isValidUrl,
|
|
||||||
} from '@blocksuite/affine-shared/utils';
|
|
||||||
import { BaseCellRenderer } from '@blocksuite/data-view';
|
import { BaseCellRenderer } from '@blocksuite/data-view';
|
||||||
import { IS_MAC } from '@blocksuite/global/env';
|
import { IS_MAC } from '@blocksuite/global/env';
|
||||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
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 { EditorHostKey } from '../../context/host-context.js';
|
||||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||||
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
|
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
|
||||||
|
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
|
||||||
import {
|
import {
|
||||||
headerAreaIconStyle,
|
headerAreaIconStyle,
|
||||||
titleCellStyle,
|
titleCellStyle,
|
||||||
@@ -95,7 +93,9 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
private readonly _onPaste = (e: ClipboardEvent) => {
|
private readonly _onPaste = (e: ClipboardEvent) => {
|
||||||
const inlineEditor = this.inlineEditor;
|
const inlineEditor = this.inlineEditor;
|
||||||
const inlineRange = inlineEditor?.getInlineRange();
|
const inlineRange = inlineEditor?.getInlineRange();
|
||||||
if (!inlineRange) return;
|
if (!inlineEditor || !inlineRange) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
if (e.clipboardData) {
|
if (e.clipboardData) {
|
||||||
try {
|
try {
|
||||||
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
|
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
|
||||||
@@ -121,14 +121,15 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
?.getData('text/plain')
|
?.getData('text/plain')
|
||||||
?.replace(/\r?\n|\r/g, '\n');
|
?.replace(/\r?\n|\r/g, '\n');
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
e.preventDefault();
|
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
|
||||||
e.stopPropagation();
|
if (singleUrl) {
|
||||||
if (isValidUrl(text)) {
|
|
||||||
const std = this.std;
|
const std = this.std;
|
||||||
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
|
const result = std
|
||||||
|
?.getOptional(ParseDocUrlProvider)
|
||||||
|
?.parseDocUrl(singleUrl);
|
||||||
if (result) {
|
if (result) {
|
||||||
const text = ' ';
|
const text = ' ';
|
||||||
inlineEditor?.insertText(inlineRange, text, {
|
inlineEditor.insertText(inlineRange, text, {
|
||||||
reference: {
|
reference: {
|
||||||
type: 'LinkedPage',
|
type: 'LinkedPage',
|
||||||
pageId: result.docId,
|
pageId: result.docId,
|
||||||
@@ -139,7 +140,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
inlineEditor?.setInlineRange({
|
inlineEditor.setInlineRange({
|
||||||
index: inlineRange.index + text.length,
|
index: inlineRange.index + text.length,
|
||||||
length: 0,
|
length: 0,
|
||||||
});
|
});
|
||||||
@@ -151,22 +152,10 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
segment: 'database',
|
segment: 'database',
|
||||||
parentFlavour: 'affine:database',
|
parentFlavour: 'affine:database',
|
||||||
});
|
});
|
||||||
} else {
|
return;
|
||||||
inlineEditor?.insertText(inlineRange, text, {
|
|
||||||
link: text,
|
|
||||||
});
|
|
||||||
inlineEditor?.setInlineRange({
|
|
||||||
index: inlineRange.index + text.length,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
inlineEditor?.insertText(inlineRange, text);
|
|
||||||
inlineEditor?.setInlineRange({
|
|
||||||
index: inlineRange.index + text.length,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
insertUrlTextSegments(inlineEditor, inlineRange, segments);
|
||||||
};
|
};
|
||||||
|
|
||||||
insertDelta = (delta: DeltaInsert) => {
|
insertDelta = (delta: DeltaInsert) => {
|
||||||
@@ -240,7 +229,8 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
this.disposables.addFromEvent(
|
this.disposables.addFromEvent(
|
||||||
this.richText.value,
|
this.richText.value,
|
||||||
'paste',
|
'paste',
|
||||||
this._onPaste
|
this._onPaste,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
const inlineEditor = this.inlineEditor;
|
const inlineEditor = this.inlineEditor;
|
||||||
if (inlineEditor) {
|
if (inlineEditor) {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import type { GroupBy } from '../core/common/types.js';
|
import type { GroupBy } from '../core/common/types.js';
|
||||||
import type { DataSource } from '../core/data-source/base.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 { groupByMatchers } from '../core/group-by/define.js';
|
||||||
import { t } from '../core/logical/type-presets.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 { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js';
|
||||||
import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js';
|
import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js';
|
||||||
import { selectPropertyModelConfig } from '../property-presets/select/define.js';
|
import { selectPropertyModelConfig } from '../property-presets/select/define.js';
|
||||||
@@ -456,4 +458,60 @@ describe('kanban', () => {
|
|||||||
expect(next?.hideEmpty).toBe(true);
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,7 +22,6 @@ import { html } from 'lit/static-html.js';
|
|||||||
import { dataViewCommonStyle } from './common/css-variable.js';
|
import { dataViewCommonStyle } from './common/css-variable.js';
|
||||||
import type { DataSource } from './data-source/index.js';
|
import type { DataSource } from './data-source/index.js';
|
||||||
import type { DataViewSelection } from './types.js';
|
import type { DataViewSelection } from './types.js';
|
||||||
import { cacheComputed } from './utils/cache.js';
|
|
||||||
import { renderUniLit } from './utils/uni-component/index.js';
|
import { renderUniLit } from './utils/uni-component/index.js';
|
||||||
import type { DataViewUILogicBase } from './view/data-view-base.js';
|
import type { DataViewUILogicBase } from './view/data-view-base.js';
|
||||||
import type { SingleView } from './view-manager/single-view.js';
|
import type { SingleView } from './view-manager/single-view.js';
|
||||||
@@ -75,12 +74,38 @@ export class DataViewRootUILogic {
|
|||||||
|
|
||||||
return new (logic(view))(this, view);
|
return new (logic(view))(this, view);
|
||||||
}
|
}
|
||||||
private readonly views$ = cacheComputed(this.viewManager.views$, viewId =>
|
private readonly _viewsCache = new Map<
|
||||||
this.createDataViewUILogic(viewId)
|
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(() => {
|
private readonly viewsMap$ = computed(() => {
|
||||||
return Object.fromEntries(
|
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<DataViewRootUI>();
|
private readonly _uiRef = signal<DataViewRootUI>();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { KanbanCardSelection } from '../../view-presets';
|
import type { KanbanCardSelection } from '../../view-presets';
|
||||||
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
|
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
|
||||||
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
|
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
|
||||||
import type { RecordDetail } from './detail.js';
|
|
||||||
import { RecordField } from './field.js';
|
import { RecordField } from './field.js';
|
||||||
|
|
||||||
type DetailViewSelection = {
|
type DetailViewSelection = {
|
||||||
@@ -9,16 +8,39 @@ type DetailViewSelection = {
|
|||||||
isEditing: boolean;
|
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 {
|
export class DetailSelection {
|
||||||
_selection?: DetailViewSelection;
|
_selection?: DetailViewSelection;
|
||||||
|
|
||||||
onSelect = (selection?: DetailViewSelection) => {
|
onSelect = (selection?: DetailViewSelection) => {
|
||||||
|
if (isSameDetailSelection(this._selection, selection)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const old = this._selection;
|
const old = this._selection;
|
||||||
|
this._selection = selection;
|
||||||
if (old) {
|
if (old) {
|
||||||
this.blur(old);
|
this.blur(old);
|
||||||
}
|
}
|
||||||
this._selection = selection;
|
if (selection && isSameDetailSelection(this._selection, selection)) {
|
||||||
if (selection) {
|
|
||||||
this.focus(selection);
|
this.focus(selection);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -49,7 +71,7 @@ export class DetailSelection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(private readonly viewEle: RecordDetail) {}
|
constructor(private readonly viewEle: DetailSelectionHost) {}
|
||||||
|
|
||||||
blur(selection: DetailViewSelection) {
|
blur(selection: DetailViewSelection) {
|
||||||
const container = this.getFocusCellContainer(selection);
|
const container = this.getFocusCellContainer(selection);
|
||||||
@@ -111,8 +133,10 @@ export class DetailSelection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
focusFirstCell() {
|
focusFirstCell() {
|
||||||
const firstId = this.viewEle.querySelector('affine-data-view-record-field')
|
const firstField = this.viewEle.querySelector(
|
||||||
?.column.id;
|
'affine-data-view-record-field'
|
||||||
|
) as RecordField | undefined;
|
||||||
|
const firstId = firstField?.column.id;
|
||||||
if (firstId) {
|
if (firstId) {
|
||||||
this.selection = {
|
this.selection = {
|
||||||
propertyId: firstId,
|
propertyId: firstId,
|
||||||
@@ -144,11 +168,12 @@ export class DetailSelection {
|
|||||||
|
|
||||||
getSelectCard(selection: KanbanCardSelection) {
|
getSelectCard(selection: KanbanCardSelection) {
|
||||||
const { groupKey, cardId } = selection.cards[0];
|
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
|
return group?.querySelector(
|
||||||
.querySelector(`affine-data-view-kanban-group[data-key="${groupKey}"]`)
|
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
|
||||||
?.querySelector(
|
) as KanbanCard | undefined;
|
||||||
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
|
|
||||||
) as KanbanCard | undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,5 @@ export type PropertyDataUpdater<
|
|||||||
> = (data: Data) => Partial<Data>;
|
> = (data: Data) => Partial<Data>;
|
||||||
|
|
||||||
export interface DatabaseFlags {
|
export interface DatabaseFlags {
|
||||||
enable_number_formatting: boolean;
|
|
||||||
enable_table_virtual_scroll: boolean;
|
enable_table_virtual_scroll: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,17 +24,11 @@ export class NumberCell extends BaseCellRenderer<
|
|||||||
private accessor _inputEle!: HTMLInputElement;
|
private accessor _inputEle!: HTMLInputElement;
|
||||||
|
|
||||||
private _getFormattedString(value: number | undefined = this.value) {
|
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 decimals = this.property.data$.value.decimal ?? 0;
|
||||||
const formatMode = (this.property.data$.value.format ??
|
const formatMode = (this.property.data$.value.format ??
|
||||||
'number') as NumberFormat;
|
'number') as NumberFormat;
|
||||||
|
|
||||||
return value != undefined
|
return value != undefined ? formatNumber(value, formatMode, decimals) : '';
|
||||||
? enableNewFormatting
|
|
||||||
? formatNumber(value, formatMode, decimals)
|
|
||||||
: value.toString()
|
|
||||||
: '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly _keydown = (e: KeyboardEvent) => {
|
private readonly _keydown = (e: KeyboardEvent) => {
|
||||||
@@ -58,9 +52,7 @@ export class NumberCell extends BaseCellRenderer<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enableNewFormatting =
|
const value = parseNumber(str);
|
||||||
this.view.featureFlags$.value.enable_number_formatting;
|
|
||||||
const value = enableNewFormatting ? parseNumber(str) : parseFloat(str);
|
|
||||||
if (isNaN(value)) {
|
if (isNaN(value)) {
|
||||||
if (this._inputEle) {
|
if (this._inputEle) {
|
||||||
this._inputEle.value = this.value
|
this._inputEle.value = this.value
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import zod from 'zod';
|
|||||||
import { t } from '../../core/logical/type-presets.js';
|
import { t } from '../../core/logical/type-presets.js';
|
||||||
import { propertyType } from '../../core/property/property-config.js';
|
import { propertyType } from '../../core/property/property-config.js';
|
||||||
import { NumberPropertySchema } from './types.js';
|
import { NumberPropertySchema } from './types.js';
|
||||||
|
import { parseNumber } from './utils/formatter.js';
|
||||||
export const numberPropertyType = propertyType('number');
|
export const numberPropertyType = propertyType('number');
|
||||||
|
|
||||||
export const numberPropertyModelConfig = numberPropertyType.modelConfig({
|
export const numberPropertyModelConfig = numberPropertyType.modelConfig({
|
||||||
@@ -21,7 +22,7 @@ export const numberPropertyModelConfig = numberPropertyType.modelConfig({
|
|||||||
default: () => null,
|
default: () => null,
|
||||||
toString: ({ value }) => value?.toString() ?? '',
|
toString: ({ value }) => value?.toString() ?? '',
|
||||||
fromString: ({ value }) => {
|
fromString: ({ value }) => {
|
||||||
const num = value ? Number(value) : NaN;
|
const num = value ? parseNumber(value) : NaN;
|
||||||
return { value: isNaN(num) ? null : num };
|
return { value: isNaN(num) ? null : num };
|
||||||
},
|
},
|
||||||
toJson: ({ value }) => value ?? null,
|
toJson: ({ value }) => value ?? null,
|
||||||
|
|||||||
@@ -64,9 +64,6 @@ export class MobileTableColumnHeader extends SignalWatcher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
private popMenu(ele?: HTMLElement) {
|
private popMenu(ele?: HTMLElement) {
|
||||||
const enableNumberFormatting =
|
|
||||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
|
||||||
|
|
||||||
popMenu(popupTargetFromElement(ele ?? this), {
|
popMenu(popupTargetFromElement(ele ?? this), {
|
||||||
options: {
|
options: {
|
||||||
title: {
|
title: {
|
||||||
@@ -76,41 +73,36 @@ export class MobileTableColumnHeader extends SignalWatcher(
|
|||||||
inputConfig(this.column),
|
inputConfig(this.column),
|
||||||
typeConfig(this.column),
|
typeConfig(this.column),
|
||||||
// Number format begin
|
// Number format begin
|
||||||
...(enableNumberFormatting
|
menu.subMenu({
|
||||||
? [
|
name: 'Number Format',
|
||||||
menu.subMenu({
|
hide: () =>
|
||||||
name: 'Number Format',
|
!this.column.dataUpdate || this.column.type$.value !== 'number',
|
||||||
hide: () =>
|
options: {
|
||||||
!this.column.dataUpdate ||
|
title: {
|
||||||
this.column.type$.value !== 'number',
|
text: 'Number Format',
|
||||||
options: {
|
},
|
||||||
title: {
|
items: [
|
||||||
text: 'Number Format',
|
numberFormatConfig(this.column),
|
||||||
|
...numberFormats.map(format => {
|
||||||
|
const data = this.column.data$.value;
|
||||||
|
return menu.action({
|
||||||
|
isSelected: data.format === format.type,
|
||||||
|
prefix: html`<span
|
||||||
|
style="font-size: var(--affine-font-base); scale: 1.2;"
|
||||||
|
>${format.symbol}</span
|
||||||
|
>`,
|
||||||
|
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`<span
|
|
||||||
style="font-size: var(--affine-font-base); scale: 1.2;"
|
|
||||||
>${format.symbol}</span
|
|
||||||
>`,
|
|
||||||
name: format.label,
|
|
||||||
select: () => {
|
|
||||||
if (data.format === format.type) return;
|
|
||||||
this.column.dataUpdate(() => ({
|
|
||||||
format: format.type,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]
|
],
|
||||||
: []),
|
},
|
||||||
|
}),
|
||||||
// Number format end
|
// Number format end
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
+26
-34
@@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private popMenu(ele?: HTMLElement) {
|
private popMenu(ele?: HTMLElement) {
|
||||||
const enableNumberFormatting =
|
|
||||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
|
||||||
|
|
||||||
popMenu(popupTargetFromElement(ele ?? this), {
|
popMenu(popupTargetFromElement(ele ?? this), {
|
||||||
options: {
|
options: {
|
||||||
items: [
|
items: [
|
||||||
inputConfig(this.column),
|
inputConfig(this.column),
|
||||||
typeConfig(this.column),
|
typeConfig(this.column),
|
||||||
// Number format begin
|
// Number format begin
|
||||||
...(enableNumberFormatting
|
menu.subMenu({
|
||||||
? [
|
name: 'Number Format',
|
||||||
menu.subMenu({
|
hide: () =>
|
||||||
name: 'Number Format',
|
!this.column.dataUpdate || this.column.type$.value !== 'number',
|
||||||
hide: () =>
|
options: {
|
||||||
!this.column.dataUpdate ||
|
items: [
|
||||||
this.column.type$.value !== 'number',
|
numberFormatConfig(this.column),
|
||||||
options: {
|
...numberFormats.map(format => {
|
||||||
items: [
|
const data = this.column.data$.value;
|
||||||
numberFormatConfig(this.column),
|
return menu.action({
|
||||||
...numberFormats.map(format => {
|
isSelected: data.format === format.type,
|
||||||
const data = this.column.data$.value;
|
prefix: html`<span
|
||||||
return menu.action({
|
style="font-size: var(--affine-font-base); scale: 1.2;"
|
||||||
isSelected: data.format === format.type,
|
>${format.symbol}</span
|
||||||
prefix: html`<span
|
>`,
|
||||||
style="font-size: var(--affine-font-base); scale: 1.2;"
|
name: format.label,
|
||||||
>${format.symbol}</span
|
select: () => {
|
||||||
>`,
|
if (data.format === format.type) return;
|
||||||
name: format.label,
|
this.column.dataUpdate(() => ({
|
||||||
select: () => {
|
format: format.type,
|
||||||
if (data.format === format.type) return;
|
}));
|
||||||
this.column.dataUpdate(() => ({
|
},
|
||||||
format: format.type,
|
});
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]
|
],
|
||||||
: []),
|
},
|
||||||
|
}),
|
||||||
// Number format end
|
// Number format end
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
+26
-34
@@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private popMenu(ele?: HTMLElement) {
|
private popMenu(ele?: HTMLElement) {
|
||||||
const enableNumberFormatting =
|
|
||||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
|
||||||
|
|
||||||
popMenu(popupTargetFromElement(ele ?? this), {
|
popMenu(popupTargetFromElement(ele ?? this), {
|
||||||
options: {
|
options: {
|
||||||
items: [
|
items: [
|
||||||
inputConfig(this.column),
|
inputConfig(this.column),
|
||||||
typeConfig(this.column),
|
typeConfig(this.column),
|
||||||
// Number format begin
|
// Number format begin
|
||||||
...(enableNumberFormatting
|
menu.subMenu({
|
||||||
? [
|
name: 'Number Format',
|
||||||
menu.subMenu({
|
hide: () =>
|
||||||
name: 'Number Format',
|
!this.column.dataUpdate || this.column.type$.value !== 'number',
|
||||||
hide: () =>
|
options: {
|
||||||
!this.column.dataUpdate ||
|
items: [
|
||||||
this.column.type$.value !== 'number',
|
numberFormatConfig(this.column),
|
||||||
options: {
|
...numberFormats.map(format => {
|
||||||
items: [
|
const data = this.column.data$.value;
|
||||||
numberFormatConfig(this.column),
|
return menu.action({
|
||||||
...numberFormats.map(format => {
|
isSelected: data.format === format.type,
|
||||||
const data = this.column.data$.value;
|
prefix: html`<span
|
||||||
return menu.action({
|
style="font-size: var(--affine-font-base); scale: 1.2;"
|
||||||
isSelected: data.format === format.type,
|
>${format.symbol}</span
|
||||||
prefix: html`<span
|
>`,
|
||||||
style="font-size: var(--affine-font-base); scale: 1.2;"
|
name: format.label,
|
||||||
>${format.symbol}</span
|
select: () => {
|
||||||
>`,
|
if (data.format === format.type) return;
|
||||||
name: format.label,
|
this.column.dataUpdate(() => ({
|
||||||
select: () => {
|
format: format.type,
|
||||||
if (data.format === format.type) return;
|
}));
|
||||||
this.column.dataUpdate(() => ({
|
},
|
||||||
format: format.type,
|
});
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]
|
],
|
||||||
: []),
|
},
|
||||||
|
}),
|
||||||
// Number format end
|
// Number format end
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
+32
-59
@@ -337,6 +337,7 @@ export const popViewOptions = (
|
|||||||
const reopen = () => {
|
const reopen = () => {
|
||||||
popViewOptions(target, dataViewLogic);
|
popViewOptions(target, dataViewLogic);
|
||||||
};
|
};
|
||||||
|
let handler: ReturnType<typeof popMenu>;
|
||||||
const items: MenuConfig[] = [];
|
const items: MenuConfig[] = [];
|
||||||
items.push(
|
items.push(
|
||||||
menu.input({
|
menu.input({
|
||||||
@@ -350,16 +351,9 @@ export const popViewOptions = (
|
|||||||
items.push(
|
items.push(
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
menu.action({
|
menu => {
|
||||||
name: 'Layout',
|
const viewTypeItems = menu.renderItems(
|
||||||
postfix: html` <div
|
view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||||
style="font-size: 14px;text-transform: capitalize;"
|
|
||||||
>
|
|
||||||
${view.type}
|
|
||||||
</div>
|
|
||||||
${ArrowRightSmallIcon()}`,
|
|
||||||
select: () => {
|
|
||||||
const viewTypes = view.manager.viewMetas.map<MenuConfig>(meta => {
|
|
||||||
return menu => {
|
return menu => {
|
||||||
if (!menu.search(meta.model.defaultName)) {
|
if (!menu.search(meta.model.defaultName)) {
|
||||||
return;
|
return;
|
||||||
@@ -379,10 +373,10 @@ export const popViewOptions = (
|
|||||||
? 'var(--affine-text-emphasis-color)'
|
? 'var(--affine-text-emphasis-color)'
|
||||||
: 'var(--affine-text-secondary-color)',
|
: 'var(--affine-text-secondary-color)',
|
||||||
});
|
});
|
||||||
const data: MenuButtonData = {
|
const buttonData: MenuButtonData = {
|
||||||
content: () => html`
|
content: () => html`
|
||||||
<div
|
<div
|
||||||
style="color:var(--affine-text-emphasis-color);width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
|
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
|
||||||
>
|
>
|
||||||
<div style="${iconStyle}">
|
<div style="${iconStyle}">
|
||||||
${renderUniLit(meta.renderer.icon)}
|
${renderUniLit(meta.renderer.icon)}
|
||||||
@@ -392,7 +386,7 @@ export const popViewOptions = (
|
|||||||
`,
|
`,
|
||||||
select: () => {
|
select: () => {
|
||||||
const id = view.manager.currentViewId$.value;
|
const id = view.manager.currentViewId$.value;
|
||||||
if (!id) {
|
if (!id || meta.type === view.type) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
view.manager.viewChangeType(id, meta.type);
|
view.manager.viewChangeType(id, meta.type);
|
||||||
@@ -403,55 +397,35 @@ export const popViewOptions = (
|
|||||||
const containerStyle = styleMap({
|
const containerStyle = styleMap({
|
||||||
flex: '1',
|
flex: '1',
|
||||||
});
|
});
|
||||||
return html` <affine-menu-button
|
return html`<affine-menu-button
|
||||||
style="${containerStyle}"
|
style="${containerStyle}"
|
||||||
.data="${data}"
|
.data="${buttonData}"
|
||||||
.menu="${menu}"
|
.menu="${menu}"
|
||||||
></affine-menu-button>`;
|
></affine-menu-button>`;
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
const subHandler = popMenu(target, {
|
);
|
||||||
options: {
|
if (!viewTypeItems.length) {
|
||||||
title: {
|
return html``;
|
||||||
onBack: reopen,
|
}
|
||||||
text: 'Layout',
|
return html`
|
||||||
},
|
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
|
||||||
items: [
|
<div
|
||||||
menu => {
|
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
||||||
const result = menu.renderItems(viewTypes);
|
>
|
||||||
if (result.length) {
|
${LayoutIcon()}
|
||||||
return html` <div style="display: flex">${result}</div>`;
|
</div>
|
||||||
}
|
<div
|
||||||
return html``;
|
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
|
||||||
},
|
>
|
||||||
// menu.toggleSwitch({
|
Layout
|
||||||
// name: 'Show block icon',
|
</div>
|
||||||
// on: true,
|
</div>
|
||||||
// onChange: value => {
|
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||||
// console.log(value);
|
${viewTypeItems}
|
||||||
// },
|
</div>
|
||||||
// }),
|
`;
|
||||||
// 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(),
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -486,7 +460,6 @@ export const popViewOptions = (
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
let handler: ReturnType<typeof popMenu>;
|
|
||||||
handler = popMenu(target, {
|
handler = popMenu(target, {
|
||||||
options: {
|
options: {
|
||||||
title: {
|
title: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
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', () => {
|
describe('isValidUrl: determining whether a URL is valid is very complicated', () => {
|
||||||
test('basic case', () => {
|
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);
|
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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { type Store, StoreExtension } from '@blocksuite/store';
|
|||||||
import { type Signal, signal } from '@preact/signals-core';
|
import { type Signal, signal } from '@preact/signals-core';
|
||||||
|
|
||||||
export interface BlockSuiteFlags {
|
export interface BlockSuiteFlags {
|
||||||
enable_database_number_formatting: boolean;
|
|
||||||
enable_database_attachment_note: boolean;
|
enable_database_attachment_note: boolean;
|
||||||
enable_database_full_width: boolean;
|
enable_database_full_width: boolean;
|
||||||
enable_block_query: boolean;
|
enable_block_query: boolean;
|
||||||
@@ -28,7 +27,6 @@ export class FeatureFlagService extends StoreExtension {
|
|||||||
static override key = 'feature-flag-server';
|
static override key = 'feature-flag-server';
|
||||||
|
|
||||||
private readonly _flags: Signal<BlockSuiteFlags> = signal({
|
private readonly _flags: Signal<BlockSuiteFlags> = signal({
|
||||||
enable_database_number_formatting: false,
|
|
||||||
enable_database_attachment_note: false,
|
enable_database_attachment_note: false,
|
||||||
enable_database_full_width: false,
|
enable_database_full_width: false,
|
||||||
enable_block_query: false,
|
enable_block_query: false,
|
||||||
|
|||||||
@@ -95,28 +95,107 @@ export function isValidUrl(str: string, baseUrl = location.origin) {
|
|||||||
return result?.allowed ?? false;
|
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
|
// https://en.wikipedia.org/wiki/Top-level_domain
|
||||||
const COMMON_TLDS = new Set([
|
const COMMON_TLDS = new Set([
|
||||||
'com',
|
|
||||||
'org',
|
|
||||||
'net',
|
|
||||||
'edu',
|
|
||||||
'gov',
|
|
||||||
'co',
|
|
||||||
'io',
|
|
||||||
'me',
|
|
||||||
'moe',
|
|
||||||
'mil',
|
|
||||||
'top',
|
|
||||||
'dev',
|
|
||||||
'xyz',
|
|
||||||
'info',
|
|
||||||
'cat',
|
'cat',
|
||||||
'ru',
|
'co',
|
||||||
|
'com',
|
||||||
'de',
|
'de',
|
||||||
|
'dev',
|
||||||
|
'edu',
|
||||||
|
'eu',
|
||||||
|
'gov',
|
||||||
|
'info',
|
||||||
|
'io',
|
||||||
'jp',
|
'jp',
|
||||||
'uk',
|
'me',
|
||||||
|
'mil',
|
||||||
|
'moe',
|
||||||
|
'net',
|
||||||
|
'org',
|
||||||
'pro',
|
'pro',
|
||||||
|
'ru',
|
||||||
|
'top',
|
||||||
|
'uk',
|
||||||
|
'xyz',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isCommonTLD(url: URL) {
|
function isCommonTLD(url: URL) {
|
||||||
|
|||||||
Reference in New Issue
Block a user