mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-09 19:18:07 +00:00
Compare commits
3 Commits
renovate/m
...
v0.26.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c0f873c9d | ||
|
|
8b68574820 | ||
|
|
bb01bb1aef |
@@ -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(() => {
|
||||
const featureFlagService = this.doc.get(FeatureFlagService);
|
||||
const enableNumberFormat = featureFlagService.getFlag(
|
||||
'enable_database_number_formatting'
|
||||
);
|
||||
const enableTableVirtualScroll = featureFlagService.getFlag(
|
||||
'enable_table_virtual_scroll'
|
||||
);
|
||||
return {
|
||||
enable_number_formatting: enableNumberFormat ?? false,
|
||||
enable_table_virtual_scroll: enableTableVirtualScroll ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
AffineInlineEditor,
|
||||
AffineTextAttributes,
|
||||
} from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
splitTextByUrl,
|
||||
type UrlTextSegment,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { InlineRange } from '@blocksuite/std/inline';
|
||||
|
||||
type UrlPasteInlineEditor = Pick<
|
||||
AffineInlineEditor,
|
||||
'insertText' | 'setInlineRange'
|
||||
>;
|
||||
|
||||
export function analyzeTextForUrlPaste(text: string) {
|
||||
const segments = splitTextByUrl(text);
|
||||
const firstSegment = segments[0];
|
||||
const singleUrl =
|
||||
segments.length === 1 && firstSegment?.link && firstSegment.text === text
|
||||
? firstSegment.link
|
||||
: undefined;
|
||||
return {
|
||||
segments,
|
||||
singleUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertUrlTextSegments(
|
||||
inlineEditor: UrlPasteInlineEditor,
|
||||
inlineRange: InlineRange,
|
||||
segments: UrlTextSegment[]
|
||||
) {
|
||||
let index = inlineRange.index;
|
||||
let replacedSelection = false;
|
||||
segments.forEach(segment => {
|
||||
if (!segment.text) return;
|
||||
const attributes: AffineTextAttributes | undefined = segment.link
|
||||
? { link: segment.link }
|
||||
: undefined;
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index,
|
||||
length: replacedSelection ? 0 : inlineRange.length,
|
||||
},
|
||||
segment.text,
|
||||
attributes
|
||||
);
|
||||
replacedSelection = true;
|
||||
index += segment.text.length;
|
||||
});
|
||||
inlineEditor.setInlineRange({
|
||||
index,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
@@ -8,10 +8,7 @@ import type {
|
||||
AffineInlineEditor,
|
||||
AffineTextAttributes,
|
||||
} from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
getViewportElement,
|
||||
isValidUrl,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BaseCellRenderer,
|
||||
createFromBaseCellRenderer,
|
||||
@@ -26,6 +23,7 @@ import { html } from 'lit/static-html.js';
|
||||
|
||||
import { EditorHostKey } from '../../context/host-context.js';
|
||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
|
||||
import {
|
||||
richTextCellStyle,
|
||||
richTextContainerStyle,
|
||||
@@ -271,10 +269,13 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
|
||||
?.getData('text/plain')
|
||||
?.replace(/\r?\n|\r/g, '\n');
|
||||
if (!text) return;
|
||||
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
|
||||
|
||||
if (isValidUrl(text)) {
|
||||
if (singleUrl) {
|
||||
const std = this.std;
|
||||
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
|
||||
const result = std
|
||||
?.getOptional(ParseDocUrlProvider)
|
||||
?.parseDocUrl(singleUrl);
|
||||
if (result) {
|
||||
const text = ' ';
|
||||
inlineEditor.insertText(inlineRange, text, {
|
||||
@@ -300,22 +301,10 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
|
||||
segment: 'database',
|
||||
parentFlavour: 'affine:database',
|
||||
});
|
||||
} else {
|
||||
inlineEditor.insertText(inlineRange, text, {
|
||||
link: text,
|
||||
});
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
inlineEditor.insertText(inlineRange, text);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
insertUrlTextSegments(inlineEditor, inlineRange, segments);
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
ParseDocUrlProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getViewportElement,
|
||||
isValidUrl,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import { BaseCellRenderer } from '@blocksuite/data-view';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
||||
@@ -20,6 +17,7 @@ import { html } from 'lit/static-html.js';
|
||||
import { EditorHostKey } from '../../context/host-context.js';
|
||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
|
||||
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
|
||||
import {
|
||||
headerAreaIconStyle,
|
||||
titleCellStyle,
|
||||
@@ -95,7 +93,9 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
||||
private readonly _onPaste = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
if (!inlineEditor || !inlineRange) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.clipboardData) {
|
||||
try {
|
||||
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
|
||||
@@ -121,14 +121,15 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
||||
?.getData('text/plain')
|
||||
?.replace(/\r?\n|\r/g, '\n');
|
||||
if (!text) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isValidUrl(text)) {
|
||||
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
|
||||
if (singleUrl) {
|
||||
const std = this.std;
|
||||
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
|
||||
const result = std
|
||||
?.getOptional(ParseDocUrlProvider)
|
||||
?.parseDocUrl(singleUrl);
|
||||
if (result) {
|
||||
const text = ' ';
|
||||
inlineEditor?.insertText(inlineRange, text, {
|
||||
inlineEditor.insertText(inlineRange, text, {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: result.docId,
|
||||
@@ -139,7 +140,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
||||
},
|
||||
},
|
||||
});
|
||||
inlineEditor?.setInlineRange({
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
@@ -151,22 +152,10 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
||||
segment: 'database',
|
||||
parentFlavour: 'affine:database',
|
||||
});
|
||||
} else {
|
||||
inlineEditor?.insertText(inlineRange, text, {
|
||||
link: text,
|
||||
});
|
||||
inlineEditor?.setInlineRange({
|
||||
index: inlineRange.index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
inlineEditor?.insertText(inlineRange, text);
|
||||
inlineEditor?.setInlineRange({
|
||||
index: inlineRange.index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
insertUrlTextSegments(inlineEditor, inlineRange, segments);
|
||||
};
|
||||
|
||||
insertDelta = (delta: DeltaInsert) => {
|
||||
@@ -240,7 +229,8 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
||||
this.disposables.addFromEvent(
|
||||
this.richText.value,
|
||||
'paste',
|
||||
this._onPaste
|
||||
this._onPaste,
|
||||
true
|
||||
);
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (inlineEditor) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
101
blocksuite/affine/data-view/src/__tests__/table.unit.spec.ts
Normal file
101
blocksuite/affine/data-view/src/__tests__/table.unit.spec.ts
Normal file
@@ -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 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<DataViewRootUI>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,5 @@ export type PropertyDataUpdater<
|
||||
> = (data: Data) => Partial<Data>;
|
||||
|
||||
export interface DatabaseFlags {
|
||||
enable_number_formatting: boolean;
|
||||
enable_table_virtual_scroll: boolean;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`<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
|
||||
menu.group({
|
||||
items: [
|
||||
|
||||
@@ -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`<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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
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`<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
|
||||
menu.group({
|
||||
items: [
|
||||
|
||||
@@ -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`<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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
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`<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
|
||||
menu.group({
|
||||
items: [
|
||||
|
||||
@@ -337,6 +337,7 @@ export const popViewOptions = (
|
||||
const reopen = () => {
|
||||
popViewOptions(target, dataViewLogic);
|
||||
};
|
||||
let handler: ReturnType<typeof popMenu>;
|
||||
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` <div
|
||||
style="font-size: 14px;text-transform: capitalize;"
|
||||
>
|
||||
${view.type}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
const viewTypes = view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||
menu => {
|
||||
const viewTypeItems = menu.renderItems(
|
||||
view.manager.viewMetas.map<MenuConfig>(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`
|
||||
<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}">
|
||||
${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` <affine-menu-button
|
||||
return html`<affine-menu-button
|
||||
style="${containerStyle}"
|
||||
.data="${data}"
|
||||
.data="${buttonData}"
|
||||
.menu="${menu}"
|
||||
></affine-menu-button>`;
|
||||
};
|
||||
});
|
||||
const subHandler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
onBack: reopen,
|
||||
text: 'Layout',
|
||||
},
|
||||
items: [
|
||||
menu => {
|
||||
const result = menu.renderItems(viewTypes);
|
||||
if (result.length) {
|
||||
return html` <div style="display: flex">${result}</div>`;
|
||||
}
|
||||
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`
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
|
||||
<div
|
||||
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
||||
>
|
||||
${LayoutIcon()}
|
||||
</div>
|
||||
<div
|
||||
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
|
||||
>
|
||||
Layout
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
${viewTypeItems}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
@@ -486,7 +460,6 @@ export const popViewOptions = (
|
||||
],
|
||||
})
|
||||
);
|
||||
let handler: ReturnType<typeof popMenu>;
|
||||
handler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<BlockSuiteFlags> = signal({
|
||||
enable_database_number_formatting: false,
|
||||
enable_database_attachment_note: false,
|
||||
enable_database_full_width: false,
|
||||
enable_block_query: false,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { WorkspaceSQLiteDB } from '../nbstore/v1/workspace-db-adapter';
|
||||
import type { WorkspaceMeta } from '../type';
|
||||
import {
|
||||
getDeletedWorkspacesBasePath,
|
||||
getSpaceBasePath,
|
||||
getSpaceDBPath,
|
||||
getWorkspaceBasePathV1,
|
||||
getWorkspaceMeta,
|
||||
@@ -96,6 +97,33 @@ export async function storeWorkspaceMeta(
|
||||
}
|
||||
}
|
||||
|
||||
export async function listLocalWorkspaceIds(): Promise<string[]> {
|
||||
const localWorkspaceBasePath = path.join(
|
||||
await getSpaceBasePath('workspace'),
|
||||
'local'
|
||||
);
|
||||
if (!(await fs.pathExists(localWorkspaceBasePath))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(localWorkspaceBasePath);
|
||||
const ids = await Promise.all(
|
||||
entries.map(async entry => {
|
||||
const workspacePath = path.join(localWorkspaceBasePath, entry);
|
||||
const stat = await fs.stat(workspacePath).catch(() => null);
|
||||
if (!stat?.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
if (!(await fs.pathExists(path.join(workspacePath, 'storage.db')))) {
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
})
|
||||
);
|
||||
|
||||
return ids.filter((id): id is string => typeof id === 'string');
|
||||
}
|
||||
|
||||
type WorkspaceDocMeta = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
deleteBackupWorkspace,
|
||||
deleteWorkspace,
|
||||
getDeletedWorkspaces,
|
||||
listLocalWorkspaceIds,
|
||||
trashWorkspace,
|
||||
} from './handlers';
|
||||
|
||||
@@ -18,4 +19,5 @@ export const workspaceHandlers = {
|
||||
return getDeletedWorkspaces();
|
||||
},
|
||||
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
|
||||
listLocalWorkspaceIds: async () => listLocalWorkspaceIds(),
|
||||
};
|
||||
|
||||
@@ -33,6 +33,43 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe('workspace db management', () => {
|
||||
test('list local workspace ids', async () => {
|
||||
const { listLocalWorkspaceIds } =
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
const validWorkspaceId = v4();
|
||||
const noDbWorkspaceId = v4();
|
||||
const fileEntry = 'README.txt';
|
||||
|
||||
const validWorkspacePath = path.join(
|
||||
appDataPath,
|
||||
'workspaces',
|
||||
'local',
|
||||
validWorkspaceId
|
||||
);
|
||||
const noDbWorkspacePath = path.join(
|
||||
appDataPath,
|
||||
'workspaces',
|
||||
'local',
|
||||
noDbWorkspaceId
|
||||
);
|
||||
const nonDirectoryPath = path.join(
|
||||
appDataPath,
|
||||
'workspaces',
|
||||
'local',
|
||||
fileEntry
|
||||
);
|
||||
|
||||
await fs.ensureDir(validWorkspacePath);
|
||||
await fs.ensureFile(path.join(validWorkspacePath, 'storage.db'));
|
||||
await fs.ensureDir(noDbWorkspacePath);
|
||||
await fs.outputFile(nonDirectoryPath, 'not-a-workspace');
|
||||
|
||||
const ids = await listLocalWorkspaceIds();
|
||||
expect(ids).toContain(validWorkspaceId);
|
||||
expect(ids).not.toContain(noDbWorkspaceId);
|
||||
expect(ids).not.toContain(fileEntry);
|
||||
});
|
||||
|
||||
test('trash workspace', async () => {
|
||||
const { trashWorkspace } =
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
|
||||
@@ -17,7 +17,7 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../AffineResources"),
|
||||
.package(url: "https://github.com/RevenueCat/purchases-ios-spm.git", from: "5.56.1"),
|
||||
.package(url: "https://github.com/RevenueCat/purchases-ios-spm.git", from: "5.58.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
|
||||
@@ -48,15 +48,44 @@ import { WorkspaceImpl } from '../../workspace/impls/workspace';
|
||||
import { getWorkspaceProfileWorker } from './out-worker';
|
||||
|
||||
export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace';
|
||||
export const LOCAL_WORKSPACE_GLOBAL_STATE_KEY =
|
||||
'workspace-engine:local-workspace-ids:v1';
|
||||
const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY =
|
||||
'affine-local-workspace-changed';
|
||||
|
||||
const logger = new DebugLogger('local-workspace');
|
||||
|
||||
export function getLocalWorkspaceIds(): string[] {
|
||||
type GlobalStateStorageLike = {
|
||||
ready: Promise<void>;
|
||||
get<T>(key: string): T | undefined;
|
||||
set<T>(key: string, value: T): void;
|
||||
};
|
||||
|
||||
function normalizeWorkspaceIds(ids: unknown): string[] {
|
||||
if (!Array.isArray(ids)) {
|
||||
return [];
|
||||
}
|
||||
return ids.filter((id): id is string => typeof id === 'string');
|
||||
}
|
||||
|
||||
function getElectronGlobalStateStorage(): GlobalStateStorageLike | null {
|
||||
if (!BUILD_CONFIG.isElectron) {
|
||||
return null;
|
||||
}
|
||||
const sharedStorage = (
|
||||
globalThis as {
|
||||
__sharedStorage?: { globalState?: GlobalStateStorageLike };
|
||||
}
|
||||
).__sharedStorage;
|
||||
return sharedStorage?.globalState ?? null;
|
||||
}
|
||||
|
||||
function getLegacyLocalWorkspaceIds(): string[] {
|
||||
try {
|
||||
return JSON.parse(
|
||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
||||
return normalizeWorkspaceIds(
|
||||
JSON.parse(
|
||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error('Failed to get local workspace ids', e);
|
||||
@@ -64,21 +93,98 @@ export function getLocalWorkspaceIds(): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocalWorkspaceIds(): string[] {
|
||||
const globalState = getElectronGlobalStateStorage();
|
||||
if (globalState) {
|
||||
const value = globalState.get(LOCAL_WORKSPACE_GLOBAL_STATE_KEY);
|
||||
if (value !== undefined) {
|
||||
return normalizeWorkspaceIds(value);
|
||||
}
|
||||
}
|
||||
|
||||
return getLegacyLocalWorkspaceIds();
|
||||
}
|
||||
|
||||
export function setLocalWorkspaceIds(
|
||||
idsOrUpdater: string[] | ((ids: string[]) => string[])
|
||||
) {
|
||||
localStorage.setItem(
|
||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(
|
||||
typeof idsOrUpdater === 'function'
|
||||
? idsOrUpdater(getLocalWorkspaceIds())
|
||||
: idsOrUpdater
|
||||
)
|
||||
const next = normalizeWorkspaceIds(
|
||||
typeof idsOrUpdater === 'function'
|
||||
? idsOrUpdater(getLocalWorkspaceIds())
|
||||
: idsOrUpdater
|
||||
);
|
||||
const deduplicated = [...new Set(next)];
|
||||
|
||||
const globalState = getElectronGlobalStateStorage();
|
||||
if (globalState) {
|
||||
globalState.set(LOCAL_WORKSPACE_GLOBAL_STATE_KEY, deduplicated);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(deduplicated)
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error('Failed to set local workspace ids', e);
|
||||
}
|
||||
}
|
||||
|
||||
class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
constructor(private readonly framework: FrameworkProvider) {}
|
||||
constructor(private readonly framework: FrameworkProvider) {
|
||||
if (BUILD_CONFIG.isElectron) {
|
||||
void this.ensureWorkspaceIdsMigrated();
|
||||
}
|
||||
}
|
||||
|
||||
private migration: Promise<void> | null = null;
|
||||
|
||||
private ensureWorkspaceIdsMigrated() {
|
||||
if (!BUILD_CONFIG.isElectron) {
|
||||
return;
|
||||
}
|
||||
if (this.migration) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.migration = (async () => {
|
||||
const electronApi = this.framework.get(DesktopApiService);
|
||||
await electronApi.sharedStorage.globalState.ready;
|
||||
|
||||
const persistedIds = normalizeWorkspaceIds(
|
||||
electronApi.sharedStorage.globalState.get(
|
||||
LOCAL_WORKSPACE_GLOBAL_STATE_KEY
|
||||
)
|
||||
);
|
||||
const legacyIds = getLegacyLocalWorkspaceIds();
|
||||
|
||||
let scannedIds: string[] = [];
|
||||
try {
|
||||
scannedIds =
|
||||
await electronApi.handler.workspace.listLocalWorkspaceIds();
|
||||
} catch (e) {
|
||||
logger.error('Failed to scan local workspace ids', e);
|
||||
}
|
||||
|
||||
setLocalWorkspaceIds(currentIds => {
|
||||
return [
|
||||
...new Set([
|
||||
...currentIds,
|
||||
...persistedIds,
|
||||
...legacyIds,
|
||||
...scannedIds,
|
||||
]),
|
||||
];
|
||||
});
|
||||
})()
|
||||
.catch(e => {
|
||||
logger.error('Failed to migrate local workspace ids', e);
|
||||
})
|
||||
.finally(() => {
|
||||
this.notifyChannel.postMessage(null);
|
||||
});
|
||||
}
|
||||
|
||||
readonly flavour = 'local';
|
||||
readonly notifyChannel = new BroadcastChannel(
|
||||
@@ -242,6 +348,9 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
revalidate(): void {
|
||||
if (BUILD_CONFIG.isElectron) {
|
||||
void this.ensureWorkspaceIdsMigrated();
|
||||
}
|
||||
// notify livedata to re-scan workspaces
|
||||
this.notifyChannel.postMessage(null);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { GlobalState } from '../storage';
|
||||
import { WorkspaceFlavoursProvider } from '../workspace';
|
||||
import { CloudWorkspaceFlavoursProvider } from './impls/cloud';
|
||||
import {
|
||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
||||
LocalWorkspaceFlavoursProvider,
|
||||
setLocalWorkspaceIds,
|
||||
} from './impls/local';
|
||||
|
||||
export { base64ToUint8Array, uint8ArrayToBase64 } from './utils/base64';
|
||||
@@ -25,12 +25,5 @@ export function configureBrowserWorkspaceFlavours(framework: Framework) {
|
||||
* Used after copying sqlite database file to appdata folder
|
||||
*/
|
||||
export function _addLocalWorkspace(id: string) {
|
||||
const allWorkspaceIDs: string[] = JSON.parse(
|
||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
||||
);
|
||||
allWorkspaceIDs.push(id);
|
||||
localStorage.setItem(
|
||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(allWorkspaceIDs)
|
||||
);
|
||||
setLocalWorkspaceIds(ids => (ids.includes(id) ? ids : [...ids, id]));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user