diff --git a/packages/frontend/component/src/theme/global.css b/packages/frontend/component/src/theme/global.css index bf67aaeb41346..636a0cba77d03 100644 --- a/packages/frontend/component/src/theme/global.css +++ b/packages/frontend/component/src/theme/global.css @@ -282,7 +282,8 @@ body { /** * A hack to make the anchor wrapper not affect the layout of the page. */ -[data-lit-react-wrapper] { +[data-lit-react-wrapper], +affine-lit-template-wrapper { display: contents; } diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index d1c3be3c81a5a..b8915193cfcbe 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -17,6 +17,7 @@ "@affine/templates": "workspace:*", "@affine/track": "workspace:*", "@blocksuite/affine": "0.17.18", + "@blocksuite/data-view": "0.17.18", "@blocksuite/icons": "2.1.68", "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^7.0.0", diff --git a/packages/frontend/core/src/components/doc-properties/table.tsx b/packages/frontend/core/src/components/doc-properties/table.tsx index c27501d363616..d3541afa88f4d 100644 --- a/packages/frontend/core/src/components/doc-properties/table.tsx +++ b/packages/frontend/core/src/components/doc-properties/table.tsx @@ -10,6 +10,7 @@ import { useDraggable, useDropTarget, } from '@affine/component'; +import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info'; import { DocLinksService } from '@affine/core/modules/doc-link'; import { EditorSettingService } from '@affine/core/modules/editor-settting'; import { WorkbenchService } from '@affine/core/modules/workbench'; @@ -443,18 +444,24 @@ DocPropertiesTableBody.displayName = 'PagePropertiesTableBody'; const DocPropertiesTableInner = () => { const [expanded, setExpanded] = useState(false); return ( -
- - - - - - -
+ <> +
+ + + + + + +
+ + ); }; diff --git a/packages/frontend/core/src/components/doc-properties/types/types.ts b/packages/frontend/core/src/components/doc-properties/types/types.ts index 5eda8c8c89f56..93cbd7190f957 100644 --- a/packages/frontend/core/src/components/doc-properties/types/types.ts +++ b/packages/frontend/core/src/components/doc-properties/types/types.ts @@ -1,7 +1,7 @@ import type { DocCustomPropertyInfo } from '@toeverything/infra'; export interface PropertyValueProps { - propertyInfo: DocCustomPropertyInfo; + propertyInfo?: DocCustomPropertyInfo; value: any; onChange: (value: any) => void; } diff --git a/packages/frontend/core/src/modules/doc-info/index.ts b/packages/frontend/core/src/modules/doc-info/index.ts index 14d8afa3df581..01805077da64d 100644 --- a/packages/frontend/core/src/modules/doc-info/index.ts +++ b/packages/frontend/core/src/modules/doc-info/index.ts @@ -1,10 +1,22 @@ -import { type Framework, WorkspaceScope } from '@toeverything/infra'; +import { + DocsService, + type Framework, + WorkspaceScope, +} from '@toeverything/infra'; +import { DocsSearchService } from '../docs-search'; import { DocInfoModal } from './entities/modal'; +import { DocDatabaseBacklinksService } from './services/doc-database-backlinks'; import { DocInfoService } from './services/doc-info'; +export { DocDatabaseBacklinkInfo } from './views/database-properties/doc-database-backlink-info'; + export { DocInfoService }; export function configureDocInfoModule(framework: Framework) { - framework.scope(WorkspaceScope).service(DocInfoService).entity(DocInfoModal); + framework + .scope(WorkspaceScope) + .service(DocInfoService) + .service(DocDatabaseBacklinksService, [DocsService, DocsSearchService]) + .entity(DocInfoModal); } diff --git a/packages/frontend/core/src/modules/doc-info/services/doc-database-backlinks.ts b/packages/frontend/core/src/modules/doc-info/services/doc-database-backlinks.ts new file mode 100644 index 0000000000000..b95effb054a89 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/services/doc-database-backlinks.ts @@ -0,0 +1,136 @@ +import { + DatabaseBlockDataSource, + type DatabaseBlockModel, +} from '@blocksuite/affine/blocks'; +import type { DocsService } from '@toeverything/infra'; +import { Service } from '@toeverything/infra'; +import { combineLatest, map, Observable, switchMap } from 'rxjs'; + +import type { Backlink } from '../../doc-link'; +import type { DocsSearchService } from '../../docs-search'; +import type { DatabaseRow, DatabaseValueCell } from '../types'; +import { signalToObservable } from '../utils'; + +export class DocDatabaseBacklinksService extends Service { + constructor( + private readonly docsService: DocsService, + private readonly docsSearchService: DocsSearchService + ) { + super(); + } + + private async ensureDocLoaded(docId: string) { + const docRef = this.docsService.open(docId); + if (!docRef.doc.blockSuiteDoc.ready) { + docRef.doc.blockSuiteDoc.load(); + } + docRef.doc.setPriorityLoad(10); + await docRef.doc.waitForSyncReady(); + return docRef; + } + + private watchRowCells$(dbModel: DatabaseBlockModel, rowId: string) { + const dataSource = new DatabaseBlockDataSource(dbModel); + + console.log('watchRowCells$', dataSource.propertyMetas); + + const hydratedRows$ = combineLatest([ + signalToObservable(dataSource.rows$), + signalToObservable(dataSource.properties$), + ]).pipe( + map(([rowIds, propertyIds]) => { + const rowExists = rowIds.some(id => id === rowId); + if (!rowExists) { + return undefined; + } + return propertyIds + .map(id => { + const type = dataSource.propertyTypeGet(id); + return { + value: dataSource.cellValueGet(rowId, id), + property: { + id, + type, + name: dataSource.propertyNameGet(id), + additionalData: dataSource.propertyDataGet(id), + }, + }; + }) + .filter((p: any): p is DatabaseValueCell => !!p) + .toSorted((a, b) => + (a.property.name ?? '').localeCompare(b.property.name ?? '') + ); + }) + ); + + return [hydratedRows$, dataSource] as const; + } + + // for each backlink, + // 1. check if it is in a database block + // 2. if it is, return the related database row + // 3. if it is not, return undefined + private watchDatabaseRow$(backlink: Backlink) { + return new Observable(subscriber => { + let disposed = false; + let unsubscribe = () => {}; + const docRef = this.docsService.open(backlink.docId); + const run = async () => { + await this.ensureDocLoaded(backlink.docId); + if (disposed) { + return; + } + const block = docRef.doc.blockSuiteDoc.getBlock(backlink.blockId); + const parent = block?.model.parent; + if (parent?.flavour === 'affine:database') { + const dbModel = parent as DatabaseBlockModel; + const [cells$, dataSource] = this.watchRowCells$( + dbModel, + backlink.blockId + ); + const subscription = cells$.subscribe(cells => { + if (cells) { + subscriber.next({ + cells, + id: backlink.blockId, + doc: docRef.doc, + docId: backlink.docId, + databaseId: dbModel.id, + databaseName: dbModel.title.yText.toString(), + dataSource: dataSource, + }); + } else { + subscriber.next(undefined); + } + }); + unsubscribe = () => subscription.unsubscribe(); + } + }; + + run().catch(e => { + console.error(`failed to get database info:`, e); + docRef.release(); + }); + + return () => { + docRef.release(); + disposed = true; + unsubscribe(); + }; + }); + } + + // backlinks (docid:blockid) -> related db rows (DatabaseRow[]) + watchDbBacklinkRows$(docId: string) { + return this.docsSearchService.watchRefsTo(docId).pipe( + switchMap(backlinks => { + return combineLatest( + backlinks.map(backlink => { + return this.watchDatabaseRow$(backlink); + }) + ); + }), + map(rows => rows.filter((row): row is DatabaseRow => Boolean(row))) + ); + } +} diff --git a/packages/frontend/core/src/modules/doc-info/types.ts b/packages/frontend/core/src/modules/doc-info/types.ts new file mode 100644 index 0000000000000..cda4ec953147d --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/types.ts @@ -0,0 +1,29 @@ +import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks'; +import type { Doc, DocCustomPropertyInfo } from '@toeverything/infra'; + +// make database property type to be compatible with DocCustomPropertyInfo +export type DatabaseProperty = Required< + Pick +>; + +export interface DatabaseValueCell { + property: DatabaseProperty; + value: unknown; +} + +export interface DatabaseRow { + cells: DatabaseValueCell[]; + id: string; // row id (block id) + doc: Doc; // the doc that contains the database. required for editing etc. + docId: string; // for rendering the doc reference + dataSource: DatabaseBlockDataSource; + databaseId: string; + databaseName: string; // the title +} + +export interface DatabaseCellRendererProps { + rowId: string; + cell: DatabaseValueCell; + dataSource: DatabaseBlockDataSource; + doc: Doc; +} diff --git a/packages/frontend/core/src/modules/doc-info/utils.ts b/packages/frontend/core/src/modules/doc-info/utils.ts new file mode 100644 index 0000000000000..08a90521d4020 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/utils.ts @@ -0,0 +1,35 @@ +import { BlockStdScope } from '@blocksuite/affine/block-std'; +import { PageEditorBlockSpecs } from '@blocksuite/affine/blocks'; +import type { Doc } from '@blocksuite/affine/store'; +import { useMemo } from 'react'; +import { Observable } from 'rxjs'; + +interface ReadonlySignal { + subscribe: (fn: (value: T) => void) => () => void; +} + +export function signalToObservable( + signal: ReadonlySignal +): Observable { + return new Observable(subscriber => { + const unsub = signal.subscribe(value => { + subscriber.next(value); + }); + return () => { + unsub(); + }; + }); +} + +// todo(pengx17): use rc pool? +export function createBlockStdScope(doc: Doc) { + const std = new BlockStdScope({ + doc, + extensions: PageEditorBlockSpecs, + }); + return std; +} + +export function useBlockStdScope(doc: Doc) { + return useMemo(() => createBlockStdScope(doc), [doc]); +} diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx new file mode 100644 index 0000000000000..e6acb309054d6 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx @@ -0,0 +1,19 @@ +import { CheckboxValue } from '@affine/core/components/doc-properties/types/checkbox'; + +import type { DatabaseCellRendererProps } from '../../../types'; + +export const CheckboxCell = ({ + cell, + rowId, + dataSource, +}: DatabaseCellRendererProps) => { + return ( + { + dataSource.cellValueChange(rowId, cell.property.id, v === 'true'); + }} + /> + ); +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/constant.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/constant.tsx new file mode 100644 index 0000000000000..913e9dce682ed --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/constant.tsx @@ -0,0 +1,75 @@ +import type { I18nString } from '@affine/i18n'; +import { + CheckBoxCheckLinearIcon, + DateTimeIcon, + LinkIcon, + NumberIcon, + ProgressIcon, + SelectIcon, + TextIcon, +} from '@blocksuite/icons/rc'; + +import type { DatabaseCellRendererProps } from '../../../types'; +import { CheckboxCell } from './checkbox'; +import { DateCell } from './date'; +import { LinkCell } from './link'; +import { NumberCell } from './number'; +import { RichTextCell } from './rich-text'; + +export const DatabaseRendererTypes = { + 'rich-text': { + icon: TextIcon, + Renderer: RichTextCell, + name: 'com.affine.page-properties.property.text', + }, + checkbox: { + icon: CheckBoxCheckLinearIcon, + Renderer: CheckboxCell, + name: 'com.affine.page-properties.property.checkbox', + }, + date: { + icon: DateTimeIcon, + Renderer: DateCell, + name: 'com.affine.page-properties.property.date', + }, + number: { + icon: NumberIcon, + Renderer: NumberCell, + name: 'com.affine.page-properties.property.number', + }, + link: { + icon: LinkIcon, + Renderer: LinkCell, + name: 'com.affine.page-properties.property.link', + }, + progress: { + icon: ProgressIcon, + Renderer: NumberCell, + name: 'com.affine.page-properties.property.progress', + }, + select: { + icon: SelectIcon, + Renderer: ({ cell }) => { + return cell.value.toString(); + }, + name: 'com.affine.page-properties.property.select', + }, + 'multi-select': { + icon: SelectIcon, + Renderer: ({ cell }) => { + return cell.value.toString(); + }, + name: 'com.affine.page-properties.property.multi-select', + }, +} as Record< + string, + { + icon: React.FC>; + Renderer: React.FC; + name: I18nString; + } +>; + +export const isSupportedDatabaseRendererType = (type?: string): boolean => { + return type ? type in DatabaseRendererTypes : false; +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx new file mode 100644 index 0000000000000..c1d29c7834fbd --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx @@ -0,0 +1,34 @@ +import { DateValue } from '@affine/core/components/doc-properties/types/date'; +import dayjs from 'dayjs'; + +import type { DatabaseCellRendererProps } from '../../../types'; + +const toInternalDateString = (date: unknown) => { + if (typeof date !== 'string' && typeof date !== 'number') { + return ''; + } + return dayjs(date).format('YYYY-MM-DD'); +}; + +const fromInternalDateString = (date: string) => { + return dayjs(date).toDate().getTime(); +}; + +export const DateCell = ({ + cell, + rowId, + dataSource, +}: DatabaseCellRendererProps) => { + return ( + { + dataSource.cellValueChange( + rowId, + cell.property.id, + fromInternalDateString(v) + ); + }} + /> + ); +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.tsx new file mode 100644 index 0000000000000..054057b6d623d --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.tsx @@ -0,0 +1,16 @@ +import { PropertyValue } from '@affine/component'; + +import type { DatabaseCellRendererProps } from '../../../types'; + +export const LinkCell = ({ cell }: DatabaseCellRendererProps) => { + const isEmpty = typeof cell.value !== 'string' || !cell.value; + const link = cell.value as string; + // todo(pengx17): support edit + return ( + + + {link.replace(/^https?:\/\//, '')} + + + ); +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx new file mode 100644 index 0000000000000..699171fc46032 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx @@ -0,0 +1,18 @@ +import { NumberValue } from '@affine/core/components/doc-properties/types/number'; + +import type { DatabaseCellRendererProps } from '../../../types'; + +export const NumberCell = ({ + cell, + rowId, + dataSource, +}: DatabaseCellRendererProps) => { + return ( + { + dataSource.cellValueChange(rowId, cell.property.id, v); + }} + /> + ); +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/rich-text.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/rich-text.tsx new file mode 100644 index 0000000000000..9ec8607058643 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/rich-text.tsx @@ -0,0 +1,48 @@ +import { PropertyValue, toReactNode } from '@affine/component'; +import type { BlockStdScope } from '@blocksuite/affine/block-std'; +import { DefaultInlineManagerExtension } from '@blocksuite/affine/blocks'; +import type { Y } from '@blocksuite/store'; +import type { Doc } from '@toeverything/infra'; +import { html } from 'lit'; + +import type { + DatabaseCellRendererProps, + DatabaseValueCell, +} from '../../../types'; +import { useBlockStdScope } from '../../../utils'; + +const renderRichText = ({ + cell, + doc, + std, +}: { + std: BlockStdScope; + cell: DatabaseValueCell; + doc: Doc; +}) => { + const inlineManager = std.get(DefaultInlineManagerExtension.identifier); + + if (!inlineManager) { + return null; + } + + return html``; +}; + +export const RichTextCell = ({ cell, doc }: DatabaseCellRendererProps) => { + const std = useBlockStdScope(doc.blockSuiteDoc); + const template = renderRichText({ cell, doc, std }); + return ( + + {template ? toReactNode(template) : null} + + ); +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.tsx new file mode 100644 index 0000000000000..9b68d133ff4b6 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.tsx @@ -0,0 +1,62 @@ +import { AffinePageReference } from '@affine/core/components/affine/reference-link'; +import { + DocService, + LiveData, + useLiveData, + useService, +} from '@toeverything/infra'; +import { useMemo } from 'react'; + +import { DocDatabaseBacklinksService } from '../../services/doc-database-backlinks'; +import { DatabaseRendererTypes } from './cells/constant'; + +export const DocDatabaseBacklinkInfo = () => { + const docService = useService(DocService).doc; + const docDatabaseBacklinks = useService(DocDatabaseBacklinksService); + const rows = useLiveData( + useMemo( + () => + LiveData.from( + docDatabaseBacklinks.watchDbBacklinkRows$(docService.id), + [] + ), + [docDatabaseBacklinks, docService.id] + ) + ); + console.log(rows); + return ( +
+
Database Backlinks
+ {rows.map(row => ( +
    + {row.databaseName} + + {row.cells + .filter(cell => cell.property.type !== 'title') + .map(cell => { + const DatabaseRendererConfig = + DatabaseRendererTypes[cell.property.type]; + return ( +
  • + {cell.property.name}({cell.property.type}): + {DatabaseRendererConfig ? ( + + ) : ( + JSON.stringify(cell.value) + )} +
  • + ); + })} +
+ ))} +
+ ); +}; diff --git a/yarn.lock b/yarn.lock index cf27ce3fb42ab..e436d5963e7ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -365,6 +365,7 @@ __metadata: "@affine/templates": "workspace:*" "@affine/track": "workspace:*" "@blocksuite/affine": "npm:0.17.18" + "@blocksuite/data-view": "npm:0.17.18" "@blocksuite/icons": "npm:2.1.68" "@dnd-kit/core": "npm:^6.1.0" "@dnd-kit/modifiers": "npm:^7.0.0"