mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
This PR performs a significant architectural refactoring by extracting rich text functionality into a dedicated package. Here are the key changes: 1. **New Package Creation** - Created a new package `@blocksuite/affine-rich-text` to house rich text related functionality - Moved rich text components, utilities, and types from `@blocksuite/affine-components` to this new package 2. **Dependency Updates** - Updated multiple block packages to include the new `@blocksuite/affine-rich-text` as a direct dependency: - block-callout - block-code - block-database - block-edgeless-text - block-embed - block-list - block-note - block-paragraph 3. **Import Path Updates** - Refactored all imports that previously referenced rich text functionality from `@blocksuite/affine-components/rich-text` to now use `@blocksuite/affine-rich-text` - Updated imports for components like: - DefaultInlineManagerExtension - RichText types and interfaces - Text manipulation utilities (focusTextModel, textKeymap, etc.) - Reference node components and providers 4. **Build Configuration Updates** - Added references to the new rich text package in the `tsconfig.json` files of all affected packages - Maintained workspace dependencies using the `workspace:*` version specifier The primary motivation appears to be: 1. Better separation of concerns by isolating rich text functionality 2. Improved maintainability through more modular package structure 3. Clearer dependencies between packages 4. Potential for better tree-shaking and bundle optimization This is primarily an architectural improvement that should make the codebase more maintainable and better organized.
590 lines
15 KiB
TypeScript
590 lines
15 KiB
TypeScript
import type { RichTextCell, RichTextCellEditing } from '@blocksuite/blocks';
|
|
import { ZERO_WIDTH_SPACE } from '@blocksuite/inline';
|
|
import { expect, type Locator, type Page } from '@playwright/test';
|
|
|
|
import {
|
|
pressEnter,
|
|
selectAllByKeyboard,
|
|
type,
|
|
} from '../utils/actions/keyboard.js';
|
|
import {
|
|
getBoundingBox,
|
|
getBoundingClientRect,
|
|
getEditorLocator,
|
|
waitNextFrame,
|
|
} from '../utils/actions/misc.js';
|
|
|
|
export async function press(page: Page, content: string) {
|
|
await page.keyboard.press(content, { delay: 50 });
|
|
await page.waitForTimeout(50);
|
|
}
|
|
|
|
export async function initDatabaseColumn(page: Page, title = '') {
|
|
const editor = getEditorLocator(page);
|
|
await editor.locator('affine-data-view-table-group').first().hover();
|
|
const columnAddBtn = editor.locator('.header-add-column-button');
|
|
await columnAddBtn.click();
|
|
await waitNextFrame(page, 200);
|
|
|
|
if (title) {
|
|
await selectAllByKeyboard(page);
|
|
await type(page, title);
|
|
await waitNextFrame(page);
|
|
await pressEnter(page);
|
|
} else {
|
|
await pressEnter(page);
|
|
}
|
|
}
|
|
|
|
export const renameColumn = async (page: Page, name: string) => {
|
|
const column = page.locator('affine-database-header-column', {
|
|
hasText: name,
|
|
});
|
|
await column.click();
|
|
};
|
|
|
|
export async function performColumnAction(
|
|
page: Page,
|
|
name: string,
|
|
action: string
|
|
) {
|
|
await renameColumn(page, name);
|
|
|
|
const actionMenu = page.locator(`.affine-menu-button`, { hasText: action });
|
|
await actionMenu.click();
|
|
}
|
|
|
|
export async function switchColumnType(
|
|
page: Page,
|
|
columnType: string,
|
|
columnIndex = 1
|
|
) {
|
|
const { typeIcon } = await getDatabaseHeaderColumn(page, columnIndex);
|
|
await typeIcon.click();
|
|
|
|
await clickColumnType(page, columnType);
|
|
}
|
|
|
|
export function clickColumnType(page: Page, columnType: string) {
|
|
const typeMenu = page.locator(`.affine-menu-button`, {
|
|
hasText: new RegExp(`${columnType}`),
|
|
});
|
|
return typeMenu.click();
|
|
}
|
|
|
|
export function getDatabaseBodyRows(page: Page) {
|
|
const rowContainer = page.locator('.affine-database-block-rows');
|
|
return rowContainer.locator('.database-row');
|
|
}
|
|
|
|
export function getDatabaseBodyRow(page: Page, rowIndex = 0) {
|
|
const rows = getDatabaseBodyRows(page);
|
|
return rows.nth(rowIndex);
|
|
}
|
|
|
|
export function getDatabaseTableContainer(page: Page) {
|
|
const container = page.locator('.affine-database-table-container');
|
|
return container;
|
|
}
|
|
|
|
export async function assertDatabaseTitleColumnText(
|
|
page: Page,
|
|
title: string,
|
|
index = 0
|
|
) {
|
|
const text = await page.evaluate(index => {
|
|
const rowContainer = document.querySelector('.affine-database-block-rows');
|
|
const row = rowContainer?.querySelector(
|
|
`.database-row:nth-child(${index + 1})`
|
|
);
|
|
const titleColumnCell = row?.querySelector('.database-cell:nth-child(1)');
|
|
const titleSpan = titleColumnCell?.querySelector(
|
|
'.data-view-header-area-rich-text'
|
|
) as HTMLElement;
|
|
if (!titleSpan) throw new Error('Cannot find database title column editor');
|
|
return titleSpan.innerText;
|
|
}, index);
|
|
|
|
if (title === '') {
|
|
expect(text).toMatch(new RegExp(`^(|[${ZERO_WIDTH_SPACE}])$`));
|
|
} else {
|
|
expect(text).toBe(title);
|
|
}
|
|
}
|
|
|
|
export function getDatabaseBodyCell(
|
|
page: Page,
|
|
{
|
|
rowIndex,
|
|
columnIndex,
|
|
}: {
|
|
rowIndex: number;
|
|
columnIndex: number;
|
|
}
|
|
) {
|
|
const row = getDatabaseBodyRow(page, rowIndex);
|
|
const cell = row.locator('.database-cell').nth(columnIndex);
|
|
return cell;
|
|
}
|
|
|
|
export function getDatabaseBodyCellContent(
|
|
page: Page,
|
|
{
|
|
rowIndex,
|
|
columnIndex,
|
|
cellClass,
|
|
}: {
|
|
rowIndex: number;
|
|
columnIndex: number;
|
|
cellClass: string;
|
|
}
|
|
) {
|
|
const cell = getDatabaseBodyCell(page, { rowIndex, columnIndex });
|
|
const cellContent = cell.locator(`.${cellClass}`);
|
|
return cellContent;
|
|
}
|
|
|
|
export function getFirstColumnCell(page: Page, cellClass: string) {
|
|
const cellContent = getDatabaseBodyCellContent(page, {
|
|
rowIndex: 0,
|
|
columnIndex: 1,
|
|
cellClass,
|
|
});
|
|
return cellContent;
|
|
}
|
|
|
|
export async function clickSelectOption(page: Page, index = 0) {
|
|
await page.locator('.select-option-icon').nth(index).click();
|
|
}
|
|
|
|
export async function performSelectColumnTagAction(
|
|
page: Page,
|
|
name: string,
|
|
index = 0
|
|
) {
|
|
await clickSelectOption(page, index);
|
|
await page
|
|
.locator('.affine-menu-button', { hasText: new RegExp(name) })
|
|
.click();
|
|
}
|
|
|
|
export async function assertSelectedStyle(
|
|
page: Page,
|
|
key: keyof CSSStyleDeclaration,
|
|
value: string
|
|
) {
|
|
const style = await getElementStyle(page, '.select-selected', key);
|
|
expect(style).toBe(value);
|
|
}
|
|
|
|
export async function clickDatabaseOutside(page: Page) {
|
|
const docTitle = page.locator('.doc-title-container');
|
|
await docTitle.click();
|
|
}
|
|
|
|
export async function assertColumnWidth(locator: Locator, width: number) {
|
|
const box = await getBoundingBox(locator);
|
|
expect(box.width).toBe(width + 1);
|
|
return box;
|
|
}
|
|
|
|
export async function assertDatabaseCellRichTexts(
|
|
page: Page,
|
|
{
|
|
rowIndex = 0,
|
|
columnIndex = 1,
|
|
text,
|
|
}: {
|
|
rowIndex?: number;
|
|
columnIndex?: number;
|
|
text: string;
|
|
}
|
|
) {
|
|
const cellContainer = page.locator(
|
|
`affine-database-cell-container[data-row-index='${rowIndex}'][data-column-index='${columnIndex}']`
|
|
);
|
|
|
|
const cellEditing = cellContainer.locator(
|
|
'affine-database-rich-text-cell-editing'
|
|
);
|
|
const cell = cellContainer.locator('affine-database-rich-text-cell');
|
|
|
|
const richText = (await cellEditing.count()) === 0 ? cell : cellEditing;
|
|
const actualTexts = await richText.evaluate(ele => {
|
|
return (ele as RichTextCellEditing).inlineEditor?.yTextString;
|
|
});
|
|
expect(actualTexts).toEqual(text);
|
|
}
|
|
|
|
export async function assertDatabaseCellNumber(
|
|
page: Page,
|
|
{
|
|
rowIndex = 0,
|
|
columnIndex = 1,
|
|
text,
|
|
}: {
|
|
rowIndex?: number;
|
|
columnIndex?: number;
|
|
text: string;
|
|
}
|
|
) {
|
|
const actualText = await page
|
|
.locator('.affine-database-block-rows')
|
|
.locator('.database-row')
|
|
.nth(rowIndex)
|
|
.locator('.database-cell')
|
|
.nth(columnIndex)
|
|
.locator('.number')
|
|
.textContent();
|
|
expect(actualText?.trim()).toEqual(text);
|
|
}
|
|
|
|
export async function assertDatabaseCellLink(
|
|
page: Page,
|
|
{
|
|
rowIndex = 0,
|
|
columnIndex = 1,
|
|
text,
|
|
}: {
|
|
rowIndex?: number;
|
|
columnIndex?: number;
|
|
text: string;
|
|
}
|
|
) {
|
|
const actualTexts = await page.evaluate(
|
|
({ rowIndex, columnIndex }) => {
|
|
const rows = document.querySelector('.affine-database-block-rows');
|
|
const row = rows?.querySelector(
|
|
`.database-row:nth-child(${rowIndex + 1})`
|
|
);
|
|
const cell = row?.querySelector(
|
|
`.database-cell:nth-child(${columnIndex + 1})`
|
|
);
|
|
const richText =
|
|
cell?.querySelector<RichTextCell>('affine-database-link-cell') ??
|
|
cell?.querySelector<RichTextCellEditing>(
|
|
'affine-database-link-cell-editing'
|
|
);
|
|
if (!richText) throw new Error('Missing database rich text cell');
|
|
return richText.inlineEditor!.yText.toString();
|
|
},
|
|
{ rowIndex, columnIndex }
|
|
);
|
|
expect(actualTexts).toEqual(text);
|
|
}
|
|
|
|
export async function assertDatabaseTitleText(page: Page, text: string) {
|
|
const dbTitle = page.locator('[data-block-is-database-title="true"]');
|
|
expect(await dbTitle.inputValue()).toEqual(text);
|
|
}
|
|
|
|
export async function waitSearchTransitionEnd(page: Page) {
|
|
await waitNextFrame(page, 400);
|
|
}
|
|
|
|
export async function assertDatabaseSearching(
|
|
page: Page,
|
|
isSearching: boolean
|
|
) {
|
|
const searchExpand = page.locator('.search-container-expand');
|
|
const count = await searchExpand.count();
|
|
expect(count).toBe(isSearching ? 1 : 0);
|
|
}
|
|
|
|
export async function focusDatabaseSearch(page: Page) {
|
|
await (await getDatabaseMouse(page)).mouseOver();
|
|
|
|
const searchExpand = page.locator('.search-container-expand');
|
|
const count = await searchExpand.count();
|
|
if (count === 1) {
|
|
const input = page.locator('.affine-database-search-input');
|
|
await input.click();
|
|
} else {
|
|
const searchIcon = page.locator('.affine-database-search-input-icon');
|
|
await searchIcon.click();
|
|
await waitSearchTransitionEnd(page);
|
|
}
|
|
}
|
|
|
|
export async function blurDatabaseSearch(page: Page) {
|
|
const dbTitle = page.locator('[data-block-is-database-title="true"]');
|
|
await dbTitle.click();
|
|
}
|
|
|
|
export async function focusDatabaseHeader(page: Page, columnIndex = 0) {
|
|
const column = page.locator('.affine-database-column').nth(columnIndex);
|
|
const box = await getBoundingBox(column);
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
await waitNextFrame(page);
|
|
return column;
|
|
}
|
|
|
|
export async function getDatabaseMouse(page: Page) {
|
|
const databaseRect = await getBoundingClientRect(
|
|
page,
|
|
'.affine-database-table'
|
|
);
|
|
return {
|
|
mouseOver: async () => {
|
|
await page.mouse.move(databaseRect.x, databaseRect.y);
|
|
},
|
|
mouseLeave: async () => {
|
|
await page.mouse.move(databaseRect.x - 1, databaseRect.y - 1);
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function getDatabaseHeaderColumn(page: Page, index = 0) {
|
|
const column = page.locator('.affine-database-column').nth(index);
|
|
const box = await getBoundingBox(column);
|
|
const textElement = column.locator('.affine-database-column-text-input');
|
|
const text = await textElement.innerText();
|
|
const typeIcon = column.locator('.affine-database-column-type-icon');
|
|
|
|
return {
|
|
column,
|
|
box,
|
|
text,
|
|
textElement,
|
|
typeIcon,
|
|
};
|
|
}
|
|
|
|
export async function assertRowsSelection(
|
|
page: Page,
|
|
rowIndexes: [start: number, end: number]
|
|
) {
|
|
const rows = page.locator('data-view-table-row');
|
|
const startIndex = rowIndexes[0];
|
|
const endIndex = rowIndexes[1];
|
|
for (let i = startIndex; i <= endIndex; i++) {
|
|
const row = rows.nth(i);
|
|
await row.locator('.row-select-checkbox .selected').isVisible();
|
|
}
|
|
}
|
|
|
|
export async function assertCellsSelection(
|
|
page: Page,
|
|
cellIndexes: {
|
|
start: [rowIndex: number, columnIndex: number];
|
|
end?: [rowIndex: number, columnIndex: number];
|
|
}
|
|
) {
|
|
const { start, end } = cellIndexes;
|
|
|
|
if (!end) {
|
|
// single cell
|
|
const focus = page.locator('.database-focus');
|
|
const focusBox = await getBoundingBox(focus);
|
|
|
|
const [rowIndex, columnIndex] = start;
|
|
const cell = getDatabaseBodyCell(page, { rowIndex, columnIndex });
|
|
const cellBox = await getBoundingBox(cell);
|
|
expect(focusBox).toEqual({
|
|
x: cellBox.x,
|
|
y: cellBox.y - 1,
|
|
height: cellBox.height + 2,
|
|
width: cellBox.width + 1,
|
|
});
|
|
} else {
|
|
// multi cells
|
|
const selection = page.locator('.database-selection');
|
|
const selectionBox = await getBoundingBox(selection);
|
|
|
|
const [startRowIndex, startColumnIndex] = start;
|
|
const [endRowIndex, endColumnIndex] = end;
|
|
|
|
const rowIndexStart = Math.min(startRowIndex, endRowIndex);
|
|
const rowIndexEnd = Math.max(startRowIndex, endRowIndex);
|
|
const columnIndexStart = Math.min(startColumnIndex, endColumnIndex);
|
|
const columnIndexEnd = Math.max(startColumnIndex, endColumnIndex);
|
|
|
|
let height = 0;
|
|
let width = 0;
|
|
let x = 0;
|
|
let y = 0;
|
|
for (let i = rowIndexStart; i <= rowIndexEnd; i++) {
|
|
const cell = getDatabaseBodyCell(page, {
|
|
rowIndex: i,
|
|
columnIndex: columnIndexStart,
|
|
});
|
|
const box = await getBoundingBox(cell);
|
|
height += box.height + 1;
|
|
if (i === rowIndexStart) {
|
|
y = box.y;
|
|
}
|
|
}
|
|
|
|
for (let j = columnIndexStart; j <= columnIndexEnd; j++) {
|
|
const cell = getDatabaseBodyCell(page, {
|
|
rowIndex: rowIndexStart,
|
|
columnIndex: j,
|
|
});
|
|
const box = await getBoundingBox(cell);
|
|
width += box.width;
|
|
if (j === columnIndexStart) {
|
|
x = box.x;
|
|
}
|
|
}
|
|
|
|
expect(selectionBox).toEqual({
|
|
x,
|
|
y,
|
|
height,
|
|
width: width + 1,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function getElementStyle(
|
|
page: Page,
|
|
selector: string,
|
|
key: keyof CSSStyleDeclaration
|
|
) {
|
|
const style = await page.evaluate(
|
|
({ key, selector }) => {
|
|
const el = document.querySelector<HTMLElement>(selector);
|
|
if (!el) throw new Error(`Missing ${selector} tag`);
|
|
// @ts-ignore
|
|
return el.style[key];
|
|
},
|
|
{
|
|
key,
|
|
selector,
|
|
}
|
|
);
|
|
|
|
return style;
|
|
}
|
|
|
|
export async function focusKanbanCardHeader(page: Page, index = 0) {
|
|
const cardHeader = page.locator('data-view-header-area-text').nth(index);
|
|
await cardHeader.click();
|
|
}
|
|
|
|
export async function clickKanbanCardHeader(page: Page, index = 0) {
|
|
const cardHeader = page.locator('data-view-header-area-text').nth(index);
|
|
await cardHeader.click();
|
|
await cardHeader.click();
|
|
}
|
|
|
|
export async function assertKanbanCardHeaderText(
|
|
page: Page,
|
|
text: string,
|
|
index = 0
|
|
) {
|
|
const cardHeader = page.locator('data-view-header-area-text').nth(index);
|
|
|
|
await expect(cardHeader).toHaveText(text);
|
|
}
|
|
|
|
export async function assertKanbanCellSelected(
|
|
page: Page,
|
|
{
|
|
groupIndex,
|
|
cardIndex,
|
|
cellIndex,
|
|
}: {
|
|
groupIndex: number;
|
|
cardIndex: number;
|
|
cellIndex: number;
|
|
}
|
|
) {
|
|
const border = await page.evaluate(
|
|
({ groupIndex, cardIndex, cellIndex }) => {
|
|
const group = document.querySelector(
|
|
`affine-data-view-kanban-group:nth-child(${groupIndex + 1})`
|
|
);
|
|
const card = group?.querySelector(
|
|
`affine-data-view-kanban-card:nth-child(${cardIndex + 1})`
|
|
);
|
|
const cells = Array.from(
|
|
card?.querySelectorAll<HTMLElement>(`affine-data-view-kanban-cell`) ??
|
|
[]
|
|
);
|
|
const cell = cells[cellIndex];
|
|
if (!cell) throw new Error(`Missing cell tag`);
|
|
return cell.style.border;
|
|
},
|
|
{
|
|
groupIndex,
|
|
cardIndex,
|
|
cellIndex,
|
|
}
|
|
);
|
|
|
|
expect(border).toEqual('1px solid var(--affine-primary-color)');
|
|
}
|
|
|
|
export async function assertKanbanCardSelected(
|
|
page: Page,
|
|
{
|
|
groupIndex,
|
|
cardIndex,
|
|
}: {
|
|
groupIndex: number;
|
|
cardIndex: number;
|
|
}
|
|
) {
|
|
const border = await page.evaluate(
|
|
({ groupIndex, cardIndex }) => {
|
|
const group = document.querySelector(
|
|
`affine-data-view-kanban-group:nth-child(${groupIndex + 1})`
|
|
);
|
|
const card = group?.querySelector<HTMLElement>(
|
|
`affine-data-view-kanban-card:nth-child(${cardIndex + 1})`
|
|
);
|
|
if (!card) throw new Error(`Missing card tag`);
|
|
return card.style.border;
|
|
},
|
|
{
|
|
groupIndex,
|
|
cardIndex,
|
|
}
|
|
);
|
|
|
|
expect(border).toEqual('1px solid var(--affine-primary-color)');
|
|
}
|
|
|
|
export function getKanbanCard(
|
|
page: Page,
|
|
{
|
|
groupIndex,
|
|
cardIndex,
|
|
}: {
|
|
groupIndex: number;
|
|
cardIndex: number;
|
|
}
|
|
) {
|
|
const group = page.locator('affine-data-view-kanban-group').nth(groupIndex);
|
|
const card = group.locator('affine-data-view-kanban-card').nth(cardIndex);
|
|
return card;
|
|
}
|
|
export const moveToCenterOf = async (page: Page, locator: Locator) => {
|
|
const box = (await locator.boundingBox())!;
|
|
expect(box).toBeDefined();
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
};
|
|
export const changeColumnType = async (
|
|
page: Page,
|
|
column: number,
|
|
name: string
|
|
) => {
|
|
await waitNextFrame(page);
|
|
await page.locator('affine-database-header-column').nth(column).click();
|
|
await waitNextFrame(page, 200);
|
|
await pressKey(page, 'Escape');
|
|
await pressKey(page, 'ArrowDown');
|
|
await pressKey(page, 'Enter');
|
|
await type(page, name);
|
|
await pressKey(page, 'ArrowDown');
|
|
await pressKey(page, 'Enter');
|
|
};
|
|
export const pressKey = async (page: Page, key: string, count: number = 1) => {
|
|
for (let i = 0; i < count; i++) {
|
|
await waitNextFrame(page);
|
|
await press(page, key);
|
|
}
|
|
await waitNextFrame(page);
|
|
};
|