mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
refactor(editor): unify directories naming (#11516)
**Directory Structure Changes** - Renamed multiple block-related directories by removing the "block-" prefix: - `block-attachment` → `attachment` - `block-bookmark` → `bookmark` - `block-callout` → `callout` - `block-code` → `code` - `block-data-view` → `data-view` - `block-database` → `database` - `block-divider` → `divider` - `block-edgeless-text` → `edgeless-text` - `block-embed` → `embed`
This commit is contained in:
13
blocksuite/affine/blocks/database/src/adapters/extension.ts
Normal file
13
blocksuite/affine/blocks/database/src/adapters/extension.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { DatabaseBlockHtmlAdapterExtension } from './html.js';
|
||||
import { DatabaseBlockMarkdownAdapterExtension } from './markdown.js';
|
||||
import { DatabaseBlockNotionHtmlAdapterExtension } from './notion-html.js';
|
||||
import { DatabaseBlockPlainTextAdapterExtension } from './plain-text.js';
|
||||
|
||||
export const DatabaseBlockAdapterExtensions: ExtensionType[] = [
|
||||
DatabaseBlockHtmlAdapterExtension,
|
||||
DatabaseBlockMarkdownAdapterExtension,
|
||||
DatabaseBlockNotionHtmlAdapterExtension,
|
||||
DatabaseBlockPlainTextAdapterExtension,
|
||||
];
|
||||
105
blocksuite/affine/blocks/database/src/adapters/html.ts
Normal file
105
blocksuite/affine/blocks/database/src/adapters/html.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
type ColumnDataType,
|
||||
DatabaseBlockSchema,
|
||||
type SerializedCells,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
type InlineHtmlAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { Element } from 'hast';
|
||||
|
||||
import { processTable } from './utils';
|
||||
|
||||
export const databaseBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
const columns = o.node.props.columns as Array<ColumnDataType>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const table = processTable(columns, children, cells);
|
||||
const createAstTableCell = (
|
||||
children: InlineHtmlAST[]
|
||||
): InlineHtmlAST => ({
|
||||
type: 'element',
|
||||
tagName: 'td',
|
||||
properties: Object.create(null),
|
||||
children,
|
||||
});
|
||||
|
||||
const createAstTableHeaderCell = (
|
||||
children: InlineHtmlAST[]
|
||||
): InlineHtmlAST => ({
|
||||
type: 'element',
|
||||
tagName: 'th',
|
||||
properties: Object.create(null),
|
||||
children,
|
||||
});
|
||||
|
||||
const createAstTableRow = (cells: InlineHtmlAST[]): Element => ({
|
||||
type: 'element',
|
||||
tagName: 'tr',
|
||||
properties: Object.create(null),
|
||||
children: cells,
|
||||
});
|
||||
|
||||
const { deltaConverter } = context;
|
||||
|
||||
const tableHeaderAst: Element = {
|
||||
type: 'element',
|
||||
tagName: 'thead',
|
||||
properties: Object.create(null),
|
||||
children: [
|
||||
createAstTableRow(
|
||||
table.headers.map(v =>
|
||||
createAstTableHeaderCell([
|
||||
{
|
||||
type: 'text',
|
||||
value: v.name ?? '',
|
||||
},
|
||||
])
|
||||
)
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const tableBodyAst: Element = {
|
||||
type: 'element',
|
||||
tagName: 'tbody',
|
||||
properties: Object.create(null),
|
||||
children: table.rows.map(v => {
|
||||
return createAstTableRow(
|
||||
v.cells.map(cell => {
|
||||
return createAstTableCell(
|
||||
typeof cell.value === 'string'
|
||||
? [{ type: 'text', value: cell.value }]
|
||||
: deltaConverter.deltaToAST(cell.value.delta)
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'element',
|
||||
tagName: 'table',
|
||||
properties: Object.create(null),
|
||||
children: [tableHeaderAst, tableBodyAst],
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DatabaseBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
databaseBlockHtmlAdapterMatcher
|
||||
);
|
||||
4
blocksuite/affine/blocks/database/src/adapters/index.ts
Normal file
4
blocksuite/affine/blocks/database/src/adapters/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './html';
|
||||
export * from './markdown';
|
||||
export * from './notion-html';
|
||||
export * from './plain-text';
|
||||
67
blocksuite/affine/blocks/database/src/adapters/markdown.ts
Normal file
67
blocksuite/affine/blocks/database/src/adapters/markdown.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
type ColumnDataType,
|
||||
DatabaseBlockSchema,
|
||||
type SerializedCells,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
type MarkdownAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { TableRow } from 'mdast';
|
||||
|
||||
import { processTable } from './utils';
|
||||
|
||||
const DATABASE_NODE_TYPES = new Set(['table', 'tableRow']);
|
||||
|
||||
const isDatabaseNode = (node: MarkdownAST) =>
|
||||
DATABASE_NODE_TYPES.has(node.type);
|
||||
|
||||
export const databaseBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
{
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: o => isDatabaseNode(o.node),
|
||||
fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
const rows: TableRow[] = [];
|
||||
const columns = o.node.props.columns as Array<ColumnDataType>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const table = processTable(columns, children, cells);
|
||||
rows.push({
|
||||
type: 'tableRow',
|
||||
children: table.headers.map(v => ({
|
||||
type: 'tableCell',
|
||||
children: [{ type: 'text', value: v.name }],
|
||||
})),
|
||||
});
|
||||
table.rows.forEach(v => {
|
||||
rows.push({
|
||||
type: 'tableRow',
|
||||
children: v.cells.map(v => ({
|
||||
type: 'tableCell',
|
||||
children:
|
||||
typeof v.value === 'string'
|
||||
? [{ type: 'text', value: v.value }]
|
||||
: deltaConverter.deltaToAST(v.value.delta),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'table',
|
||||
children: rows,
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DatabaseBlockMarkdownAdapterExtension =
|
||||
BlockMarkdownAdapterExtension(databaseBlockMarkdownAdapterMatcher);
|
||||
358
blocksuite/affine/blocks/database/src/adapters/notion-html.ts
Normal file
358
blocksuite/affine/blocks/database/src/adapters/notion-html.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { DatabaseBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
AdapterTextUtils,
|
||||
BlockNotionHtmlAdapterExtension,
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { getTagColor } from '@blocksuite/data-view';
|
||||
import { type BlockSnapshot, nanoid } from '@blocksuite/store';
|
||||
|
||||
const ColumnClassMap: Record<string, string> = {
|
||||
typesSelect: 'select',
|
||||
typesMultipleSelect: 'multi-select',
|
||||
typesNumber: 'number',
|
||||
typesCheckbox: 'checkbox',
|
||||
typesText: 'rich-text',
|
||||
typesTitle: 'title',
|
||||
};
|
||||
|
||||
const NotionDatabaseToken = '.collection-content';
|
||||
const NotionDatabaseTitleToken = '.collection-title';
|
||||
|
||||
type BlocksuiteTableColumn = {
|
||||
type: string;
|
||||
name: string;
|
||||
data: {
|
||||
options?: {
|
||||
id: string;
|
||||
value: string;
|
||||
color: string;
|
||||
}[];
|
||||
};
|
||||
id: string;
|
||||
};
|
||||
|
||||
type BlocksuiteTableRow = Record<
|
||||
string,
|
||||
{
|
||||
columnId: string;
|
||||
value: unknown;
|
||||
}
|
||||
>;
|
||||
|
||||
const DATABASE_NODE_TYPES = new Set(['table', 'th', 'tr']);
|
||||
|
||||
export const databaseBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
|
||||
{
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: o =>
|
||||
HastUtils.isElement(o.node) && DATABASE_NODE_TYPES.has(o.node.tagName),
|
||||
fromMatch: () => false,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext, deltaConverter, pageMap } = context;
|
||||
switch (o.node.tagName) {
|
||||
case 'th': {
|
||||
const columnId = nanoid();
|
||||
const columnTypeClass = HastUtils.querySelector(o.node, 'svg')
|
||||
?.properties?.className;
|
||||
const columnType = Array.isArray(columnTypeClass)
|
||||
? (ColumnClassMap[columnTypeClass[0] ?? ''] ?? 'rich-text')
|
||||
: 'rich-text';
|
||||
walkerContext.pushGlobalContextStack<BlocksuiteTableColumn>(
|
||||
'hast:table:column',
|
||||
{
|
||||
type: columnType,
|
||||
name: HastUtils.getTextContent(
|
||||
HastUtils.getTextChildrenOnlyAst(o.node)
|
||||
),
|
||||
data: Object.create(null),
|
||||
id: columnId,
|
||||
}
|
||||
);
|
||||
// disable icon img in th
|
||||
walkerContext.setGlobalContext('hast:disableimg', true);
|
||||
break;
|
||||
}
|
||||
case 'tr': {
|
||||
if (
|
||||
o.parent?.node.type === 'element' &&
|
||||
o.parent.node.tagName === 'tbody'
|
||||
) {
|
||||
const columns =
|
||||
walkerContext.getGlobalContextStack<BlocksuiteTableColumn>(
|
||||
'hast:table:column'
|
||||
);
|
||||
const row = Object.create(null);
|
||||
let plainTable = false;
|
||||
HastUtils.getElementChildren(o.node).forEach((child, index) => {
|
||||
if (plainTable || columns[index] === undefined) {
|
||||
plainTable = true;
|
||||
if (columns[index] === undefined) {
|
||||
columns.push({
|
||||
type: 'rich-text',
|
||||
name: '',
|
||||
data: Object.create(null),
|
||||
id: nanoid(),
|
||||
});
|
||||
walkerContext.pushGlobalContextStack<BlockSnapshot>(
|
||||
'hast:table:children',
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(child),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
walkerContext.pushGlobalContextStack<BlockSnapshot>(
|
||||
'hast:table:children',
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(child),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
}
|
||||
);
|
||||
const column = columns[index];
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: HastUtils.getTextContent(child),
|
||||
};
|
||||
} else if (HastUtils.querySelector(child, '.cell-title')) {
|
||||
walkerContext.pushGlobalContextStack<BlockSnapshot>(
|
||||
'hast:table:children',
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(child, { pageMap }),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
}
|
||||
);
|
||||
columns[index].type = 'title';
|
||||
return;
|
||||
}
|
||||
const optionIds: string[] = [];
|
||||
const column = columns[index];
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
if (HastUtils.querySelector(child, '.selected-value')) {
|
||||
if (!('options' in column.data)) {
|
||||
column.data.options = [];
|
||||
}
|
||||
if (!['multi-select', 'select'].includes(column.type)) {
|
||||
column.type = 'select';
|
||||
}
|
||||
if (
|
||||
column.type === 'select' &&
|
||||
child.type === 'element' &&
|
||||
child.children.length > 1
|
||||
) {
|
||||
column.type = 'multi-select';
|
||||
}
|
||||
child.type === 'element' &&
|
||||
child.children.forEach(span => {
|
||||
const filteredArray = column.data.options?.filter(
|
||||
option =>
|
||||
option.value === HastUtils.getTextContent(span)
|
||||
);
|
||||
const id = filteredArray?.length
|
||||
? (filteredArray[0]?.id ?? nanoid())
|
||||
: nanoid();
|
||||
if (!filteredArray?.length) {
|
||||
column.data.options?.push({
|
||||
id,
|
||||
value: HastUtils.getTextContent(span),
|
||||
color: getTagColor(),
|
||||
});
|
||||
}
|
||||
optionIds.push(id);
|
||||
});
|
||||
// Expand will be done when leaving the table
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: optionIds,
|
||||
};
|
||||
} else if (HastUtils.querySelector(child, '.checkbox')) {
|
||||
if (column.type !== 'checkbox') {
|
||||
column.type = 'checkbox';
|
||||
}
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: HastUtils.querySelector(child, '.checkbox-on')
|
||||
? true
|
||||
: false,
|
||||
};
|
||||
} else if (column.type === 'number') {
|
||||
const text = HastUtils.getTextContent(child);
|
||||
const number = Number(text);
|
||||
if (Number.isNaN(number)) {
|
||||
column.type = 'rich-text';
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: AdapterTextUtils.createText(text),
|
||||
};
|
||||
} else {
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: number,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: HastUtils.getTextContent(child),
|
||||
};
|
||||
}
|
||||
if (
|
||||
column.type === 'rich-text' &&
|
||||
!AdapterTextUtils.isText(row[column.id].value)
|
||||
) {
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: AdapterTextUtils.createText(row[column.id].value),
|
||||
};
|
||||
}
|
||||
});
|
||||
walkerContext.setGlobalContextStack('hast:table:column', columns);
|
||||
walkerContext.pushGlobalContextStack('hast:table:rows', row);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
leave: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext } = context;
|
||||
switch (o.node.tagName) {
|
||||
case 'table': {
|
||||
const columns =
|
||||
walkerContext.getGlobalContextStack<BlocksuiteTableColumn>(
|
||||
'hast:table:column'
|
||||
);
|
||||
walkerContext.setGlobalContextStack('hast:table:column', []);
|
||||
const children = walkerContext.getGlobalContextStack<BlockSnapshot>(
|
||||
'hast:table:children'
|
||||
);
|
||||
walkerContext.setGlobalContextStack('hast:table:children', []);
|
||||
const cells = Object.create(null);
|
||||
walkerContext
|
||||
.getGlobalContextStack<BlocksuiteTableRow>('hast:table:rows')
|
||||
.forEach((row, i) => {
|
||||
Object.keys(row).forEach(columnId => {
|
||||
const cell = row[columnId];
|
||||
if (!cell) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
columns.find(column => column.id === columnId)?.type ===
|
||||
'select'
|
||||
) {
|
||||
cell.value = (cell.value as string[])[0];
|
||||
}
|
||||
});
|
||||
cells[children.at(i)?.id ?? nanoid()] = row;
|
||||
});
|
||||
walkerContext.setGlobalContextStack('hast:table:cells', []);
|
||||
let databaseTitle = '';
|
||||
if (
|
||||
o.parent?.node.type === 'element' &&
|
||||
HastUtils.querySelector(o.parent.node, NotionDatabaseToken)
|
||||
) {
|
||||
databaseTitle = HastUtils.getTextContent(
|
||||
HastUtils.querySelector(o.parent.node, NotionDatabaseTitleToken)
|
||||
);
|
||||
}
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
props: {
|
||||
views: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'Table View',
|
||||
mode: 'table',
|
||||
columns: [],
|
||||
filter: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
header: {
|
||||
titleColumn:
|
||||
columns.find(column => column.type === 'title')?.id ??
|
||||
'',
|
||||
iconColumn: 'type',
|
||||
},
|
||||
},
|
||||
],
|
||||
title: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: databaseTitle
|
||||
? [
|
||||
{
|
||||
insert: databaseTitle,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
columns,
|
||||
cells,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
children.forEach(child => {
|
||||
walkerContext.openNode(child, 'children').closeNode();
|
||||
});
|
||||
walkerContext.closeNode();
|
||||
walkerContext.cleanGlobalContextStack('hast:table:column');
|
||||
walkerContext.cleanGlobalContextStack('hast:table:rows');
|
||||
walkerContext.cleanGlobalContextStack('hast:table:children');
|
||||
break;
|
||||
}
|
||||
case 'th': {
|
||||
walkerContext.setGlobalContext('hast:disableimg', false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {},
|
||||
};
|
||||
|
||||
export const DatabaseBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(databaseBlockNotionHtmlAdapterMatcher);
|
||||
48
blocksuite/affine/blocks/database/src/adapters/plain-text.ts
Normal file
48
blocksuite/affine/blocks/database/src/adapters/plain-text.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
type ColumnDataType,
|
||||
DatabaseBlockSchema,
|
||||
type SerializedCells,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { formatTable, processTable } from './utils.js';
|
||||
|
||||
export const databaseBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher =
|
||||
{
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
const rows: string[][] = [];
|
||||
const columns = o.node.props.columns as Array<ColumnDataType>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const table = processTable(columns, children, cells);
|
||||
rows.push(table.headers.map(v => v.name));
|
||||
table.rows.forEach(v => {
|
||||
rows.push(
|
||||
v.cells.map(v =>
|
||||
typeof v.value === 'string'
|
||||
? v.value
|
||||
: deltaConverter.deltaToAST(v.value.delta).join('')
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const tableString = formatTable(rows);
|
||||
|
||||
context.textBuffer.content += tableString;
|
||||
context.textBuffer.content += '\n';
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DatabaseBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(databaseBlockPlainTextAdapterMatcher);
|
||||
109
blocksuite/affine/blocks/database/src/adapters/utils.ts
Normal file
109
blocksuite/affine/blocks/database/src/adapters/utils.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ColumnDataType, SerializedCells } from '@blocksuite/affine-model';
|
||||
import type { BlockSnapshot, DeltaInsert } from '@blocksuite/store';
|
||||
|
||||
import { databaseBlockModels } from '../properties/model';
|
||||
|
||||
function calculateColumnWidths(rows: string[][]): number[] {
|
||||
return (
|
||||
rows[0]?.map((_, colIndex) =>
|
||||
Math.max(...rows.map(row => (row[colIndex] || '').length))
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function formatRow(
|
||||
row: string[],
|
||||
columnWidths: number[],
|
||||
isHeader: boolean
|
||||
): string {
|
||||
const cells = row.map((cell, colIndex) =>
|
||||
cell?.padEnd(columnWidths[colIndex] ?? 0, ' ')
|
||||
);
|
||||
const rowString = `| ${cells.join(' | ')} |`;
|
||||
return isHeader
|
||||
? `${rowString}\n${formatSeparator(columnWidths)}`
|
||||
: rowString;
|
||||
}
|
||||
|
||||
function formatSeparator(columnWidths: number[]): string {
|
||||
const separator = columnWidths.map(width => '-'.repeat(width)).join(' | ');
|
||||
return `| ${separator} |`;
|
||||
}
|
||||
|
||||
export function formatTable(rows: string[][]): string {
|
||||
const columnWidths = calculateColumnWidths(rows);
|
||||
const formattedRows = rows.map((row, index) =>
|
||||
formatRow(row, columnWidths, index === 0)
|
||||
);
|
||||
return formattedRows.join('\n');
|
||||
}
|
||||
export const isDelta = (value: unknown): value is { delta: DeltaInsert[] } => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return '$blocksuite:internal:text$' in value;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
type Table = {
|
||||
headers: ColumnDataType[];
|
||||
rows: Row[];
|
||||
};
|
||||
type Row = {
|
||||
cells: Cell[];
|
||||
};
|
||||
type Cell = {
|
||||
value: string | { delta: DeltaInsert[] };
|
||||
};
|
||||
export const processTable = (
|
||||
columns: ColumnDataType[],
|
||||
children: BlockSnapshot[],
|
||||
cells: SerializedCells
|
||||
): Table => {
|
||||
const table: Table = {
|
||||
headers: columns,
|
||||
rows: [],
|
||||
};
|
||||
children.forEach(v => {
|
||||
const row: Row = {
|
||||
cells: [],
|
||||
};
|
||||
const title = v.props.text;
|
||||
if (isDelta(title)) {
|
||||
row.cells.push({
|
||||
value: title,
|
||||
});
|
||||
} else {
|
||||
row.cells.push({
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
|
||||
columns.forEach(col => {
|
||||
const property = databaseBlockModels[col.type];
|
||||
const cell = cells[v.id]?.[col.id];
|
||||
if (col.type === 'title') {
|
||||
return;
|
||||
}
|
||||
if (!cell || !property) {
|
||||
row.cells.push({
|
||||
value: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
let value: string | { delta: DeltaInsert[] };
|
||||
if (isDelta(cell.value)) {
|
||||
value = cell.value;
|
||||
} else {
|
||||
value = property.config.rawValue.toString({
|
||||
value: cell.value,
|
||||
data: col.data,
|
||||
});
|
||||
}
|
||||
row.cells.push({
|
||||
value,
|
||||
});
|
||||
});
|
||||
table.rows.push(row);
|
||||
});
|
||||
|
||||
return table;
|
||||
};
|
||||
50
blocksuite/affine/blocks/database/src/block-icons.ts
Normal file
50
blocksuite/affine/blocks/database/src/block-icons.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ParagraphType } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BulletedListIcon,
|
||||
CheckBoxCheckLinearIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
Heading5Icon,
|
||||
Heading6Icon,
|
||||
NumberedListIcon,
|
||||
QuoteIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
export const getIcon = (
|
||||
model: BlockModel & {
|
||||
props: {
|
||||
type?: string;
|
||||
};
|
||||
}
|
||||
): TemplateResult => {
|
||||
if (model.flavour === 'affine:paragraph') {
|
||||
const type = model.props.type as ParagraphType;
|
||||
return (
|
||||
{
|
||||
text: TextIcon(),
|
||||
quote: QuoteIcon(),
|
||||
h1: Heading1Icon(),
|
||||
h2: Heading2Icon(),
|
||||
h3: Heading3Icon(),
|
||||
h4: Heading4Icon(),
|
||||
h5: Heading5Icon(),
|
||||
h6: Heading6Icon(),
|
||||
} as Record<ParagraphType, TemplateResult>
|
||||
)[type];
|
||||
}
|
||||
if (model.flavour === 'affine:list') {
|
||||
return (
|
||||
{
|
||||
bulleted: BulletedListIcon(),
|
||||
numbered: NumberedListIcon(),
|
||||
todo: CheckBoxCheckLinearIcon(),
|
||||
}[model.props.type ?? 'bulleted'] ?? BulletedListIcon()
|
||||
);
|
||||
}
|
||||
return TextIcon();
|
||||
};
|
||||
69
blocksuite/affine/blocks/database/src/commands.ts
Normal file
69
blocksuite/affine/blocks/database/src/commands.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { DatabaseBlockModel } from '@blocksuite/affine-model';
|
||||
import type { Command } from '@blocksuite/std';
|
||||
import type { BlockModel, Store } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
DatabaseBlockDataSource,
|
||||
databaseViewInitTemplate,
|
||||
} from './data-source';
|
||||
|
||||
export const insertDatabaseBlockCommand: Command<
|
||||
{
|
||||
selectedModels?: BlockModel[];
|
||||
viewType: string;
|
||||
place?: 'after' | 'before';
|
||||
removeEmptyLine?: boolean;
|
||||
},
|
||||
{
|
||||
insertedDatabaseBlockId: string;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { selectedModels, viewType, place, removeEmptyLine, std } = ctx;
|
||||
if (!selectedModels?.length) return;
|
||||
|
||||
const targetModel =
|
||||
place === 'before'
|
||||
? selectedModels[0]
|
||||
: selectedModels[selectedModels.length - 1];
|
||||
|
||||
if (!targetModel) return;
|
||||
|
||||
const result = std.store.addSiblingBlocks(
|
||||
targetModel,
|
||||
[{ flavour: 'affine:database' }],
|
||||
place
|
||||
);
|
||||
const string = result[0];
|
||||
|
||||
if (string == null) return;
|
||||
|
||||
initDatabaseBlock(std.store, targetModel, string, viewType, false);
|
||||
|
||||
if (removeEmptyLine && targetModel.text?.length === 0) {
|
||||
std.store.deleteBlock(targetModel);
|
||||
}
|
||||
|
||||
next({ insertedDatabaseBlockId: string });
|
||||
};
|
||||
|
||||
export const initDatabaseBlock = (
|
||||
doc: Store,
|
||||
model: BlockModel,
|
||||
databaseId: string,
|
||||
viewType: string,
|
||||
isAppendNewRow = true
|
||||
) => {
|
||||
const blockModel = doc.getBlock(databaseId)?.model as
|
||||
| DatabaseBlockModel
|
||||
| undefined;
|
||||
if (!blockModel) {
|
||||
return;
|
||||
}
|
||||
const datasource = new DatabaseBlockDataSource(blockModel);
|
||||
databaseViewInitTemplate(datasource, viewType);
|
||||
if (isAppendNewRow) {
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return;
|
||||
doc.addBlock('affine:paragraph', {}, parent.id);
|
||||
}
|
||||
};
|
||||
69
blocksuite/affine/blocks/database/src/components/layout.ts
Normal file
69
blocksuite/affine/blocks/database/src/components/layout.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { createModal } from '@blocksuite/affine-components/context-menu';
|
||||
import { CloseIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { css, html, type TemplateResult } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class CenterPeek extends ShadowlessElement {
|
||||
static override styles = css`
|
||||
center-peek {
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 5%;
|
||||
left: 5%;
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.side-modal-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.close-modal:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
.close-modal {
|
||||
position: absolute;
|
||||
right: -32px;
|
||||
top: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div @click="${this.close}" class="close-modal">${CloseIcon()}</div>
|
||||
${this.content}
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor close: (() => void) | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor content: TemplateResult | undefined = undefined;
|
||||
}
|
||||
|
||||
export const popSideDetail = (template: TemplateResult) => {
|
||||
return new Promise<void>(res => {
|
||||
const modal = createModal(document.body);
|
||||
const close = () => {
|
||||
modal.remove();
|
||||
res();
|
||||
};
|
||||
const sideContainer = new CenterPeek();
|
||||
sideContainer.content = template;
|
||||
sideContainer.close = close;
|
||||
modal.onclick = e => e.target === modal && close();
|
||||
modal.append(sideContainer);
|
||||
});
|
||||
};
|
||||
185
blocksuite/affine/blocks/database/src/components/title/index.ts
Normal file
185
blocksuite/affine/blocks/database/src/components/title/index.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import type { Text } from '@blocksuite/store';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||
|
||||
export class DatabaseTitle extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.affine-database-title {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
font-family: inherit;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--affine-text-primary-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.affine-database-title textarea {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
letter-spacing: inherit;
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
outline: none;
|
||||
resize: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.affine-database-title .text {
|
||||
user-select: none;
|
||||
opacity: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.affine-database-title[data-title-focus='false'] textarea {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.affine-database-title[data-title-focus='false'] .text {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.affine-database-title [data-title-empty='true']::before {
|
||||
content: 'Untitled';
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.affine-database-title [data-title-focus='true']::before {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly compositionEnd = () => {
|
||||
this.titleText.replace(0, this.titleText.length, this.input.value);
|
||||
};
|
||||
|
||||
private readonly onBlur = () => {
|
||||
this.isFocus = false;
|
||||
};
|
||||
|
||||
private readonly onFocus = () => {
|
||||
this.isFocus = true;
|
||||
if (this.database?.viewSelection$?.value) {
|
||||
this.database?.setSelection(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onInput = (e: InputEvent) => {
|
||||
this.text = this.input.value;
|
||||
if (!e.isComposing) {
|
||||
this.titleText.replace(0, this.titleText.length, this.input.value);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter' && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
this.onPressEnterKey?.();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
updateText = () => {
|
||||
if (!this.isFocus) {
|
||||
this.input.value = this.titleText.toString();
|
||||
this.text = this.input.value;
|
||||
}
|
||||
};
|
||||
|
||||
get database() {
|
||||
return this.closest<DatabaseBlockComponent>('affine-database');
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
requestAnimationFrame(() => {
|
||||
this.updateText();
|
||||
});
|
||||
this.titleText.yText.observe(this.updateText);
|
||||
this.disposables.add(() => {
|
||||
this.titleText.yText.unobserve(this.updateText);
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const isEmpty = !this.text;
|
||||
|
||||
const classList = classMap({
|
||||
'affine-database-title': true,
|
||||
ellipsis: !this.isFocus,
|
||||
});
|
||||
const untitledStyle = styleMap({
|
||||
height: isEmpty ? 'auto' : 0,
|
||||
opacity: isEmpty && !this.isFocus ? 1 : 0,
|
||||
});
|
||||
return html` <div
|
||||
class="${classList}"
|
||||
data-title-empty="${isEmpty}"
|
||||
data-title-focus="${this.isFocus}"
|
||||
>
|
||||
<div class="text" style="${untitledStyle}">Untitled</div>
|
||||
<div class="text">${this.text}</div>
|
||||
<textarea
|
||||
.disabled="${this.readonly}"
|
||||
@input="${this.onInput}"
|
||||
@keydown="${this.onKeyDown}"
|
||||
@copy="${stopPropagation}"
|
||||
@paste="${stopPropagation}"
|
||||
@focus="${this.onFocus}"
|
||||
@blur="${this.onBlur}"
|
||||
@compositionend="${this.compositionEnd}"
|
||||
data-block-is-database-title="true"
|
||||
title="${this.titleText.toString()}"
|
||||
></textarea>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@query('textarea')
|
||||
private accessor input!: HTMLTextAreaElement;
|
||||
|
||||
@state()
|
||||
accessor isComposing = false;
|
||||
|
||||
@state()
|
||||
private accessor isFocus = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onPressEnterKey: (() => void) | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor readonly!: boolean;
|
||||
|
||||
@state()
|
||||
private accessor text = '';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor titleText!: Text;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-database-title': DatabaseTitle;
|
||||
}
|
||||
}
|
||||
10
blocksuite/affine/blocks/database/src/config.ts
Normal file
10
blocksuite/affine/blocks/database/src/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { MenuOptions } from '@blocksuite/affine-components/context-menu';
|
||||
import { type DatabaseBlockModel } from '@blocksuite/affine-model';
|
||||
import { ConfigExtensionFactory } from '@blocksuite/std';
|
||||
|
||||
export interface DatabaseOptionsConfig {
|
||||
configure: (model: DatabaseBlockModel, options: MenuOptions) => MenuOptions;
|
||||
}
|
||||
|
||||
export const DatabaseConfigExtension =
|
||||
ConfigExtensionFactory<DatabaseOptionsConfig>('affine:database');
|
||||
83
blocksuite/affine/blocks/database/src/configs/slash-menu.ts
Normal file
83
blocksuite/affine/blocks/database/src/configs/slash-menu.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils';
|
||||
import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { viewPresets } from '@blocksuite/data-view/view-presets';
|
||||
import {
|
||||
DatabaseKanbanViewIcon,
|
||||
DatabaseTableViewIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
|
||||
import { insertDatabaseBlockCommand } from '../commands';
|
||||
import { KanbanViewTooltip, TableViewTooltip } from './tooltips';
|
||||
|
||||
export const databaseSlashMenuConfig: SlashMenuConfig = {
|
||||
disableWhen: ({ model }) => model.flavour === 'affine:database',
|
||||
items: [
|
||||
{
|
||||
name: 'Table View',
|
||||
description: 'Display items in a table format.',
|
||||
searchAlias: ['database'],
|
||||
icon: DatabaseTableViewIcon(),
|
||||
tooltip: {
|
||||
figure: TableViewTooltip,
|
||||
caption: 'Table View',
|
||||
},
|
||||
group: '7_Database@0',
|
||||
when: ({ model }) =>
|
||||
!isInsideBlockByFlavour(model.doc, model, 'affine:edgeless-text'),
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(insertDatabaseBlockCommand, {
|
||||
viewType: viewPresets.tableViewMeta.type,
|
||||
place: 'after',
|
||||
removeEmptyLine: true,
|
||||
})
|
||||
.pipe(({ insertedDatabaseBlockId }) => {
|
||||
if (insertedDatabaseBlockId) {
|
||||
const telemetry = std.getOptional(TelemetryProvider);
|
||||
telemetry?.track('BlockCreated', {
|
||||
blockType: 'affine:database',
|
||||
});
|
||||
}
|
||||
})
|
||||
.run();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Kanban View',
|
||||
description: 'Visualize data in a dashboard.',
|
||||
searchAlias: ['database'],
|
||||
icon: DatabaseKanbanViewIcon(),
|
||||
tooltip: {
|
||||
figure: KanbanViewTooltip,
|
||||
caption: 'Kanban View',
|
||||
},
|
||||
group: '7_Database@2',
|
||||
when: ({ model }) =>
|
||||
!isInsideBlockByFlavour(model.doc, model, 'affine:edgeless-text'),
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(insertDatabaseBlockCommand, {
|
||||
viewType: viewPresets.kanbanViewMeta.type,
|
||||
place: 'after',
|
||||
removeEmptyLine: true,
|
||||
})
|
||||
.pipe(({ insertedDatabaseBlockId }) => {
|
||||
if (insertedDatabaseBlockId) {
|
||||
const telemetry = std.getOptional(TelemetryProvider);
|
||||
telemetry?.track('BlockCreated', {
|
||||
blockType: 'affine:database',
|
||||
});
|
||||
}
|
||||
})
|
||||
.run();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
181
blocksuite/affine/blocks/database/src/configs/tooltips.ts
Normal file
181
blocksuite/affine/blocks/database/src/configs/tooltips.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { html } from 'lit';
|
||||
// prettier-ignore
|
||||
export const TableViewTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="170" height="106" rx="2" fill="white"/>
|
||||
<mask id="mask0_16460_1148" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="106">
|
||||
<rect width="170" height="106" rx="2" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_16460_1148)">
|
||||
<rect x="7.5" y="26.5" width="169" height="84" rx="3.5" fill="white" stroke="#E3E2E4"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" font-weight="500" letter-spacing="0em"><tspan x="17" y="42.7727">Untitled Table</tspan></text>
|
||||
<line x1="17" y1="50.75" x2="176" y2="50.75" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<line x1="65.25" y1="51" x2="65.25" y2="110" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<line x1="17" y1="63.75" x2="176" y2="63.75" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<line x1="17" y1="76.75" x2="176" y2="76.75" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<line x1="17" y1="89.75" x2="176" y2="89.75" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<line x1="17" y1="102.75" x2="176" y2="102.75" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="25" y="58.8182">Title</tspan></text>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="28" y="71.8182">Task 1</tspan></text>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="78" y="58.8182">Status</tspan></text>
|
||||
<rect x="69" y="66" width="16" height="8" rx="1" fill="#FFE1E1"/>
|
||||
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="71" y="71.8182">Todo</tspan></text>
|
||||
<rect x="69" y="79" width="31" height="8" rx="1" fill="#F3F0FF"/>
|
||||
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="71" y="84.8182">In Progress</tspan></text>
|
||||
<rect x="69" y="93" width="17" height="8" rx="1" fill="#DFF4E8"/>
|
||||
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="71" y="98.8182">Done</tspan></text>
|
||||
<g clip-path="url(#clip0_16460_1148)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.7102 55.3125C17.6067 55.3125 17.5227 55.3964 17.5227 55.5V56C17.5227 56.1036 17.6067 56.1875 17.7102 56.1875C17.8138 56.1875 17.8977 56.1036 17.8977 56V55.6875H18.75V58.3125H18.2557C18.1521 58.3125 18.0682 58.3964 18.0682 58.5C18.0682 58.6036 18.1521 58.6875 18.2557 58.6875H18.9375H19.6193C19.7228 58.6875 19.8068 58.6036 19.8068 58.5C19.8068 58.3964 19.7228 58.3125 19.6193 58.3125H19.125V55.6875H19.9773V56C19.9773 56.1036 20.0612 56.1875 20.1648 56.1875C20.2683 56.1875 20.3523 56.1036 20.3523 56V55.5C20.3523 55.3964 20.2683 55.3125 20.1648 55.3125H18.9375H17.7102ZM20.9011 55.3125C20.7976 55.3125 20.7136 55.3964 20.7136 55.5C20.7136 55.6036 20.7976 55.6875 20.9011 55.6875H22.2647C22.3683 55.6875 22.4522 55.6036 22.4522 55.5C22.4522 55.3964 22.3683 55.3125 22.2647 55.3125H20.9011ZM20.7136 57C20.7136 56.8964 20.7976 56.8125 20.9011 56.8125H22.2647C22.3683 56.8125 22.4522 56.8964 22.4522 57C22.4522 57.1036 22.3683 57.1875 22.2647 57.1875H20.9011C20.7976 57.1875 20.7136 57.1036 20.7136 57ZM20.9011 58.3125C20.7976 58.3125 20.7136 58.3964 20.7136 58.5C20.7136 58.6036 20.7976 58.6875 20.9011 58.6875H22.2647C22.3683 58.6875 22.4522 58.6036 22.4522 58.5C22.4522 58.3964 22.3683 58.3125 22.2647 58.3125H20.9011Z" fill="#8E8D91"/>
|
||||
</g>
|
||||
<rect x="17" y="66" width="8" height="8" rx="1" fill="#EEEEEE"/>
|
||||
<g clip-path="url(#clip1_16460_1148)">
|
||||
<g clip-path="url(#clip2_16460_1148)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.8125 68C18.8125 67.8964 18.8964 67.8125 19 67.8125H23C23.1036 67.8125 23.1875 67.8964 23.1875 68V68.6667C23.1875 68.7702 23.1036 68.8542 23 68.8542C22.8964 68.8542 22.8125 68.7702 22.8125 68.6667V68.1875H21.1875V71.8125H22C22.1036 71.8125 22.1875 71.8964 22.1875 72C22.1875 72.1036 22.1036 72.1875 22 72.1875H20C19.8964 72.1875 19.8125 72.1036 19.8125 72C19.8125 71.8964 19.8964 71.8125 20 71.8125H20.8125V68.1875H19.1875V68.6667C19.1875 68.7702 19.1036 68.8542 19 68.8542C18.8964 68.8542 18.8125 68.7702 18.8125 68.6667V68Z" fill="#77757D"/>
|
||||
</g>
|
||||
</g>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="28" y="84.8182">Task 2</tspan></text>
|
||||
<rect x="17" y="79" width="8" height="8" rx="1" fill="#EEEEEE"/>
|
||||
<g clip-path="url(#clip3_16460_1148)">
|
||||
<g clip-path="url(#clip4_16460_1148)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.8125 81C18.8125 80.8964 18.8964 80.8125 19 80.8125H23C23.1036 80.8125 23.1875 80.8964 23.1875 81V81.6667C23.1875 81.7702 23.1036 81.8542 23 81.8542C22.8964 81.8542 22.8125 81.7702 22.8125 81.6667V81.1875H21.1875V84.8125H22C22.1036 84.8125 22.1875 84.8964 22.1875 85C22.1875 85.1036 22.1036 85.1875 22 85.1875H20C19.8964 85.1875 19.8125 85.1036 19.8125 85C19.8125 84.8964 19.8964 84.8125 20 84.8125H20.8125V81.1875H19.1875V81.6667C19.1875 81.7702 19.1036 81.8542 19 81.8542C18.8964 81.8542 18.8125 81.7702 18.8125 81.6667V81Z" fill="#77757D"/>
|
||||
</g>
|
||||
</g>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="28" y="98.8182">Task 3</tspan></text>
|
||||
<rect x="17" y="93" width="8" height="8" rx="1" fill="#EEEEEE"/>
|
||||
<g clip-path="url(#clip5_16460_1148)">
|
||||
<g clip-path="url(#clip6_16460_1148)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.8125 95C18.8125 94.8964 18.8964 94.8125 19 94.8125H23C23.1036 94.8125 23.1875 94.8964 23.1875 95V95.6667C23.1875 95.7702 23.1036 95.8542 23 95.8542C22.8964 95.8542 22.8125 95.7702 22.8125 95.6667V95.1875H21.1875V98.8125H22C22.1036 98.8125 22.1875 98.8964 22.1875 99C22.1875 99.1036 22.1036 99.1875 22 99.1875H20C19.8964 99.1875 19.8125 99.1036 19.8125 99C19.8125 98.8964 19.8964 98.8125 20 98.8125H20.8125V95.1875H19.1875V95.6667C19.1875 95.7702 19.1036 95.8542 19 95.8542C18.8964 95.8542 18.8125 95.7702 18.8125 95.6667V95Z" fill="#77757D"/>
|
||||
</g>
|
||||
</g>
|
||||
<g clip-path="url(#clip7_16460_1148)">
|
||||
<g clip-path="url(#clip8_16460_1148)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M71 54.6875C71.1036 54.6875 71.1875 54.7714 71.1875 54.875V55.0625H72.8125V54.875C72.8125 54.7714 72.8964 54.6875 73 54.6875C73.1036 54.6875 73.1875 54.7714 73.1875 54.875V55.0625H73.75C74.1297 55.0625 74.4375 55.3703 74.4375 55.75V56.375V57C74.4375 57.1036 74.3536 57.1875 74.25 57.1875C74.1464 57.1875 74.0625 57.1036 74.0625 57V56.5625H69.9375V58.75C69.9375 58.9226 70.0774 59.0625 70.25 59.0625H72C72.1036 59.0625 72.1875 59.1464 72.1875 59.25C72.1875 59.3536 72.1036 59.4375 72 59.4375H70.25C69.8703 59.4375 69.5625 59.1297 69.5625 58.75V56.375V55.75C69.5625 55.3703 69.8703 55.0625 70.25 55.0625H70.8125V54.875C70.8125 54.7714 70.8964 54.6875 71 54.6875ZM72.8125 55.4375V55.625C72.8125 55.7286 72.8964 55.8125 73 55.8125C73.1036 55.8125 73.1875 55.7286 73.1875 55.625V55.4375H73.75C73.9226 55.4375 74.0625 55.5774 74.0625 55.75V56.1875H69.9375V55.75C69.9375 55.5774 70.0774 55.4375 70.25 55.4375H70.8125V55.625C70.8125 55.7286 70.8964 55.8125 71 55.8125C71.1036 55.8125 71.1875 55.7286 71.1875 55.625V55.4375H72.8125ZM72.8586 57.8981C72.9975 57.7326 73.2059 57.6276 73.4386 57.6276C73.7911 57.6276 74.0877 57.8687 74.1718 58.1952C74.1976 58.2955 74.2998 58.3559 74.4001 58.33C74.5004 58.3042 74.5607 58.202 74.5349 58.1017C74.4093 57.6135 73.9663 57.2526 73.4386 57.2526C73.0495 57.2526 72.7065 57.4489 72.5029 57.7474L72.354 57.6842C72.2983 57.6606 72.239 57.7093 72.2513 57.7686L72.3723 58.3507C72.383 58.402 72.4416 58.4269 72.4859 58.3989L72.9884 58.081C73.0395 58.0487 73.0333 57.9722 72.9776 57.9486L72.8586 57.8981ZM73.3836 59.2519C73.6163 59.2519 73.8246 59.1469 73.9636 58.9814L73.8446 58.9309C73.7889 58.9073 73.7827 58.8308 73.8338 58.7985L74.3363 58.4806C74.3806 58.4526 74.4392 58.4775 74.4499 58.5287L74.5709 59.1109C74.5832 59.1702 74.5239 59.2189 74.4682 59.1952L74.3193 59.1321C74.1156 59.4306 73.7727 59.6269 73.3836 59.6269C72.9868 59.6269 72.6378 59.4226 72.436 59.1143C72.3693 59.0125 72.3185 58.8991 72.2873 58.7778C72.2615 58.6775 72.3218 58.5752 72.4221 58.5494C72.5224 58.5236 72.6246 58.584 72.6504 58.6843C72.6712 58.7651 72.7051 58.8407 72.7497 58.9089C72.8852 59.1158 73.1186 59.2519 73.3836 59.2519Z" fill="#8E8D91"/>
|
||||
</g>
|
||||
</g>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Display items in a table format.</tspan></text>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_16460_1148">
|
||||
<rect width="6" height="6" fill="white" transform="translate(17 54)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_16460_1148">
|
||||
<rect width="6" height="6" fill="white" transform="translate(18 67)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip2_16460_1148">
|
||||
<rect width="6" height="6" fill="white" transform="translate(18 67)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip3_16460_1148">
|
||||
<rect width="6" height="6" fill="white" transform="translate(18 80)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip4_16460_1148">
|
||||
<rect width="6" height="6" fill="white" transform="translate(18 80)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip5_16460_1148">
|
||||
<rect width="6" height="6" fill="white" transform="translate(18 94)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip6_16460_1148">
|
||||
<rect width="6" height="6" fill="white" transform="translate(18 94)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip7_16460_1148">
|
||||
<rect width="6" height="6" fill="white" transform="translate(69 54)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip8_16460_1148">
|
||||
<rect width="6" height="6" fill="white" transform="translate(69 54)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// prettier-ignore
|
||||
export const KanbanViewTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="170" height="106" rx="2" fill="white"/>
|
||||
<mask id="mask0_16460_1185" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="106">
|
||||
<rect width="170" height="106" rx="2" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_16460_1185)">
|
||||
<rect x="8.5" y="26.5" width="169" height="84" rx="3.5" fill="white" stroke="#E3E2E4"/>
|
||||
<mask id="mask1_16460_1185" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="9" y="27" width="168" height="83">
|
||||
<rect x="9" y="27" width="168" height="83" rx="4" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_16460_1185)">
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" font-weight="500" letter-spacing="0em"><tspan x="18" y="42.7727">Untitled Kanban</tspan></text>
|
||||
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="18" y="55.8182">Ungroups</tspan></text>
|
||||
<rect x="84" y="50" width="31" height="8" rx="1" fill="#F3F0FF"/>
|
||||
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="86" y="55.8182">In Progress</tspan></text>
|
||||
<rect x="150" y="50" width="17" height="8" rx="1" fill="#DFF4E8"/>
|
||||
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="152" y="55.8182">Done</tspan></text>
|
||||
<rect x="16.25" y="60.25" width="59.5" height="53.5" rx="1.75" fill="white" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="21" y="68.8182">Task 5</tspan></text>
|
||||
<rect x="20" y="88" width="8" height="8" rx="1" fill="#EEEEEE"/>
|
||||
<g clip-path="url(#clip0_16460_1185)">
|
||||
<g clip-path="url(#clip1_16460_1185)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.8125 90C21.8125 89.8964 21.8964 89.8125 22 89.8125H26C26.1036 89.8125 26.1875 89.8964 26.1875 90V90.6667C26.1875 90.7702 26.1036 90.8542 26 90.8542C25.8964 90.8542 25.8125 90.7702 25.8125 90.6667V90.1875H24.1875V93.8125H25C25.1036 93.8125 25.1875 93.8964 25.1875 94C25.1875 94.1036 25.1036 94.1875 25 94.1875H23C22.8964 94.1875 22.8125 94.1036 22.8125 94C22.8125 93.8964 22.8964 93.8125 23 93.8125H23.8125V90.1875H22.1875V90.6667C22.1875 90.7702 22.1036 90.8542 22 90.8542C21.8964 90.8542 21.8125 90.7702 21.8125 90.6667V90Z" fill="#77757D"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="82.25" y="60.25" width="59.5" height="53.5" rx="1.75" fill="white" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<rect x="148.25" y="60.25" width="59.5" height="53.5" rx="1.75" fill="white" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<line x1="16" y1="99.75" x2="76" y2="99.75" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<line x1="82" y1="99.75" x2="142" y2="99.75" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<line x1="148" y1="99.75" x2="208" y2="99.75" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="87" y="68.8182">Task 1</tspan></text>
|
||||
<rect x="86" y="88" width="8" height="8" rx="1" fill="#EEEEEE"/>
|
||||
<g clip-path="url(#clip2_16460_1185)">
|
||||
<g clip-path="url(#clip3_16460_1185)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M87.8125 90C87.8125 89.8964 87.8964 89.8125 88 89.8125H92C92.1036 89.8125 92.1875 89.8964 92.1875 90V90.6667C92.1875 90.7702 92.1036 90.8542 92 90.8542C91.8964 90.8542 91.8125 90.7702 91.8125 90.6667V90.1875H90.1875V93.8125H91C91.1036 93.8125 91.1875 93.8964 91.1875 94C91.1875 94.1036 91.1036 94.1875 91 94.1875H89C88.8964 94.1875 88.8125 94.1036 88.8125 94C88.8125 93.8964 88.8964 93.8125 89 93.8125H89.8125V90.1875H88.1875V90.6667C88.1875 90.7702 88.1036 90.8542 88 90.8542C87.8964 90.8542 87.8125 90.7702 87.8125 90.6667V90Z" fill="#77757D"/>
|
||||
</g>
|
||||
</g>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="153" y="68.8182">Task 4</tspan></text>
|
||||
<rect x="152" y="88" width="8" height="8" rx="1" fill="#EEEEEE"/>
|
||||
<g clip-path="url(#clip4_16460_1185)">
|
||||
<g clip-path="url(#clip5_16460_1185)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M153.812 90C153.812 89.8964 153.896 89.8125 154 89.8125H158C158.104 89.8125 158.188 89.8964 158.188 90V90.6667C158.188 90.7702 158.104 90.8542 158 90.8542C157.896 90.8542 157.812 90.7702 157.812 90.6667V90.1875H156.188V93.8125H157C157.104 93.8125 157.188 93.8964 157.188 94C157.188 94.1036 157.104 94.1875 157 94.1875H155C154.896 94.1875 154.812 94.1036 154.812 94C154.812 93.8964 154.896 93.8125 155 93.8125H155.812V90.1875H154.188V90.6667C154.188 90.7702 154.104 90.8542 154 90.8542C153.896 90.8542 153.812 90.7702 153.812 90.6667V90Z" fill="#77757D"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="43" y="50" width="8" height="8" rx="1" fill="#F5F5F5"/>
|
||||
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="45.5" y="55.8182">1</tspan></text>
|
||||
<rect x="117" y="50" width="8" height="8" rx="1" fill="#F5F5F5"/>
|
||||
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="119.5" y="55.8182">1</tspan></text>
|
||||
<rect x="169" y="50" width="8" height="8" rx="1" fill="#F5F5F5"/>
|
||||
<rect x="86" y="103" width="31" height="8" rx="1" fill="#F3F0FF"/>
|
||||
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="88" y="108.818">In Progress</tspan></text>
|
||||
<rect x="152" y="103" width="17" height="8" rx="1" fill="#DFF4E8"/>
|
||||
<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="154" y="108.818">Done</tspan></text>
|
||||
</g>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Visualize data in a dashboard.</tspan></text>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_16460_1185">
|
||||
<rect width="6" height="6" fill="white" transform="translate(21 89)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_16460_1185">
|
||||
<rect width="6" height="6" fill="white" transform="translate(21 89)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip2_16460_1185">
|
||||
<rect width="6" height="6" fill="white" transform="translate(87 89)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip3_16460_1185">
|
||||
<rect width="6" height="6" fill="white" transform="translate(87 89)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip4_16460_1185">
|
||||
<rect width="6" height="6" fill="white" transform="translate(153 89)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip5_16460_1185">
|
||||
<rect width="6" height="6" fill="white" transform="translate(153 89)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// prettier-ignore
|
||||
export const ToDoListTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
<mask id="mask0_16460_960" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_16460_960)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.6667 19C12.7462 19 12 19.7462 12 20.6667V27.3333C12 28.2538 12.7462 29 13.6667 29H20.3333C21.2538 29 22 28.2538 22 27.3333V20.6667C22 19.7462 21.2538 19 20.3333 19H13.6667ZM12.9091 20.6667C12.9091 20.2483 13.2483 19.9091 13.6667 19.9091H20.3333C20.7517 19.9091 21.0909 20.2483 21.0909 20.6667V27.3333C21.0909 27.7517 20.7517 28.0909 20.3333 28.0909H13.6667C13.2483 28.0909 12.9091 27.7517 12.9091 27.3333V20.6667Z" fill="#77757D"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="28" y="27.6364">Here is an example of todo list.</tspan></text>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 40.6667C12 39.7462 12.7462 39 13.6667 39H20.3333C21.2538 39 22 39.7462 22 40.6667V47.3333C22 48.2538 21.2538 49 20.3333 49H13.6667C12.7462 49 12 48.2538 12 47.3333V40.6667ZM19.7457 42.5032C19.9232 42.3257 19.9232 42.0379 19.7457 41.8604C19.5681 41.6829 19.2803 41.6829 19.1028 41.8604L16.0909 44.8723L15.2002 43.9816C15.0227 43.8041 14.7349 43.8041 14.5574 43.9816C14.3799 44.1591 14.3799 44.4469 14.5574 44.6244L15.7695 45.8366C15.947 46.0141 16.2348 46.0141 16.4123 45.8366L19.7457 42.5032Z" fill="#1E96EB"/>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="28" y="47.6364">Make a list for building preview.</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createContextKey } from '@blocksuite/data-view';
|
||||
import type { EditorHost } from '@blocksuite/std';
|
||||
|
||||
export const HostContextKey = createContextKey<EditorHost | undefined>(
|
||||
'editor-host',
|
||||
undefined
|
||||
);
|
||||
1
blocksuite/affine/blocks/database/src/context/index.ts
Normal file
1
blocksuite/affine/blocks/database/src/context/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './host-context';
|
||||
592
blocksuite/affine/blocks/database/src/data-source.ts
Normal file
592
blocksuite/affine/blocks/database/src/data-source.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
import type {
|
||||
ColumnDataType,
|
||||
ColumnUpdater,
|
||||
DatabaseBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
insertPositionToIndex,
|
||||
type InsertToPosition,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type DatabaseFlags,
|
||||
DataSourceBase,
|
||||
type DataViewDataType,
|
||||
type PropertyMetaConfig,
|
||||
type TypeInstance,
|
||||
type ViewManager,
|
||||
ViewManagerBase,
|
||||
type ViewMeta,
|
||||
} from '@blocksuite/data-view';
|
||||
import { propertyPresets } from '@blocksuite/data-view/property-presets';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { EditorHost } from '@blocksuite/std';
|
||||
import { type BlockModel } from '@blocksuite/store';
|
||||
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||
|
||||
import { getIcon } from './block-icons.js';
|
||||
import {
|
||||
databaseBlockProperties,
|
||||
databasePropertyConverts,
|
||||
} from './properties/index.js';
|
||||
import {
|
||||
addProperty,
|
||||
copyCellsByProperty,
|
||||
deleteRows,
|
||||
deleteView,
|
||||
duplicateView,
|
||||
getCell,
|
||||
getProperty,
|
||||
moveViewTo,
|
||||
updateCell,
|
||||
updateCells,
|
||||
updateProperty,
|
||||
updateView,
|
||||
} from './utils/block-utils.js';
|
||||
import {
|
||||
databaseBlockViewConverts,
|
||||
databaseBlockViewMap,
|
||||
databaseBlockViews,
|
||||
} from './views/index.js';
|
||||
|
||||
export class DatabaseBlockDataSource extends DataSourceBase {
|
||||
static externalProperties = signal<PropertyMetaConfig[]>([]);
|
||||
static propertiesList = computed(() => {
|
||||
return [
|
||||
...Object.values(databaseBlockProperties),
|
||||
...this.externalProperties.value,
|
||||
];
|
||||
});
|
||||
static propertiesMap = computed(() => {
|
||||
return Object.fromEntries(
|
||||
this.propertiesList.value.map(v => [v.type, v as PropertyMetaConfig])
|
||||
);
|
||||
});
|
||||
|
||||
private _batch = 0;
|
||||
|
||||
private readonly _model: DatabaseBlockModel;
|
||||
|
||||
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
|
||||
const featureFlagService = this.doc.get(FeatureFlagService);
|
||||
const flag = featureFlagService.getFlag(
|
||||
'enable_database_number_formatting'
|
||||
);
|
||||
return {
|
||||
enable_number_formatting: flag ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
properties$: ReadonlySignal<string[]> = computed(() => {
|
||||
const fixedPropertiesSet = new Set(this.fixedProperties$.value);
|
||||
const properties: string[] = [];
|
||||
this._model.props.columns$.value.forEach(column => {
|
||||
if (fixedPropertiesSet.has(column.type)) {
|
||||
fixedPropertiesSet.delete(column.type);
|
||||
}
|
||||
properties.push(column.id);
|
||||
});
|
||||
|
||||
const result = [...fixedPropertiesSet, ...properties];
|
||||
return result;
|
||||
});
|
||||
|
||||
readonly$: ReadonlySignal<boolean> = computed(() => {
|
||||
return (
|
||||
this._model.doc.readonly ||
|
||||
// TODO(@L-Sun): use block level readonly
|
||||
IS_MOBILE
|
||||
);
|
||||
});
|
||||
|
||||
rows$: ReadonlySignal<string[]> = computed(() => {
|
||||
return this._model.children.map(v => v.id);
|
||||
});
|
||||
|
||||
viewConverts = databaseBlockViewConverts;
|
||||
|
||||
viewDataList$: ReadonlySignal<DataViewDataType[]> = computed(() => {
|
||||
return this._model.props.views$.value as DataViewDataType[];
|
||||
});
|
||||
|
||||
override viewManager: ViewManager = new ViewManagerBase(this);
|
||||
|
||||
viewMetas = databaseBlockViews;
|
||||
|
||||
get doc() {
|
||||
return this._model.doc;
|
||||
}
|
||||
|
||||
allPropertyMetas$ = computed<PropertyMetaConfig<any, any, any, any>[]>(() => {
|
||||
return DatabaseBlockDataSource.propertiesList.value;
|
||||
});
|
||||
|
||||
propertyMetas$ = computed<PropertyMetaConfig[]>(() => {
|
||||
return this.allPropertyMetas$.value.filter(
|
||||
v => !v.config.fixed && !v.config.hide
|
||||
);
|
||||
});
|
||||
|
||||
constructor(model: DatabaseBlockModel) {
|
||||
super();
|
||||
this._model = model;
|
||||
}
|
||||
|
||||
private _runCapture() {
|
||||
if (this._batch) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._batch = requestAnimationFrame(() => {
|
||||
this.doc.captureSync();
|
||||
this._batch = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private getModelById(rowId: string): BlockModel | undefined {
|
||||
return this._model.children[this._model.childMap.value.get(rowId) ?? -1];
|
||||
}
|
||||
|
||||
private newPropertyName() {
|
||||
let i = 1;
|
||||
while (
|
||||
this._model.props.columns$.value.some(
|
||||
column => column.name === `Column ${i}`
|
||||
)
|
||||
) {
|
||||
i++;
|
||||
}
|
||||
return `Column ${i}`;
|
||||
}
|
||||
|
||||
cellValueChange(rowId: string, propertyId: string, value: unknown): void {
|
||||
this._runCapture();
|
||||
|
||||
const type = this.propertyTypeGet(propertyId);
|
||||
if (type == null) {
|
||||
return;
|
||||
}
|
||||
const update = this.propertyMetaGet(type)?.config.rawValue.setValue;
|
||||
const old = this.cellValueGet(rowId, propertyId);
|
||||
const updateFn =
|
||||
update ??
|
||||
(({ setValue, newValue }) => {
|
||||
setValue(newValue);
|
||||
});
|
||||
updateFn({
|
||||
value: old,
|
||||
data: this.propertyDataGet(propertyId),
|
||||
dataSource: this,
|
||||
newValue: value,
|
||||
setValue: newValue => {
|
||||
if (this._model.props.columns$.value.some(v => v.id === propertyId)) {
|
||||
updateCell(this._model, rowId, {
|
||||
columnId: propertyId,
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cellValueGet(rowId: string, propertyId: string): unknown {
|
||||
if (propertyId === 'type') {
|
||||
const model = this.getModelById(rowId);
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
return getIcon(model);
|
||||
}
|
||||
const type = this.propertyTypeGet(propertyId);
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
if (type === 'title') {
|
||||
const model = this.getModelById(rowId);
|
||||
return model?.text;
|
||||
}
|
||||
const meta = this.propertyMetaGet(type);
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
const rawValue =
|
||||
getCell(this._model, rowId, propertyId)?.value ??
|
||||
meta.config.rawValue.default();
|
||||
const schema = meta.config.rawValue.schema;
|
||||
const result = schema.safeParse(rawValue);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
propertyAdd(
|
||||
insertToPosition: InsertToPosition,
|
||||
type?: string
|
||||
): string | undefined {
|
||||
this.doc.captureSync();
|
||||
const property = this.propertyMetaGet(
|
||||
type ?? propertyPresets.multiSelectPropertyConfig.type
|
||||
);
|
||||
if (!property) {
|
||||
return;
|
||||
}
|
||||
const result = addProperty(
|
||||
this._model,
|
||||
insertToPosition,
|
||||
property.create(this.newPropertyName())
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
protected override getNormalPropertyAndIndex(propertyId: string):
|
||||
| {
|
||||
column: ColumnDataType<Record<string, unknown>>;
|
||||
index: number;
|
||||
}
|
||||
| undefined {
|
||||
const index = this._model.props.columns$.value.findIndex(
|
||||
v => v.id === propertyId
|
||||
);
|
||||
if (index >= 0) {
|
||||
const column = this._model.props.columns$.value[index];
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
column,
|
||||
index,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
private getPropertyAndIndex(propertyId: string):
|
||||
| {
|
||||
column: ColumnDataType<Record<string, unknown>>;
|
||||
index: number;
|
||||
}
|
||||
| undefined {
|
||||
const result = this.getNormalPropertyAndIndex(propertyId);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
if (this.isFixedProperty(propertyId)) {
|
||||
const meta = this.propertyMetaGet(propertyId);
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
const defaultData = meta.config.fixed?.defaultData ?? {};
|
||||
return {
|
||||
column: {
|
||||
data: defaultData,
|
||||
id: propertyId,
|
||||
type: propertyId,
|
||||
name: meta.config.name,
|
||||
},
|
||||
index: -1,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private updateProperty(id: string, updater: ColumnUpdater) {
|
||||
const result = this.getPropertyAndIndex(id);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
const { column: prevColumn, index } = result;
|
||||
this._model.doc.transact(() => {
|
||||
if (index >= 0) {
|
||||
const result = updater(prevColumn);
|
||||
this._model.props.columns[index] = { ...prevColumn, ...result };
|
||||
} else {
|
||||
const result = updater(prevColumn);
|
||||
this._model.props.columns = [
|
||||
...this._model.props.columns,
|
||||
{ ...prevColumn, ...result },
|
||||
];
|
||||
}
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
propertyDataGet(propertyId: string): Record<string, unknown> {
|
||||
const result = this.getPropertyAndIndex(propertyId);
|
||||
if (!result) {
|
||||
return {};
|
||||
}
|
||||
return result.column.data;
|
||||
}
|
||||
|
||||
propertyDataSet(propertyId: string, data: Record<string, unknown>): void {
|
||||
this._runCapture();
|
||||
this.updateProperty(propertyId, () => ({ data }));
|
||||
}
|
||||
|
||||
propertyDataTypeGet(propertyId: string): TypeInstance | undefined {
|
||||
const result = this.getPropertyAndIndex(propertyId);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
const { column } = result;
|
||||
const meta = this.propertyMetaGet(column.type);
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
return meta.config?.jsonValue.type({
|
||||
data: column.data,
|
||||
dataSource: this,
|
||||
});
|
||||
}
|
||||
|
||||
propertyDelete(id: string): void {
|
||||
if (this.isFixedProperty(id)) {
|
||||
return;
|
||||
}
|
||||
this.doc.captureSync();
|
||||
const index = this._model.props.columns.findIndex(v => v.id === id);
|
||||
if (index < 0) return;
|
||||
|
||||
this.doc.transact(() => {
|
||||
this._model.props.columns = this._model.props.columns.filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
propertyDuplicate(propertyId: string): string | undefined {
|
||||
if (this.isFixedProperty(propertyId)) {
|
||||
return;
|
||||
}
|
||||
this.doc.captureSync();
|
||||
const currentSchema = getProperty(this._model, propertyId);
|
||||
if (!currentSchema) {
|
||||
return;
|
||||
}
|
||||
const { id: copyId, ...nonIdProps } = currentSchema;
|
||||
const names = new Set(this._model.props.columns$.value.map(v => v.name));
|
||||
let index = 1;
|
||||
while (names.has(`${nonIdProps.name}(${index})`)) {
|
||||
index++;
|
||||
}
|
||||
const schema = { ...nonIdProps, name: `${nonIdProps.name}(${index})` };
|
||||
const id = addProperty(
|
||||
this._model,
|
||||
{
|
||||
before: false,
|
||||
id: propertyId,
|
||||
},
|
||||
schema
|
||||
);
|
||||
copyCellsByProperty(this._model, copyId, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
propertyMetaGet(type: string): PropertyMetaConfig | undefined {
|
||||
return DatabaseBlockDataSource.propertiesMap.value[type];
|
||||
}
|
||||
|
||||
propertyNameGet(propertyId: string): string {
|
||||
if (propertyId === 'type') {
|
||||
return 'Block Type';
|
||||
}
|
||||
const result = this.getPropertyAndIndex(propertyId);
|
||||
if (!result) {
|
||||
return '';
|
||||
}
|
||||
return result.column.name;
|
||||
}
|
||||
|
||||
propertyNameSet(propertyId: string, name: string): void {
|
||||
this.doc.captureSync();
|
||||
this.updateProperty(propertyId, () => ({ name }));
|
||||
}
|
||||
|
||||
override propertyReadonlyGet(propertyId: string): boolean {
|
||||
if (propertyId === 'type') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
propertyTypeGet(propertyId: string): string | undefined {
|
||||
if (propertyId === 'type') {
|
||||
return 'image';
|
||||
}
|
||||
const result = this.getPropertyAndIndex(propertyId);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
return result.column.type;
|
||||
}
|
||||
|
||||
propertyTypeSet(propertyId: string, toType: string): void {
|
||||
if (this.isFixedProperty(propertyId)) {
|
||||
return;
|
||||
}
|
||||
const meta = this.propertyMetaGet(toType);
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
const currentType = this.propertyTypeGet(propertyId);
|
||||
const currentData = this.propertyDataGet(propertyId);
|
||||
const rows = this.rows$.value;
|
||||
const currentCells = rows.map(rowId =>
|
||||
this.cellValueGet(rowId, propertyId)
|
||||
);
|
||||
const convertFunction = databasePropertyConverts.find(
|
||||
v => v.from === currentType && v.to === toType
|
||||
)?.convert;
|
||||
const result = convertFunction?.(
|
||||
currentData as any,
|
||||
|
||||
currentCells as any
|
||||
) ?? {
|
||||
property: meta.config.propertyData.default(),
|
||||
cells: currentCells.map(() => undefined),
|
||||
};
|
||||
this.doc.captureSync();
|
||||
updateProperty(this._model, propertyId, () => ({
|
||||
type: toType,
|
||||
data: result.property,
|
||||
}));
|
||||
const cells: Record<string, unknown> = {};
|
||||
currentCells.forEach((value, i) => {
|
||||
if (value != null || result.cells[i] != null) {
|
||||
const rowId = rows[i];
|
||||
if (rowId) {
|
||||
cells[rowId] = result.cells[i];
|
||||
}
|
||||
}
|
||||
});
|
||||
updateCells(this._model, propertyId, cells);
|
||||
}
|
||||
|
||||
rowAdd(insertPosition: InsertToPosition | number): string {
|
||||
this.doc.captureSync();
|
||||
const index =
|
||||
typeof insertPosition === 'number'
|
||||
? insertPosition
|
||||
: insertPositionToIndex(insertPosition, this._model.children);
|
||||
return this.doc.addBlock('affine:paragraph', {}, this._model.id, index);
|
||||
}
|
||||
|
||||
rowDelete(ids: string[]): void {
|
||||
this.doc.captureSync();
|
||||
for (const id of ids) {
|
||||
const block = this.doc.getBlock(id);
|
||||
if (block) {
|
||||
this.doc.deleteBlock(block.model);
|
||||
}
|
||||
}
|
||||
deleteRows(this._model, ids);
|
||||
}
|
||||
|
||||
rowMove(rowId: string, position: InsertToPosition): void {
|
||||
const model = this.doc.getModelById(rowId);
|
||||
if (model) {
|
||||
const index = insertPositionToIndex(position, this._model.children);
|
||||
const target = this._model.children[index];
|
||||
if (target?.id === rowId) {
|
||||
return;
|
||||
}
|
||||
this.doc.moveBlocks([model], this._model, target);
|
||||
}
|
||||
}
|
||||
|
||||
viewDataAdd(viewData: DataViewDataType): string {
|
||||
this._model.doc.captureSync();
|
||||
this._model.doc.transact(() => {
|
||||
this._model.props.views = [...this._model.props.views, viewData];
|
||||
});
|
||||
return viewData.id;
|
||||
}
|
||||
|
||||
viewDataDelete(viewId: string): void {
|
||||
this._model.doc.captureSync();
|
||||
deleteView(this._model, viewId);
|
||||
}
|
||||
|
||||
viewDataDuplicate(id: string): string {
|
||||
return duplicateView(this._model, id);
|
||||
}
|
||||
|
||||
viewDataGet(viewId: string): DataViewDataType | undefined {
|
||||
return this.viewDataList$.value.find(data => data.id === viewId)!;
|
||||
}
|
||||
|
||||
viewDataMoveTo(id: string, position: InsertToPosition): void {
|
||||
moveViewTo(this._model, id, position);
|
||||
}
|
||||
|
||||
viewDataUpdate<ViewData extends DataViewDataType>(
|
||||
id: string,
|
||||
updater: (data: ViewData) => Partial<ViewData>
|
||||
): void {
|
||||
updateView(this._model, id, updater);
|
||||
}
|
||||
|
||||
viewMetaGet(type: string): ViewMeta {
|
||||
const view = databaseBlockViewMap[type];
|
||||
if (!view) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DatabaseBlockError,
|
||||
`Unknown view type: ${type}`
|
||||
);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
viewMetaGetById(viewId: string): ViewMeta | undefined {
|
||||
const view = this.viewDataGet(viewId);
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
return this.viewMetaGet(view.mode);
|
||||
}
|
||||
}
|
||||
|
||||
export const databaseViewInitTemplate = (
|
||||
datasource: DatabaseBlockDataSource,
|
||||
viewType: string
|
||||
) => {
|
||||
Array.from({ length: 3 }).forEach(() => {
|
||||
datasource.rowAdd('end');
|
||||
});
|
||||
datasource.viewManager.viewAdd(viewType);
|
||||
};
|
||||
export const convertToDatabase = (host: EditorHost, viewType: string) => {
|
||||
const [_, ctx] = host.std.command.exec(getSelectedModelsCommand, {
|
||||
types: ['block', 'text'],
|
||||
});
|
||||
const { selectedModels } = ctx;
|
||||
const firstModel = selectedModels?.[0];
|
||||
if (!firstModel) return;
|
||||
|
||||
host.doc.captureSync();
|
||||
|
||||
const parentModel = host.doc.getParent(firstModel);
|
||||
if (!parentModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = host.doc.addBlock(
|
||||
'affine:database',
|
||||
{},
|
||||
parentModel,
|
||||
parentModel.children.indexOf(firstModel)
|
||||
);
|
||||
const databaseModel = host.doc.getBlock(id)?.model as
|
||||
| DatabaseBlockModel
|
||||
| undefined;
|
||||
if (!databaseModel) {
|
||||
return;
|
||||
}
|
||||
const datasource = new DatabaseBlockDataSource(databaseModel);
|
||||
datasource.viewManager.viewAdd(viewType);
|
||||
host.doc.moveBlocks(selectedModels, databaseModel);
|
||||
|
||||
const selectionManager = host.selection;
|
||||
selectionManager.clear();
|
||||
};
|
||||
487
blocksuite/affine/blocks/database/src/database-block.ts
Normal file
487
blocksuite/affine/blocks/database/src/database-block.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { DropIndicator } from '@blocksuite/affine-components/drop-indicator';
|
||||
import { PeekViewProvider } from '@blocksuite/affine-components/peek';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import type { DatabaseBlockModel } from '@blocksuite/affine-model';
|
||||
import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
DocModeProvider,
|
||||
NotificationProvider,
|
||||
type TelemetryEventMap,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getDropResult } from '@blocksuite/affine-widget-drag-handle';
|
||||
import {
|
||||
createRecordDetail,
|
||||
createUniComponentFromWebComponent,
|
||||
DataView,
|
||||
dataViewCommonStyle,
|
||||
type DataViewInstance,
|
||||
type DataViewProps,
|
||||
type DataViewSelection,
|
||||
type DataViewWidget,
|
||||
type DataViewWidgetProps,
|
||||
defineUniComponent,
|
||||
renderUniLit,
|
||||
type SingleView,
|
||||
uniMap,
|
||||
} from '@blocksuite/data-view';
|
||||
import { widgetPresets } from '@blocksuite/data-view/widget-presets';
|
||||
import { Rect } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { type BlockComponent } from '@blocksuite/std';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import { Slice } from '@blocksuite/store';
|
||||
import { autoUpdate } from '@floating-ui/dom';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { css, html, nothing, unsafeCSS } from 'lit';
|
||||
|
||||
import { popSideDetail } from './components/layout.js';
|
||||
import {
|
||||
DatabaseConfigExtension,
|
||||
type DatabaseOptionsConfig,
|
||||
} from './config.js';
|
||||
import { HostContextKey } from './context/host-context.js';
|
||||
import { DatabaseBlockDataSource } from './data-source.js';
|
||||
import { BlockRenderer } from './detail-panel/block-renderer.js';
|
||||
import { NoteRenderer } from './detail-panel/note-renderer.js';
|
||||
import { DatabaseSelection } from './selection.js';
|
||||
import { currentViewStorage } from './utils/current-view.js';
|
||||
import { getSingleDocIdFromText } from './utils/title-doc.js';
|
||||
|
||||
export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBlockModel> {
|
||||
static override styles = css`
|
||||
${unsafeCSS(dataViewCommonStyle('affine-database'))}
|
||||
affine-database {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
background-color: var(--affine-background-primary-color);
|
||||
padding: 8px;
|
||||
margin: 8px -8px -8px;
|
||||
}
|
||||
|
||||
.database-block-selected {
|
||||
background-color: var(--affine-hover-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.database-ops {
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
height: max-content;
|
||||
}
|
||||
|
||||
.database-ops svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
|
||||
.database-ops:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
@media print {
|
||||
.database-ops {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.database-header-bar {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _clickDatabaseOps = (e: MouseEvent) => {
|
||||
const options = this.optionsConfig.configure(this.model, {
|
||||
items: [
|
||||
menu.input({
|
||||
initialValue: this.model.props.title.toString(),
|
||||
placeholder: 'Database title',
|
||||
onChange: text => {
|
||||
this.model.props.title.replace(
|
||||
0,
|
||||
this.model.props.title.length,
|
||||
text
|
||||
);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
prefix: CopyIcon(),
|
||||
name: 'Copy',
|
||||
select: () => {
|
||||
const slice = Slice.fromModels(this.doc, [this.model]);
|
||||
this.std.clipboard
|
||||
.copySlice(slice)
|
||||
.then(() => {
|
||||
toast(this.host, 'Copied to clipboard');
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
prefix: DeleteIcon(),
|
||||
class: {
|
||||
'delete-item': true,
|
||||
},
|
||||
name: 'Delete Database',
|
||||
select: () => {
|
||||
this.model.children.slice().forEach(block => {
|
||||
this.doc.deleteBlock(block);
|
||||
});
|
||||
this.doc.deleteBlock(this.model);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
popMenu(popupTargetFromElement(e.currentTarget as HTMLElement), {
|
||||
options,
|
||||
});
|
||||
};
|
||||
|
||||
private _dataSource?: DatabaseBlockDataSource;
|
||||
|
||||
private readonly dataView = new DataView();
|
||||
|
||||
private readonly renderTitle = (dataViewMethod: DataViewInstance) => {
|
||||
const addRow = () => dataViewMethod.addRow?.('start');
|
||||
return html` <affine-database-title
|
||||
style="overflow: hidden"
|
||||
.titleText="${this.model.props.title}"
|
||||
.readonly="${this.dataSource.readonly$.value}"
|
||||
.onPressEnterKey="${addRow}"
|
||||
></affine-database-title>`;
|
||||
};
|
||||
|
||||
_bindHotkey: DataViewProps['bindHotkey'] = hotkeys => {
|
||||
return {
|
||||
dispose: this.host.event.bindHotkey(hotkeys, {
|
||||
blockId: this.topContenteditableElement?.blockId ?? this.blockId,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
_handleEvent: DataViewProps['handleEvent'] = (name, handler) => {
|
||||
return {
|
||||
dispose: this.host.event.add(name, handler, {
|
||||
blockId: this.blockId,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
createTemplate = (
|
||||
data: {
|
||||
view: SingleView;
|
||||
rowId: string;
|
||||
},
|
||||
openDoc: (docId: string) => void
|
||||
) => {
|
||||
return createRecordDetail({
|
||||
...data,
|
||||
openDoc,
|
||||
detail: {
|
||||
header: uniMap(
|
||||
createUniComponentFromWebComponent(BlockRenderer),
|
||||
props => ({
|
||||
...props,
|
||||
host: this.host,
|
||||
})
|
||||
),
|
||||
note: uniMap(
|
||||
createUniComponentFromWebComponent(NoteRenderer),
|
||||
props => ({
|
||||
...props,
|
||||
model: this.model,
|
||||
host: this.host,
|
||||
})
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
headerWidget: DataViewWidget = defineUniComponent(
|
||||
(props: DataViewWidgetProps) => {
|
||||
return html`
|
||||
<div style="margin-bottom: 16px;display:flex;flex-direction: column">
|
||||
<div
|
||||
style="display:flex;gap:12px;margin-bottom: 8px;align-items: center"
|
||||
>
|
||||
${this.renderTitle(props.dataViewInstance)}
|
||||
${this.renderDatabaseOps()}
|
||||
</div>
|
||||
<div
|
||||
style="display:flex;align-items:center;justify-content: space-between;gap: 12px"
|
||||
class="database-header-bar"
|
||||
>
|
||||
<div style="flex:1">
|
||||
${renderUniLit(widgetPresets.viewBar, {
|
||||
...props,
|
||||
onChangeView: id => {
|
||||
currentViewStorage.setCurrentView(this.blockId, id);
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
${renderUniLit(this.toolsWidget, props)}
|
||||
</div>
|
||||
${renderUniLit(widgetPresets.quickSettingBar, props)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
);
|
||||
|
||||
indicator = new DropIndicator();
|
||||
|
||||
onDrag = (evt: MouseEvent, id: string): (() => void) => {
|
||||
const result = getDropResult(evt);
|
||||
if (result && result.rect) {
|
||||
document.body.append(this.indicator);
|
||||
this.indicator.rect = Rect.fromLWTH(
|
||||
result.rect.left,
|
||||
result.rect.width,
|
||||
result.rect.top,
|
||||
result.rect.height
|
||||
);
|
||||
return () => {
|
||||
this.indicator.remove();
|
||||
const model = this.doc.getBlock(id)?.model;
|
||||
const target = result.modelState.model;
|
||||
let parent = this.doc.getParent(target.id);
|
||||
const shouldInsertIn = result.placement === 'in';
|
||||
if (shouldInsertIn) {
|
||||
parent = target;
|
||||
}
|
||||
if (model && target && parent) {
|
||||
if (shouldInsertIn) {
|
||||
this.doc.moveBlocks([model], parent);
|
||||
} else {
|
||||
this.doc.moveBlocks(
|
||||
[model],
|
||||
parent,
|
||||
target,
|
||||
result.placement === 'before'
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
this.indicator.remove();
|
||||
return () => {};
|
||||
};
|
||||
|
||||
setSelection = (selection: DataViewSelection | undefined) => {
|
||||
if (selection) {
|
||||
getSelection()?.removeAllRanges();
|
||||
}
|
||||
this.selection.setGroup(
|
||||
'note',
|
||||
selection
|
||||
? [
|
||||
new DatabaseSelection({
|
||||
blockId: this.blockId,
|
||||
viewSelection: selection,
|
||||
}),
|
||||
]
|
||||
: []
|
||||
);
|
||||
};
|
||||
|
||||
toolsWidget: DataViewWidget = widgetPresets.createTools({
|
||||
table: [
|
||||
widgetPresets.tools.filter,
|
||||
widgetPresets.tools.sort,
|
||||
widgetPresets.tools.search,
|
||||
widgetPresets.tools.viewOptions,
|
||||
widgetPresets.tools.tableAddRow,
|
||||
],
|
||||
kanban: [
|
||||
widgetPresets.tools.filter,
|
||||
widgetPresets.tools.sort,
|
||||
widgetPresets.tools.search,
|
||||
widgetPresets.tools.viewOptions,
|
||||
widgetPresets.tools.tableAddRow,
|
||||
],
|
||||
});
|
||||
|
||||
viewSelection$ = computed(() => {
|
||||
const databaseSelection = this.selection.value.find(
|
||||
(selection): selection is DatabaseSelection => {
|
||||
if (selection.blockId !== this.blockId) {
|
||||
return false;
|
||||
}
|
||||
return selection instanceof DatabaseSelection;
|
||||
}
|
||||
);
|
||||
return databaseSelection?.viewSelection;
|
||||
});
|
||||
|
||||
virtualPadding$ = signal(0);
|
||||
|
||||
get dataSource(): DatabaseBlockDataSource {
|
||||
if (!this._dataSource) {
|
||||
this._dataSource = new DatabaseBlockDataSource(this.model);
|
||||
this._dataSource.contextSet(HostContextKey, this.host);
|
||||
const id = currentViewStorage.getCurrentView(this.model.id);
|
||||
if (id && this.dataSource.viewManager.viewGet(id)) {
|
||||
this.dataSource.viewManager.setCurrentView(id);
|
||||
}
|
||||
}
|
||||
return this._dataSource;
|
||||
}
|
||||
|
||||
get optionsConfig(): DatabaseOptionsConfig {
|
||||
return {
|
||||
configure: (_model, options) => options,
|
||||
...this.std.getOptional(DatabaseConfigExtension.identifier),
|
||||
};
|
||||
}
|
||||
|
||||
override get topContenteditableElement() {
|
||||
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
|
||||
return this.closest<BlockComponent>(
|
||||
EDGELESS_TOP_CONTENTEDITABLE_SELECTOR
|
||||
);
|
||||
}
|
||||
return this.rootComponent;
|
||||
}
|
||||
|
||||
get view() {
|
||||
return this.dataView.expose;
|
||||
}
|
||||
|
||||
private renderDatabaseOps() {
|
||||
if (this.dataSource.readonly$.value) {
|
||||
return nothing;
|
||||
}
|
||||
return html` <div class="database-ops" @click="${this._clickDatabaseOps}">
|
||||
${MoreHorizontalIcon()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
this.listenFullWidthChange();
|
||||
}
|
||||
|
||||
listenFullWidthChange() {
|
||||
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
|
||||
return;
|
||||
}
|
||||
this.disposables.add(
|
||||
autoUpdate(this.host, this, () => {
|
||||
const padding =
|
||||
this.getBoundingClientRect().left -
|
||||
this.host.getBoundingClientRect().left;
|
||||
this.virtualPadding$.value = Math.max(0, padding - 72);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const peekViewService = this.std.getOptional(PeekViewProvider);
|
||||
const telemetryService = this.std.getOptional(TelemetryProvider);
|
||||
return html`
|
||||
<div
|
||||
contenteditable="false"
|
||||
style="position: relative;background-color: var(--affine-background-primary-color);border-radius: 4px"
|
||||
>
|
||||
${this.dataView.render({
|
||||
virtualPadding$: this.virtualPadding$,
|
||||
bindHotkey: this._bindHotkey,
|
||||
handleEvent: this._handleEvent,
|
||||
selection$: this.viewSelection$,
|
||||
setSelection: this.setSelection,
|
||||
dataSource: this.dataSource,
|
||||
headerWidget: this.headerWidget,
|
||||
onDrag: this.onDrag,
|
||||
clipboard: this.std.clipboard,
|
||||
notification: {
|
||||
toast: message => {
|
||||
const notification = this.std.getOptional(NotificationProvider);
|
||||
if (notification) {
|
||||
notification.toast(message);
|
||||
} else {
|
||||
toast(this.host, message);
|
||||
}
|
||||
},
|
||||
},
|
||||
eventTrace: (key, params) => {
|
||||
telemetryService?.track(key, {
|
||||
...(params as TelemetryEventMap[typeof key]),
|
||||
blockId: this.blockId,
|
||||
});
|
||||
},
|
||||
detailPanelConfig: {
|
||||
openDetailPanel: (target, data) => {
|
||||
if (peekViewService) {
|
||||
const openDoc = (docId: string) => {
|
||||
return peekViewService.peek({
|
||||
docId,
|
||||
databaseId: this.blockId,
|
||||
databaseDocId: this.model.doc.id,
|
||||
databaseRowId: data.rowId,
|
||||
target: this,
|
||||
});
|
||||
};
|
||||
const doc = getSingleDocIdFromText(
|
||||
this.model.doc.getBlock(data.rowId)?.model?.text
|
||||
);
|
||||
if (doc) {
|
||||
return openDoc(doc);
|
||||
}
|
||||
const abort = new AbortController();
|
||||
return new Promise<void>(focusBack => {
|
||||
peekViewService
|
||||
.peek(
|
||||
{
|
||||
target,
|
||||
template: this.createTemplate(data, docId => {
|
||||
// abort.abort();
|
||||
openDoc(docId).then(focusBack).catch(focusBack);
|
||||
}),
|
||||
},
|
||||
{ abortSignal: abort.signal }
|
||||
)
|
||||
.then(focusBack)
|
||||
.catch(focusBack);
|
||||
});
|
||||
} else {
|
||||
return popSideDetail(
|
||||
this.createTemplate(data, () => {
|
||||
//
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override accessor useZeroWidth = true;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-database': DatabaseBlockComponent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { DatabaseBlockModel } from '@blocksuite/affine-model';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { DatabaseListViewIcon } from '@blocksuite/icons/lit';
|
||||
import { BlockComponent } from '@blocksuite/std';
|
||||
import { css, html } from 'lit';
|
||||
|
||||
export class DatabaseDndPreviewBlockComponent extends BlockComponent<DatabaseBlockModel> {
|
||||
static override styles = css`
|
||||
.affine-database-preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
|
||||
border-radius: 8px;
|
||||
background-color: ${unsafeCSSVarV2(
|
||||
'layer/background/overlayPanel',
|
||||
'#FBFBFC'
|
||||
)};
|
||||
}
|
||||
|
||||
.database-preview-content {
|
||||
padding: 4px 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.database-preview-content > svg {
|
||||
color: ${unsafeCSSVarV2('icon/primary', '#77757D')};
|
||||
}
|
||||
|
||||
.database-preview-content > .text {
|
||||
color: var(--affine-text-primary-color);
|
||||
color: ${unsafeCSSVarV2('text/primary', '#121212')};
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
override renderBlock() {
|
||||
return html`<div
|
||||
class="affine-database-preview-container"
|
||||
contenteditable="false"
|
||||
>
|
||||
<div class="database-preview-content">
|
||||
${DatabaseListViewIcon({ width: '24px', height: '24px' })}
|
||||
<span class="text">Database Block</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-dnd-preview-database': DatabaseDndPreviewBlockComponent;
|
||||
}
|
||||
}
|
||||
14
blocksuite/affine/blocks/database/src/database-spec.ts
Normal file
14
blocksuite/affine/blocks/database/src/database-spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { DatabaseBlockAdapterExtensions } from './adapters/extension.js';
|
||||
import { databaseSlashMenuConfig } from './configs/slash-menu.js';
|
||||
|
||||
export const DatabaseBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:database'),
|
||||
BlockViewExtension('affine:database', literal`affine-database`),
|
||||
DatabaseBlockAdapterExtensions,
|
||||
SlashMenuConfigExtension('affine:database', databaseSlashMenuConfig),
|
||||
].flat();
|
||||
@@ -0,0 +1,158 @@
|
||||
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
|
||||
import type { DetailSlotProps } from '@blocksuite/data-view';
|
||||
import type {
|
||||
KanbanSingleView,
|
||||
TableSingleView,
|
||||
} from '@blocksuite/data-view/view-presets';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import type { EditorHost } from '@blocksuite/std';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, html, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class BlockRenderer
|
||||
extends WithDisposable(ShadowlessElement)
|
||||
implements DetailSlotProps
|
||||
{
|
||||
static override styles = css`
|
||||
database-datasource-block-renderer {
|
||||
padding-top: 36px;
|
||||
padding-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid ${unsafeCSS(cssVarV2.layer.insideBorder.border)};
|
||||
font-size: var(--affine-font-base);
|
||||
line-height: var(--affine-line-height);
|
||||
}
|
||||
|
||||
database-datasource-block-renderer .tips-placeholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
database-datasource-block-renderer rich-text {
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
database-datasource-block-renderer.empty rich-text::before {
|
||||
content: 'Untitled';
|
||||
position: absolute;
|
||||
color: var(--affine-text-disable-color);
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.database-block-detail-header-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--affine-background-secondary-color);
|
||||
}
|
||||
|
||||
.database-block-detail-header-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
get attributeRenderer() {
|
||||
return this.inlineManager.getRenderer();
|
||||
}
|
||||
|
||||
get attributesSchema() {
|
||||
return this.inlineManager.getSchema();
|
||||
}
|
||||
|
||||
get inlineManager() {
|
||||
return this.host.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
get model() {
|
||||
return this.host?.doc.getBlock(this.rowId)?.model;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.model && this.model.text) {
|
||||
const cb = () => {
|
||||
if (this.model?.text?.length == 0) {
|
||||
this.classList.add('empty');
|
||||
} else {
|
||||
this.classList.remove('empty');
|
||||
}
|
||||
};
|
||||
this.model.text.yText.observe(cb);
|
||||
this.disposables.add(() => {
|
||||
this.model?.text?.yText.unobserve(cb);
|
||||
});
|
||||
}
|
||||
this._disposables.addFromEvent(
|
||||
this,
|
||||
'keydown',
|
||||
e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
e.key === 'Backspace' &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
this.model?.text?.length === 0
|
||||
) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
const model = this.model;
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
return html`
|
||||
${this.renderIcon()}
|
||||
<rich-text
|
||||
.yText=${model.text}
|
||||
.attributesSchema=${this.attributesSchema}
|
||||
.attributeRenderer=${this.attributeRenderer}
|
||||
.embedChecker=${this.inlineManager.embedChecker}
|
||||
.markdownMatches=${this.inlineManager.markdownMatches}
|
||||
class="inline-editor"
|
||||
></rich-text>
|
||||
`;
|
||||
}
|
||||
|
||||
renderIcon() {
|
||||
const iconColumn = this.view.mainProperties$.value.iconColumn;
|
||||
if (!iconColumn) {
|
||||
return;
|
||||
}
|
||||
return html` <div class="database-block-detail-header-icon">
|
||||
${this.view.cellValueGet(this.rowId, iconColumn)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor host!: EditorHost;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor openDoc!: (docId: string) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor rowId!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor view!: TableSingleView | KanbanSingleView;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
CodeBlockModel,
|
||||
type DatabaseBlockModel,
|
||||
ListBlockModel,
|
||||
ParagraphBlockModel,
|
||||
type RootBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { createDefaultDoc, matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import type { DetailSlotProps, SingleView } from '@blocksuite/data-view';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { type EditorHost, ShadowlessElement } from '@blocksuite/std';
|
||||
import type { BaseTextAttributes } from '@blocksuite/store';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, html, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { isPureText } from '../utils/title-doc.js';
|
||||
|
||||
export class NoteRenderer
|
||||
extends SignalWatcher(WithDisposable(ShadowlessElement))
|
||||
implements DetailSlotProps
|
||||
{
|
||||
static override styles = css`
|
||||
database-datasource-note-renderer {
|
||||
width: 100%;
|
||||
--affine-editor-side-padding: 0;
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor rowId!: string;
|
||||
|
||||
rowText$ = computed(() => {
|
||||
return this.databaseBlock.doc.getBlock(this.rowId)?.model?.text;
|
||||
});
|
||||
|
||||
allowCreateDoc$ = computed(() => {
|
||||
return isPureText(this.rowText$.value);
|
||||
});
|
||||
|
||||
get databaseBlock(): DatabaseBlockModel {
|
||||
return this.model;
|
||||
}
|
||||
|
||||
addNote() {
|
||||
const collection = this.host?.std.workspace;
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
const note = createDefaultDoc(collection);
|
||||
if (note) {
|
||||
this.openDoc(note.id);
|
||||
const rowContent = this.rowText$.value?.toString();
|
||||
this.rowText$.value?.replace(
|
||||
0,
|
||||
this.rowText$.value.length,
|
||||
REFERENCE_NODE,
|
||||
{
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: note.id,
|
||||
},
|
||||
} satisfies AffineTextAttributes as BaseTextAttributes
|
||||
);
|
||||
collection.meta.setDocMeta(note.id, { title: rowContent });
|
||||
if (note.root) {
|
||||
(note.root as RootBlockModel).props.title.insert(rowContent ?? '', 0);
|
||||
note.root.children
|
||||
.find(child => child.flavour === 'affine:note')
|
||||
?.children.find(block =>
|
||||
matchModels(block, [
|
||||
ParagraphBlockModel,
|
||||
ListBlockModel,
|
||||
CodeBlockModel,
|
||||
])
|
||||
);
|
||||
}
|
||||
// Track when a linked doc is created in database title column
|
||||
this.host.std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
|
||||
segment: 'database',
|
||||
module: 'center peek in database',
|
||||
type: 'turn into',
|
||||
parentFlavour: 'affine:database',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
return html`
|
||||
<div
|
||||
style="height: 1px;max-width: var(--affine-editor-width);background-color: ${unsafeCSS(
|
||||
cssVarV2.layer.insideBorder.border
|
||||
)};margin: auto;margin-bottom: 16px"
|
||||
></div>
|
||||
${this.renderNote()}
|
||||
`;
|
||||
}
|
||||
|
||||
renderNote() {
|
||||
if (this.allowCreateDoc$.value) {
|
||||
return html` <div>
|
||||
<div
|
||||
@click="${this.addNote}"
|
||||
style="max-width: var(--affine-editor-width);margin: auto;cursor: pointer;color: var(--affine-text-disable-color)"
|
||||
>
|
||||
Click to create a linked doc in center peek.
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor host!: EditorHost;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor model!: DatabaseBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor openDoc!: (docId: string) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor view!: SingleView;
|
||||
}
|
||||
27
blocksuite/affine/blocks/database/src/effects.ts
Normal file
27
blocksuite/affine/blocks/database/src/effects.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { CenterPeek } from './components/layout';
|
||||
import { DatabaseTitle } from './components/title';
|
||||
import { DatabaseBlockComponent } from './database-block';
|
||||
import { DatabaseDndPreviewBlockComponent } from './database-dnd-preview-block';
|
||||
import { BlockRenderer } from './detail-panel/block-renderer';
|
||||
import { NoteRenderer } from './detail-panel/note-renderer';
|
||||
import { LinkCell } from './properties/link/cell-renderer';
|
||||
import { RichTextCell } from './properties/rich-text/cell-renderer';
|
||||
import { IconCell } from './properties/title/icon';
|
||||
import { HeaderAreaTextCell } from './properties/title/text';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-database-title', DatabaseTitle);
|
||||
customElements.define('data-view-header-area-icon', IconCell);
|
||||
customElements.define('affine-database-link-cell', LinkCell);
|
||||
customElements.define('data-view-header-area-text', HeaderAreaTextCell);
|
||||
customElements.define('affine-database-rich-text-cell', RichTextCell);
|
||||
customElements.define('center-peek', CenterPeek);
|
||||
customElements.define('database-datasource-note-renderer', NoteRenderer);
|
||||
customElements.define('database-datasource-block-renderer', BlockRenderer);
|
||||
customElements.define('affine-database', DatabaseBlockComponent);
|
||||
|
||||
customElements.define(
|
||||
'affine-dnd-preview-database',
|
||||
DatabaseDndPreviewBlockComponent
|
||||
);
|
||||
}
|
||||
15
blocksuite/affine/blocks/database/src/index.ts
Normal file
15
blocksuite/affine/blocks/database/src/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export * from './adapters';
|
||||
export * from './commands';
|
||||
export * from './config';
|
||||
export * from './context';
|
||||
export * from './data-source';
|
||||
export * from './database-block';
|
||||
export * from './database-spec';
|
||||
export * from './detail-panel/block-renderer';
|
||||
export * from './detail-panel/note-renderer';
|
||||
export * from './properties';
|
||||
export * from './properties/rich-text/cell-renderer';
|
||||
export * from './selection.js';
|
||||
export * from './service';
|
||||
export * from './utils/block-utils';
|
||||
export * from '@blocksuite/data-view';
|
||||
176
blocksuite/affine/blocks/database/src/properties/converts.ts
Normal file
176
blocksuite/affine/blocks/database/src/properties/converts.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
createPropertyConvert,
|
||||
getTagColor,
|
||||
type SelectTag,
|
||||
} from '@blocksuite/data-view';
|
||||
import { presetPropertyConverts } from '@blocksuite/data-view/property-presets';
|
||||
import { propertyModelPresets } from '@blocksuite/data-view/property-pure-presets';
|
||||
import { clamp } from '@blocksuite/global/gfx';
|
||||
import { nanoid, Text } from '@blocksuite/store';
|
||||
|
||||
import { richTextPropertyModelConfig } from './rich-text/define.js';
|
||||
|
||||
export const databasePropertyConverts = [
|
||||
...presetPropertyConverts,
|
||||
createPropertyConvert(
|
||||
richTextPropertyModelConfig,
|
||||
propertyModelPresets.selectPropertyModelConfig,
|
||||
(_property, cells) => {
|
||||
const options: Record<string, SelectTag> = {};
|
||||
const getTag = (name: string) => {
|
||||
if (options[name]) return options[name];
|
||||
const tag: SelectTag = {
|
||||
id: nanoid(),
|
||||
value: name,
|
||||
color: getTagColor(),
|
||||
};
|
||||
options[name] = tag;
|
||||
return tag;
|
||||
};
|
||||
return {
|
||||
cells: cells.map(v => {
|
||||
const tags = v?.toString().split(',');
|
||||
const value = tags?.[0]?.trim();
|
||||
if (value) {
|
||||
return getTag(value).id;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
property: {
|
||||
options: Object.values(options),
|
||||
},
|
||||
};
|
||||
}
|
||||
),
|
||||
createPropertyConvert(
|
||||
richTextPropertyModelConfig,
|
||||
propertyModelPresets.multiSelectPropertyModelConfig,
|
||||
(_property, cells) => {
|
||||
const options: Record<string, SelectTag> = {};
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
const getTag = (name: string) => {
|
||||
if (options[name]) return options[name];
|
||||
const tag: SelectTag = {
|
||||
id: nanoid(),
|
||||
value: name,
|
||||
color: getTagColor(),
|
||||
};
|
||||
options[name] = tag;
|
||||
return tag;
|
||||
};
|
||||
return {
|
||||
cells: cells.map(v => {
|
||||
const result: string[] = [];
|
||||
const values = v?.toString().split(',');
|
||||
values?.forEach(value => {
|
||||
value = value.trim();
|
||||
if (value) {
|
||||
result.push(getTag(value).id);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
property: {
|
||||
options: Object.values(options),
|
||||
},
|
||||
};
|
||||
}
|
||||
),
|
||||
createPropertyConvert(
|
||||
richTextPropertyModelConfig,
|
||||
propertyModelPresets.numberPropertyModelConfig,
|
||||
(_property, cells) => {
|
||||
return {
|
||||
property: {
|
||||
decimal: 0,
|
||||
format: 'number' as const,
|
||||
},
|
||||
cells: cells.map(v => {
|
||||
const num = v ? parseFloat(v.toString()) : NaN;
|
||||
return isNaN(num) ? undefined : num;
|
||||
}),
|
||||
};
|
||||
}
|
||||
),
|
||||
createPropertyConvert(
|
||||
richTextPropertyModelConfig,
|
||||
propertyModelPresets.progressPropertyModelConfig,
|
||||
(_property, cells) => {
|
||||
return {
|
||||
property: {},
|
||||
cells: cells.map(v => {
|
||||
const progress = v ? parseInt(v.toString()) : NaN;
|
||||
return !isNaN(progress) ? clamp(progress, 0, 100) : undefined;
|
||||
}),
|
||||
};
|
||||
}
|
||||
),
|
||||
createPropertyConvert(
|
||||
richTextPropertyModelConfig,
|
||||
propertyModelPresets.checkboxPropertyModelConfig,
|
||||
(_property, cells) => {
|
||||
const truthyValues = new Set(['yes', 'true']);
|
||||
return {
|
||||
property: {},
|
||||
cells: cells.map(v =>
|
||||
v && truthyValues.has(v.toString().toLowerCase()) ? true : undefined
|
||||
),
|
||||
};
|
||||
}
|
||||
),
|
||||
createPropertyConvert(
|
||||
propertyModelPresets.checkboxPropertyModelConfig,
|
||||
richTextPropertyModelConfig,
|
||||
(_property, cells) => {
|
||||
return {
|
||||
property: {},
|
||||
cells: cells.map(v => new Text(v ? 'Yes' : 'No').yText),
|
||||
};
|
||||
}
|
||||
),
|
||||
createPropertyConvert(
|
||||
propertyModelPresets.multiSelectPropertyModelConfig,
|
||||
richTextPropertyModelConfig,
|
||||
(property, cells) => {
|
||||
const optionMap = Object.fromEntries(
|
||||
property.options.map(v => [v.id, v])
|
||||
);
|
||||
return {
|
||||
property: {},
|
||||
cells: cells.map(
|
||||
arr =>
|
||||
new Text(arr?.map(v => optionMap[v]?.value ?? '').join(',')).yText
|
||||
),
|
||||
};
|
||||
}
|
||||
),
|
||||
createPropertyConvert(
|
||||
propertyModelPresets.numberPropertyModelConfig,
|
||||
richTextPropertyModelConfig,
|
||||
(_property, cells) => ({
|
||||
property: {},
|
||||
cells: cells.map(v => new Text(v?.toString()).yText),
|
||||
})
|
||||
),
|
||||
createPropertyConvert(
|
||||
propertyModelPresets.progressPropertyModelConfig,
|
||||
richTextPropertyModelConfig,
|
||||
(_property, cells) => ({
|
||||
property: {},
|
||||
cells: cells.map(v => new Text(v?.toString()).yText),
|
||||
})
|
||||
),
|
||||
createPropertyConvert(
|
||||
propertyModelPresets.selectPropertyModelConfig,
|
||||
richTextPropertyModelConfig,
|
||||
(property, cells) => {
|
||||
const optionMap = Object.fromEntries(
|
||||
property.options.map(v => [v.id, v])
|
||||
);
|
||||
return {
|
||||
property: {},
|
||||
cells: cells.map(v => new Text(v ? optionMap[v]?.value : '').yText),
|
||||
};
|
||||
}
|
||||
),
|
||||
];
|
||||
27
blocksuite/affine/blocks/database/src/properties/index.ts
Normal file
27
blocksuite/affine/blocks/database/src/properties/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { propertyPresets } from '@blocksuite/data-view/property-presets';
|
||||
|
||||
import { linkColumnConfig } from './link/cell-renderer.js';
|
||||
import { richTextColumnConfig } from './rich-text/cell-renderer.js';
|
||||
import { titleColumnConfig } from './title/cell-renderer.js';
|
||||
|
||||
export * from './converts.js';
|
||||
const {
|
||||
checkboxPropertyConfig,
|
||||
datePropertyConfig,
|
||||
multiSelectPropertyConfig,
|
||||
numberPropertyConfig,
|
||||
progressPropertyConfig,
|
||||
selectPropertyConfig,
|
||||
} = propertyPresets;
|
||||
export const databaseBlockProperties = {
|
||||
checkboxColumnConfig: checkboxPropertyConfig,
|
||||
dateColumnConfig: datePropertyConfig,
|
||||
multiSelectColumnConfig: multiSelectPropertyConfig,
|
||||
numberColumnConfig: numberPropertyConfig,
|
||||
progressColumnConfig: progressPropertyConfig,
|
||||
selectColumnConfig: selectPropertyConfig,
|
||||
imageColumnConfig: propertyPresets.imagePropertyConfig,
|
||||
linkColumnConfig,
|
||||
richTextColumnConfig,
|
||||
titleColumnConfig,
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const linkCellStyle = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
userSelect: 'none',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const linkContainerStyle = style({
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
outline: 'none',
|
||||
overflow: 'hidden',
|
||||
fontSize: 'var(--data-view-cell-text-size)',
|
||||
lineHeight: 'var(--data-view-cell-text-line-height)',
|
||||
wordBreak: 'break-all',
|
||||
});
|
||||
export const linkIconContainerStyle = style({
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
top: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
visibility: 'hidden',
|
||||
backgroundColor: cssVarV2.layer.background.primary,
|
||||
boxShadow: 'var(--affine-button-shadow)',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
zIndex: 1,
|
||||
});
|
||||
export const linkIconStyle = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
color: cssVarV2.icon.primary,
|
||||
fontSize: '14px',
|
||||
padding: '2px',
|
||||
':hover': {
|
||||
backgroundColor: cssVarV2.layer.background.hoverOverlay,
|
||||
},
|
||||
});
|
||||
|
||||
export const showLinkIconStyle = style({
|
||||
selectors: {
|
||||
[`${linkCellStyle}:hover &`]: {
|
||||
visibility: 'visible',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const linkedDocStyle = style({
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: 'var(--affine-divider-color)',
|
||||
transition: 'text-decoration-color 0.2s ease-out',
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
textDecorationColor: 'var(--affine-icon-color)',
|
||||
},
|
||||
});
|
||||
|
||||
export const linkEditingStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
fontFamily: baseTheme.fontSansFamily,
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontWeight: '400',
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 'var(--data-view-cell-text-size)',
|
||||
lineHeight: 'var(--data-view-cell-text-line-height)',
|
||||
wordBreak: 'break-all',
|
||||
':focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
export const inlineLinkNodeStyle = style({
|
||||
wordBreak: 'break-all',
|
||||
color: 'var(--affine-link-color)',
|
||||
fill: 'var(--affine-link-color)',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'normal',
|
||||
fontStyle: 'normal',
|
||||
textDecoration: 'none',
|
||||
});
|
||||
|
||||
export const normalTextStyle = style({
|
||||
wordBreak: 'break-all',
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { RefNodeSlotsProvider } from '@blocksuite/affine-inline-reference';
|
||||
import { ParseDocUrlProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
isValidUrl,
|
||||
normalizeUrl,
|
||||
stopPropagation,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BaseCellRenderer,
|
||||
createFromBaseCellRenderer,
|
||||
createIcon,
|
||||
} from '@blocksuite/data-view';
|
||||
import { EditIcon } from '@blocksuite/icons/lit';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { html, nothing, type PropertyValues } from 'lit';
|
||||
import { createRef, ref } from 'lit/directives/ref.js';
|
||||
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import {
|
||||
inlineLinkNodeStyle,
|
||||
linkCellStyle,
|
||||
linkContainerStyle,
|
||||
linkedDocStyle,
|
||||
linkEditingStyle,
|
||||
linkIconContainerStyle,
|
||||
linkIconStyle,
|
||||
normalTextStyle,
|
||||
showLinkIconStyle,
|
||||
} from './cell-renderer.css.js';
|
||||
import { linkPropertyModelConfig } from './define.js';
|
||||
|
||||
export class LinkCell extends BaseCellRenderer<string, string> {
|
||||
protected override firstUpdated(_changedProperties: PropertyValues) {
|
||||
super.firstUpdated(_changedProperties);
|
||||
this.classList.add(linkCellStyle);
|
||||
}
|
||||
|
||||
private readonly _onEdit = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.selectCurrentCell(true);
|
||||
this.selectCurrentCell(true);
|
||||
};
|
||||
|
||||
private readonly _focusEnd = () => {
|
||||
const ele = this._container.value;
|
||||
if (!ele) {
|
||||
return;
|
||||
}
|
||||
const end = ele?.value.length;
|
||||
ele?.focus();
|
||||
ele?.setSelectionRange(end, end);
|
||||
};
|
||||
|
||||
private readonly _onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
this.selectCurrentCell(false);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _setValue = (
|
||||
value: string = this._container.value?.value ?? ''
|
||||
) => {
|
||||
let url = value;
|
||||
if (isValidUrl(value)) {
|
||||
url = normalizeUrl(value);
|
||||
}
|
||||
|
||||
this.valueSetNextTick(url);
|
||||
if (this._container.value) {
|
||||
this._container.value.value = url;
|
||||
}
|
||||
};
|
||||
|
||||
openDoc = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!this.docId$.value) {
|
||||
return;
|
||||
}
|
||||
const std = this.std;
|
||||
if (!std) {
|
||||
return;
|
||||
}
|
||||
|
||||
std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.next({
|
||||
pageId: this.docId$.value,
|
||||
host: std.host,
|
||||
});
|
||||
};
|
||||
|
||||
get std() {
|
||||
const host = this.view.contextGet(HostContextKey);
|
||||
return host?.std;
|
||||
}
|
||||
|
||||
docId$ = computed(() => {
|
||||
if (!this.value || !isValidUrl(this.value)) {
|
||||
return;
|
||||
}
|
||||
return this.parseDocUrl(this.value)?.docId;
|
||||
});
|
||||
|
||||
private readonly _container = createRef<HTMLInputElement>();
|
||||
|
||||
override afterEnterEditingMode() {
|
||||
this._focusEnd();
|
||||
}
|
||||
|
||||
override beforeExitEditingMode() {
|
||||
this._setValue();
|
||||
}
|
||||
|
||||
parseDocUrl(url: string) {
|
||||
return this.std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(url);
|
||||
}
|
||||
|
||||
docName$ = computed(() => {
|
||||
const title =
|
||||
this.docId$.value &&
|
||||
this.std?.workspace.getDoc(this.docId$.value)?.meta?.title;
|
||||
if (title == null) {
|
||||
return;
|
||||
}
|
||||
return title || 'Untitled';
|
||||
});
|
||||
|
||||
renderLink() {
|
||||
const linkText = this.value ?? '';
|
||||
const docName = this.docName$.value;
|
||||
const isDoc = !!docName;
|
||||
const isLink = !!linkText;
|
||||
const hasLink = isDoc || isLink;
|
||||
return html`
|
||||
<div>
|
||||
<div class="${linkContainerStyle}">
|
||||
${isDoc
|
||||
? html`<span class="${linkedDocStyle}" @click="${this.openDoc}"
|
||||
>${docName}</span
|
||||
>`
|
||||
: isValidUrl(linkText)
|
||||
? html`<a
|
||||
data-testid="property-link-a"
|
||||
class="${inlineLinkNodeStyle}"
|
||||
href="${linkText}"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>${linkText}</a
|
||||
>`
|
||||
: html`<span class="${normalTextStyle}">${linkText}</span>`}
|
||||
</div>
|
||||
${hasLink
|
||||
? html` <div class="${linkIconContainerStyle} ${showLinkIconStyle}">
|
||||
<div
|
||||
class="${linkIconStyle}"
|
||||
data-testid="edit-link-button"
|
||||
@click="${this._onEdit}"
|
||||
>
|
||||
${EditIcon()}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.isEditing$.value) {
|
||||
const linkText = this.value ?? '';
|
||||
return html`<input
|
||||
class="${linkEditingStyle} link"
|
||||
${ref(this._container)}
|
||||
.value="${linkText}"
|
||||
@keydown="${this._onKeydown}"
|
||||
@pointerdown="${stopPropagation}"
|
||||
/>`;
|
||||
} else {
|
||||
return this.renderLink();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const linkColumnConfig = linkPropertyModelConfig.createPropertyMeta({
|
||||
icon: createIcon('LinkIcon'),
|
||||
cellRenderer: {
|
||||
view: createFromBaseCellRenderer(LinkCell),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { propertyType, t } from '@blocksuite/data-view';
|
||||
import zod from 'zod';
|
||||
export const linkColumnType = propertyType('link');
|
||||
export const linkPropertyModelConfig = linkColumnType.modelConfig({
|
||||
name: 'Link',
|
||||
propertyData: {
|
||||
schema: zod.object({}),
|
||||
default: () => ({}),
|
||||
},
|
||||
jsonValue: {
|
||||
schema: zod.string(),
|
||||
type: () => t.string.instance(),
|
||||
isEmpty: ({ value }) => !value,
|
||||
},
|
||||
rawValue: {
|
||||
schema: zod.string(),
|
||||
default: () => '',
|
||||
toString: ({ value }) => value,
|
||||
fromString: ({ value }) => {
|
||||
return { value: value };
|
||||
},
|
||||
toJson: ({ value }) => value,
|
||||
fromJson: ({ value }) => value,
|
||||
},
|
||||
});
|
||||
20
blocksuite/affine/blocks/database/src/properties/model.ts
Normal file
20
blocksuite/affine/blocks/database/src/properties/model.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { PropertyModel } from '@blocksuite/data-view';
|
||||
import { propertyModelPresets } from '@blocksuite/data-view/property-pure-presets';
|
||||
|
||||
import { linkPropertyModelConfig } from './link/define';
|
||||
import { richTextPropertyModelConfig } from './rich-text/define';
|
||||
import { titlePropertyModelConfig } from './title/define';
|
||||
|
||||
export const databaseBlockModels = Object.fromEntries(
|
||||
[
|
||||
propertyModelPresets.checkboxPropertyModelConfig,
|
||||
propertyModelPresets.datePropertyModelConfig,
|
||||
propertyModelPresets.numberPropertyModelConfig,
|
||||
propertyModelPresets.progressPropertyModelConfig,
|
||||
propertyModelPresets.selectPropertyModelConfig,
|
||||
propertyModelPresets.multiSelectPropertyModelConfig,
|
||||
linkPropertyModelConfig,
|
||||
richTextPropertyModelConfig,
|
||||
titlePropertyModelConfig,
|
||||
].map(v => [v.type, v as PropertyModel])
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const richTextCellStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
export const richTextContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
outline: 'none',
|
||||
fontSize: 'var(--data-view-cell-text-size)',
|
||||
lineHeight: 'var(--data-view-cell-text-line-height)',
|
||||
wordBreak: 'break-all',
|
||||
});
|
||||
@@ -0,0 +1,432 @@
|
||||
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
ParseDocUrlProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type {
|
||||
AffineInlineEditor,
|
||||
AffineTextAttributes,
|
||||
} from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
getViewportElement,
|
||||
isValidUrl,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BaseCellRenderer,
|
||||
createFromBaseCellRenderer,
|
||||
createIcon,
|
||||
} from '@blocksuite/data-view';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import type { BlockSnapshot, DeltaInsert } from '@blocksuite/store';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { computed, effect, signal } from '@preact/signals-core';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||
import {
|
||||
richTextCellStyle,
|
||||
richTextContainerStyle,
|
||||
} from './cell-renderer.css.js';
|
||||
import { richTextPropertyModelConfig } from './define.js';
|
||||
|
||||
function toggleStyle(
|
||||
inlineEditor: AffineInlineEditor | null,
|
||||
attrs: AffineTextAttributes
|
||||
): void {
|
||||
if (!inlineEditor) return;
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
const root = inlineEditor.rootElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltas = inlineEditor.getDeltasByInlineRange(inlineRange);
|
||||
let oldAttributes: AffineTextAttributes = {};
|
||||
|
||||
for (const [delta] of deltas) {
|
||||
const attributes = delta.attributes;
|
||||
|
||||
if (!attributes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
oldAttributes = { ...attributes };
|
||||
}
|
||||
|
||||
const newAttributes = Object.fromEntries(
|
||||
Object.entries(attrs).map(([k, v]) => {
|
||||
if (
|
||||
typeof v === 'boolean' &&
|
||||
v === (oldAttributes as Record<string, unknown>)[k]
|
||||
) {
|
||||
return [k, !v];
|
||||
} else {
|
||||
return [k, v];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
inlineEditor.formatText(inlineRange, newAttributes, {
|
||||
mode: 'merge',
|
||||
});
|
||||
root.blur();
|
||||
|
||||
inlineEditor.syncInlineRange();
|
||||
}
|
||||
|
||||
export class RichTextCell extends BaseCellRenderer<Text, string> {
|
||||
inlineEditor$ = computed(() => {
|
||||
return this.richText$.value?.inlineEditor;
|
||||
});
|
||||
|
||||
get inlineManager() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
get topContenteditableElement() {
|
||||
const databaseBlock =
|
||||
this.closest<DatabaseBlockComponent>('affine-database');
|
||||
return databaseBlock?.topContenteditableElement;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.view.contextGet(HostContextKey);
|
||||
}
|
||||
|
||||
private readonly richText$ = signal<RichText>();
|
||||
|
||||
private changeUserSelectAccordToReadOnly() {
|
||||
if (this && this instanceof HTMLElement) {
|
||||
this.style.userSelect = this.readonly ? 'text' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
private readonly _handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.isComposing) {
|
||||
if (event.shiftKey) {
|
||||
// soft enter
|
||||
this._onSoftEnter();
|
||||
} else {
|
||||
// exit editing
|
||||
this.selectCurrentCell(false);
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const inlineEditor = this.inlineEditor$.value;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
switch (event.key) {
|
||||
// bold ctrl+b
|
||||
case 'B':
|
||||
case 'b':
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(inlineEditor, { bold: true });
|
||||
}
|
||||
break;
|
||||
// italic ctrl+i
|
||||
case 'I':
|
||||
case 'i':
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(inlineEditor, { italic: true });
|
||||
}
|
||||
break;
|
||||
// underline ctrl+u
|
||||
case 'U':
|
||||
case 'u':
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(inlineEditor, { underline: true });
|
||||
}
|
||||
break;
|
||||
// strikethrough ctrl+shift+s
|
||||
case 'S':
|
||||
case 's':
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(inlineEditor, { strike: true });
|
||||
}
|
||||
break;
|
||||
// inline code ctrl+shift+e
|
||||
case 'E':
|
||||
case 'e':
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(inlineEditor, { code: true });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _initYText = (text?: string) => {
|
||||
const yText = new Text(text);
|
||||
this.valueSetImmediate(yText);
|
||||
};
|
||||
|
||||
private readonly _onSoftEnter = () => {
|
||||
if (this.value && this.inlineEditor$.value) {
|
||||
const inlineRange = this.inlineEditor$.value.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
const text = new Text(this.inlineEditor$.value.yText);
|
||||
text.replace(inlineRange.index, inlineRange.length, '\n');
|
||||
this.inlineEditor$.value.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _onCopy = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor$.value;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
const text = inlineEditor.yTextString.slice(
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
);
|
||||
|
||||
e.clipboardData?.setData('text/plain', text);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
private readonly _onCut = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor$.value;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const inlineEditor = this.inlineEditor$.value;
|
||||
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;
|
||||
|
||||
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 {
|
||||
console.log(text);
|
||||
inlineEditor.insertText(inlineRange, text);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.add(richTextCellStyle);
|
||||
|
||||
this.changeUserSelectAccordToReadOnly();
|
||||
|
||||
const selectAll = (e: KeyboardEvent) => {
|
||||
if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.inlineEditor$.value?.selectAll();
|
||||
}
|
||||
};
|
||||
this.addEventListener('keydown', selectAll);
|
||||
this.disposables.addFromEvent(this, 'keydown', selectAll);
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const editor = this.inlineEditor$.value;
|
||||
if (editor) {
|
||||
const disposable = editor.slots.keydown.subscribe(
|
||||
this._handleKeyDown
|
||||
);
|
||||
return () => disposable.unsubscribe();
|
||||
}
|
||||
return;
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const richText = this.richText$.value;
|
||||
if (richText) {
|
||||
richText.addEventListener('copy', this._onCopy, true);
|
||||
richText.addEventListener('cut', this._onCut, true);
|
||||
richText.addEventListener('paste', this._onPaste, true);
|
||||
return () => {
|
||||
richText.removeEventListener('copy', this._onCopy);
|
||||
richText.removeEventListener('cut', this._onCut);
|
||||
richText.removeEventListener('paste', this._onPaste);
|
||||
};
|
||||
}
|
||||
return;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override beforeEnterEditMode() {
|
||||
if (!this.value || typeof this.value === 'string') {
|
||||
this._initYText(this.value);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
override afterEnterEditingMode() {
|
||||
this.inlineEditor$.value?.focusEnd();
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.value || !(this.value instanceof Text)) {
|
||||
return html` <div class="${richTextContainerStyle}"></div>`;
|
||||
}
|
||||
return html` <rich-text
|
||||
${ref(this.richText$)}
|
||||
data-disable-ask-ai
|
||||
data-not-block-text
|
||||
.yText="${this.value}"
|
||||
.inlineEventSource="${this.topContenteditableElement}"
|
||||
.attributesSchema="${this.inlineManager?.getSchema()}"
|
||||
.attributeRenderer="${this.inlineManager?.getRenderer()}"
|
||||
.embedChecker="${this.inlineManager?.embedChecker}"
|
||||
.markdownMatches="${this.inlineManager?.markdownMatches}"
|
||||
.readonly="${!this.isEditing$.value || this.readonly}"
|
||||
.verticalScrollContainerGetter="${() =>
|
||||
this.topContenteditableElement?.host
|
||||
? getViewportElement(this.topContenteditableElement.host)
|
||||
: null}"
|
||||
class="${richTextContainerStyle} inline-editor"
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
private get std() {
|
||||
return this.view.contextGet(HostContextKey)?.std;
|
||||
}
|
||||
|
||||
insertDelta = (delta: DeltaInsert<AffineTextAttributes>) => {
|
||||
const inlineEditor = this.inlineEditor$.value;
|
||||
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 {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-database-rich-text-cell': RichTextCell;
|
||||
}
|
||||
}
|
||||
|
||||
export const richTextColumnConfig =
|
||||
richTextPropertyModelConfig.createPropertyMeta({
|
||||
icon: createIcon('TextIcon'),
|
||||
|
||||
cellRenderer: {
|
||||
view: createFromBaseCellRenderer(RichTextCell),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { propertyType, t } from '@blocksuite/data-view';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import * as Y from 'yjs';
|
||||
import zod from 'zod';
|
||||
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import { isLinkedDoc } from '../../utils/title-doc.js';
|
||||
|
||||
export const richTextColumnType = propertyType('rich-text');
|
||||
export type RichTextCellType = Text | Text['yText'];
|
||||
export const toYText = (text?: RichTextCellType): undefined | Text['yText'] => {
|
||||
if (text instanceof Text) {
|
||||
return text.yText;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
export const richTextPropertyModelConfig = richTextColumnType.modelConfig({
|
||||
name: 'Text',
|
||||
propertyData: {
|
||||
schema: zod.object({}),
|
||||
default: () => ({}),
|
||||
},
|
||||
jsonValue: {
|
||||
schema: zod.string(),
|
||||
type: () => t.richText.instance(),
|
||||
isEmpty: ({ value }) => !value,
|
||||
},
|
||||
rawValue: {
|
||||
schema: zod
|
||||
.custom<RichTextCellType>(
|
||||
data => data instanceof Text || data instanceof Y.Text
|
||||
)
|
||||
.optional(),
|
||||
default: () => undefined,
|
||||
toString: ({ value }) => value?.toString() ?? '',
|
||||
fromString: ({ value }) => {
|
||||
return {
|
||||
value: new Text(value),
|
||||
};
|
||||
},
|
||||
toJson: ({ value, dataSource }) => {
|
||||
if (!value) return null;
|
||||
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;
|
||||
},
|
||||
fromJson: ({ value }) =>
|
||||
typeof value !== 'string' ? undefined : new Text(value),
|
||||
onUpdate: ({ value, callback }) => {
|
||||
const yText = toYText(value);
|
||||
yText?.observe(callback);
|
||||
callback();
|
||||
return {
|
||||
dispose: () => {
|
||||
yText?.unobserve(callback);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const titleCellStyle = style({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
export const titleRichTextStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
outline: 'none',
|
||||
wordBreak: 'break-all',
|
||||
fontSize: 'var(--data-view-cell-text-size)',
|
||||
lineHeight: 'var(--data-view-cell-text-line-height)',
|
||||
});
|
||||
|
||||
export const headerAreaIconStyle = style({
|
||||
height: 'max-content',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginRight: '8px',
|
||||
padding: '2px',
|
||||
borderRadius: '4px',
|
||||
marginTop: '2px',
|
||||
color: cssVarV2.icon.primary,
|
||||
backgroundColor: 'var(--affine-background-secondary-color)',
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
type CellRenderProps,
|
||||
createFromBaseCellRenderer,
|
||||
createIcon,
|
||||
uniMap,
|
||||
} from '@blocksuite/data-view';
|
||||
import { TableSingleView } from '@blocksuite/data-view/view-presets';
|
||||
|
||||
import { titlePropertyModelConfig } from './define.js';
|
||||
import { HeaderAreaTextCell } from './text.js';
|
||||
|
||||
export const titleColumnConfig = titlePropertyModelConfig.createPropertyMeta({
|
||||
icon: createIcon('TitleIcon'),
|
||||
cellRenderer: {
|
||||
view: uniMap(
|
||||
createFromBaseCellRenderer(HeaderAreaTextCell),
|
||||
(props: CellRenderProps) => ({
|
||||
...props,
|
||||
showIcon: props.cell.view instanceof TableSingleView,
|
||||
})
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { propertyType, t } from '@blocksuite/data-view';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { Doc } from 'yjs';
|
||||
import zod from 'zod';
|
||||
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import { isLinkedDoc } from '../../utils/title-doc.js';
|
||||
|
||||
export const titleColumnType = propertyType('title');
|
||||
|
||||
export const titlePropertyModelConfig = titleColumnType.modelConfig({
|
||||
name: 'Title',
|
||||
propertyData: {
|
||||
schema: zod.object({}),
|
||||
default: () => ({}),
|
||||
},
|
||||
jsonValue: {
|
||||
schema: zod.string(),
|
||||
type: () => t.richText.instance(),
|
||||
isEmpty: ({ value }) => !value,
|
||||
},
|
||||
rawValue: {
|
||||
schema: zod.custom<Text>(data => data instanceof Text).optional(),
|
||||
default: () => undefined,
|
||||
toString: ({ value }) => value?.toString() ?? '',
|
||||
fromString: ({ value }) => {
|
||||
return { value: new Text(value) };
|
||||
},
|
||||
toJson: ({ value, dataSource }) => {
|
||||
if (!value) return '';
|
||||
const host = dataSource.contextGet(HostContextKey);
|
||||
if (host) {
|
||||
const collection = host.std.workspace;
|
||||
const deltas = value.deltas$.value;
|
||||
const text = deltas
|
||||
.map(delta => {
|
||||
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() ?? '';
|
||||
},
|
||||
fromJson: ({ value }) => new Text(value),
|
||||
onUpdate: ({ value, callback }) => {
|
||||
value?.yText.observe(callback);
|
||||
callback();
|
||||
return {
|
||||
dispose: () => {
|
||||
value?.yText.unobserve(callback);
|
||||
},
|
||||
};
|
||||
},
|
||||
setValue: ({ value, newValue }) => {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
const v = newValue as unknown;
|
||||
if (v == null) {
|
||||
value.replace(0, value.length, '');
|
||||
return;
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
value.replace(0, value.length, v);
|
||||
return;
|
||||
}
|
||||
if (newValue instanceof Text) {
|
||||
new Doc().getMap('root').set('text', newValue.yText);
|
||||
value.clear();
|
||||
value.applyDelta(newValue.toDelta());
|
||||
return;
|
||||
}
|
||||
},
|
||||
},
|
||||
fixed: {
|
||||
defaultData: {},
|
||||
defaultShow: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { BaseCellRenderer } from '@blocksuite/data-view';
|
||||
import { css, html } from 'lit';
|
||||
|
||||
export class IconCell extends BaseCellRenderer<string> {
|
||||
static override styles = css`
|
||||
affine-database-image-cell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
affine-database-image-cell img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return html`<img src=${this.value ?? ''}></img>`;
|
||||
}
|
||||
}
|
||||
293
blocksuite/affine/blocks/database/src/properties/title/text.ts
Normal file
293
blocksuite/affine/blocks/database/src/properties/title/text.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
ParseDocUrlProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getViewportElement,
|
||||
isValidUrl,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { BaseCellRenderer } from '@blocksuite/data-view';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
||||
import type { BlockSnapshot, DeltaInsert, Text } from '@blocksuite/store';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { createRef, ref } from 'lit/directives/ref.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
|
||||
import {
|
||||
headerAreaIconStyle,
|
||||
titleCellStyle,
|
||||
titleRichTextStyle,
|
||||
} from './cell-renderer.css.js';
|
||||
|
||||
export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
||||
activity = true;
|
||||
|
||||
docId$ = signal<string>();
|
||||
|
||||
get host() {
|
||||
return this.view.contextGet(HostContextKey);
|
||||
}
|
||||
|
||||
get inlineEditor() {
|
||||
return this.richText.value?.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineManager() {
|
||||
return this.host?.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
get topContenteditableElement() {
|
||||
const databaseBlock =
|
||||
this.closest<DatabaseBlockComponent>('affine-database');
|
||||
return databaseBlock?.topContenteditableElement;
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this.view.contextGet(HostContextKey)?.std;
|
||||
}
|
||||
|
||||
private readonly _onCopy = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
const text = inlineEditor.yTextString.slice(
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
);
|
||||
|
||||
e.clipboardData?.setData('text/plain', text);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
private readonly _onCut = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
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;
|
||||
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 title column
|
||||
std?.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
|
||||
module: 'database title 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
insertDelta = (delta: DeltaInsert) => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.add(titleCellStyle);
|
||||
|
||||
const yText = this.value?.yText;
|
||||
if (yText) {
|
||||
const cb = () => {
|
||||
const id = getSingleDocIdFromText(this.value);
|
||||
this.docId$.value = id;
|
||||
};
|
||||
cb();
|
||||
if (this.activity) {
|
||||
yText.observe(cb);
|
||||
this.disposables.add(() => {
|
||||
yText.unobserve(cb);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const selectAll = (e: KeyboardEvent) => {
|
||||
if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.inlineEditor?.selectAll();
|
||||
}
|
||||
};
|
||||
|
||||
this.addEventListener('keydown', selectAll);
|
||||
this.disposables.addFromEvent(this, 'keydown', selectAll);
|
||||
}
|
||||
|
||||
override firstUpdated(props: Map<string, unknown>) {
|
||||
super.firstUpdated(props);
|
||||
this.richText.value?.updateComplete
|
||||
.then(() => {
|
||||
this.disposables.addFromEvent(
|
||||
this.richText.value,
|
||||
'copy',
|
||||
this._onCopy
|
||||
);
|
||||
this.disposables.addFromEvent(this.richText.value, 'cut', this._onCut);
|
||||
this.disposables.addFromEvent(
|
||||
this.richText.value,
|
||||
'paste',
|
||||
this._onPaste
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override afterEnterEditingMode() {
|
||||
this.inlineEditor?.focusEnd();
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
return html`${this.renderIcon()}${this.renderBlockText()}`;
|
||||
}
|
||||
|
||||
renderBlockText() {
|
||||
return html` <rich-text
|
||||
${ref(this.richText)}
|
||||
data-disable-ask-ai
|
||||
data-not-block-text
|
||||
.yText="${this.value}"
|
||||
.inlineEventSource="${this.topContenteditableElement}"
|
||||
.attributesSchema="${this.inlineManager?.getSchema()}"
|
||||
.attributeRenderer="${this.inlineManager?.getRenderer()}"
|
||||
.embedChecker="${this.inlineManager?.embedChecker}"
|
||||
.markdownMatches="${this.inlineManager?.markdownMatches}"
|
||||
.readonly="${!this.isEditing$.value}"
|
||||
.enableClipboard="${false}"
|
||||
.verticalScrollContainerGetter="${() =>
|
||||
this.topContenteditableElement?.host
|
||||
? getViewportElement(this.topContenteditableElement.host)
|
||||
: null}"
|
||||
data-parent-flavour="affine:database"
|
||||
class="${titleRichTextStyle}"
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
renderIcon() {
|
||||
if (!this.showIcon) {
|
||||
return;
|
||||
}
|
||||
if (this.docId$.value) {
|
||||
return html` <div class="${headerAreaIconStyle}">
|
||||
${LinkedPageIcon({})}
|
||||
</div>`;
|
||||
}
|
||||
const iconColumn = this.view.mainProperties$.value.iconColumn;
|
||||
if (!iconColumn) return;
|
||||
|
||||
const icon = this.view.cellValueGet(this.cell.rowId, iconColumn) as string;
|
||||
if (!icon) return;
|
||||
|
||||
return html` <div class="${headerAreaIconStyle}">${icon}</div>`;
|
||||
}
|
||||
|
||||
private readonly richText = createRef<RichText>();
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showIcon = false;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'data-view-header-area-text': HeaderAreaTextCell;
|
||||
}
|
||||
}
|
||||
68
blocksuite/affine/blocks/database/src/selection.ts
Normal file
68
blocksuite/affine/blocks/database/src/selection.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { DataViewSelection } from '@blocksuite/data-view';
|
||||
import {
|
||||
KanbanViewSelectionWithTypeSchema,
|
||||
TableViewSelectionWithTypeSchema,
|
||||
} from '@blocksuite/data-view/view-presets';
|
||||
import { BaseSelection, SelectionExtension } from '@blocksuite/store';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ViewSelectionSchema = z.union([
|
||||
TableViewSelectionWithTypeSchema,
|
||||
KanbanViewSelectionWithTypeSchema,
|
||||
]);
|
||||
|
||||
const DatabaseSelectionSchema = z.object({
|
||||
blockId: z.string(),
|
||||
viewSelection: ViewSelectionSchema,
|
||||
});
|
||||
|
||||
export class DatabaseSelection extends BaseSelection {
|
||||
static override group = 'note';
|
||||
|
||||
static override type = 'database';
|
||||
|
||||
readonly viewSelection: DataViewSelection;
|
||||
|
||||
get viewId() {
|
||||
return this.viewSelection.viewId;
|
||||
}
|
||||
|
||||
constructor({
|
||||
blockId,
|
||||
viewSelection,
|
||||
}: {
|
||||
blockId: string;
|
||||
viewSelection: DataViewSelection;
|
||||
}) {
|
||||
super({
|
||||
blockId,
|
||||
});
|
||||
|
||||
this.viewSelection = viewSelection;
|
||||
}
|
||||
|
||||
static override fromJSON(json: Record<string, unknown>): DatabaseSelection {
|
||||
const { blockId, viewSelection } = DatabaseSelectionSchema.parse(json);
|
||||
return new DatabaseSelection({
|
||||
blockId,
|
||||
viewSelection: viewSelection,
|
||||
});
|
||||
}
|
||||
|
||||
override equals(other: BaseSelection): boolean {
|
||||
if (!(other instanceof DatabaseSelection)) {
|
||||
return false;
|
||||
}
|
||||
return this.blockId === other.blockId;
|
||||
}
|
||||
|
||||
override toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
type: 'database',
|
||||
blockId: this.blockId,
|
||||
viewSelection: this.viewSelection,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const DatabaseSelectionExtension = SelectionExtension(DatabaseSelection);
|
||||
11
blocksuite/affine/blocks/database/src/service/index.ts
Normal file
11
blocksuite/affine/blocks/database/src/service/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PropertyMetaConfig } from '@blocksuite/data-view';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
|
||||
export interface DatabaseBlockConfigService {
|
||||
propertiesPresets: PropertyMetaConfig[];
|
||||
}
|
||||
|
||||
export const DatabaseBlockConfigService =
|
||||
createIdentifier<DatabaseBlockConfigService>(
|
||||
'AffineDatabaseBlockConfigService'
|
||||
);
|
||||
240
blocksuite/affine/blocks/database/src/utils/block-utils.ts
Normal file
240
blocksuite/affine/blocks/database/src/utils/block-utils.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type {
|
||||
CellDataType,
|
||||
ColumnDataType,
|
||||
ColumnUpdater,
|
||||
DatabaseBlockModel,
|
||||
ViewBasicDataType,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
arrayMove,
|
||||
insertPositionToIndex,
|
||||
type InsertToPosition,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
export function addProperty(
|
||||
model: DatabaseBlockModel,
|
||||
position: InsertToPosition,
|
||||
column: Omit<ColumnDataType, 'id'> & {
|
||||
id?: string;
|
||||
}
|
||||
): string {
|
||||
const id = column.id ?? model.doc.workspace.idGenerator();
|
||||
if (model.props.columns.some(v => v.id === id)) {
|
||||
return id;
|
||||
}
|
||||
model.doc.transact(() => {
|
||||
const col: ColumnDataType = {
|
||||
...column,
|
||||
id,
|
||||
};
|
||||
model.props.columns.splice(
|
||||
insertPositionToIndex(position, model.props.columns),
|
||||
0,
|
||||
col
|
||||
);
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export function copyCellsByProperty(
|
||||
model: DatabaseBlockModel,
|
||||
fromId: ColumnDataType['id'],
|
||||
toId: ColumnDataType['id']
|
||||
) {
|
||||
model.doc.transact(() => {
|
||||
Object.keys(model.props.cells).forEach(rowId => {
|
||||
const cell = model.props.cells[rowId]?.[fromId];
|
||||
if (cell && model.props.cells[rowId]) {
|
||||
model.props.cells[rowId][toId] = {
|
||||
...cell,
|
||||
columnId: toId,
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteColumn(
|
||||
model: DatabaseBlockModel,
|
||||
columnId: ColumnDataType['id']
|
||||
) {
|
||||
const index = model.props.columns.findIndex(v => v.id === columnId);
|
||||
if (index < 0) return;
|
||||
|
||||
model.doc.transact(() => {
|
||||
model.props.columns.splice(index, 1);
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRows(model: DatabaseBlockModel, rowIds: string[]) {
|
||||
model.doc.transact(() => {
|
||||
for (const rowId of rowIds) {
|
||||
delete model.props.cells[rowId];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteView(model: DatabaseBlockModel, id: string) {
|
||||
model.doc.captureSync();
|
||||
model.doc.transact(() => {
|
||||
model.props.views = model.props.views.filter(v => v.id !== id);
|
||||
});
|
||||
}
|
||||
|
||||
export function duplicateView(model: DatabaseBlockModel, id: string): string {
|
||||
const newId = model.doc.workspace.idGenerator();
|
||||
model.doc.transact(() => {
|
||||
const index = model.props.views.findIndex(v => v.id === id);
|
||||
const view = model.props.views[index];
|
||||
if (view) {
|
||||
model.props.views.splice(
|
||||
index + 1,
|
||||
0,
|
||||
JSON.parse(JSON.stringify({ ...view, id: newId }))
|
||||
);
|
||||
}
|
||||
});
|
||||
return newId;
|
||||
}
|
||||
|
||||
export function getCell(
|
||||
model: DatabaseBlockModel,
|
||||
rowId: BlockModel['id'],
|
||||
columnId: ColumnDataType['id']
|
||||
): CellDataType | null {
|
||||
if (columnId === 'title') {
|
||||
return {
|
||||
columnId: 'title',
|
||||
value: rowId,
|
||||
};
|
||||
}
|
||||
const yRow = model.props.cells$.value[rowId];
|
||||
const yCell = yRow?.[columnId] ?? null;
|
||||
if (!yCell) return null;
|
||||
|
||||
return {
|
||||
columnId: yCell.columnId,
|
||||
value: yCell.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function getProperty(
|
||||
model: DatabaseBlockModel,
|
||||
id: ColumnDataType['id']
|
||||
): ColumnDataType | undefined {
|
||||
return model.props.columns.find(v => v.id === id);
|
||||
}
|
||||
|
||||
export function moveViewTo(
|
||||
model: DatabaseBlockModel,
|
||||
id: string,
|
||||
position: InsertToPosition
|
||||
) {
|
||||
model.doc.transact(() => {
|
||||
model.props.views = arrayMove(
|
||||
model.props.views,
|
||||
v => v.id === id,
|
||||
arr => insertPositionToIndex(position, arr)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCell(
|
||||
model: DatabaseBlockModel,
|
||||
rowId: string,
|
||||
cell: CellDataType
|
||||
) {
|
||||
model.doc.transact(() => {
|
||||
const columnId = cell.columnId;
|
||||
if (
|
||||
rowId === '__proto__' ||
|
||||
rowId === 'constructor' ||
|
||||
rowId === 'prototype'
|
||||
) {
|
||||
console.error('Invalid rowId');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
columnId === '__proto__' ||
|
||||
columnId === 'constructor' ||
|
||||
columnId === 'prototype'
|
||||
) {
|
||||
console.error('Invalid columnId');
|
||||
return;
|
||||
}
|
||||
if (!model.props.cells[rowId]) {
|
||||
model.props.cells[rowId] = Object.create(null);
|
||||
}
|
||||
if (model.props.cells[rowId]) {
|
||||
model.props.cells[rowId][columnId] = {
|
||||
columnId: columnId,
|
||||
value: cell.value,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCells(
|
||||
model: DatabaseBlockModel,
|
||||
columnId: string,
|
||||
cells: Record<string, unknown>
|
||||
) {
|
||||
model.doc.transact(() => {
|
||||
Object.entries(cells).forEach(([rowId, value]) => {
|
||||
if (
|
||||
rowId === '__proto__' ||
|
||||
rowId === 'constructor' ||
|
||||
rowId === 'prototype'
|
||||
) {
|
||||
throw new Error('Invalid rowId');
|
||||
}
|
||||
if (!model.props.cells[rowId]) {
|
||||
model.props.cells[rowId] = Object.create(null);
|
||||
}
|
||||
if (model.props.cells[rowId]) {
|
||||
model.props.cells[rowId][columnId] = {
|
||||
columnId,
|
||||
value,
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProperty(
|
||||
model: DatabaseBlockModel,
|
||||
id: string,
|
||||
updater: ColumnUpdater,
|
||||
defaultValue?: Record<string, unknown>
|
||||
) {
|
||||
const index = model.props.columns.findIndex(v => v.id === id);
|
||||
if (index == null) {
|
||||
return;
|
||||
}
|
||||
model.doc.transact(() => {
|
||||
const column = model.props.columns[index];
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
const result = updater(column);
|
||||
model.props.columns[index] = { ...defaultValue, ...column, ...result };
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export const updateView = <ViewData extends ViewBasicDataType>(
|
||||
model: DatabaseBlockModel,
|
||||
id: string,
|
||||
update: (data: ViewData) => Partial<ViewData>
|
||||
) => {
|
||||
model.doc.transact(() => {
|
||||
model.props.views = model.props.views.map(v => {
|
||||
if (v.id !== id) {
|
||||
return v;
|
||||
}
|
||||
return { ...v, ...update(v as ViewData) };
|
||||
});
|
||||
});
|
||||
};
|
||||
export const DATABASE_CONVERT_WHITE_LIST = ['affine:list', 'affine:paragraph'];
|
||||
52
blocksuite/affine/blocks/database/src/utils/current-view.ts
Normal file
52
blocksuite/affine/blocks/database/src/utils/current-view.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const currentViewListSchema = z.array(
|
||||
z.object({
|
||||
blockId: z.string(),
|
||||
viewId: z.string(),
|
||||
})
|
||||
);
|
||||
const maxLength = 20;
|
||||
const currentViewListKey = 'blocksuite:databaseBlock:view:currentViewList';
|
||||
const storage = globalThis.sessionStorage;
|
||||
const createCurrentViewStorage = () => {
|
||||
const getList = () => {
|
||||
const string = storage?.getItem(currentViewListKey);
|
||||
if (!string) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = currentViewListSchema.safeParse(JSON.parse(string));
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
return;
|
||||
};
|
||||
const saveList = () => {
|
||||
storage.setItem(currentViewListKey, JSON.stringify(list));
|
||||
};
|
||||
|
||||
const list = getList() ?? [];
|
||||
|
||||
return {
|
||||
getCurrentView: (blockId: string) => {
|
||||
return list.find(item => item.blockId === blockId)?.viewId;
|
||||
},
|
||||
setCurrentView: (blockId: string, viewId: string) => {
|
||||
const configIndex = list.findIndex(item => item.blockId === blockId);
|
||||
if (configIndex >= 0) {
|
||||
list.splice(configIndex, 1);
|
||||
}
|
||||
if (list.length >= maxLength) {
|
||||
list.pop();
|
||||
}
|
||||
list.unshift({ blockId, viewId });
|
||||
saveList();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const currentViewStorage = createCurrentViewStorage();
|
||||
30
blocksuite/affine/blocks/database/src/utils/title-doc.ts
Normal file
30
blocksuite/affine/blocks/database/src/utils/title-doc.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { DeltaOperation, Text } from '@blocksuite/store';
|
||||
|
||||
export const getSingleDocIdFromText = (text?: Text) => {
|
||||
const deltas = text?.deltas$.value;
|
||||
if (!deltas) return;
|
||||
let linkedDocId: string | undefined = undefined;
|
||||
for (const delta of deltas) {
|
||||
if (isLinkedDoc(delta)) {
|
||||
if (linkedDocId) {
|
||||
return;
|
||||
}
|
||||
linkedDocId = delta.attributes?.reference?.pageId as string;
|
||||
} else if (delta.insert) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return linkedDocId;
|
||||
};
|
||||
|
||||
export const isLinkedDoc = (delta: DeltaOperation) => {
|
||||
const attributes: AffineTextAttributes | undefined = delta.attributes;
|
||||
return attributes?.reference?.type === 'LinkedPage';
|
||||
};
|
||||
|
||||
export const isPureText = (text?: Text): boolean => {
|
||||
const deltas = text?.deltas$.value;
|
||||
if (!deltas) return true;
|
||||
return deltas.every(v => !isLinkedDoc(v));
|
||||
};
|
||||
12
blocksuite/affine/blocks/database/src/views/index.ts
Normal file
12
blocksuite/affine/blocks/database/src/views/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ViewMeta } from '@blocksuite/data-view';
|
||||
import { viewConverts, viewPresets } from '@blocksuite/data-view/view-presets';
|
||||
|
||||
export const databaseBlockViews: ViewMeta[] = [
|
||||
viewPresets.tableViewMeta,
|
||||
viewPresets.kanbanViewMeta,
|
||||
];
|
||||
|
||||
export const databaseBlockViewMap = Object.fromEntries(
|
||||
databaseBlockViews.map(view => [view.type, view])
|
||||
);
|
||||
export const databaseBlockViewConverts = [...viewConverts];
|
||||
1
blocksuite/affine/blocks/database/src/widgets/index.ts
Normal file
1
blocksuite/affine/blocks/database/src/widgets/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const commonTools = [];
|
||||
Reference in New Issue
Block a user