mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-16 13:57:02 +08:00
refactor(editor): extract database block (#9435)
Part of: [BS-2269](https://linear.app/affine-design/issue/BS-2269/%E8%BF%81%E7%A7%BB-database-block-%E5%88%B0-affine-%E6%96%87%E4%BB%B6%E5%A4%B9%E4%B8%8B%E5%B9%B6%E5%BC%80%E5%90%AF-nouncheckedindexedaccess)
This commit is contained in:
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
addProperty,
|
||||
copyCellsByProperty,
|
||||
databaseBlockColumns,
|
||||
deleteColumn,
|
||||
getCell,
|
||||
getProperty,
|
||||
updateCell,
|
||||
} from '@blocksuite/affine-block-database';
|
||||
import {
|
||||
type Cell,
|
||||
type Column,
|
||||
@@ -12,16 +21,6 @@ import type { BlockModel, Doc } from '@blocksuite/store';
|
||||
import { DocCollection, IdGeneratorType, Schema } from '@blocksuite/store';
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { databaseBlockColumns } from '../../database-block/index.js';
|
||||
import {
|
||||
addProperty,
|
||||
copyCellsByProperty,
|
||||
deleteColumn,
|
||||
getCell,
|
||||
getProperty,
|
||||
updateCell,
|
||||
} from '../../database-block/utils/block-utils.js';
|
||||
|
||||
const AffineSchemas = [
|
||||
RootBlockSchema,
|
||||
NoteBlockSchema,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BookmarkBlockHtmlAdapterExtension } from '@blocksuite/affine-block-bookmark';
|
||||
import { CodeBlockHtmlAdapterExtension } from '@blocksuite/affine-block-code';
|
||||
import { DatabaseBlockHtmlAdapterExtension } from '@blocksuite/affine-block-database';
|
||||
import { DividerBlockHtmlAdapterExtension } from '@blocksuite/affine-block-divider';
|
||||
import {
|
||||
EmbedFigmaBlockHtmlAdapterExtension,
|
||||
@@ -13,7 +14,6 @@ import { ImageBlockHtmlAdapterExtension } from '@blocksuite/affine-block-image';
|
||||
import { ListBlockHtmlAdapterExtension } from '@blocksuite/affine-block-list';
|
||||
import { ParagraphBlockHtmlAdapterExtension } from '@blocksuite/affine-block-paragraph';
|
||||
|
||||
import { DatabaseBlockHtmlAdapterExtension } from '../../../database-block/adapters/html.js';
|
||||
import { RootBlockHtmlAdapterExtension } from '../../../root-block/adapters/html.js';
|
||||
|
||||
export const defaultBlockHtmlAdapterMatchers = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bookmarkBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-bookmark';
|
||||
import { codeBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-code';
|
||||
import { databaseBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-database';
|
||||
import { dividerBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-divider';
|
||||
import {
|
||||
embedFigmaBlockMarkdownAdapterMatcher,
|
||||
@@ -14,7 +15,6 @@ import { latexBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-latex
|
||||
import { listBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-list';
|
||||
import { paragraphBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-paragraph';
|
||||
|
||||
import { databaseBlockMarkdownAdapterMatcher } from '../../../database-block/adapters/markdown.js';
|
||||
import { rootBlockMarkdownAdapterMatcher } from '../../../root-block/adapters/markdown.js';
|
||||
|
||||
export const defaultBlockMarkdownAdapterMatchers = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AttachmentBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-attachment';
|
||||
import { BookmarkBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-bookmark';
|
||||
import { CodeBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-code';
|
||||
import { DatabaseBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-database';
|
||||
import { DividerBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-divider';
|
||||
import {
|
||||
EmbedFigmaBlockNotionHtmlAdapterExtension,
|
||||
@@ -14,7 +15,6 @@ import { ListBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-li
|
||||
import { ParagraphBlockNotionHtmlAdapterExtension } from '@blocksuite/affine-block-paragraph';
|
||||
import type { ExtensionType } from '@blocksuite/block-std';
|
||||
|
||||
import { DatabaseBlockNotionHtmlAdapterExtension } from '../../../database-block/adapters/notion-html.js';
|
||||
import { RootBlockNotionHtmlAdapterExtension } from '../../../root-block/adapters/notion-html.js';
|
||||
|
||||
export const defaultBlockNotionHtmlAdapterMatchers: ExtensionType[] = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BookmarkBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-bookmark';
|
||||
import { CodeBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-code';
|
||||
import { DatabaseBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-database';
|
||||
import { DividerBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-divider';
|
||||
import {
|
||||
EmbedFigmaBlockPlainTextAdapterExtension,
|
||||
@@ -14,8 +15,6 @@ import { ListBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-lis
|
||||
import { ParagraphBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-paragraph';
|
||||
import type { ExtensionType } from '@blocksuite/block-std';
|
||||
|
||||
import { DatabaseBlockPlainTextAdapterExtension } from '../../../database-block/adapters/plain-text.js';
|
||||
|
||||
export const defaultBlockPlainTextAdapterMatchers: ExtensionType[] = [
|
||||
ParagraphBlockPlainTextAdapterExtension,
|
||||
ListBlockPlainTextAdapterExtension,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AttachmentBlockSpec } from '@blocksuite/affine-block-attachment';
|
||||
import { BookmarkBlockSpec } from '@blocksuite/affine-block-bookmark';
|
||||
import { CodeBlockSpec } from '@blocksuite/affine-block-code';
|
||||
import { DatabaseBlockSpec } from '@blocksuite/affine-block-database';
|
||||
import { DividerBlockSpec } from '@blocksuite/affine-block-divider';
|
||||
import { EdgelessTextBlockSpec } from '@blocksuite/affine-block-edgeless-text';
|
||||
import { EmbedExtensions } from '@blocksuite/affine-block-embed';
|
||||
@@ -34,7 +35,6 @@ import type { ExtensionType } from '@blocksuite/block-std';
|
||||
|
||||
import { AdapterFactoryExtensions } from '../_common/adapters/extension.js';
|
||||
import { DataViewBlockSpec } from '../data-view-block/data-view-spec.js';
|
||||
import { DatabaseBlockSpec } from '../database-block/database-spec.js';
|
||||
|
||||
export const CommonBlockSpecs: ExtensionType[] = [
|
||||
DocDisplayMetaService,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { richTextColumnConfig } from '@blocksuite/affine-block-database';
|
||||
import { type ListBlockModel, ListBlockSchema } from '@blocksuite/affine-model';
|
||||
import { propertyPresets } from '@blocksuite/data-view/property-presets';
|
||||
|
||||
import { richTextColumnConfig } from '../../database-block/properties/rich-text/cell-renderer.js';
|
||||
import { createBlockMeta } from './base.js';
|
||||
|
||||
export const todoMeta = createBlockMeta<ListBlockModel>({
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { richTextColumnConfig } from '@blocksuite/affine-block-database';
|
||||
import type { PropertyMetaConfig } from '@blocksuite/data-view';
|
||||
import { propertyPresets } from '@blocksuite/data-view/property-presets';
|
||||
|
||||
import { richTextColumnConfig } from '../../database-block/properties/rich-text/cell-renderer.js';
|
||||
|
||||
export const queryBlockColumns = [
|
||||
propertyPresets.datePropertyConfig,
|
||||
propertyPresets.numberPropertyConfig,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
databaseBlockAllPropertyMap,
|
||||
databasePropertyConverts,
|
||||
} from '@blocksuite/affine-block-database';
|
||||
import type { Column } from '@blocksuite/affine-model';
|
||||
import {
|
||||
insertPositionToIndex,
|
||||
@@ -9,10 +13,6 @@ import { propertyPresets } from '@blocksuite/data-view/property-presets';
|
||||
import { assertExists, Slot } from '@blocksuite/global/utils';
|
||||
import type { Block, Doc } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
databaseBlockAllPropertyMap,
|
||||
databasePropertyConverts,
|
||||
} from '../database-block/properties/index.js';
|
||||
import type { BlockMeta } from './block-meta/base.js';
|
||||
import { blockMetaMap } from './block-meta/index.js';
|
||||
import { queryBlockAllColumnMap, queryBlockColumns } from './columns/index.js';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BlockRenderer, NoteRenderer } from '@blocksuite/affine-block-database';
|
||||
import type { NoteBlockComponent } from '@blocksuite/affine-block-note';
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import {
|
||||
@@ -39,8 +40,6 @@ import { computed, signal } from '@preact/signals-core';
|
||||
import { css, nothing, unsafeCSS } from 'lit';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { BlockRenderer } from '../database-block/detail-panel/block-renderer.js';
|
||||
import { NoteRenderer } from '../database-block/detail-panel/note-renderer.js';
|
||||
import {
|
||||
EdgelessRootBlockComponent,
|
||||
type RootService,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ExtensionType } from '@blocksuite/block-std';
|
||||
|
||||
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,
|
||||
];
|
||||
@@ -1,292 +0,0 @@
|
||||
import {
|
||||
type Column,
|
||||
DatabaseBlockSchema,
|
||||
type SerializedCells,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
type InlineHtmlAST,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import { type BlockSnapshot, nanoid } from '@blocksuite/store';
|
||||
import { format } from 'date-fns/format';
|
||||
import type { Element } from 'hast';
|
||||
|
||||
const DATABASE_NODE_TYPES = new Set(['table', 'thead', 'tbody', 'th', 'tr']);
|
||||
|
||||
export const databaseBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: o =>
|
||||
HastUtils.isElement(o.node) && DATABASE_NODE_TYPES.has(o.node.tagName),
|
||||
fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext } = context;
|
||||
if (o.node.tagName === 'table') {
|
||||
const tableHeader = HastUtils.querySelector(o.node, 'thead');
|
||||
if (!tableHeader) {
|
||||
return;
|
||||
}
|
||||
const tableHeaderRow = HastUtils.querySelector(tableHeader, 'tr');
|
||||
if (!tableHeaderRow) {
|
||||
return;
|
||||
}
|
||||
// Table header row as database header row
|
||||
const viewsColumns = tableHeaderRow.children.map(() => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
hide: false,
|
||||
width: 180,
|
||||
};
|
||||
});
|
||||
|
||||
// Build database cells from table body rows
|
||||
const cells = Object.create(null);
|
||||
const tableBody = HastUtils.querySelector(o.node, 'tbody');
|
||||
tableBody?.children.forEach(row => {
|
||||
const rowId = nanoid();
|
||||
cells[rowId] = Object.create(null);
|
||||
(row as Element).children.forEach((cell, index) => {
|
||||
cells[rowId][viewsColumns[index].id] = {
|
||||
columnId: viewsColumns[index].id,
|
||||
value: TextUtils.createText(
|
||||
(cell as Element).children
|
||||
.map(child => ('value' in child ? child.value : ''))
|
||||
.join('')
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Build database columns from table header row
|
||||
const columns = tableHeaderRow.children.map((_child, index) => {
|
||||
return {
|
||||
type: index === 0 ? 'title' : 'rich-text',
|
||||
name: (_child as Element).children
|
||||
.map(child => ('value' in child ? child.value : ''))
|
||||
.join(''),
|
||||
data: {},
|
||||
id: viewsColumns[index].id,
|
||||
};
|
||||
});
|
||||
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:database',
|
||||
props: {
|
||||
views: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'Table View',
|
||||
mode: 'table',
|
||||
columns: [],
|
||||
filter: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
header: {
|
||||
titleColumn: viewsColumns[0]?.id,
|
||||
iconColumn: 'type',
|
||||
},
|
||||
},
|
||||
],
|
||||
title: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [],
|
||||
},
|
||||
cells,
|
||||
columns,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
walkerContext.setNodeContext('affine:table:rowid', Object.keys(cells));
|
||||
walkerContext.skipChildren(1);
|
||||
}
|
||||
|
||||
// The first child of each table body row is the database title cell
|
||||
if (o.node.tagName === 'tr') {
|
||||
const { deltaConverter } = context;
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'block',
|
||||
id:
|
||||
(
|
||||
walkerContext.getNodeContext(
|
||||
'affine:table:rowid'
|
||||
) as Array<string>
|
||||
).shift() ?? nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(o.node.children[0]),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
})
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
}
|
||||
},
|
||||
leave: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext } = context;
|
||||
if (o.node.tagName === 'table') {
|
||||
walkerContext.closeNode();
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
const columns = o.node.props.columns as Array<Column>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
|
||||
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 htmlAstRows = Array.prototype.map.call(
|
||||
children,
|
||||
(v: BlockSnapshot) => {
|
||||
const rowCells = Array.prototype.map.call(columns, col => {
|
||||
const cell = cells[v.id]?.[col.id];
|
||||
if (!cell && col.type !== 'title') {
|
||||
return createAstTableCell([{ type: 'text', value: '' }]);
|
||||
}
|
||||
switch (col.type) {
|
||||
case 'rich-text':
|
||||
return createAstTableCell(
|
||||
deltaConverter.deltaToAST(
|
||||
(cell.value as { delta: DeltaInsert[] }).delta
|
||||
)
|
||||
);
|
||||
case 'title':
|
||||
return createAstTableCell(
|
||||
deltaConverter.deltaToAST(
|
||||
(v.props.text as { delta: DeltaInsert[] }).delta
|
||||
)
|
||||
);
|
||||
case 'date':
|
||||
return createAstTableCell([
|
||||
{
|
||||
type: 'text',
|
||||
value: format(new Date(cell.value as number), 'yyyy-MM-dd'),
|
||||
},
|
||||
]);
|
||||
case 'select': {
|
||||
const value =
|
||||
(col.data.options.find(
|
||||
(opt: Record<string, string>) => opt.id === cell.value
|
||||
)?.value as string) ?? '';
|
||||
return createAstTableCell([{ type: 'text', value }]);
|
||||
}
|
||||
case 'multi-select': {
|
||||
const value = Array.prototype.map
|
||||
.call(
|
||||
cell.value,
|
||||
val =>
|
||||
col.data.options.find(
|
||||
(opt: Record<string, string>) => val === opt.id
|
||||
).value ?? ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
return createAstTableCell([{ type: 'text', value }]);
|
||||
}
|
||||
case 'checkbox': {
|
||||
return createAstTableCell([
|
||||
{ type: 'text', value: String(cell.value) },
|
||||
]);
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/no-duplicated-branches
|
||||
default:
|
||||
return createAstTableCell([
|
||||
{ type: 'text', value: String(cell.value) },
|
||||
]);
|
||||
}
|
||||
}) as InlineHtmlAST[];
|
||||
return createAstTableRow(rowCells);
|
||||
}
|
||||
) as Element[];
|
||||
|
||||
// Handle first row (header).
|
||||
const headerRow = createAstTableRow(
|
||||
Array.prototype.map.call(columns, v =>
|
||||
createAstTableHeaderCell([
|
||||
{
|
||||
type: 'text',
|
||||
value: v.name ?? '',
|
||||
},
|
||||
])
|
||||
) as Element[]
|
||||
);
|
||||
|
||||
const tableHeaderAst: Element = {
|
||||
type: 'element',
|
||||
tagName: 'thead',
|
||||
properties: Object.create(null),
|
||||
children: [headerRow],
|
||||
};
|
||||
|
||||
const tableBodyAst: Element = {
|
||||
type: 'element',
|
||||
tagName: 'tbody',
|
||||
properties: Object.create(null),
|
||||
children: [...htmlAstRows],
|
||||
};
|
||||
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'element',
|
||||
tagName: 'table',
|
||||
properties: Object.create(null),
|
||||
children: [tableHeaderAst, tableBodyAst],
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DatabaseBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
databaseBlockHtmlAdapterMatcher
|
||||
);
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './notion-html.js';
|
||||
@@ -1,253 +0,0 @@
|
||||
import {
|
||||
type Column,
|
||||
DatabaseBlockSchema,
|
||||
type SerializedCells,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
type MarkdownAST,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import { type BlockSnapshot, nanoid } from '@blocksuite/store';
|
||||
import { format } from 'date-fns/format';
|
||||
import type { TableRow } from 'mdast';
|
||||
|
||||
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: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
if (o.node.type === 'table') {
|
||||
const viewsColumns = o.node.children[0].children.map(() => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
hide: false,
|
||||
width: 180,
|
||||
};
|
||||
});
|
||||
const cells = Object.create(null);
|
||||
o.node.children.slice(1).forEach(row => {
|
||||
const rowId = nanoid();
|
||||
cells[rowId] = Object.create(null);
|
||||
row.children.slice(1).forEach((cell, index) => {
|
||||
cells[rowId][viewsColumns[index + 1].id] = {
|
||||
columnId: viewsColumns[index + 1].id,
|
||||
value: TextUtils.createText(
|
||||
cell.children
|
||||
.map(child => ('value' in child ? child.value : ''))
|
||||
.join('')
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
const columns = o.node.children[0].children.map((_child, index) => {
|
||||
return {
|
||||
type: index === 0 ? 'title' : 'rich-text',
|
||||
name: _child.children
|
||||
.map(child => ('value' in child ? child.value : ''))
|
||||
.join(''),
|
||||
data: {},
|
||||
id: viewsColumns[index].id,
|
||||
};
|
||||
});
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:database',
|
||||
props: {
|
||||
views: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'Table View',
|
||||
mode: 'table',
|
||||
columns: [],
|
||||
filter: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
header: {
|
||||
titleColumn: viewsColumns[0]?.id,
|
||||
iconColumn: 'type',
|
||||
},
|
||||
},
|
||||
],
|
||||
title: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [],
|
||||
},
|
||||
cells,
|
||||
columns,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
walkerContext.setNodeContext(
|
||||
'affine:table:rowid',
|
||||
Object.keys(cells)
|
||||
);
|
||||
walkerContext.skipChildren(1);
|
||||
}
|
||||
|
||||
if (o.node.type === 'tableRow') {
|
||||
const { deltaConverter } = context;
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'block',
|
||||
id:
|
||||
(
|
||||
walkerContext.getNodeContext(
|
||||
'affine:table:rowid'
|
||||
) as Array<string>
|
||||
).shift() ?? nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(o.node.children[0]),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
})
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
}
|
||||
},
|
||||
leave: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
if (o.node.type === 'table') {
|
||||
walkerContext.closeNode();
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
const rows: TableRow[] = [];
|
||||
const columns = o.node.props.columns as Array<Column>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const createAstCell = (children: MarkdownAST[]) => ({
|
||||
type: 'tableCell',
|
||||
children,
|
||||
});
|
||||
const mdAstCells = Array.prototype.map.call(
|
||||
children,
|
||||
(v: BlockSnapshot) =>
|
||||
Array.prototype.map.call(columns, col => {
|
||||
const cell = cells[v.id]?.[col.id];
|
||||
if (!cell && col.type !== 'title') {
|
||||
return createAstCell([{ type: 'text', value: '' }]);
|
||||
}
|
||||
switch (col.type) {
|
||||
case 'link':
|
||||
case 'progress':
|
||||
case 'number':
|
||||
return createAstCell([
|
||||
{
|
||||
type: 'text',
|
||||
value: cell.value as string,
|
||||
},
|
||||
]);
|
||||
case 'rich-text':
|
||||
return createAstCell(
|
||||
deltaConverter.deltaToAST(
|
||||
(cell.value as { delta: DeltaInsert[] }).delta
|
||||
)
|
||||
);
|
||||
case 'title':
|
||||
return createAstCell(
|
||||
deltaConverter.deltaToAST(
|
||||
(v.props.text as { delta: DeltaInsert[] }).delta
|
||||
)
|
||||
);
|
||||
case 'date':
|
||||
return createAstCell([
|
||||
{
|
||||
type: 'text',
|
||||
value: format(
|
||||
new Date(cell.value as number),
|
||||
'yyyy-MM-dd'
|
||||
),
|
||||
},
|
||||
]);
|
||||
case 'select': {
|
||||
const value = col.data.options.find(
|
||||
(opt: Record<string, string>) => opt.id === cell.value
|
||||
)?.value;
|
||||
return createAstCell([{ type: 'text', value }]);
|
||||
}
|
||||
case 'multi-select': {
|
||||
const value = Array.prototype.map
|
||||
.call(
|
||||
cell.value,
|
||||
val =>
|
||||
col.data.options.find(
|
||||
(opt: Record<string, string>) => val === opt.id
|
||||
).value
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
return createAstCell([{ type: 'text', value }]);
|
||||
}
|
||||
case 'checkbox': {
|
||||
return createAstCell([
|
||||
{ type: 'text', value: cell.value as string },
|
||||
]);
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/no-duplicated-branches
|
||||
default:
|
||||
return createAstCell([
|
||||
{ type: 'text', value: cell.value as string },
|
||||
]);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Handle first row.
|
||||
if (Array.isArray(columns)) {
|
||||
rows.push({
|
||||
type: 'tableRow',
|
||||
children: Array.prototype.map.call(columns, v =>
|
||||
createAstCell([
|
||||
{
|
||||
type: 'text',
|
||||
value: v.name,
|
||||
},
|
||||
])
|
||||
) as [],
|
||||
});
|
||||
}
|
||||
|
||||
// Handle 2-... rows
|
||||
Array.prototype.forEach.call(mdAstCells, children => {
|
||||
rows.push({ type: 'tableRow', children });
|
||||
});
|
||||
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'table',
|
||||
children: rows,
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DatabaseBlockMarkdownAdapterExtension =
|
||||
BlockMarkdownAdapterExtension(databaseBlockMarkdownAdapterMatcher);
|
||||
@@ -1,348 +0,0 @@
|
||||
import { DatabaseBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockNotionHtmlAdapterExtension,
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
TextUtils,
|
||||
} 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: [],
|
||||
}
|
||||
);
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].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[] = [];
|
||||
if (HastUtils.querySelector(child, '.selected-value')) {
|
||||
if (!('options' in columns[index].data)) {
|
||||
columns[index].data.options = [];
|
||||
}
|
||||
if (
|
||||
!['multi-select', 'select'].includes(columns[index].type)
|
||||
) {
|
||||
columns[index].type = 'select';
|
||||
}
|
||||
if (
|
||||
columns[index].type === 'select' &&
|
||||
child.type === 'element' &&
|
||||
child.children.length > 1
|
||||
) {
|
||||
columns[index].type = 'multi-select';
|
||||
}
|
||||
child.type === 'element' &&
|
||||
child.children.forEach(span => {
|
||||
const filteredArray = columns[index].data.options?.filter(
|
||||
option =>
|
||||
option.value === HastUtils.getTextContent(span)
|
||||
);
|
||||
const id = filteredArray?.length
|
||||
? filteredArray[0].id
|
||||
: nanoid();
|
||||
if (!filteredArray?.length) {
|
||||
columns[index].data.options?.push({
|
||||
id,
|
||||
value: HastUtils.getTextContent(span),
|
||||
color: getTagColor(),
|
||||
});
|
||||
}
|
||||
optionIds.push(id);
|
||||
});
|
||||
// Expand will be done when leaving the table
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: optionIds,
|
||||
};
|
||||
} else if (HastUtils.querySelector(child, '.checkbox')) {
|
||||
if (columns[index].type !== 'checkbox') {
|
||||
columns[index].type = 'checkbox';
|
||||
}
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: HastUtils.querySelector(child, '.checkbox-on')
|
||||
? true
|
||||
: false,
|
||||
};
|
||||
} else if (columns[index].type === 'number') {
|
||||
const text = HastUtils.getTextContent(child);
|
||||
const number = Number(text);
|
||||
if (Number.isNaN(number)) {
|
||||
columns[index].type = 'rich-text';
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: TextUtils.createText(text),
|
||||
};
|
||||
} else {
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: number,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: HastUtils.getTextContent(child),
|
||||
};
|
||||
}
|
||||
if (
|
||||
columns[index].type === 'rich-text' &&
|
||||
!TextUtils.isText(row[columns[index].id].value)
|
||||
) {
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: TextUtils.createText(row[columns[index].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 => {
|
||||
if (
|
||||
columns.find(column => column.id === columnId)?.type ===
|
||||
'select'
|
||||
) {
|
||||
row[columnId].value = (row[columnId].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);
|
||||
@@ -1,92 +0,0 @@
|
||||
import {
|
||||
type Column,
|
||||
DatabaseBlockSchema,
|
||||
type SerializedCells,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import type { BlockSnapshot } from '@blocksuite/store';
|
||||
import { format } from 'date-fns/format';
|
||||
|
||||
import { formatTable } 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<Column>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const tableCells = children.map((v: BlockSnapshot) =>
|
||||
columns.map(col => {
|
||||
const cell = cells[v.id]?.[col.id];
|
||||
if (!cell && col.type !== 'title') {
|
||||
return '';
|
||||
}
|
||||
switch (col.type) {
|
||||
case 'rich-text':
|
||||
return deltaConverter
|
||||
.deltaToAST((cell.value as { delta: DeltaInsert[] }).delta)
|
||||
.join('');
|
||||
case 'title':
|
||||
return deltaConverter
|
||||
.deltaToAST((v.props.text as { delta: DeltaInsert[] }).delta)
|
||||
.join('');
|
||||
case 'date':
|
||||
return format(new Date(cell.value as number), 'yyyy-MM-dd');
|
||||
case 'select': {
|
||||
const value = (
|
||||
col.data as { options: Array<Record<string, string>> }
|
||||
).options.find(opt => opt.id === cell.value)?.value;
|
||||
return value || '';
|
||||
}
|
||||
case 'multi-select': {
|
||||
const value = (cell.value as string[])
|
||||
.map(
|
||||
val =>
|
||||
(
|
||||
col.data as { options: Array<Record<string, string>> }
|
||||
).options.find(opt => val === opt.id)?.value
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
return value || '';
|
||||
}
|
||||
default:
|
||||
return String(cell.value);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Handle first row.
|
||||
if (Array.isArray(columns)) {
|
||||
rows.push(columns.map(col => col.name));
|
||||
}
|
||||
|
||||
// Handle 2-... rows
|
||||
tableCells.forEach(children => {
|
||||
rows.push(children);
|
||||
});
|
||||
|
||||
// Convert rows to table string
|
||||
const tableString = formatTable(rows);
|
||||
|
||||
context.textBuffer.content += tableString;
|
||||
context.textBuffer.content += '\n';
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DatabaseBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(databaseBlockPlainTextAdapterMatcher);
|
||||
@@ -1,32 +0,0 @@
|
||||
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], ' ')
|
||||
);
|
||||
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');
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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 & { type?: string }
|
||||
): TemplateResult => {
|
||||
if (model.flavour === 'affine:paragraph') {
|
||||
const type = model.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.type ?? 'bulleted'] ?? BulletedListIcon()
|
||||
);
|
||||
}
|
||||
return TextIcon();
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { BlockCommands, Command } from '@blocksuite/block-std';
|
||||
|
||||
export const insertDatabaseBlockCommand: Command<
|
||||
'selectedModels',
|
||||
'insertedDatabaseBlockId',
|
||||
{
|
||||
viewType: string;
|
||||
place?: 'after' | 'before';
|
||||
removeEmptyLine?: boolean;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { selectedModels, viewType, place, removeEmptyLine, std } = ctx;
|
||||
if (!selectedModels?.length) return;
|
||||
|
||||
const targetModel =
|
||||
place === 'before'
|
||||
? selectedModels[0]
|
||||
: selectedModels[selectedModels.length - 1];
|
||||
|
||||
const service = std.getService('affine:database');
|
||||
if (!service) return;
|
||||
|
||||
const result = std.doc.addSiblingBlocks(
|
||||
targetModel,
|
||||
[{ flavour: 'affine:database' }],
|
||||
place
|
||||
);
|
||||
if (result.length === 0) return;
|
||||
|
||||
service.initDatabaseBlock(std.doc, targetModel, result[0], viewType, false);
|
||||
|
||||
if (removeEmptyLine && targetModel.text?.length === 0) {
|
||||
std.doc.deleteBlock(targetModel);
|
||||
}
|
||||
|
||||
next({ insertedDatabaseBlockId: result[0] });
|
||||
};
|
||||
|
||||
export const commands: BlockCommands = {
|
||||
insertDatabaseBlock: insertDatabaseBlockCommand,
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { createModal } from '@blocksuite/affine-components/context-menu';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { CloseIcon } from '@blocksuite/icons/lit';
|
||||
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);
|
||||
});
|
||||
};
|
||||
@@ -1,185 +0,0 @@
|
||||
import { stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { MenuOptions } from '@blocksuite/affine-components/context-menu';
|
||||
import { type DatabaseBlockModel } from '@blocksuite/affine-model';
|
||||
|
||||
export interface DatabaseOptionsConfig {
|
||||
configure: (model: DatabaseBlockModel, options: MenuOptions) => MenuOptions;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { createContextKey } from '@blocksuite/data-view';
|
||||
|
||||
export const HostContextKey = createContextKey<EditorHost | undefined>(
|
||||
'editor-host',
|
||||
undefined
|
||||
);
|
||||
@@ -1,498 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import type { DatabaseBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
insertPositionToIndex,
|
||||
type InsertToPosition,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import {
|
||||
type DatabaseFlags,
|
||||
DataSourceBase,
|
||||
type DataViewDataType,
|
||||
getTagColor,
|
||||
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 { assertExists } from '@blocksuite/global/utils';
|
||||
import { type BlockModel, nanoid, Text } from '@blocksuite/store';
|
||||
import { computed, type ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
import { getIcon } from './block-icons.js';
|
||||
import {
|
||||
databaseBlockAllPropertyMap,
|
||||
databaseBlockPropertyList,
|
||||
databasePropertyConverts,
|
||||
} from './properties/index.js';
|
||||
import { titlePurePropertyConfig } from './properties/title/define.js';
|
||||
import {
|
||||
addProperty,
|
||||
applyCellsUpdate,
|
||||
applyPropertyUpdate,
|
||||
copyCellsByProperty,
|
||||
deleteRows,
|
||||
deleteView,
|
||||
duplicateView,
|
||||
findPropertyIndex,
|
||||
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 {
|
||||
private _batch = 0;
|
||||
|
||||
private readonly _model: DatabaseBlockModel;
|
||||
|
||||
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
|
||||
return {
|
||||
enable_number_formatting:
|
||||
this.doc.awarenessStore.getFlag('enable_database_number_formatting') ??
|
||||
false,
|
||||
};
|
||||
});
|
||||
|
||||
properties$: ReadonlySignal<string[]> = computed(() => {
|
||||
return this._model.columns$.value.map(column => column.id);
|
||||
});
|
||||
|
||||
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.views$.value as DataViewDataType[];
|
||||
});
|
||||
|
||||
override viewManager: ViewManager = new ViewManagerBase(this);
|
||||
|
||||
viewMetas = databaseBlockViews;
|
||||
|
||||
get doc() {
|
||||
return this._model.doc;
|
||||
}
|
||||
|
||||
get propertyMetas(): PropertyMetaConfig<any, any, any>[] {
|
||||
return databaseBlockPropertyList;
|
||||
}
|
||||
|
||||
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.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);
|
||||
const update = this.propertyMetaGet(type).config.valueUpdate;
|
||||
let newValue = value;
|
||||
if (update) {
|
||||
const old = this.cellValueGet(rowId, propertyId);
|
||||
newValue = update({
|
||||
value: old,
|
||||
data: this.propertyDataGet(propertyId),
|
||||
dataSource: this,
|
||||
newValue: value,
|
||||
});
|
||||
}
|
||||
if (type === 'title' && newValue instanceof Text) {
|
||||
this._model.doc.transact(() => {
|
||||
this._model.text?.clear();
|
||||
this._model.text?.join(newValue);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this._model.columns$.value.some(v => v.id === propertyId)) {
|
||||
updateCell(this._model, rowId, {
|
||||
columnId: propertyId,
|
||||
value: newValue,
|
||||
});
|
||||
applyCellsUpdate(this._model);
|
||||
}
|
||||
}
|
||||
|
||||
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 === 'title') {
|
||||
const model = this.getModelById(rowId);
|
||||
return model?.text;
|
||||
}
|
||||
return getCell(this._model, rowId, propertyId)?.value;
|
||||
}
|
||||
|
||||
propertyAdd(insertToPosition: InsertToPosition, type?: string): string {
|
||||
this.doc.captureSync();
|
||||
const result = addProperty(
|
||||
this._model,
|
||||
insertToPosition,
|
||||
databaseBlockAllPropertyMap[
|
||||
type ?? propertyPresets.multiSelectPropertyConfig.type
|
||||
].create(this.newPropertyName())
|
||||
);
|
||||
applyPropertyUpdate(this._model);
|
||||
return result;
|
||||
}
|
||||
|
||||
propertyDataGet(propertyId: string): Record<string, unknown> {
|
||||
return (
|
||||
this._model.columns$.value.find(v => v.id === propertyId)?.data ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
propertyDataSet(propertyId: string, data: Record<string, unknown>): void {
|
||||
this._runCapture();
|
||||
|
||||
updateProperty(this._model, propertyId, () => ({ data }));
|
||||
applyPropertyUpdate(this._model);
|
||||
}
|
||||
|
||||
propertyDataTypeGet(propertyId: string): TypeInstance | undefined {
|
||||
const data = this._model.columns$.value.find(v => v.id === propertyId);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const meta = this.propertyMetaGet(data.type);
|
||||
return meta.config.type({
|
||||
data: data.data,
|
||||
dataSource: this,
|
||||
});
|
||||
}
|
||||
|
||||
propertyDelete(id: string): void {
|
||||
this.doc.captureSync();
|
||||
const index = findPropertyIndex(this._model, id);
|
||||
if (index < 0) return;
|
||||
|
||||
this.doc.transact(() => {
|
||||
this._model.columns = this._model.columns.filter((_, i) => i !== index);
|
||||
});
|
||||
}
|
||||
|
||||
propertyDuplicate(propertyId: string): string {
|
||||
this.doc.captureSync();
|
||||
const currentSchema = getProperty(this._model, propertyId);
|
||||
assertExists(currentSchema);
|
||||
const { id: copyId, ...nonIdProps } = currentSchema;
|
||||
const names = new Set(this._model.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);
|
||||
applyPropertyUpdate(this._model);
|
||||
return id;
|
||||
}
|
||||
|
||||
propertyMetaGet(type: string): PropertyMetaConfig {
|
||||
return databaseBlockAllPropertyMap[type];
|
||||
}
|
||||
|
||||
propertyNameGet(propertyId: string): string {
|
||||
if (propertyId === 'type') {
|
||||
return 'Block Type';
|
||||
}
|
||||
return (
|
||||
this._model.columns$.value.find(v => v.id === propertyId)?.name ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
propertyNameSet(propertyId: string, name: string): void {
|
||||
this.doc.captureSync();
|
||||
updateProperty(this._model, propertyId, () => ({ name }));
|
||||
applyPropertyUpdate(this._model);
|
||||
}
|
||||
|
||||
override propertyReadonlyGet(propertyId: string): boolean {
|
||||
if (propertyId === 'type') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
propertyTypeGet(propertyId: string): string {
|
||||
if (propertyId === 'type') {
|
||||
return 'image';
|
||||
}
|
||||
return (
|
||||
this._model.columns$.value.find(v => v.id === propertyId)?.type ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
propertyTypeSet(propertyId: string, toType: string): void {
|
||||
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: databaseBlockAllPropertyMap[toType].config.defaultData(),
|
||||
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) {
|
||||
cells[rows[i]] = result.cells[i];
|
||||
}
|
||||
});
|
||||
updateCells(this._model, propertyId, cells);
|
||||
applyPropertyUpdate(this._model);
|
||||
}
|
||||
|
||||
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.getBlockById(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.views = [...this._model.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 {
|
||||
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 {
|
||||
return databaseBlockViewMap[type];
|
||||
}
|
||||
|
||||
viewMetaGetById(viewId: string): ViewMeta {
|
||||
const view = this.viewDataGet(viewId);
|
||||
return this.viewMetaGet(view.mode);
|
||||
}
|
||||
}
|
||||
|
||||
export const databaseViewAddView = (
|
||||
model: DatabaseBlockModel,
|
||||
viewType: string
|
||||
) => {
|
||||
const dataSource = new DatabaseBlockDataSource(model);
|
||||
dataSource.viewManager.viewAdd(viewType);
|
||||
};
|
||||
export const databaseViewInitEmpty = (
|
||||
model: DatabaseBlockModel,
|
||||
viewType: string
|
||||
) => {
|
||||
addProperty(
|
||||
model,
|
||||
'start',
|
||||
titlePurePropertyConfig.create(titlePurePropertyConfig.config.name)
|
||||
);
|
||||
databaseViewAddView(model, viewType);
|
||||
};
|
||||
export const databaseViewInitConvert = (
|
||||
model: DatabaseBlockModel,
|
||||
viewType: string
|
||||
) => {
|
||||
addProperty(
|
||||
model,
|
||||
'end',
|
||||
propertyPresets.multiSelectPropertyConfig.create('Tag', { options: [] })
|
||||
);
|
||||
databaseViewInitEmpty(model, viewType);
|
||||
};
|
||||
export const databaseViewInitTemplate = (
|
||||
model: DatabaseBlockModel,
|
||||
viewType: string
|
||||
) => {
|
||||
const ids = [nanoid(), nanoid(), nanoid()];
|
||||
const statusId = addProperty(
|
||||
model,
|
||||
'end',
|
||||
propertyPresets.selectPropertyConfig.create('Status', {
|
||||
options: [
|
||||
{
|
||||
id: ids[0],
|
||||
color: getTagColor(),
|
||||
value: 'TODO',
|
||||
},
|
||||
{
|
||||
id: ids[1],
|
||||
color: getTagColor(),
|
||||
value: 'In Progress',
|
||||
},
|
||||
{
|
||||
id: ids[2],
|
||||
color: getTagColor(),
|
||||
value: 'Done',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const rowId = model.doc.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: new model.doc.Text(`Task ${i + 1}`),
|
||||
},
|
||||
model.id
|
||||
);
|
||||
updateCell(model, rowId, {
|
||||
columnId: statusId,
|
||||
value: ids[i],
|
||||
});
|
||||
}
|
||||
databaseViewInitEmpty(model, viewType);
|
||||
};
|
||||
export const convertToDatabase = (host: EditorHost, viewType: string) => {
|
||||
const [_, ctx] = host.std.command
|
||||
.chain()
|
||||
.getSelectedModels({
|
||||
types: ['block', 'text'],
|
||||
})
|
||||
.run();
|
||||
const { selectedModels } = ctx;
|
||||
if (!selectedModels || selectedModels.length === 0) return;
|
||||
|
||||
host.doc.captureSync();
|
||||
|
||||
const parentModel = host.doc.getParent(selectedModels[0]);
|
||||
if (!parentModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = host.doc.addBlock(
|
||||
'affine:database',
|
||||
{},
|
||||
parentModel,
|
||||
parentModel.children.indexOf(selectedModels[0])
|
||||
);
|
||||
const databaseModel = host.doc.getBlock(id)?.model as
|
||||
| DatabaseBlockModel
|
||||
| undefined;
|
||||
if (!databaseModel) {
|
||||
return;
|
||||
}
|
||||
databaseViewInitConvert(databaseModel, viewType);
|
||||
applyPropertyUpdate(databaseModel);
|
||||
host.doc.moveBlocks(selectedModels, databaseModel);
|
||||
|
||||
const selectionManager = host.selection;
|
||||
selectionManager.clear();
|
||||
};
|
||||
@@ -1,484 +0,0 @@
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { DragIndicator } from '@blocksuite/affine-components/drag-indicator';
|
||||
import { PeekViewProvider } from '@blocksuite/affine-components/peek';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import type { DatabaseBlockModel } from '@blocksuite/affine-model';
|
||||
import { NOTE_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 {
|
||||
type BlockComponent,
|
||||
RANGE_SYNC_EXCLUDE_ATTR,
|
||||
} from '@blocksuite/block-std';
|
||||
import {
|
||||
createRecordDetail,
|
||||
createUniComponentFromWebComponent,
|
||||
DatabaseSelection,
|
||||
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/utils';
|
||||
import {
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
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 type { DatabaseOptionsConfig } from './config.js';
|
||||
import { HostContextKey } from './context/host-context.js';
|
||||
import { DatabaseBlockDataSource } from './data-source.js';
|
||||
import type { DatabaseBlockService } from './database-service.js';
|
||||
import { BlockRenderer } from './detail-panel/block-renderer.js';
|
||||
import { NoteRenderer } from './detail-panel/note-renderer.js';
|
||||
import { currentViewStorage } from './utils/current-view.js';
|
||||
import { getSingleDocIdFromText } from './utils/title-doc.js';
|
||||
|
||||
export class DatabaseBlockComponent extends CaptionedBlockComponent<
|
||||
DatabaseBlockModel,
|
||||
DatabaseBlockService
|
||||
> {
|
||||
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.title.toString(),
|
||||
placeholder: 'Untitled',
|
||||
onChange: text => {
|
||||
this.model.title.replace(0, this.model.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.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 DragIndicator();
|
||||
|
||||
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.type === 'in';
|
||||
if (shouldInsertIn) {
|
||||
parent = target;
|
||||
}
|
||||
if (model && target && parent) {
|
||||
if (shouldInsertIn) {
|
||||
this.doc.moveBlocks([model], parent);
|
||||
} else {
|
||||
this.doc.moveBlocks(
|
||||
[model],
|
||||
parent,
|
||||
target,
|
||||
result.type === '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.setCurrentView(id);
|
||||
}
|
||||
}
|
||||
return this._dataSource;
|
||||
}
|
||||
|
||||
get optionsConfig(): DatabaseOptionsConfig {
|
||||
return {
|
||||
configure: (_model, options) => options,
|
||||
...this.std.getConfig('affine:page')?.databaseOptions,
|
||||
};
|
||||
}
|
||||
|
||||
override get topContenteditableElement() {
|
||||
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
|
||||
return this.closest<BlockComponent>(NOTE_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;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import {
|
||||
type DatabaseBlockModel,
|
||||
DatabaseBlockSchema,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { BlockService } from '@blocksuite/block-std';
|
||||
import { viewPresets } from '@blocksuite/data-view/view-presets';
|
||||
import type { BlockModel, Doc } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
databaseViewAddView,
|
||||
databaseViewInitEmpty,
|
||||
databaseViewInitTemplate,
|
||||
} from './data-source.js';
|
||||
import {
|
||||
addProperty,
|
||||
applyPropertyUpdate,
|
||||
updateCell,
|
||||
updateView,
|
||||
} from './utils/block-utils.js';
|
||||
|
||||
export class DatabaseBlockService extends BlockService {
|
||||
static override readonly flavour = DatabaseBlockSchema.model.flavour;
|
||||
|
||||
addColumn = addProperty;
|
||||
|
||||
applyColumnUpdate = applyPropertyUpdate;
|
||||
|
||||
databaseViewAddView = databaseViewAddView;
|
||||
|
||||
databaseViewInitEmpty = databaseViewInitEmpty;
|
||||
|
||||
updateCell = updateCell;
|
||||
|
||||
updateView = updateView;
|
||||
|
||||
viewPresets = viewPresets;
|
||||
|
||||
initDatabaseBlock(
|
||||
doc: Doc,
|
||||
model: BlockModel,
|
||||
databaseId: string,
|
||||
viewType: string,
|
||||
isAppendNewRow = true
|
||||
) {
|
||||
const blockModel = doc.getBlock(databaseId)?.model as
|
||||
| DatabaseBlockModel
|
||||
| undefined;
|
||||
if (!blockModel) {
|
||||
return;
|
||||
}
|
||||
databaseViewInitTemplate(blockModel, viewType);
|
||||
if (isAppendNewRow) {
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return;
|
||||
doc.addBlock('affine:paragraph', {}, parent.id);
|
||||
}
|
||||
applyPropertyUpdate(blockModel);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import {
|
||||
BlockViewExtension,
|
||||
CommandExtension,
|
||||
type ExtensionType,
|
||||
FlavourExtension,
|
||||
} from '@blocksuite/block-std';
|
||||
import { DatabaseSelectionExtension } from '@blocksuite/data-view';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { DatabaseBlockAdapterExtensions } from './adapters/extension.js';
|
||||
import { commands } from './commands.js';
|
||||
import { DatabaseBlockService } from './database-service.js';
|
||||
|
||||
export const DatabaseBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:database'),
|
||||
DatabaseBlockService,
|
||||
CommandExtension(commands),
|
||||
BlockViewExtension('affine:database', literal`affine-database`),
|
||||
DatabaseSelectionExtension,
|
||||
DatabaseBlockAdapterExtensions,
|
||||
].flat();
|
||||
@@ -1,161 +0,0 @@
|
||||
import { DefaultInlineManagerExtension } from '@blocksuite/affine-components/rich-text';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import type { DetailSlotProps } from '@blocksuite/data-view';
|
||||
import type {
|
||||
KanbanSingleView,
|
||||
TableSingleView,
|
||||
} from '@blocksuite/data-view/view-presets';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html } 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 var(--affine-border-color);
|
||||
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;
|
||||
}
|
||||
|
||||
get service() {
|
||||
return this.host.std.getService('affine:database');
|
||||
}
|
||||
|
||||
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}
|
||||
.markdownShortcutHandler=${this.inlineManager.markdownShortcutHandler}
|
||||
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;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import type {
|
||||
DatabaseBlockModel,
|
||||
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,
|
||||
matchFlavours,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { type EditorHost, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import type { DetailSlotProps, SingleView } from '@blocksuite/data-view';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { BaseTextAttributes } from '@blocksuite/inline';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html } 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.collection;
|
||||
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.setDocMeta(note.id, { title: rowContent });
|
||||
if (note.root) {
|
||||
(note.root as RootBlockModel).title.insert(rowContent ?? '', 0);
|
||||
note.root.children
|
||||
.find(child => child.flavour === 'affine:note')
|
||||
?.children.find(block =>
|
||||
matchFlavours(block, [
|
||||
'affine:paragraph',
|
||||
'affine:list',
|
||||
'affine:code',
|
||||
])
|
||||
);
|
||||
}
|
||||
// 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: var(--affine-border-color);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;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { DatabaseBlockModel } from '@blocksuite/affine-model';
|
||||
|
||||
import type { insertDatabaseBlockCommand } from './commands.js';
|
||||
|
||||
export function effects() {
|
||||
// TODO(@L-Sun): move other effects to this file
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface BlockModels {
|
||||
'affine:database': DatabaseBlockModel;
|
||||
}
|
||||
|
||||
interface CommandContext {
|
||||
insertedDatabaseBlockId?: string;
|
||||
}
|
||||
|
||||
interface Commands {
|
||||
/**
|
||||
* insert a database block after or before the current block selection
|
||||
* @param latex the LaTeX content. A input dialog will be shown if not provided
|
||||
* @param removeEmptyLine remove the current block if it is empty
|
||||
* @param place where to insert the LaTeX block
|
||||
* @returns the id of the inserted LaTeX block
|
||||
*/
|
||||
insertDatabaseBlock: typeof insertDatabaseBlockCommand;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { DatabaseBlockModel } from '@blocksuite/affine-model';
|
||||
|
||||
export * from './adapters/markdown.js';
|
||||
export type { DatabaseOptionsConfig } from './config.js';
|
||||
export * from './data-source.js';
|
||||
export * from './database-block.js';
|
||||
export * from './database-service.js';
|
||||
export * from './database-spec.js';
|
||||
export { databaseBlockColumns } from './properties/index.js';
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface BlockModels {
|
||||
'affine:database': DatabaseBlockModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { clamp } from '@blocksuite/affine-shared/utils';
|
||||
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 { nanoid, Text } from '@blocksuite/store';
|
||||
|
||||
import { richTextColumnModelConfig } from './rich-text/define.js';
|
||||
|
||||
export const databasePropertyConverts = [
|
||||
...presetPropertyConverts,
|
||||
createPropertyConvert(
|
||||
richTextColumnModelConfig,
|
||||
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(
|
||||
richTextColumnModelConfig,
|
||||
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(
|
||||
richTextColumnModelConfig,
|
||||
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(
|
||||
richTextColumnModelConfig,
|
||||
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(
|
||||
richTextColumnModelConfig,
|
||||
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,
|
||||
richTextColumnModelConfig,
|
||||
(_property, cells) => {
|
||||
return {
|
||||
property: {},
|
||||
cells: cells.map(v => new Text(v ? 'Yes' : 'No').yText),
|
||||
};
|
||||
}
|
||||
),
|
||||
createPropertyConvert(
|
||||
propertyModelPresets.multiSelectPropertyModelConfig,
|
||||
richTextColumnModelConfig,
|
||||
(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,
|
||||
richTextColumnModelConfig,
|
||||
(_property, cells) => ({
|
||||
property: {},
|
||||
cells: cells.map(v => new Text(v?.toString()).yText),
|
||||
})
|
||||
),
|
||||
createPropertyConvert(
|
||||
propertyModelPresets.progressPropertyModelConfig,
|
||||
richTextColumnModelConfig,
|
||||
(_property, cells) => ({
|
||||
property: {},
|
||||
cells: cells.map(v => new Text(v?.toString()).yText),
|
||||
})
|
||||
),
|
||||
createPropertyConvert(
|
||||
propertyModelPresets.selectPropertyModelConfig,
|
||||
richTextColumnModelConfig,
|
||||
(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),
|
||||
};
|
||||
}
|
||||
),
|
||||
];
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { PropertyMetaConfig } from '@blocksuite/data-view';
|
||||
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 databaseBlockColumns = {
|
||||
checkboxColumnConfig: checkboxPropertyConfig,
|
||||
dateColumnConfig: datePropertyConfig,
|
||||
multiSelectColumnConfig: multiSelectPropertyConfig,
|
||||
numberColumnConfig: numberPropertyConfig,
|
||||
progressColumnConfig: progressPropertyConfig,
|
||||
selectColumnConfig: selectPropertyConfig,
|
||||
linkColumnConfig,
|
||||
richTextColumnConfig,
|
||||
};
|
||||
export const databaseBlockPropertyList = Object.values(databaseBlockColumns);
|
||||
export const databaseBlockHiddenColumns = [
|
||||
propertyPresets.imagePropertyConfig,
|
||||
titleColumnConfig,
|
||||
];
|
||||
const databaseBlockAllColumns = [
|
||||
...databaseBlockPropertyList,
|
||||
...databaseBlockHiddenColumns,
|
||||
];
|
||||
export const databaseBlockAllPropertyMap = Object.fromEntries(
|
||||
databaseBlockAllColumns.map(v => [v.type, v as PropertyMetaConfig])
|
||||
);
|
||||
@@ -1,256 +0,0 @@
|
||||
import { RefNodeSlotsProvider } from '@blocksuite/affine-components/rich-text';
|
||||
import { ParseDocUrlProvider } from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import {
|
||||
isValidUrl,
|
||||
normalizeUrl,
|
||||
stopPropagation,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BaseCellRenderer,
|
||||
createFromBaseCellRenderer,
|
||||
createIcon,
|
||||
} from '@blocksuite/data-view';
|
||||
import { EditIcon } from '@blocksuite/icons/lit';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, nothing, unsafeCSS } from 'lit';
|
||||
import { query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import { linkColumnModelConfig } from './define.js';
|
||||
|
||||
export class LinkCell extends BaseCellRenderer<string> {
|
||||
static override styles = css`
|
||||
affine-database-link-cell {
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
affine-database-link-cell:hover .affine-database-link-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.affine-database-link {
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
line-height: var(--data-view-cell-text-line-height);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
affine-database-link-node {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.affine-database-link-icon {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
visibility: hidden;
|
||||
cursor: pointer;
|
||||
background: ${unsafeCSSVarV2('button/iconButtonSolid')};
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
box-shadow: var(--affine-button-shadow);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.affine-database-link-icon:hover {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.data-view-link-column-linked-doc {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--affine-divider-color);
|
||||
transition: text-decoration-color 0.2s ease-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-view-link-column-linked-doc:hover {
|
||||
text-decoration-color: var(--affine-icon-color);
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _onClick = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
const value = this.value ?? '';
|
||||
|
||||
if (!value || !isValidUrl(value)) {
|
||||
this.selectCurrentCell(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidUrl(value)) {
|
||||
const target = event.target as HTMLElement;
|
||||
const link = target.querySelector<HTMLAnchorElement>('.link-node');
|
||||
if (link) {
|
||||
event.preventDefault();
|
||||
link.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _onEdit = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.selectCurrentCell(true);
|
||||
};
|
||||
|
||||
private preValue?: string;
|
||||
|
||||
openDoc = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!this.docId) {
|
||||
return;
|
||||
}
|
||||
const std = this.std;
|
||||
if (!std) {
|
||||
return;
|
||||
}
|
||||
|
||||
std
|
||||
.getOptional(RefNodeSlotsProvider)
|
||||
?.docLinkClicked.emit({ pageId: this.docId });
|
||||
};
|
||||
|
||||
get std() {
|
||||
const host = this.view.contextGet(HostContextKey);
|
||||
return host?.std;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const linkText = this.value ?? '';
|
||||
const docName =
|
||||
this.docId && this.std?.collection.getDoc(this.docId)?.meta?.title;
|
||||
return html`
|
||||
<div class="affine-database-link" @click="${this._onClick}">
|
||||
${docName
|
||||
? html`<span
|
||||
class="data-view-link-column-linked-doc"
|
||||
@click="${this.openDoc}"
|
||||
>${docName}</span
|
||||
>`
|
||||
: html` <affine-database-link-node
|
||||
.link="${linkText}"
|
||||
></affine-database-link-node>`}
|
||||
</div>
|
||||
${docName || linkText
|
||||
? html` <div class="affine-database-link-icon" @click="${this._onEdit}">
|
||||
${EditIcon()}
|
||||
</div>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
override updated() {
|
||||
if (this.value !== this.preValue) {
|
||||
const std = this.std;
|
||||
this.preValue = this.value;
|
||||
if (!this.value || !isValidUrl(this.value)) {
|
||||
this.docId = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
this.docId =
|
||||
std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(this.value)?.docId ??
|
||||
undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor docId: string | undefined = undefined;
|
||||
}
|
||||
|
||||
export class LinkCellEditing extends BaseCellRenderer<string> {
|
||||
static override styles = css`
|
||||
affine-database-link-cell-editing {
|
||||
width: 100%;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.affine-database-link-editing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
color: var(--affine-text-primary-color);
|
||||
font-weight: 400;
|
||||
background-color: transparent;
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
line-height: var(--data-view-cell-text-line-height);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.affine-database-link-editing:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _focusEnd = () => {
|
||||
const end = this._container.value.length;
|
||||
this._container.focus();
|
||||
this._container.setSelectionRange(end, end);
|
||||
};
|
||||
|
||||
private readonly _onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
this._setValue();
|
||||
setTimeout(() => {
|
||||
this.selectCurrentCell(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _setValue = (value: string = this._container.value) => {
|
||||
let url = value;
|
||||
if (isValidUrl(value)) {
|
||||
url = normalizeUrl(value);
|
||||
}
|
||||
|
||||
this.onChange(url);
|
||||
this._container.value = url;
|
||||
};
|
||||
|
||||
override firstUpdated() {
|
||||
this._focusEnd();
|
||||
}
|
||||
|
||||
override onExitEditMode() {
|
||||
this._setValue();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const linkText = this.value ?? '';
|
||||
|
||||
return html`<input
|
||||
class="affine-database-link-editing link"
|
||||
.value="${linkText}"
|
||||
@keydown="${this._onKeydown}"
|
||||
@pointerdown="${stopPropagation}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
@query('.affine-database-link-editing')
|
||||
private accessor _container!: HTMLInputElement;
|
||||
}
|
||||
|
||||
export const linkColumnConfig = linkColumnModelConfig.createPropertyMeta({
|
||||
icon: createIcon('LinkIcon'),
|
||||
cellRenderer: {
|
||||
view: createFromBaseCellRenderer(LinkCell),
|
||||
edit: createFromBaseCellRenderer(LinkCellEditing),
|
||||
},
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { isValidUrl } from '@blocksuite/affine-shared/utils';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class LinkNode extends ShadowlessElement {
|
||||
static override styles = css`
|
||||
.link-node {
|
||||
word-break: break-all;
|
||||
color: var(--affine-link-color);
|
||||
fill: var(--affine-link-color);
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
text-decoration: none;
|
||||
}
|
||||
`;
|
||||
|
||||
protected override render() {
|
||||
if (!isValidUrl(this.link)) {
|
||||
return html`<span class="normal-text">${this.link}</span>`;
|
||||
}
|
||||
|
||||
return html`<a
|
||||
class="link-node"
|
||||
href=${this.link}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
><span class="link-node-text">${this.link}</span></a
|
||||
>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor link!: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-database-link-node': LinkNode;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { propertyType, t } from '@blocksuite/data-view';
|
||||
|
||||
export const linkColumnType = propertyType('link');
|
||||
export const linkColumnModelConfig = linkColumnType.modelConfig<string>({
|
||||
name: 'Link',
|
||||
type: () => t.string.instance(),
|
||||
defaultData: () => ({}),
|
||||
cellToString: ({ value }) => value?.toString() ?? '',
|
||||
cellFromString: ({ value }) => {
|
||||
return {
|
||||
value: value,
|
||||
};
|
||||
},
|
||||
cellToJson: ({ value }) => value ?? null,
|
||||
cellFromJson: ({ value }) => (typeof value !== 'string' ? undefined : value),
|
||||
|
||||
isEmpty: ({ value }) => value == null || value.length == 0,
|
||||
});
|
||||
@@ -1,398 +0,0 @@
|
||||
import {
|
||||
type AffineInlineEditor,
|
||||
DefaultInlineManagerExtension,
|
||||
type RichText,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BaseCellRenderer,
|
||||
createFromBaseCellRenderer,
|
||||
createIcon,
|
||||
} from '@blocksuite/data-view';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { css, nothing, type PropertyValues } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||
import { richTextColumnModelConfig } from './define.js';
|
||||
|
||||
function toggleStyle(
|
||||
inlineEditor: AffineInlineEditor,
|
||||
attrs: AffineTextAttributes
|
||||
): void {
|
||||
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> {
|
||||
static override styles = css`
|
||||
affine-database-rich-text-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.affine-database-rich-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
line-height: var(--data-view-cell-text-line-height);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.affine-database-rich-text v-line {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.affine-database-rich-text v-line > div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
get attributeRenderer() {
|
||||
return this.inlineManager?.getRenderer();
|
||||
}
|
||||
|
||||
get attributesSchema() {
|
||||
return this.inlineManager?.getSchema();
|
||||
}
|
||||
|
||||
get inlineEditor() {
|
||||
assertExists(this._richTextElement);
|
||||
const inlineEditor = this._richTextElement.inlineEditor;
|
||||
assertExists(inlineEditor);
|
||||
return inlineEditor;
|
||||
}
|
||||
|
||||
get inlineManager() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
get service() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.getService('affine:database');
|
||||
}
|
||||
|
||||
get topContenteditableElement() {
|
||||
const databaseBlock =
|
||||
this.closest<DatabaseBlockComponent>('affine-database');
|
||||
return databaseBlock?.topContenteditableElement;
|
||||
}
|
||||
|
||||
private changeUserSelectAccordToReadOnly() {
|
||||
if (this && this instanceof HTMLElement) {
|
||||
this.style.userSelect = this.readonly ? 'text' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.changeUserSelectAccordToReadOnly();
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.service) return nothing;
|
||||
if (!this.value || !(this.value instanceof Text)) {
|
||||
return html`<div class="affine-database-rich-text"></div>`;
|
||||
}
|
||||
return keyed(
|
||||
this.value,
|
||||
html`<rich-text
|
||||
.yText=${this.value}
|
||||
.attributesSchema=${this.attributesSchema}
|
||||
.attributeRenderer=${this.attributeRenderer}
|
||||
.embedChecker=${this.inlineManager?.embedChecker}
|
||||
.markdownShortcutHandler=${this.inlineManager?.markdownShortcutHandler}
|
||||
.readonly=${true}
|
||||
class="affine-database-rich-text inline-editor"
|
||||
></rich-text>`
|
||||
);
|
||||
}
|
||||
|
||||
override updated(changedProperties: PropertyValues) {
|
||||
if (changedProperties.has('readonly')) {
|
||||
this.changeUserSelectAccordToReadOnly();
|
||||
}
|
||||
}
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _richTextElement: RichText | null = null;
|
||||
}
|
||||
|
||||
export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
||||
static override styles = css`
|
||||
affine-database-rich-text-cell-editing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 1px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.affine-database-rich-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.affine-database-rich-text v-line {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.affine-database-rich-text v-line > div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
|
||||
switch (event.key) {
|
||||
// bold ctrl+b
|
||||
case 'B':
|
||||
case 'b':
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(this.inlineEditor, { bold: true });
|
||||
}
|
||||
break;
|
||||
// italic ctrl+i
|
||||
case 'I':
|
||||
case 'i':
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(this.inlineEditor, { italic: true });
|
||||
}
|
||||
break;
|
||||
// underline ctrl+u
|
||||
case 'U':
|
||||
case 'u':
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(this.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.onChange(yText);
|
||||
};
|
||||
|
||||
private readonly _onSoftEnter = () => {
|
||||
if (this.value && this.inlineEditor) {
|
||||
const inlineRange = this.inlineEditor.getInlineRange();
|
||||
assertExists(inlineRange);
|
||||
|
||||
const text = new Text(this.inlineEditor.yText);
|
||||
text.replace(inlineRange.index, inlineRange.length, '\n');
|
||||
this.inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
get attributeRenderer() {
|
||||
return this.inlineManager?.getRenderer();
|
||||
}
|
||||
|
||||
get attributesSchema() {
|
||||
return this.inlineManager?.getSchema();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
get inlineEditor() {
|
||||
assertExists(this._richTextElement);
|
||||
const inlineEditor = this._richTextElement.inlineEditor;
|
||||
assertExists(inlineEditor);
|
||||
return inlineEditor;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
get inlineManager() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
get service() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.getService('affine:database');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
get topContenteditableElement() {
|
||||
const databaseBlock =
|
||||
this.closest<DatabaseBlockComponent>('affine-database');
|
||||
return databaseBlock?.topContenteditableElement;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.value || typeof this.value === 'string') {
|
||||
this._initYText(this.value);
|
||||
}
|
||||
|
||||
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() {
|
||||
this._richTextElement?.updateComplete
|
||||
.then(() => {
|
||||
this.disposables.add(
|
||||
this.inlineEditor.slots.keydown.on(this._handleKeyDown)
|
||||
);
|
||||
|
||||
this.inlineEditor.focusEnd();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.service) return nothing;
|
||||
return html`<rich-text
|
||||
.yText=${this.value}
|
||||
.inlineEventSource=${this.topContenteditableElement}
|
||||
.attributesSchema=${this.attributesSchema}
|
||||
.attributeRenderer=${this.attributeRenderer}
|
||||
.embedChecker=${this.inlineManager?.embedChecker}
|
||||
.markdownShortcutHandler=${this.inlineManager?.markdownShortcutHandler}
|
||||
.verticalScrollContainerGetter=${() =>
|
||||
this.topContenteditableElement?.host
|
||||
? getViewportElement(this.topContenteditableElement.host)
|
||||
: null}
|
||||
class="affine-database-rich-text inline-editor"
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _richTextElement: RichText | null = null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-database-rich-text-cell-editing': RichTextCellEditing;
|
||||
}
|
||||
}
|
||||
|
||||
export const richTextColumnConfig =
|
||||
richTextColumnModelConfig.createPropertyMeta({
|
||||
icon: createIcon('TextIcon'),
|
||||
|
||||
cellRenderer: {
|
||||
view: createFromBaseCellRenderer(RichTextCell),
|
||||
edit: createFromBaseCellRenderer(RichTextCellEditing),
|
||||
},
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { propertyType, t } from '@blocksuite/data-view';
|
||||
import { Text } from '@blocksuite/store';
|
||||
|
||||
import { type RichTextCellType, toYText } from '../utils.js';
|
||||
|
||||
export const richTextColumnType = propertyType('rich-text');
|
||||
|
||||
export const richTextColumnModelConfig =
|
||||
richTextColumnType.modelConfig<RichTextCellType>({
|
||||
name: 'Text',
|
||||
type: () => t.richText.instance(),
|
||||
defaultData: () => ({}),
|
||||
cellToString: ({ value }) => value?.toString() ?? '',
|
||||
cellFromString: ({ value }) => {
|
||||
return {
|
||||
value: new Text(value),
|
||||
};
|
||||
},
|
||||
cellToJson: ({ value }) => value?.toString() ?? null,
|
||||
cellFromJson: ({ value }) =>
|
||||
typeof value !== 'string' ? undefined : new Text(value),
|
||||
onUpdate: ({ value, callback }) => {
|
||||
const yText = toYText(value);
|
||||
yText.observe(callback);
|
||||
callback();
|
||||
return {
|
||||
dispose: () => {
|
||||
yText.unobserve(callback);
|
||||
},
|
||||
};
|
||||
},
|
||||
isEmpty: ({ value }) => value == null || value.length === 0,
|
||||
values: ({ value }) => (value?.toString() ? [value.toString()] : []),
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import {
|
||||
type CellRenderProps,
|
||||
createFromBaseCellRenderer,
|
||||
createIcon,
|
||||
uniMap,
|
||||
} from '@blocksuite/data-view';
|
||||
import { TableSingleView } from '@blocksuite/data-view/view-presets';
|
||||
|
||||
import { titlePurePropertyConfig } from './define.js';
|
||||
import { HeaderAreaTextCell, HeaderAreaTextCellEditing } from './text.js';
|
||||
|
||||
export const titleColumnConfig = titlePurePropertyConfig.createPropertyMeta({
|
||||
icon: createIcon('TitleIcon'),
|
||||
cellRenderer: {
|
||||
view: uniMap(
|
||||
createFromBaseCellRenderer(HeaderAreaTextCell),
|
||||
(props: CellRenderProps) => ({
|
||||
...props,
|
||||
showIcon: props.cell.view instanceof TableSingleView,
|
||||
})
|
||||
),
|
||||
edit: uniMap(
|
||||
createFromBaseCellRenderer(HeaderAreaTextCellEditing),
|
||||
(props: CellRenderProps) => ({
|
||||
...props,
|
||||
showIcon: props.cell.view instanceof TableSingleView,
|
||||
})
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { propertyType, t } from '@blocksuite/data-view';
|
||||
import { Text } from '@blocksuite/store';
|
||||
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import { isLinkedDoc } from '../../utils/title-doc.js';
|
||||
|
||||
export const titleColumnType = propertyType('title');
|
||||
|
||||
export const titlePurePropertyConfig = titleColumnType.modelConfig<Text>({
|
||||
name: 'Title',
|
||||
type: () => t.richText.instance(),
|
||||
defaultData: () => ({}),
|
||||
cellToString: ({ value }) => value?.toString() ?? '',
|
||||
cellFromString: ({ value }) => {
|
||||
return {
|
||||
value: value,
|
||||
};
|
||||
},
|
||||
cellToJson: ({ value, dataSource }) => {
|
||||
const host = dataSource.contextGet(HostContextKey);
|
||||
if (host) {
|
||||
const collection = host.std.collection;
|
||||
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() ?? null;
|
||||
},
|
||||
cellFromJson: ({ value }) =>
|
||||
typeof value !== 'string' ? undefined : new Text(value),
|
||||
onUpdate: ({ value, callback }) => {
|
||||
value.yText.observe(callback);
|
||||
callback();
|
||||
return {
|
||||
dispose: () => {
|
||||
value.yText.unobserve(callback);
|
||||
},
|
||||
};
|
||||
},
|
||||
valueUpdate: ({ value, newValue }) => {
|
||||
const v = newValue as unknown;
|
||||
if (typeof v === 'string') {
|
||||
value.replace(0, value.length, v);
|
||||
return value;
|
||||
}
|
||||
if (v == null) {
|
||||
value.replace(0, value.length, '');
|
||||
return value;
|
||||
}
|
||||
return newValue;
|
||||
},
|
||||
isEmpty: ({ value }) => value == null || value.length === 0,
|
||||
values: ({ value }) => (value?.toString() ? [value.toString()] : []),
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
import {
|
||||
DefaultInlineManagerExtension,
|
||||
type RichText,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
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 { assertExists } from '@blocksuite/global/utils';
|
||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import type { BlockSnapshot, Text } from '@blocksuite/store';
|
||||
import { computed, effect, signal } from '@preact/signals-core';
|
||||
import { css, type TemplateResult } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { ClipboardAdapter } from '../../../root-block/clipboard/adapter.js';
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
|
||||
|
||||
const styles = css`
|
||||
data-view-header-area-text {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
data-view-header-area-text rich-text {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
data-view-header-area-text-editing {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.data-view-header-area-rich-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
word-break: break-all;
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
line-height: var(--data-view-cell-text-line-height);
|
||||
}
|
||||
|
||||
.data-view-header-area-rich-text v-line {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-view-header-area-rich-text v-line > div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.data-view-header-area-icon {
|
||||
height: max-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
margin-top: 2px;
|
||||
background-color: var(--affine-background-secondary-color);
|
||||
}
|
||||
|
||||
.data-view-header-area-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: var(--affine-icon-color);
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
`;
|
||||
|
||||
abstract class BaseTextCell extends BaseCellRenderer<Text> {
|
||||
static override styles = styles;
|
||||
|
||||
activity = true;
|
||||
|
||||
docId$ = signal<string>();
|
||||
|
||||
isLinkedDoc$ = computed(() => false);
|
||||
|
||||
linkedDocTitle$ = computed(() => {
|
||||
if (!this.docId$.value) {
|
||||
return this.value;
|
||||
}
|
||||
const doc = this.host?.std.collection.getDoc(this.docId$.value);
|
||||
const root = doc?.root as RootBlockModel;
|
||||
return root.title;
|
||||
});
|
||||
|
||||
get attributeRenderer() {
|
||||
return this.inlineManager?.getRenderer();
|
||||
}
|
||||
|
||||
get attributesSchema() {
|
||||
return this.inlineManager?.getSchema();
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.view.contextGet(HostContextKey);
|
||||
}
|
||||
|
||||
get inlineEditor() {
|
||||
return this.richText.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineManager() {
|
||||
return this.host?.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
get service() {
|
||||
return this.host?.std.getService('affine:database');
|
||||
}
|
||||
|
||||
get topContenteditableElement() {
|
||||
const databaseBlock =
|
||||
this.closest<DatabaseBlockComponent>('affine-database');
|
||||
return databaseBlock?.topContenteditableElement;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
return html`${this.renderIcon()}${this.renderBlockText()}`;
|
||||
}
|
||||
|
||||
abstract renderBlockText(): TemplateResult;
|
||||
|
||||
renderIcon() {
|
||||
if (this.docId$.value) {
|
||||
return html` <div class="data-view-header-area-icon">
|
||||
${LinkedPageIcon()}
|
||||
</div>`;
|
||||
}
|
||||
if (!this.showIcon) {
|
||||
return;
|
||||
}
|
||||
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="data-view-header-area-icon">${icon}</div>`;
|
||||
}
|
||||
|
||||
abstract renderLinkedDoc(): TemplateResult;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showIcon = false;
|
||||
}
|
||||
|
||||
export class HeaderAreaTextCell extends BaseTextCell {
|
||||
override renderBlockText() {
|
||||
return html` <rich-text
|
||||
.yText="${this.value}"
|
||||
.attributesSchema="${this.attributesSchema}"
|
||||
.attributeRenderer="${this.attributeRenderer}"
|
||||
.embedChecker="${this.inlineManager?.embedChecker}"
|
||||
.markdownShortcutHandler="${this.inlineManager?.markdownShortcutHandler}"
|
||||
.readonly="${true}"
|
||||
class="data-view-header-area-rich-text"
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
override renderLinkedDoc(): TemplateResult {
|
||||
return html` <rich-text
|
||||
.yText="${this.linkedDocTitle$.value}"
|
||||
.readonly="${true}"
|
||||
class="data-view-header-area-rich-text"
|
||||
></rich-text>`;
|
||||
}
|
||||
}
|
||||
|
||||
export class HeaderAreaTextCellEditing extends BaseTextCell {
|
||||
private readonly _onCopy = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
assertExists(inlineEditor);
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
const text = inlineEditor.yTextString.slice(
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
);
|
||||
|
||||
e.clipboardData?.setData('text/plain', text);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
private readonly _onCut = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
assertExists(inlineEditor);
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
const text = inlineEditor.yTextString.slice(
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
);
|
||||
inlineEditor.deleteText(inlineRange);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
e.clipboardData?.setData('text/plain', text);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
private readonly _onPaste = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
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
|
||||
)[ClipboardAdapter.MIME];
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
override activity = false;
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
private get std() {
|
||||
return this.host?.std;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
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.add(() => {
|
||||
this.removeEventListener('keydown', selectAll);
|
||||
});
|
||||
}
|
||||
|
||||
override firstUpdated(props: Map<string, unknown>) {
|
||||
super.firstUpdated(props);
|
||||
if (!this.isLinkedDoc$.value) {
|
||||
this.disposables.addFromEvent(this.richText, 'copy', this._onCopy);
|
||||
this.disposables.addFromEvent(this.richText, 'cut', this._onCut);
|
||||
this.disposables.addFromEvent(this.richText, 'paste', this._onPaste);
|
||||
}
|
||||
this.richText.updateComplete
|
||||
.then(() => {
|
||||
this.inlineEditor?.focusEnd();
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const inlineRange = this.inlineEditor?.inlineRange$.value;
|
||||
if (inlineRange) {
|
||||
if (!this.isEditing) {
|
||||
this.selectCurrentCell(true);
|
||||
}
|
||||
} else {
|
||||
if (this.isEditing) {
|
||||
this.selectCurrentCell(false);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override renderBlockText() {
|
||||
return html` <rich-text
|
||||
.yText="${this.value}"
|
||||
.inlineEventSource="${this.topContenteditableElement}"
|
||||
.attributesSchema="${this.attributesSchema}"
|
||||
.attributeRenderer="${this.attributeRenderer}"
|
||||
.embedChecker="${this.inlineManager?.embedChecker}"
|
||||
.markdownShortcutHandler="${this.inlineManager?.markdownShortcutHandler}"
|
||||
.readonly="${this.readonly}"
|
||||
.enableClipboard="${false}"
|
||||
.verticalScrollContainerGetter="${() =>
|
||||
this.topContenteditableElement?.host
|
||||
? getViewportElement(this.topContenteditableElement.host)
|
||||
: null}"
|
||||
data-parent-flavour="affine:database"
|
||||
class="data-view-header-area-rich-text can-link-doc"
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
override renderLinkedDoc(): TemplateResult {
|
||||
return html` <rich-text
|
||||
.yText="${this.linkedDocTitle$.value}"
|
||||
.inlineEventSource="${this.topContenteditableElement}"
|
||||
.readonly="${this.readonly}"
|
||||
.enableClipboard="${true}"
|
||||
.verticalScrollContainerGetter="${() =>
|
||||
this.topContenteditableElement?.host
|
||||
? getViewportElement(this.topContenteditableElement.host)
|
||||
: null}"
|
||||
class="data-view-header-area-rich-text"
|
||||
></rich-text>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'data-view-header-area-text': HeaderAreaTextCell;
|
||||
'data-view-header-area-text-editing': HeaderAreaTextCellEditing;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Text } from '@blocksuite/store';
|
||||
|
||||
export type RichTextCellType = Text | Text['yText'];
|
||||
export const toYText = (text: RichTextCellType): Text['yText'] => {
|
||||
if (text instanceof Text) {
|
||||
return text.yText;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
@@ -1,247 +0,0 @@
|
||||
import type {
|
||||
Cell,
|
||||
Column,
|
||||
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<Column, 'id'> & {
|
||||
id?: string;
|
||||
}
|
||||
): string {
|
||||
const id = column.id ?? model.doc.generateBlockId();
|
||||
if (model.columns.some(v => v.id === id)) {
|
||||
return id;
|
||||
}
|
||||
model.doc.transact(() => {
|
||||
const col: Column = {
|
||||
...column,
|
||||
id,
|
||||
};
|
||||
model.columns.splice(
|
||||
insertPositionToIndex(position, model.columns),
|
||||
0,
|
||||
col
|
||||
);
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export function applyCellsUpdate(model: DatabaseBlockModel) {
|
||||
model.doc.updateBlock(model, {
|
||||
cells: model.cells,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyPropertyUpdate(model: DatabaseBlockModel) {
|
||||
model.doc.updateBlock(model, {
|
||||
columns: model.columns,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyViewsUpdate(model: DatabaseBlockModel) {
|
||||
model.doc.updateBlock(model, {
|
||||
views: model.views,
|
||||
});
|
||||
}
|
||||
|
||||
export function copyCellsByProperty(
|
||||
model: DatabaseBlockModel,
|
||||
fromId: Column['id'],
|
||||
toId: Column['id']
|
||||
) {
|
||||
model.doc.transact(() => {
|
||||
Object.keys(model.cells).forEach(rowId => {
|
||||
const cell = model.cells[rowId][fromId];
|
||||
if (cell) {
|
||||
model.cells[rowId][toId] = {
|
||||
...cell,
|
||||
columnId: toId,
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteColumn(
|
||||
model: DatabaseBlockModel,
|
||||
columnId: Column['id']
|
||||
) {
|
||||
const index = findPropertyIndex(model, columnId);
|
||||
if (index < 0) return;
|
||||
|
||||
model.doc.transact(() => {
|
||||
model.columns.splice(index, 1);
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRows(model: DatabaseBlockModel, rowIds: string[]) {
|
||||
model.doc.transact(() => {
|
||||
for (const rowId of rowIds) {
|
||||
delete model.cells[rowId];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteView(model: DatabaseBlockModel, id: string) {
|
||||
model.doc.captureSync();
|
||||
model.doc.transact(() => {
|
||||
model.views = model.views.filter(v => v.id !== id);
|
||||
});
|
||||
}
|
||||
|
||||
export function duplicateView(model: DatabaseBlockModel, id: string): string {
|
||||
const newId = model.doc.generateBlockId();
|
||||
model.doc.transact(() => {
|
||||
const index = model.views.findIndex(v => v.id === id);
|
||||
const view = model.views[index];
|
||||
if (view) {
|
||||
model.views.splice(
|
||||
index + 1,
|
||||
0,
|
||||
JSON.parse(JSON.stringify({ ...view, id: newId }))
|
||||
);
|
||||
}
|
||||
});
|
||||
return newId;
|
||||
}
|
||||
|
||||
export function findPropertyIndex(model: DatabaseBlockModel, id: Column['id']) {
|
||||
return model.columns.findIndex(v => v.id === id);
|
||||
}
|
||||
|
||||
export function getCell(
|
||||
model: DatabaseBlockModel,
|
||||
rowId: BlockModel['id'],
|
||||
columnId: Column['id']
|
||||
): Cell | null {
|
||||
if (columnId === 'title') {
|
||||
return {
|
||||
columnId: 'title',
|
||||
value: rowId,
|
||||
};
|
||||
}
|
||||
const yRow = model.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: Column['id']
|
||||
): Column | undefined {
|
||||
return model.columns.find(v => v.id === id);
|
||||
}
|
||||
|
||||
export function moveViewTo(
|
||||
model: DatabaseBlockModel,
|
||||
id: string,
|
||||
position: InsertToPosition
|
||||
) {
|
||||
model.doc.transact(() => {
|
||||
model.views = arrayMove(
|
||||
model.views,
|
||||
v => v.id === id,
|
||||
arr => insertPositionToIndex(position, arr)
|
||||
);
|
||||
});
|
||||
applyViewsUpdate(model);
|
||||
}
|
||||
|
||||
export function updateCell(
|
||||
model: DatabaseBlockModel,
|
||||
rowId: string,
|
||||
cell: Cell
|
||||
) {
|
||||
if (
|
||||
rowId === '__proto__' ||
|
||||
rowId === 'constructor' ||
|
||||
rowId === 'prototype'
|
||||
) {
|
||||
throw new Error('Invalid rowId');
|
||||
}
|
||||
const hasRow = rowId in model.cells;
|
||||
if (!hasRow) {
|
||||
model.cells[rowId] = Object.create(null);
|
||||
}
|
||||
model.doc.transact(() => {
|
||||
model.cells[rowId][cell.columnId] = {
|
||||
columnId: cell.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.cells[rowId]) {
|
||||
model.cells[rowId] = Object.create(null);
|
||||
}
|
||||
model.cells[rowId][columnId] = {
|
||||
columnId,
|
||||
value,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProperty(
|
||||
model: DatabaseBlockModel,
|
||||
id: string,
|
||||
updater: ColumnUpdater
|
||||
) {
|
||||
const index = model.columns.findIndex(v => v.id === id);
|
||||
if (index == null) {
|
||||
return;
|
||||
}
|
||||
model.doc.transact(() => {
|
||||
const column = model.columns[index];
|
||||
const result = updater(column);
|
||||
model.columns[index] = { ...column, ...result };
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export const updateView = <ViewData extends ViewBasicDataType>(
|
||||
model: DatabaseBlockModel,
|
||||
id: string,
|
||||
update: (data: ViewData) => Partial<ViewData>
|
||||
) => {
|
||||
model.doc.transact(() => {
|
||||
model.views = model.views.map(v => {
|
||||
if (v.id !== id) {
|
||||
return v;
|
||||
}
|
||||
return { ...v, ...update(v as ViewData) };
|
||||
});
|
||||
});
|
||||
applyViewsUpdate(model);
|
||||
};
|
||||
export const DATABASE_CONVERT_WHITE_LIST = ['affine:list', 'affine:paragraph'];
|
||||
@@ -1,52 +0,0 @@
|
||||
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();
|
||||
@@ -1,30 +0,0 @@
|
||||
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));
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export const commonTools = [];
|
||||
@@ -1,6 +1,7 @@
|
||||
import { effects as blockAttachmentEffects } from '@blocksuite/affine-block-attachment/effects';
|
||||
import { effects as blockBookmarkEffects } from '@blocksuite/affine-block-bookmark/effects';
|
||||
import { effects as blockCodeEffects } from '@blocksuite/affine-block-code/effects';
|
||||
import { effects as blockDatabaseEffects } from '@blocksuite/affine-block-database/effects';
|
||||
import { effects as blockDividerEffects } from '@blocksuite/affine-block-divider/effects';
|
||||
import { effects as blockEdgelessTextEffects } from '@blocksuite/affine-block-edgeless-text/effects';
|
||||
import { effects as blockEmbedEffects } from '@blocksuite/affine-block-embed/effects';
|
||||
@@ -38,29 +39,6 @@ import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { registerSpecs } from './_specs/register-specs.js';
|
||||
import { DataViewBlockComponent } from './data-view-block/index.js';
|
||||
import { CenterPeek } from './database-block/components/layout.js';
|
||||
import { DatabaseTitle } from './database-block/components/title/index.js';
|
||||
import { BlockRenderer } from './database-block/detail-panel/block-renderer.js';
|
||||
import { NoteRenderer } from './database-block/detail-panel/note-renderer.js';
|
||||
import { effects as blockDatabaseEffects } from './database-block/effects.js';
|
||||
import {
|
||||
DatabaseBlockComponent,
|
||||
type DatabaseBlockService,
|
||||
} from './database-block/index.js';
|
||||
import {
|
||||
LinkCell,
|
||||
LinkCellEditing,
|
||||
} from './database-block/properties/link/cell-renderer.js';
|
||||
import { LinkNode } from './database-block/properties/link/components/link-node.js';
|
||||
import {
|
||||
RichTextCell,
|
||||
RichTextCellEditing,
|
||||
} from './database-block/properties/rich-text/cell-renderer.js';
|
||||
import { IconCell } from './database-block/properties/title/icon.js';
|
||||
import {
|
||||
HeaderAreaTextCell,
|
||||
HeaderAreaTextCellEditing,
|
||||
} from './database-block/properties/title/text.js';
|
||||
import { EdgelessAutoCompletePanel } from './root-block/edgeless/components/auto-complete/auto-complete-panel.js';
|
||||
import { EdgelessAutoComplete } from './root-block/edgeless/components/auto-complete/edgeless-auto-complete.js';
|
||||
import { EdgelessToolIconButton } from './root-block/edgeless/components/buttons/tool-icon-button.js';
|
||||
@@ -252,23 +230,6 @@ export function effects() {
|
||||
widgetDragHandleEffects();
|
||||
dataViewEffects();
|
||||
|
||||
customElements.define('affine-database-title', DatabaseTitle);
|
||||
customElements.define('data-view-header-area-icon', IconCell);
|
||||
customElements.define('affine-database-link-cell', LinkCell);
|
||||
customElements.define('affine-database-link-cell-editing', LinkCellEditing);
|
||||
customElements.define('data-view-header-area-text', HeaderAreaTextCell);
|
||||
customElements.define(
|
||||
'data-view-header-area-text-editing',
|
||||
HeaderAreaTextCellEditing
|
||||
);
|
||||
customElements.define('affine-database-rich-text-cell', RichTextCell);
|
||||
customElements.define(
|
||||
'affine-database-rich-text-cell-editing',
|
||||
RichTextCellEditing
|
||||
);
|
||||
customElements.define('center-peek', CenterPeek);
|
||||
customElements.define('database-datasource-note-renderer', NoteRenderer);
|
||||
customElements.define('database-datasource-block-renderer', BlockRenderer);
|
||||
customElements.define('affine-page-root', PageRootBlockComponent);
|
||||
customElements.define('affine-preview-root', PreviewRootBlockComponent);
|
||||
customElements.define('mini-mindmap-preview', MiniMindmapPreview);
|
||||
@@ -291,7 +252,6 @@ export function effects() {
|
||||
EdgelessRootPreviewBlockComponent
|
||||
);
|
||||
customElements.define('affine-custom-modal', AffineCustomModal);
|
||||
customElements.define('affine-database', DatabaseBlockComponent);
|
||||
customElements.define('affine-slash-menu', SlashMenu);
|
||||
customElements.define('inner-slash-menu', InnerSlashMenu);
|
||||
customElements.define('generating-placeholder', GeneratingPlaceholder);
|
||||
@@ -357,7 +317,6 @@ export function effects() {
|
||||
customElements.define('edgeless-note-tool-button', EdgelessNoteToolButton);
|
||||
customElements.define('edgeless-note-menu', EdgelessNoteMenu);
|
||||
customElements.define('edgeless-line-width-panel', EdgelessLineWidthPanel);
|
||||
customElements.define('affine-database-link-node', LinkNode);
|
||||
customElements.define(
|
||||
'edgeless-frame-order-button',
|
||||
EdgelessFrameOrderButton
|
||||
@@ -470,7 +429,6 @@ declare global {
|
||||
}
|
||||
interface BlockServices {
|
||||
'affine:page': RootService;
|
||||
'affine:database': DatabaseBlockService;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ export * from './_common/transformers/index.js';
|
||||
export { type AbstractEditor } from './_common/types.js';
|
||||
export * from './_specs/index.js';
|
||||
export * from './data-view-block';
|
||||
export * from './database-block';
|
||||
export { EdgelessTemplatePanel } from './root-block/edgeless/components/toolbar/template/template-panel.js';
|
||||
export type {
|
||||
Template,
|
||||
@@ -42,6 +41,7 @@ export {
|
||||
export * from '@blocksuite/affine-block-attachment';
|
||||
export * from '@blocksuite/affine-block-bookmark';
|
||||
export * from '@blocksuite/affine-block-code';
|
||||
export * from '@blocksuite/affine-block-database';
|
||||
export * from '@blocksuite/affine-block-divider';
|
||||
export * from '@blocksuite/affine-block-edgeless-text';
|
||||
export * from '@blocksuite/affine-block-embed';
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { ToolbarMoreMenuConfig } from '@blocksuite/affine-components/toolbar';
|
||||
|
||||
import type { DatabaseOptionsConfig } from '../database-block/config.js';
|
||||
import type { KeyboardToolbarConfig } from './widgets/keyboard-toolbar/config.js';
|
||||
import type { LinkedWidgetConfig } from './widgets/linked-doc/index.js';
|
||||
|
||||
export interface RootBlockConfig {
|
||||
linkedWidget?: Partial<LinkedWidgetConfig>;
|
||||
toolbarMoreMenu?: Partial<ToolbarMoreMenuConfig>;
|
||||
databaseOptions?: Partial<DatabaseOptionsConfig>;
|
||||
keyboardToolbar?: Partial<KeyboardToolbarConfig>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
convertToDatabase,
|
||||
DATABASE_CONVERT_WHITE_LIST,
|
||||
} from '@blocksuite/affine-block-database';
|
||||
import {
|
||||
convertSelectedBlocksToLinkedDoc,
|
||||
getTitleFromSelectedModels,
|
||||
@@ -43,8 +47,6 @@ import { assertExists } from '@blocksuite/global/utils';
|
||||
import { Slice } from '@blocksuite/store';
|
||||
import { html, type TemplateResult } from 'lit';
|
||||
|
||||
import { convertToDatabase } from '../../../database-block/data-source.js';
|
||||
import { DATABASE_CONVERT_WHITE_LIST } from '../../../database-block/utils/block-utils.js';
|
||||
import { FormatBarContext } from './context.js';
|
||||
import type { AffineFormatBarWidget } from './format-bar.js';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user