feat(editor): support linked-doc in rich-text column (#9634)

close: BS-2345
This commit is contained in:
zzj3720
2025-01-10 14:43:40 +00:00
parent cc08094b17
commit c016f8e37e
6 changed files with 488 additions and 152 deletions
-3
View File
@@ -84,6 +84,3 @@ packages/frontend/core/public/static/templates
# script # script
af af
af.cmd af.cmd
# AI agent memories
memories.md
@@ -1,10 +1,18 @@
import { import type {
type AffineInlineEditor, AffineInlineEditor,
DefaultInlineManagerExtension, RichText,
type RichText,
} from '@blocksuite/affine-components/rich-text'; } from '@blocksuite/affine-components/rich-text';
import { DefaultInlineManagerExtension } from '@blocksuite/affine-components/rich-text';
import type { RootBlockModel } from '@blocksuite/affine-model';
import {
ParseDocUrlProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { getViewportElement } from '@blocksuite/affine-shared/utils'; import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import { import {
BaseCellRenderer, BaseCellRenderer,
createFromBaseCellRenderer, createFromBaseCellRenderer,
@@ -12,8 +20,11 @@ import {
} from '@blocksuite/data-view'; } from '@blocksuite/data-view';
import { IS_MAC } from '@blocksuite/global/env'; import { IS_MAC } from '@blocksuite/global/env';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import type { DeltaInsert } from '@blocksuite/inline';
import type { BlockSnapshot } from '@blocksuite/store';
import { Text } from '@blocksuite/store'; import { Text } from '@blocksuite/store';
import { css, nothing, type PropertyValues } from 'lit'; import { computed, signal } from '@preact/signals-core';
import { css, nothing } from 'lit';
import { query } from 'lit/decorators.js'; import { query } from 'lit/decorators.js';
import { keyed } from 'lit/directives/keyed.js'; import { keyed } from 'lit/directives/keyed.js';
import { html } from 'lit/static-html.js'; import { html } from 'lit/static-html.js';
@@ -23,9 +34,11 @@ import type { DatabaseBlockComponent } from '../../database-block.js';
import { richTextColumnModelConfig } from './define.js'; import { richTextColumnModelConfig } from './define.js';
function toggleStyle( function toggleStyle(
inlineEditor: AffineInlineEditor, inlineEditor: AffineInlineEditor | null,
attrs: AffineTextAttributes attrs: AffineTextAttributes
): void { ): void {
if (!inlineEditor) return;
const inlineRange = inlineEditor.getInlineRange(); const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return; if (!inlineRange) return;
@@ -68,7 +81,113 @@ function toggleStyle(
inlineEditor.syncInlineRange(); inlineEditor.syncInlineRange();
} }
export class RichTextCell extends BaseCellRenderer<Text> { abstract class BaseRichTextCell extends BaseCellRenderer<Text> {
static override styles = css`
affine-database-rich-text-cell,
affine-database-rich-text-cell-editing {
display: flex;
align-items: center;
width: 100%;
user-select: none;
}
.affine-database-rich-text {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
outline: none;
font-size: var(--data-view-cell-text-size);
line-height: var(--data-view-cell-text-line-height);
word-break: break-all;
}
.affine-database-rich-text v-line {
display: flex !important;
align-items: center;
height: 100%;
width: 100%;
}
.affine-database-rich-text v-line > div {
flex-grow: 1;
}
.data-view-header-area-icon {
height: max-content;
display: flex;
align-items: center;
margin-right: 8px;
padding: 2px;
border-radius: 4px;
margin-top: 2px;
background-color: var(--affine-background-secondary-color);
}
.data-view-header-area-icon svg {
width: 14px;
height: 14px;
fill: var(--affine-icon-color);
color: var(--affine-icon-color);
}
`;
get inlineEditor() {
return this.richText?.inlineEditor;
}
get inlineManager() {
return this.view
.contextGet(HostContextKey)
?.std.get(DefaultInlineManagerExtension.identifier);
}
get service() {
return this.view
.contextGet(HostContextKey)
?.std.getService('affine:database');
}
get topContenteditableElement() {
const databaseBlock =
this.closest<DatabaseBlockComponent>('affine-database');
return databaseBlock?.topContenteditableElement;
}
get attributeRenderer() {
return this.inlineManager?.getRenderer();
}
get attributesSchema() {
return this.inlineManager?.getSchema();
}
get host() {
return this.view.contextGet(HostContextKey);
}
@query('rich-text')
accessor richText!: RichText;
@query('.affine-database-rich-text')
accessor _richTextElement!: HTMLElement;
docId$ = signal<string>();
isLinkedDoc$ = computed(() => false);
linkedDocTitle$ = computed(() => {
if (!this.docId$.value) {
return this.value;
}
const doc = this.host?.std.workspace.getDoc(this.docId$.value);
const root = doc?.root as RootBlockModel;
return root.title;
});
}
export class RichTextCell extends BaseRichTextCell {
static override styles = css` static override styles = css`
affine-database-rich-text-cell { affine-database-rich-text-cell {
display: flex; display: flex;
@@ -101,39 +220,6 @@ export class RichTextCell extends BaseCellRenderer<Text> {
} }
`; `;
get attributeRenderer() {
return this.inlineManager?.getRenderer();
}
get attributesSchema() {
return this.inlineManager?.getSchema();
}
get inlineEditor() {
assertExists(this._richTextElement);
const inlineEditor = this._richTextElement.inlineEditor;
assertExists(inlineEditor);
return inlineEditor;
}
get inlineManager() {
return this.view
.contextGet(HostContextKey)
?.std.get(DefaultInlineManagerExtension.identifier);
}
get service() {
return this.view
.contextGet(HostContextKey)
?.std.getService('affine:database');
}
get topContenteditableElement() {
const databaseBlock =
this.closest<DatabaseBlockComponent>('affine-database');
return databaseBlock?.topContenteditableElement;
}
private changeUserSelectAccordToReadOnly() { private changeUserSelectAccordToReadOnly() {
if (this && this instanceof HTMLElement) { if (this && this instanceof HTMLElement) {
this.style.userSelect = this.readonly ? 'text' : 'none'; this.style.userSelect = this.readonly ? 'text' : 'none';
@@ -163,18 +249,9 @@ export class RichTextCell extends BaseCellRenderer<Text> {
></rich-text>` ></rich-text>`
); );
} }
override updated(changedProperties: PropertyValues) {
if (changedProperties.has('readonly')) {
this.changeUserSelectAccordToReadOnly();
}
}
@query('rich-text')
private accessor _richTextElement: RichText | null = null;
} }
export class RichTextCellEditing extends BaseCellRenderer<Text> { export class RichTextCellEditing extends BaseRichTextCell {
static override styles = css` static override styles = css`
affine-database-rich-text-cell-editing { affine-database-rich-text-cell-editing {
display: flex; display: flex;
@@ -227,6 +304,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
} }
const inlineEditor = this.inlineEditor; const inlineEditor = this.inlineEditor;
if (!inlineEditor) return;
switch (event.key) { switch (event.key) {
// bold ctrl+b // bold ctrl+b
@@ -234,7 +312,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
case 'b': case 'b':
if (event.metaKey || event.ctrlKey) { if (event.metaKey || event.ctrlKey) {
event.preventDefault(); event.preventDefault();
toggleStyle(this.inlineEditor, { bold: true }); toggleStyle(inlineEditor, { bold: true });
} }
break; break;
// italic ctrl+i // italic ctrl+i
@@ -242,7 +320,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
case 'i': case 'i':
if (event.metaKey || event.ctrlKey) { if (event.metaKey || event.ctrlKey) {
event.preventDefault(); event.preventDefault();
toggleStyle(this.inlineEditor, { italic: true }); toggleStyle(inlineEditor, { italic: true });
} }
break; break;
// underline ctrl+u // underline ctrl+u
@@ -250,7 +328,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
case 'u': case 'u':
if (event.metaKey || event.ctrlKey) { if (event.metaKey || event.ctrlKey) {
event.preventDefault(); event.preventDefault();
toggleStyle(this.inlineEditor, { underline: true }); toggleStyle(inlineEditor, { underline: true });
} }
break; break;
// strikethrough ctrl+shift+s // strikethrough ctrl+shift+s
@@ -293,42 +371,124 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
} }
}; };
get attributeRenderer() { private readonly _onCopy = (e: ClipboardEvent) => {
return this.inlineManager?.getRenderer(); const inlineEditor = this.inlineEditor;
}
get attributesSchema() {
return this.inlineManager?.getSchema();
}
// eslint-disable-next-line sonarjs/no-identical-functions
get inlineEditor() {
assertExists(this._richTextElement);
const inlineEditor = this._richTextElement.inlineEditor;
assertExists(inlineEditor); assertExists(inlineEditor);
return inlineEditor;
}
// eslint-disable-next-line sonarjs/no-identical-functions const inlineRange = inlineEditor.getInlineRange();
get inlineManager() { if (!inlineRange) return;
return this.view
.contextGet(HostContextKey)
?.std.get(DefaultInlineManagerExtension.identifier);
}
// eslint-disable-next-line sonarjs/no-identical-functions const text = inlineEditor.yTextString.slice(
get service() { inlineRange.index,
return this.view inlineRange.index + inlineRange.length
.contextGet(HostContextKey) );
?.std.getService('affine:database');
}
// eslint-disable-next-line sonarjs/no-identical-functions e.clipboardData?.setData('text/plain', text);
get topContenteditableElement() { e.preventDefault();
const databaseBlock = e.stopPropagation();
this.closest<DatabaseBlockComponent>('affine-database'); };
return databaseBlock?.topContenteditableElement;
} private readonly _onCut = (e: ClipboardEvent) => {
const inlineEditor = this.inlineEditor;
assertExists(inlineEditor);
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const text = inlineEditor.yTextString.slice(
inlineRange.index,
inlineRange.index + inlineRange.length
);
inlineEditor.deleteText(inlineRange);
inlineEditor.setInlineRange({
index: inlineRange.index,
length: 0,
});
e.clipboardData?.setData('text/plain', text);
e.preventDefault();
e.stopPropagation();
};
private readonly _onPaste = (e: ClipboardEvent) => {
const inlineEditor = this.inlineEditor;
if (!inlineEditor) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
if (e.clipboardData) {
try {
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
// @ts-expect-error FIXME: ts error
const text = snapshot.props?.text?.delta;
return text
? [...text, ...(snapshot.children?.flatMap(getDeltas) ?? [])]
: snapshot.children?.flatMap(getDeltas);
};
const snapshot = this.std?.clipboard?.readFromClipboard(
e.clipboardData
)['BLOCKSUITE/SNAPSHOT'];
const deltas = (
JSON.parse(snapshot).snapshot.content as BlockSnapshot[]
).flatMap(getDeltas);
deltas.forEach(delta => this.insertDelta(delta));
return;
} catch {
//
}
}
const text = e.clipboardData
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
e.preventDefault();
e.stopPropagation();
if (isValidUrl(text)) {
const std = this.std;
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
if (result) {
const text = ' ';
inlineEditor.insertText(inlineRange, text, {
reference: {
type: 'LinkedPage',
pageId: result.docId,
params: {
blockIds: result.blockIds,
elementIds: result.elementIds,
mode: result.mode,
},
},
});
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
// Track when a linked doc is created in database rich-text column
std?.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
module: 'database rich-text cell',
type: 'paste',
segment: 'database',
parentFlavour: 'affine:database',
});
} else {
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,
});
}
};
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
@@ -341,7 +501,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) { if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
this.inlineEditor.selectAll(); this.inlineEditor?.selectAll();
} }
}; };
this.addEventListener('keydown', selectAll); this.addEventListener('keydown', selectAll);
@@ -349,13 +509,32 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
} }
override firstUpdated() { override firstUpdated() {
this._richTextElement?.updateComplete this.richText?.updateComplete
.then(() => { .then(() => {
const inlineEditor = this.inlineEditor;
if (!inlineEditor) return;
this.disposables.add( this.disposables.add(
this.inlineEditor.slots.keydown.on(this._handleKeyDown) inlineEditor.slots.keydown.on(this._handleKeyDown)
); );
this.inlineEditor.focusEnd(); this.disposables.addFromEvent(
this._richTextElement!,
'copy',
this._onCopy
);
this.disposables.addFromEvent(
this._richTextElement!,
'cut',
this._onCut
);
this.disposables.addFromEvent(
this._richTextElement!,
'paste',
this._onPaste
);
inlineEditor.focusEnd();
}) })
.catch(console.error); .catch(console.error);
} }
@@ -377,8 +556,22 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
></rich-text>`; ></rich-text>`;
} }
@query('rich-text') private get std() {
private accessor _richTextElement: RichText | null = null; return this.view.contextGet(HostContextKey)?.std;
}
insertDelta = (delta: DeltaInsert<AffineTextAttributes>) => {
const inlineEditor = this.inlineEditor;
const range = inlineEditor?.getInlineRange();
if (!range || !delta.insert) {
return;
}
inlineEditor?.insertText(range, delta.insert, delta.attributes);
inlineEditor?.setInlineRange({
index: range.index + delta.insert.length,
length: 0,
});
};
} }
declare global { declare global {
@@ -1,6 +1,10 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { propertyType, t } from '@blocksuite/data-view'; import { propertyType, t } from '@blocksuite/data-view';
import type { DeltaInsert } from '@blocksuite/inline';
import { Text } from '@blocksuite/store'; import { Text } from '@blocksuite/store';
import { HostContextKey } from '../../context/host-context.js';
import { isLinkedDoc } from '../../utils/title-doc.js';
import { type RichTextCellType, toYText } from '../utils.js'; import { type RichTextCellType, toYText } from '../utils.js';
export const richTextColumnType = propertyType('rich-text'); export const richTextColumnType = propertyType('rich-text');
@@ -16,7 +20,25 @@ export const richTextColumnModelConfig =
value: new Text(value), value: new Text(value),
}; };
}, },
cellToJson: ({ value }) => value?.toString() ?? null, cellToJson: ({ value, dataSource }) => {
const host = dataSource.contextGet(HostContextKey);
if (host) {
const collection = host.std.workspace;
const yText = toYText(value);
const deltas = yText.toDelta();
const text = deltas
.map((delta: DeltaInsert<AffineTextAttributes>) => {
if (isLinkedDoc(delta)) {
const linkedDocId = delta.attributes?.reference?.pageId as string;
return collection.getDoc(linkedDocId)?.meta?.title;
}
return delta.insert;
})
.join('');
return text;
}
return value?.toString() ?? null;
},
cellFromJson: ({ value }) => cellFromJson: ({ value }) =>
typeof value !== 'string' ? undefined : new Text(value), typeof value !== 'string' ? undefined : new Text(value),
onUpdate: ({ value, callback }) => { onUpdate: ({ value, callback }) => {
@@ -1,11 +1,10 @@
import { test } from '@affine-test/kit/playwright'; import { test } from '@affine-test/kit/playwright';
import { openHomePage } from '@affine-test/kit/utils/load-page';
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
import { import {
initDatabaseWithRows, addRows,
pasteExcelData, initDatabaseByOneStep,
selectFirstCell, pasteString,
selectCell,
verifyCellContents, verifyCellContents,
} from './utils'; } from './utils';
@@ -14,16 +13,14 @@ test.describe('Database Clipboard Operations', () => {
page, page,
}) => { }) => {
// Open the home page and wait for the editor to load // Open the home page and wait for the editor to load
await openHomePage(page); await initDatabaseByOneStep(page);
await waitForEditorLoad(page);
// Create a database block with two rows // Create a database block with two rows
await initDatabaseWithRows(page, 2); await addRows(page, 2);
// Select the first cell and paste data // Select the first cell and paste data
await selectFirstCell(page); await selectCell(page, 0, false);
const mockExcelData = 'Cell 1A\tCell 1B\nCell 2A\tCell 2B'; const mockExcelData = 'Cell 1A\tCell 1B\nCell 2A\tCell 2B';
await pasteExcelData(page, mockExcelData); await pasteString(page, mockExcelData);
// Verify cell contents // Verify cell contents
await verifyCellContents(page, [ await verifyCellContents(page, [
@@ -38,16 +35,14 @@ test.describe('Database Clipboard Operations', () => {
page, page,
}) => { }) => {
// Open the home page and wait for the editor to load // Open the home page and wait for the editor to load
await openHomePage(page); await initDatabaseByOneStep(page);
await waitForEditorLoad(page);
// Create a database block with two rows // Create a database block with two rows
await initDatabaseWithRows(page, 2); await addRows(page, 2);
// Select the first cell and paste data with empty cells // Select the first cell and paste data with empty cells
await selectFirstCell(page); await selectCell(page, 0, false);
const mockExcelData = 'Cell 1A\t\nCell 2A\tCell 2B'; const mockExcelData = 'Cell 1A\t\nCell 2A\tCell 2B';
await pasteExcelData(page, mockExcelData); await pasteString(page, mockExcelData);
// Verify cell contents including empty cells // Verify cell contents including empty cells
await verifyCellContents(page, ['Cell 1A', '', 'Cell 2A', 'Cell 2B']); await verifyCellContents(page, ['Cell 1A', '', 'Cell 2A', 'Cell 2B']);
@@ -55,16 +50,14 @@ test.describe('Database Clipboard Operations', () => {
test('handle pasting data larger than selected area', async ({ page }) => { test('handle pasting data larger than selected area', async ({ page }) => {
// Open the home page and wait for the editor to load // Open the home page and wait for the editor to load
await openHomePage(page); await initDatabaseByOneStep(page);
await waitForEditorLoad(page);
// Create a database block with one row // Create a database block with one row
await initDatabaseWithRows(page, 1); await addRows(page, 1);
// Select the first cell and paste data larger than table // Select the first cell and paste data larger than table
await selectFirstCell(page); await selectCell(page, 0, false);
const mockExcelData = 'Cell 1A\tCell 1B\nCell 2A\tCell 2B'; const mockExcelData = 'Cell 1A\tCell 1B\nCell 2A\tCell 2B';
await pasteExcelData(page, mockExcelData); await pasteString(page, mockExcelData);
// Verify only the cells that exist are filled // Verify only the cells that exist are filled
await verifyCellContents(page, ['Cell 1A', 'Cell 1B']); await verifyCellContents(page, ['Cell 1A', 'Cell 1B']);
@@ -0,0 +1,99 @@
import { test } from '@affine-test/kit/playwright';
import { openHomePage } from '@affine-test/kit/utils/load-page';
import {
clickNewPageButton,
waitForEditorLoad,
} from '@affine-test/kit/utils/page-logic';
import { expect } from '@playwright/test';
import {
addColumn,
createDatabaseBlock,
createNewPage,
gotoContentFromTitle,
selectCell,
} from './utils';
test.describe('Database Rich Text Column', () => {
test('paste document link into rich text cell', async ({ page }) => {
// Step 1: Open home page
await openHomePage(page);
await waitForEditorLoad(page);
// Step 2: Create a new page
await createNewPage(page);
await gotoContentFromTitle(page);
// Step 3: Create a database in the page
await createDatabaseBlock(page);
// Step 4: Add a text column
await addColumn(page, 'Text');
// Step 5: Create a new page to get its link
await clickNewPageButton(page, 'Test Page');
const pageUrl = page.url();
// Step 6: Go back to database page
await page.goBack();
await waitForEditorLoad(page);
// Step 7: Select and edit the rich text cell
const richTextCell = await selectCell(page, 2);
// Step 8: Paste the document link
await page.evaluate(url => {
const clipboardData = new DataTransfer();
clipboardData.setData('text/plain', url);
const pasteEvent = new ClipboardEvent('paste', {
clipboardData,
bubbles: true,
cancelable: true,
});
document.activeElement?.dispatchEvent(pasteEvent);
}, pageUrl);
// Step 9: Verify the result
const referenceTitle = richTextCell.locator('.affine-reference-title');
await expect(referenceTitle).toBeVisible();
await expect(referenceTitle).toContainText('Test Page');
});
test('add document link via @ in rich text cell', async ({ page }) => {
// Step 1: Open home page
await openHomePage(page);
await waitForEditorLoad(page);
// Step 2: Create a new page
await createNewPage(page);
await gotoContentFromTitle(page);
// Step 3: Create a database in the page
await createDatabaseBlock(page);
// Step 4: Add a text column
await addColumn(page, 'Text');
// Step 5: Create a new page as reference target
await clickNewPageButton(page, 'Reference Target');
await page.goBack();
await waitForEditorLoad(page);
// Step 6: Select and edit the rich text cell
const richTextCell = await selectCell(page, 2);
await richTextCell.click();
await page.keyboard.type('@');
// Step 7: Wait for reference picker and select the page
const linkedDocPopover = page.locator('.linked-doc-popover');
await expect(linkedDocPopover).toBeVisible();
const targetPage = linkedDocPopover.getByText('Reference Target');
await targetPage.click();
// Step 8: Verify the result
const referenceTitle = richTextCell.locator('.affine-reference-title');
await expect(referenceTitle).toBeVisible();
await expect(referenceTitle).toContainText('Reference Target');
});
});
@@ -1,45 +1,36 @@
import { openHomePage } from '@affine-test/kit/utils/load-page';
import { import {
addDatabase, addDatabase,
clickNewPageButton, clickNewPageButton,
waitForEditorLoad,
} from '@affine-test/kit/utils/page-logic'; } from '@affine-test/kit/utils/page-logic';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
/** export async function createNewPage(page: Page) {
* Create a new database block in the current page
*/
export async function createDatabaseBlock(page: Page) {
await clickNewPageButton(page); await clickNewPageButton(page);
await page.waitForTimeout(500); }
export const gotoContentFromTitle = async (page: Page) => {
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
};
export async function createDatabaseBlock(page: Page) {
await addDatabase(page); await addDatabase(page);
} }
/** export async function addRows(page: Page, rowCount: number) {
* Initialize a database with specified number of rows
*/
export async function initDatabaseWithRows(page: Page, rowCount: number) {
await createDatabaseBlock(page);
for (let i = 0; i < rowCount; i++) { for (let i = 0; i < rowCount; i++) {
await addDatabaseRow(page); await addDatabaseRow(page);
} }
} }
/**
* Add a new row to the database
*/
export async function addDatabaseRow(page: Page) { export async function addDatabaseRow(page: Page) {
const addButton = page.locator('.data-view-table-group-add-row'); const addButton = page.locator('.data-view-table-group-add-row');
await addButton.waitFor();
await addButton.click(); await addButton.click();
} }
/** export async function pasteString(page: Page, data: string) {
* Simulate pasting Excel data into database
* @param page Playwright page object
* @param data Tab-separated text data with newlines for rows
*/
export async function pasteExcelData(page: Page, data: string) {
await page.evaluate(data => { await page.evaluate(data => {
const clipboardData = new DataTransfer(); const clipboardData = new DataTransfer();
clipboardData.setData('text/plain', data); clipboardData.setData('text/plain', data);
@@ -48,24 +39,25 @@ export async function pasteExcelData(page: Page, data: string) {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
}); });
document.activeElement?.dispatchEvent(pasteEvent); const activeElement = document.activeElement;
if (activeElement) {
pasteEvent.preventDefault();
activeElement.dispatchEvent(pasteEvent);
}
}, data); }, data);
} }
/** export async function selectCell(page: Page, nth: number, editing = true) {
* Select the first cell in the database const firstCell = page.locator('affine-database-cell-container').nth(nth);
*/ // First click for focus
export async function selectFirstCell(page: Page) { await firstCell.click({ delay: 100 });
const firstCell = page.locator('affine-database-cell-container').first(); // Second click for edit mode
await firstCell.waitFor(); if (editing) {
await firstCell.click(); await firstCell.click({ delay: 100 });
}
return firstCell;
} }
/**
* Verify the contents of multiple cells in sequence
* @param page Playwright page object
* @param expectedContents Array of expected cell contents in order
*/
export async function verifyCellContents( export async function verifyCellContents(
page: Page, page: Page,
expectedContents: string[] expectedContents: string[]
@@ -78,3 +70,43 @@ export async function verifyCellContents(
); );
} }
} }
export async function selectColumnType(page: Page, columnType: string) {
const typeMenu = page.locator('affine-menu').getByText('Type');
await page.waitForTimeout(100);
await typeMenu.hover();
await page.waitForTimeout(100);
await page.keyboard.type(columnType);
await page.waitForTimeout(100);
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(100);
await page.keyboard.press('Enter');
await page.waitForTimeout(100);
}
export async function addColumn(page: Page, type: string) {
await clickAddColumnButton(page);
await selectColumnType(page, type);
}
export async function clickAddColumnButton(page: Page) {
const addColumnButton = page.locator('.header-add-column-button');
await addColumnButton.click();
}
export async function changeColumnType(
page: Page,
columnIndex: number,
columnType: string
) {
const header = page.locator('affine-database-header-column').nth(columnIndex);
await header.click();
await selectColumnType(page, columnType);
}
export const initDatabaseByOneStep = async (page: Page) => {
await openHomePage(page);
await createNewPage(page);
await waitForEditorLoad(page);
await gotoContentFromTitle(page);
await createDatabaseBlock(page);
};