From 24e0c5797c7159df03a4699cfb56184a0f1f385c Mon Sep 17 00:00:00 2001 From: EYHN Date: Tue, 15 Oct 2024 10:17:11 +0000 Subject: [PATCH] refactor(core): doc property (#8465) doc property upgraded to use orm. The visibility of the property are simplified to three types: `always show`, `always hide`, `hide when empty`, and the default is `always show`. ![CleanShot 2024-10-14 at 15 34 52](https://github.com/user-attachments/assets/748b8b80-061f-4d6a-8579-52e59df717c2) Added a sidebar view to manage properties ![CleanShot 2024-10-14 at 15 35 58](https://github.com/user-attachments/assets/bffa9b1a-a1a5-4708-b2e8-4963120f3af9) new property ui in workspace settings ![CleanShot 2024-10-14 at 15 36 44](https://github.com/user-attachments/assets/572d8dcc-9b3d-462a-9bcc-5f5fa8e622da) Property lists can be collapsed ![CleanShot 2024-10-14 at 15 37 59](https://github.com/user-attachments/assets/2b20be1a-8141-478a-8fe7-405aff6d04fd) --- packages/common/infra/package.json | 1 + packages/common/infra/src/modules/db/index.ts | 2 +- .../infra/src/modules/db/schema/index.ts | 2 +- .../infra/src/modules/db/schema/schema.ts | 1 + .../common/infra/src/modules/doc/constants.ts | 11 + .../infra/src/modules/doc/entities/doc.ts | 8 + .../src/modules/doc/entities/property-list.ts | 49 +- .../infra/src/modules/doc/entities/record.ts | 12 +- .../src/modules/doc/stores/doc-properties.ts | 54 +- .../infra/src/orm/core/validators/data.ts | 2 +- .../__tests__/fractional-indexing.spec.ts | 0 .../infra}/src/utils/fractional-indexing.ts | 0 packages/common/infra/src/utils/index.ts | 1 + packages/frontend/component/src/index.ts | 1 + .../component/src/ui/dnd/draggable.ts | 5 + .../src/ui/dnd/drop-indicator.css.ts | 18 +- .../component/src/ui/dnd/drop-indicator.tsx | 18 +- .../component/src/ui/property/index.ts | 1 + .../component/src/ui/property/property.css.ts | 163 +++ .../src/ui/property/property.stories.tsx | 144 +++ .../component/src/ui/property/property.tsx | 276 ++++ packages/frontend/core/package.json | 1 - .../affine/page-properties/common.ts | 6 - .../confirm-delete-property-modal.tsx | 57 - .../affine/page-properties/icons-mapping.tsx | 147 --- .../affine/page-properties/icons/constant.ts | 91 ++ .../icons/doc-property-icon.tsx | 39 + .../{ => icons}/icons-selector.css.ts | 0 .../{ => icons}/icons-selector.tsx | 49 +- .../affine/page-properties/index.ts | 2 - .../info-modal/info-modal.css.ts | 41 +- .../page-properties/info-modal/info-modal.tsx | 116 +- .../info-modal/tags-row.css.ts | 104 -- .../page-properties/info-modal/tags-row.tsx | 63 - .../info-modal/time-row.css.ts | 43 - .../page-properties/info-modal/time-row.tsx | 94 +- .../affine/page-properties/manager/index.tsx | 164 +++ .../page-properties/manager/styles.css.ts | 92 ++ .../affine/page-properties/menu-items.tsx | 133 -- .../menu/create-doc-property.css.ts | 25 + .../menu/create-doc-property.tsx | 84 ++ .../menu/edit-doc-property.css.ts | 42 + .../menu/edit-doc-property.tsx | 212 ++++ .../page-properties-manager.ts | 303 ----- .../property-row-value-renderer.tsx | 320 ----- .../affine/page-properties/sidebar/index.tsx | 108 ++ .../page-properties/sidebar/section.css.ts | 30 + .../page-properties/sidebar/section.tsx | 38 + .../page-properties/sidebar/styles.css.ts | 72 ++ .../affine/page-properties/styles.css.ts | 545 -------- .../affine/page-properties/table.css.ts | 213 ++++ .../affine/page-properties/table.tsx | 1123 ++++------------- .../page-properties/tags-inline-editor.css.ts | 17 + .../page-properties/tags-inline-editor.tsx | 129 +- .../page-properties/types/checkbox.css.ts | 6 + .../affine/page-properties/types/checkbox.tsx | 25 + .../affine/page-properties/types/constant.tsx | 73 ++ .../types/created-updated-by.tsx | 110 ++ .../affine/page-properties/types/date.css.ts | 6 + .../affine/page-properties/types/date.tsx | 29 + .../page-properties/types/number.css.ts | 25 + .../affine/page-properties/types/number.tsx | 50 + .../affine/page-properties/types/tags.css.ts | 10 + .../affine/page-properties/types/tags.tsx | 33 + .../affine/page-properties/types/text.css.ts | 46 + .../affine/page-properties/types/text.tsx | 70 + .../affine/page-properties/types/types.ts | 7 + .../workspace-setting/properties/index.tsx | 394 +----- .../properties/styles.css.ts | 2 +- .../block-suite-editor/lit-adaper.tsx | 4 +- .../workspace/detail-page/detail-page.tsx | 13 +- .../workspace/detail/sheets/doc-info.css.ts | 38 - .../workspace/detail/sheets/doc-info.tsx | 93 +- .../explorer/views/nodes/folder/index.tsx | 106 +- .../views/sections/organize/index.tsx | 2 +- .../favorite/entities/favorite-list.ts | 6 +- .../modules/organize/entities/folder-node.ts | 7 +- .../workbench/view/route-container.tsx | 2 +- packages/frontend/core/src/types/dnd.ts | 12 + packages/frontend/core/src/utils/index.ts | 1 - .../frontend/core/src/utils/unique-name.ts | 17 + .../i18n/src/i18n-completenesses.json | 8 +- packages/frontend/i18n/src/resources/en.json | 9 +- tests/affine-local/e2e/doc-info-modal.spec.ts | 28 +- .../affine-local/e2e/page-properties.spec.ts | 188 +-- tests/kit/utils/filter.ts | 4 +- tests/kit/utils/properties.ts | 70 +- yarn.lock | 2 +- 88 files changed, 3151 insertions(+), 3617 deletions(-) create mode 100644 packages/common/infra/src/modules/doc/constants.ts rename packages/{frontend/core => common/infra}/src/utils/__tests__/fractional-indexing.spec.ts (100%) rename packages/{frontend/core => common/infra}/src/utils/fractional-indexing.ts (100%) create mode 100644 packages/frontend/component/src/ui/property/index.ts create mode 100644 packages/frontend/component/src/ui/property/property.css.ts create mode 100644 packages/frontend/component/src/ui/property/property.stories.tsx create mode 100644 packages/frontend/component/src/ui/property/property.tsx delete mode 100644 packages/frontend/core/src/components/affine/page-properties/common.ts delete mode 100644 packages/frontend/core/src/components/affine/page-properties/confirm-delete-property-modal.tsx delete mode 100644 packages/frontend/core/src/components/affine/page-properties/icons-mapping.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/icons/constant.ts create mode 100644 packages/frontend/core/src/components/affine/page-properties/icons/doc-property-icon.tsx rename packages/frontend/core/src/components/affine/page-properties/{ => icons}/icons-selector.css.ts (100%) rename packages/frontend/core/src/components/affine/page-properties/{ => icons}/icons-selector.tsx (57%) delete mode 100644 packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.css.ts delete mode 100644 packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/manager/index.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/manager/styles.css.ts delete mode 100644 packages/frontend/core/src/components/affine/page-properties/menu-items.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/menu/create-doc-property.css.ts create mode 100644 packages/frontend/core/src/components/affine/page-properties/menu/create-doc-property.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/menu/edit-doc-property.css.ts create mode 100644 packages/frontend/core/src/components/affine/page-properties/menu/edit-doc-property.tsx delete mode 100644 packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts delete mode 100644 packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/sidebar/index.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/sidebar/section.css.ts create mode 100644 packages/frontend/core/src/components/affine/page-properties/sidebar/section.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/sidebar/styles.css.ts delete mode 100644 packages/frontend/core/src/components/affine/page-properties/styles.css.ts create mode 100644 packages/frontend/core/src/components/affine/page-properties/table.css.ts create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/checkbox.css.ts create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/checkbox.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/constant.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/created-updated-by.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/date.css.ts create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/date.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/number.css.ts create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/number.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/tags.css.ts create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/tags.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/text.css.ts create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/text.tsx create mode 100644 packages/frontend/core/src/components/affine/page-properties/types/types.ts create mode 100644 packages/frontend/core/src/utils/unique-name.ts diff --git a/packages/common/infra/package.json b/packages/common/infra/package.json index 6440ee7e2b..2521a48bcf 100644 --- a/packages/common/infra/package.json +++ b/packages/common/infra/package.json @@ -17,6 +17,7 @@ "@blocksuite/affine": "0.17.18", "@datastructures-js/binary-search-tree": "^5.3.2", "foxact": "^0.2.33", + "fractional-indexing": "^3.2.0", "fuse.js": "^7.0.0", "graphemer": "^1.4.0", "idb": "^8.0.0", diff --git a/packages/common/infra/src/modules/db/index.ts b/packages/common/infra/src/modules/db/index.ts index 065e916788..c4825f2fa0 100644 --- a/packages/common/infra/src/modules/db/index.ts +++ b/packages/common/infra/src/modules/db/index.ts @@ -4,7 +4,7 @@ import { WorkspaceDB } from './entities/db'; import { WorkspaceDBTable } from './entities/table'; import { WorkspaceDBService } from './services/db'; -export type { DocProperties } from './schema'; +export type { DocCustomPropertyInfo, DocProperties } from './schema'; export { WorkspaceDBService } from './services/db'; export { transformWorkspaceDBLocalToCloud } from './services/db'; diff --git a/packages/common/infra/src/modules/db/schema/index.ts b/packages/common/infra/src/modules/db/schema/index.ts index f8c9cd92e4..2991461d1f 100644 --- a/packages/common/infra/src/modules/db/schema/index.ts +++ b/packages/common/infra/src/modules/db/schema/index.ts @@ -1,4 +1,4 @@ -export type { DocProperties } from './schema'; +export type { DocCustomPropertyInfo, DocProperties } from './schema'; export { AFFiNE_WORKSPACE_DB_SCHEMA, AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA, diff --git a/packages/common/infra/src/modules/db/schema/schema.ts b/packages/common/infra/src/modules/db/schema/schema.ts index a3ec45e3b4..43b750fd24 100644 --- a/packages/common/infra/src/modules/db/schema/schema.ts +++ b/packages/common/infra/src/modules/db/schema/schema.ts @@ -23,6 +23,7 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = { type: f.string(), show: f.string().optional(), index: f.string().optional(), + icon: f.string().optional(), additionalData: f.json().optional(), isDeleted: f.boolean().optional(), // we will keep deleted properties in the database, for override legacy data diff --git a/packages/common/infra/src/modules/doc/constants.ts b/packages/common/infra/src/modules/doc/constants.ts new file mode 100644 index 0000000000..cde7a6b465 --- /dev/null +++ b/packages/common/infra/src/modules/doc/constants.ts @@ -0,0 +1,11 @@ +import type { DocCustomPropertyInfo } from '../db'; + +/** + * default built-in custom property, user can update and delete them + */ +export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [ + { + id: 'tags', + type: 'tags', + }, +] as DocCustomPropertyInfo[]; diff --git a/packages/common/infra/src/modules/doc/entities/doc.ts b/packages/common/infra/src/modules/doc/entities/doc.ts index d7dff5067e..9c06b7887d 100644 --- a/packages/common/infra/src/modules/doc/entities/doc.ts +++ b/packages/common/infra/src/modules/doc/entities/doc.ts @@ -34,6 +34,14 @@ export class Doc extends Entity { readonly title$ = this.record.title$; readonly trash$ = this.record.trash$; + customProperty$(propertyId: string) { + return this.record.customProperty$(propertyId); + } + + setCustomProperty(propertyId: string, value: string) { + return this.record.setCustomProperty(propertyId, value); + } + setPrimaryMode(mode: DocMode) { return this.record.setPrimaryMode(mode); } diff --git a/packages/common/infra/src/modules/doc/entities/property-list.ts b/packages/common/infra/src/modules/doc/entities/property-list.ts index ba11540d7d..ee014be4c3 100644 --- a/packages/common/infra/src/modules/doc/entities/property-list.ts +++ b/packages/common/infra/src/modules/doc/entities/property-list.ts @@ -1,5 +1,6 @@ import { Entity } from '../../../framework'; import { LiveData } from '../../../livedata'; +import { generateFractionalIndexingKeyBetween } from '../../../utils'; import type { DocCustomPropertyInfo } from '../../db/schema/schema'; import type { DocPropertiesStore } from '../stores/doc-properties'; @@ -13,15 +14,61 @@ export class DocPropertyList extends Entity { [] ); + sortedProperties$ = this.properties$.map(list => + // default index key is '', so always before any others + list.toSorted((a, b) => ((a.index ?? '') > (b.index ?? '') ? 1 : -1)) + ); + + propertyInfo$(id: string) { + return this.properties$.map(list => list.find(info => info.id === id)); + } + updatePropertyInfo(id: string, properties: Partial) { this.docPropertiesStore.updateDocPropertyInfo(id, properties); } - createProperty(properties: DocCustomPropertyInfo) { + createProperty( + properties: Omit & { id?: string } + ) { return this.docPropertiesStore.createDocPropertyInfo(properties); } removeProperty(id: string) { this.docPropertiesStore.removeDocPropertyInfo(id); } + + indexAt(at: 'before' | 'after', targetId?: string) { + const sortedChildren = this.sortedProperties$.value.filter( + node => node.index + ) as (DocCustomPropertyInfo & { index: string })[]; + const targetIndex = targetId + ? sortedChildren.findIndex(node => node.id === targetId) + : -1; + if (targetIndex === -1) { + if (at === 'before') { + const first = sortedChildren.at(0); + return generateFractionalIndexingKeyBetween(null, first?.index ?? null); + } else { + const last = sortedChildren.at(-1); + return generateFractionalIndexingKeyBetween(last?.index ?? null, null); + } + } else { + const target = sortedChildren[targetIndex]; + const before: DocCustomPropertyInfo | null = + sortedChildren[targetIndex - 1] || null; + const after: DocCustomPropertyInfo | null = + sortedChildren[targetIndex + 1] || null; + if (at === 'before') { + return generateFractionalIndexingKeyBetween( + before?.index ?? null, + target.index + ); + } else { + return generateFractionalIndexingKeyBetween( + target.index, + after?.index ?? null + ); + } + } + } } diff --git a/packages/common/infra/src/modules/doc/entities/record.ts b/packages/common/infra/src/modules/doc/entities/record.ts index 3c4e6dfc7f..1d2db1ffe0 100644 --- a/packages/common/infra/src/modules/doc/entities/record.ts +++ b/packages/common/infra/src/modules/doc/entities/record.ts @@ -31,8 +31,16 @@ export class DocRecord extends Entity<{ id: string }> { { id: this.id } ); - setProperties(properties: Partial): void { - this.docPropertiesStore.updateDocProperties(this.id, properties); + customProperty$(propertyId: string) { + return this.properties$.selector( + p => p['custom:' + propertyId] + ) as LiveData; + } + + setCustomProperty(propertyId: string, value: string) { + this.docPropertiesStore.updateDocProperties(this.id, { + ['custom:' + propertyId]: value, + }); } setMeta(meta: Partial): void { diff --git a/packages/common/infra/src/modules/doc/stores/doc-properties.ts b/packages/common/infra/src/modules/doc/stores/doc-properties.ts index 6f3b77db63..d668bfbc67 100644 --- a/packages/common/infra/src/modules/doc/stores/doc-properties.ts +++ b/packages/common/infra/src/modules/doc/stores/doc-properties.ts @@ -13,6 +13,7 @@ import type { DocProperties, } from '../../db/schema/schema'; import type { WorkspaceService } from '../../workspace'; +import { BUILT_IN_CUSTOM_PROPERTY_TYPE } from '../constants'; interface LegacyDocProperties { custom?: Record; @@ -23,6 +24,7 @@ type LegacyDocPropertyInfo = { id?: string; name?: string; type?: string; + icon?: string; }; type LegacyDocPropertyInfoList = Record< @@ -50,11 +52,18 @@ export class DocPropertiesStore extends Store { const legacy = this.upgradeLegacyDocPropertyInfoList( this.getLegacyDocPropertyInfoList() ); - const notOverridden = differenceBy(legacy, db, i => i.id); - return [...db, ...notOverridden].filter(i => !i.isDeleted); + const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE; + const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)]; + const all = [ + ...withLegacy, + ...differenceBy(builtIn, withLegacy, i => i.id), + ]; + return all.filter(i => !i.isDeleted); } - createDocPropertyInfo(config: DocCustomPropertyInfo) { + createDocPropertyInfo( + config: Omit & { id?: string } + ) { return this.dbService.db.docCustomPropertyInfo.create(config).id; } @@ -67,7 +76,11 @@ export class DocPropertiesStore extends Store { updateDocPropertyInfo(id: string, config: Partial) { const needMigration = !this.dbService.db.docCustomPropertyInfo.get(id); - if (needMigration) { + const isBuiltIn = + needMigration && BUILT_IN_CUSTOM_PROPERTY_TYPE.some(i => i.id === id); + if (isBuiltIn) { + this.createPropertyFromBuiltIn(id, config); + } else if (needMigration) { // if this property is not in db, we need to migration it from legacy to db, only type and name is needed this.migrateLegacyDocPropertyInfo(id, config); } else { @@ -90,16 +103,32 @@ export class DocPropertiesStore extends Store { }); } + createPropertyFromBuiltIn( + id: string, + override: Partial + ) { + const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE.find(i => i.id === id); + if (!builtIn) { + return; + } + this.createDocPropertyInfo({ ...builtIn, ...override }); + } + watchDocPropertyInfoList() { return combineLatest([ this.watchLegacyDocPropertyInfoList().pipe( map(this.upgradeLegacyDocPropertyInfoList) ), - this.dbService.db.docCustomPropertyInfo.find$({}), + this.dbService.db.docCustomPropertyInfo.find$(), ]).pipe( map(([legacy, db]) => { - const notOverridden = differenceBy(legacy, db, i => i.id); - return [...db, ...notOverridden].filter(i => !i.isDeleted); + const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE; + const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)]; + const all = [ + ...withLegacy, + ...differenceBy(builtIn, withLegacy, i => i.id), + ]; + return all.filter(i => !i.isDeleted); }) ); } @@ -133,15 +162,15 @@ export class DocPropertiesStore extends Store { if (!properties) { return {}; } - const newProperties: Record = {}; + const newProperties: Record = {}; for (const [key, info] of Object.entries(properties.system ?? {})) { - if (info?.value !== undefined) { - newProperties[key] = info.value; + if (info?.value !== undefined && info.value !== null) { + newProperties[key] = info.value.toString(); } } for (const [key, info] of Object.entries(properties.custom ?? {})) { - if (info?.value !== undefined) { - newProperties['custom:' + key] = info.value; + if (info?.value !== undefined && info.value !== null) { + newProperties['custom:' + key] = info.value.toString(); } } return newProperties; @@ -162,6 +191,7 @@ export class DocPropertiesStore extends Store { id, name: info.name, type: info.type, + icon: info.icon, }); } } diff --git a/packages/common/infra/src/orm/core/validators/data.ts b/packages/common/infra/src/orm/core/validators/data.ts index 1c8c9fdd65..1944ae7116 100644 --- a/packages/common/infra/src/orm/core/validators/data.ts +++ b/packages/common/infra/src/orm/core/validators/data.ts @@ -6,7 +6,7 @@ import type { DataValidator } from './types'; function inputType(val: any) { return val === null || Array.isArray(val) || - val.constructor === 'Object' || + val.constructor === Object || !val.constructor /* Object.create(null) */ ? 'json' : typeof val; diff --git a/packages/frontend/core/src/utils/__tests__/fractional-indexing.spec.ts b/packages/common/infra/src/utils/__tests__/fractional-indexing.spec.ts similarity index 100% rename from packages/frontend/core/src/utils/__tests__/fractional-indexing.spec.ts rename to packages/common/infra/src/utils/__tests__/fractional-indexing.spec.ts diff --git a/packages/frontend/core/src/utils/fractional-indexing.ts b/packages/common/infra/src/utils/fractional-indexing.ts similarity index 100% rename from packages/frontend/core/src/utils/fractional-indexing.ts rename to packages/common/infra/src/utils/fractional-indexing.ts diff --git a/packages/common/infra/src/utils/index.ts b/packages/common/infra/src/utils/index.ts index ba1d46b39d..ee7ce7c89e 100644 --- a/packages/common/infra/src/utils/index.ts +++ b/packages/common/infra/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './async-lock'; export * from './async-queue'; export * from './exhaustmap-with-trailing'; +export * from './fractional-indexing'; export * from './merge-updates'; export * from './object-pool'; export * from './stable-hash'; diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index 65fe616406..ffef1814df 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -20,6 +20,7 @@ export * from './ui/menu'; export * from './ui/modal'; export * from './ui/notification'; export * from './ui/popover'; +export * from './ui/property'; export * from './ui/radio'; export * from './ui/safe-area'; export * from './ui/scrollbar'; diff --git a/packages/frontend/component/src/ui/dnd/draggable.ts b/packages/frontend/component/src/ui/dnd/draggable.ts index aa3c74f9e9..bb3df781d6 100644 --- a/packages/frontend/component/src/ui/dnd/draggable.ts +++ b/packages/frontend/component/src/ui/dnd/draggable.ts @@ -182,6 +182,11 @@ export const useDraggable = ( let previewPosition: DraggableDragPreviewPosition = options.dragPreviewPosition ?? 'native'; + source.element.dataset['dragPreview'] = 'true'; + requestAnimationFrame(() => { + delete source.element.dataset['dragPreview']; + }); + if (enableCustomDragPreview.current) { setCustomNativeDragPreview({ getOffset: (...args) => { diff --git a/packages/frontend/component/src/ui/dnd/drop-indicator.css.ts b/packages/frontend/component/src/ui/dnd/drop-indicator.css.ts index 99861b28e8..550a534f0a 100644 --- a/packages/frontend/component/src/ui/dnd/drop-indicator.css.ts +++ b/packages/frontend/component/src/ui/dnd/drop-indicator.css.ts @@ -49,6 +49,12 @@ export const treeLine = style({ height: 2, right: 0, }, + + selectors: { + ['&[data-no-terminal="true"]::before']: { + display: 'none', + }, + }, }); export const lineAboveStyles = style({ @@ -143,7 +149,7 @@ export const left = style({ export const edgeLine = style({ vars: { - [terminalSize]: '8px', + [terminalSize]: '6px', }, display: 'block', position: 'absolute', @@ -156,11 +162,17 @@ export const edgeLine = style({ // Terminal '::before': { content: '""', - width: terminalSize, - height: terminalSize, + width: 0, + height: 0, boxSizing: 'border-box', position: 'absolute', border: `${terminalSize} solid ${cssVar('--affine-primary-color')}`, borderRadius: '50%', }, + + selectors: { + ['&[data-no-terminal="true"]::before']: { + display: 'none', + }, + }, }); diff --git a/packages/frontend/component/src/ui/dnd/drop-indicator.tsx b/packages/frontend/component/src/ui/dnd/drop-indicator.tsx index b9ecbb152d..6bf3c56d26 100644 --- a/packages/frontend/component/src/ui/dnd/drop-indicator.tsx +++ b/packages/frontend/component/src/ui/dnd/drop-indicator.tsx @@ -10,14 +10,17 @@ import * as styles from './drop-indicator.css'; export type DropIndicatorProps = { instruction?: Instruction | null; edge?: Edge | null; + noTerminal?: boolean; }; function getTreeElement({ instruction, isBlocked, + noTerminal, }: { instruction: Exclude; isBlocked: boolean; + noTerminal?: boolean; }): ReactElement | null { const style = { [styles.horizontalIndent]: `${instruction.currentLevel * instruction.indentPerLevel}px`, @@ -31,6 +34,7 @@ function getTreeElement({
); } @@ -39,6 +43,7 @@ function getTreeElement({
); } @@ -48,6 +53,7 @@ function getTreeElement({
); } @@ -61,6 +67,7 @@ function getTreeElement({
); } @@ -88,13 +95,14 @@ const edgeStyles: Record = { right: styles.right, }; -function getEdgeElement(edge: Edge, gap: number = 0) { +function getEdgeElement(edge: Edge, gap: number = 0, noTerminal?: boolean) { const lineOffset = `calc(-0.5 * (${gap}px + 2px))`; const orientation = edgeToOrientationMap[edge]; return (
{ + return ( + <> + + } /> + Value + + + } /> + Value + + + + } + menuItems={Menu} + /> + Value + + + + } /> + Readonly Value + + + ); +}; + +export const DNDProperty = () => { + const { dragRef: dragRef1 } = useDraggable( + () => ({ + data: { text: 'hello' }, + }), + [] + ); + const { dragRef: dragRef2 } = useDraggable( + () => ({ + data: { text: 'hello' }, + }), + [] + ); + const { dropTargetRef, closestEdge } = useDropTarget( + () => ({ + closestEdge: { + allowedEdges: ['top', 'bottom'], + }, + }), + [] + ); + return ( + <> + + } /> + Value + + + } + menuItems={Menu} + /> + Value + + + } /> + Value + + + ); +}; + +export const HideEmptyProperty = () => { + return ( + <> + + } /> + Value + + + } /> + Value + + + ); +}; + +export const BasicPropertyCollapsible = () => { + return ( + + + } /> + Value + + + } /> + Value + + + } /> + Value + + + ); +}; + +export const PropertyCollapsibleCustomButton = () => { + return ( + + `${isCollapsed ? 'Show' : 'Hide'} ${hide} properties` + } + > + + } /> + Value + + + } /> + Value + + + } /> + Value + + + ); +}; diff --git a/packages/frontend/component/src/ui/property/property.tsx b/packages/frontend/component/src/ui/property/property.tsx new file mode 100644 index 0000000000..e87e43454f --- /dev/null +++ b/packages/frontend/component/src/ui/property/property.tsx @@ -0,0 +1,276 @@ +import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { ArrowDownSmallIcon, ArrowUpSmallIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; +import { + createContext, + forwardRef, + type HTMLProps, + type PropsWithChildren, + type ReactNode, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useState, +} from 'react'; + +import { Button } from '../button'; +import { DropIndicator } from '../dnd'; +import { Menu } from '../menu'; +import * as styles from './property.css'; + +const PropertyTableContext = createContext<{ + mountProperty: (payload: { isHide: boolean }) => () => void; + showAllHide: boolean; +} | null>(null); + +export const PropertyCollapsible = forwardRef< + HTMLDivElement, + PropsWithChildren<{ + collapsible?: boolean; + defaultCollapsed?: boolean; + collapsed?: boolean; + onCollapseChange?: (collapsed: boolean) => void; + collapseButtonText?: (option: { + total: number; + hide: number; + isCollapsed: boolean; + }) => ReactNode; + }> & + HTMLProps +>( + ( + { + children, + collapsible = true, + collapsed, + defaultCollapsed, + onCollapseChange, + collapseButtonText, + ...props + }, + ref + ) => { + const [propertyCount, setPropertyCount] = useState({ total: 0, hide: 0 }); + const [showAllHide, setShowAllHide] = useState(!defaultCollapsed); + const finalCollapsible = collapsible ? propertyCount.hide !== 0 : false; + const controlled = collapsed !== undefined; + const finalShowAllHide = finalCollapsible + ? !controlled + ? showAllHide + : !collapsed + : true; + + const mountProperty = useCallback((payload: { isHide: boolean }) => { + setPropertyCount(prev => ({ + total: prev.total + 1, + hide: prev.hide + (payload.isHide ? 1 : 0), + })); + return () => { + setPropertyCount(prev => ({ + total: prev.total - 1, + hide: prev.hide - (payload.isHide ? 1 : 0), + })); + }; + }, []); + + const contextValue = useMemo( + () => ({ mountProperty, showAllHide: finalShowAllHide }), + [mountProperty, finalShowAllHide] + ); + + const handleShowAllHide = useCallback(() => { + setShowAllHide(!finalShowAllHide); + onCollapseChange?.(finalShowAllHide); + }, [finalShowAllHide, onCollapseChange]); + + return ( +
+ + {children} + {finalCollapsible && ( + + )} + +
+ ); + } +); + +PropertyCollapsible.displayName = 'PropertyCollapsible'; + +const PropertyRootContext = createContext<{ + mountValue: (payload: { isEmpty: boolean }) => () => void; +} | null>(null); + +export const PropertyRoot = forwardRef< + HTMLDivElement, + { + dropIndicatorEdge?: Edge | null; + hideEmpty?: boolean; + hide?: boolean; + } & HTMLProps +>( + ( + { children, className, dropIndicatorEdge, hideEmpty, hide, ...props }, + ref + ) => { + const [isEmpty, setIsEmpty] = useState(false); + const context = useContext(PropertyTableContext); + + const preferHide = hide || (hideEmpty && isEmpty); + const showAllHide = context?.showAllHide; + const shouldHide = preferHide && !showAllHide; + + useLayoutEffect(() => { + if (context) { + return context.mountProperty({ isHide: !!preferHide }); + } + return; + }, [context, preferHide]); + + const contextValue = useMemo( + () => ({ + mountValue: (payload: { isEmpty: boolean }) => { + setIsEmpty(payload.isEmpty); + return () => { + setIsEmpty(false); + }; + }, + }), + [setIsEmpty] + ); + + return ( + +
+ {children} + +
+
+ ); + } +); +PropertyRoot.displayName = 'PropertyRoot'; + +export const PropertyName = ({ + icon, + name, + className, + menuItems, + defaultOpenMenu, + ...props +}: { + icon?: ReactNode; + name?: ReactNode; + menuItems?: ReactNode; + defaultOpenMenu?: boolean; +} & HTMLProps) => { + const [menuOpen, setMenuOpen] = useState(defaultOpenMenu); + const hasMenu = !!menuItems; + + const handleClick = useCallback(() => { + if (!hasMenu) return; + setMenuOpen(true); + }, [hasMenu]); + + const handleMenuClose = useCallback((open: boolean) => { + if (!open) { + setMenuOpen(false); + } + }, []); + + const content = ( +
+
+ {icon &&
{icon}
} +
{name}
+
+
+ ); + + if (menuOpen && menuItems) { + // Do not mount when menuOpen is false, as will cause draggable to not work + return ( + + {content} + + ); + } + return content; +}; + +export const PropertyValue = forwardRef< + HTMLDivElement, + { readonly?: boolean; isEmpty?: boolean } & HTMLProps +>(({ children, className, readonly, isEmpty, ...props }, ref) => { + const context = useContext(PropertyRootContext); + + useLayoutEffect(() => { + if (context) { + return context.mountValue({ isEmpty: !!isEmpty }); + } + return; + }, [context, isEmpty]); + + return ( +
+ {children} +
+ ); +}); +PropertyValue.displayName = 'PropertyValue'; diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 2136c23dc5..d1c3be3c81 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -43,7 +43,6 @@ "dayjs": "^1.11.10", "file-type": "^19.1.0", "foxact": "^0.2.33", - "fractional-indexing": "^3.2.0", "fuse.js": "^7.0.0", "graphemer": "^1.4.0", "graphql": "^16.8.1", diff --git a/packages/frontend/core/src/components/affine/page-properties/common.ts b/packages/frontend/core/src/components/affine/page-properties/common.ts deleted file mode 100644 index 11b8fc1628..0000000000 --- a/packages/frontend/core/src/components/affine/page-properties/common.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createContext } from 'react'; - -import type { PagePropertiesManager } from './page-properties-manager'; - -// @ts-expect-error this should always be set -export const managerContext = createContext(); diff --git a/packages/frontend/core/src/components/affine/page-properties/confirm-delete-property-modal.tsx b/packages/frontend/core/src/components/affine/page-properties/confirm-delete-property-modal.tsx deleted file mode 100644 index 1df7bd7428..0000000000 --- a/packages/frontend/core/src/components/affine/page-properties/confirm-delete-property-modal.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { ConfirmModal } from '@affine/component'; -import { WorkspacePropertiesAdapter } from '@affine/core/modules/properties'; -import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema'; -import { Trans, useI18n } from '@affine/i18n'; -import { useService } from '@toeverything/infra'; -import { useMemo } from 'react'; - -import { PagePropertiesMetaManager } from './page-properties-manager'; - -export const ConfirmDeletePropertyModal = ({ - onConfirm, - onCancel, - property, - show, -}: { - property: PageInfoCustomPropertyMeta; - show: boolean; - onConfirm: () => void; - onCancel: () => void; -}) => { - const t = useI18n(); - const adapter = useService(WorkspacePropertiesAdapter); - const count = useMemo(() => { - const manager = new PagePropertiesMetaManager(adapter); - return manager.getPropertyRelatedPages(property.id)?.size || 0; - }, [adapter, property.id]); - - return ( - - The {{ name: property.name } as any} property will be - removed from count doc(s). This action cannot be undone. - - } - confirmText={t['Confirm']()} - onConfirm={onConfirm} - cancelButtonOptions={{ - onClick: onCancel, - }} - confirmButtonOptions={{ - variant: 'error', - }} - /> - ); -}; diff --git a/packages/frontend/core/src/components/affine/page-properties/icons-mapping.tsx b/packages/frontend/core/src/components/affine/page-properties/icons-mapping.tsx deleted file mode 100644 index 9a394d1caa..0000000000 --- a/packages/frontend/core/src/components/affine/page-properties/icons-mapping.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { PagePropertyType } from '@affine/core/modules/properties/services/schema'; -import * as icons from '@blocksuite/icons/rc'; -import type { SVGProps } from 'react'; - -type IconType = (props: SVGProps) => JSX.Element; - -// assume all exports in icons are icon Components -type LibIconComponentName = keyof typeof icons; - -type fromLibIconName = T extends `${infer N}Icon` - ? Uncapitalize - : never; - -export const iconNames = [ - 'ai', - 'email', - 'text', - 'dateTime', - 'keyboard', - 'pen', - 'account', - 'embedWeb', - 'layer', - 'pin', - 'appearance', - 'eraser', - 'layout', - 'presentation', - 'bookmark', - 'exportToHtml', - 'lightMode', - 'progress', - 'bulletedList', - 'exportToMarkdown', - 'link', - 'publish', - 'camera', - 'exportToPdf', - 'linkedEdgeless', - 'quote', - 'checkBoxCheckLinear', - 'exportToPng', - 'linkedPage', - 'save', - 'cloudWorkspace', - 'exportToSvg', - 'localData', - 'shape', - 'code', - 'favorite', - 'localWorkspace', - 'style', - 'codeBlock', - 'file', - 'lock', - 'tag', - 'collaboration', - 'folder', - 'multiSelect', - 'tags', - 'colorPicker', - 'frame', - 'new', - 'today', - 'contactWithUs', - 'grid', - 'now', - 'upgrade', - 'darkMode', - 'grouping', - 'number', - 'userGuide', - 'databaseKanbanView', - 'image', - 'numberedList', - 'view', - 'databaseListView', - 'inbox', - 'other', - 'viewLayers', - 'databaseTableView', - 'info', - 'page', - 'attachment', - 'delete', - 'issue', - 'paste', - 'heartbreak', - 'edgeless', - 'journal', - 'payment', - 'createdEdited', -] as const satisfies fromLibIconName[]; - -export type PagePropertyIcon = (typeof iconNames)[number]; - -export const getDefaultIconName = ( - type: PagePropertyType -): PagePropertyIcon => { - switch (type) { - case 'text': - return 'text'; - case 'tags': - return 'tag'; - case 'date': - return 'dateTime'; - case 'progress': - return 'progress'; - case 'checkbox': - return 'checkBoxCheckLinear'; - case 'number': - return 'number'; - case 'createdBy': - return 'createdEdited'; - case 'updatedBy': - return 'createdEdited'; - default: - return 'text'; - } -}; - -export const getSafeIconName = ( - iconName: string, - type?: PagePropertyType -): PagePropertyIcon => { - return iconNames.includes(iconName as any) - ? (iconName as PagePropertyIcon) - : getDefaultIconName(type || PagePropertyType.Text); -}; - -const nameToComponentName = ( - iconName: PagePropertyIcon -): LibIconComponentName => { - const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - return `${capitalize(iconName)}Icon` as LibIconComponentName; -}; - -export const nameToIcon = ( - iconName: string, - type?: PagePropertyType -): IconType => { - const Icon = icons[nameToComponentName(getSafeIconName(iconName, type))]; - if (!Icon) { - throw new Error(`Icon ${iconName} not found`); - } - return Icon; -}; diff --git a/packages/frontend/core/src/components/affine/page-properties/icons/constant.ts b/packages/frontend/core/src/components/affine/page-properties/icons/constant.ts new file mode 100644 index 0000000000..1901288115 --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-properties/icons/constant.ts @@ -0,0 +1,91 @@ +import type * as icons from '@blocksuite/icons/rc'; + +// assume all exports in icons are icon Components +type LibIconComponentName = keyof typeof icons; + +type fromLibIconName = T extends `${infer N}Icon` + ? Uncapitalize + : never; + +export const DocPropertyIconNames = [ + 'ai', + 'email', + 'text', + 'dateTime', + 'keyboard', + 'pen', + 'account', + 'embedWeb', + 'layer', + 'pin', + 'appearance', + 'eraser', + 'layout', + 'presentation', + 'bookmark', + 'exportToHtml', + 'lightMode', + 'progress', + 'bulletedList', + 'exportToMarkdown', + 'link', + 'publish', + 'camera', + 'exportToPdf', + 'linkedEdgeless', + 'quote', + 'checkBoxCheckLinear', + 'exportToPng', + 'linkedPage', + 'save', + 'cloudWorkspace', + 'exportToSvg', + 'localData', + 'shape', + 'code', + 'favorite', + 'localWorkspace', + 'style', + 'codeBlock', + 'file', + 'lock', + 'tag', + 'collaboration', + 'folder', + 'multiSelect', + 'tags', + 'colorPicker', + 'frame', + 'new', + 'today', + 'contactWithUs', + 'grid', + 'now', + 'upgrade', + 'darkMode', + 'grouping', + 'number', + 'userGuide', + 'databaseKanbanView', + 'image', + 'numberedList', + 'view', + 'databaseListView', + 'inbox', + 'other', + 'viewLayers', + 'databaseTableView', + 'info', + 'page', + 'attachment', + 'delete', + 'issue', + 'paste', + 'heartbreak', + 'edgeless', + 'journal', + 'payment', + 'createdEdited', +] as const satisfies fromLibIconName[]; + +export type DocPropertyIconName = (typeof DocPropertyIconNames)[number]; diff --git a/packages/frontend/core/src/components/affine/page-properties/icons/doc-property-icon.tsx b/packages/frontend/core/src/components/affine/page-properties/icons/doc-property-icon.tsx new file mode 100644 index 0000000000..52625becd2 --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-properties/icons/doc-property-icon.tsx @@ -0,0 +1,39 @@ +import * as icons from '@blocksuite/icons/rc'; +import type { DocCustomPropertyInfo } from '@toeverything/infra'; +import type { SVGProps } from 'react'; + +import { + DocPropertyTypes, + isSupportedDocPropertyType, +} from '../types/constant'; +import { type DocPropertyIconName, DocPropertyIconNames } from './constant'; + +// assume all exports in icons are icon Components +type LibIconComponentName = keyof typeof icons; + +export const iconNameToComponent = (name: DocPropertyIconName) => { + const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + const IconComponent = + icons[`${capitalize(name)}Icon` as LibIconComponentName]; + if (!IconComponent) { + throw new Error(`Icon ${name} not found`); + } + return IconComponent; +}; + +export const DocPropertyIcon = ({ + propertyInfo, + ...props +}: { + propertyInfo: DocCustomPropertyInfo; +} & SVGProps) => { + const Icon = + propertyInfo.icon && + DocPropertyIconNames.includes(propertyInfo.icon as DocPropertyIconName) + ? iconNameToComponent(propertyInfo.icon as DocPropertyIconName) + : isSupportedDocPropertyType(propertyInfo.type) + ? DocPropertyTypes[propertyInfo.type].icon + : DocPropertyTypes.text.icon; + + return ; +}; diff --git a/packages/frontend/core/src/components/affine/page-properties/icons-selector.css.ts b/packages/frontend/core/src/components/affine/page-properties/icons/icons-selector.css.ts similarity index 100% rename from packages/frontend/core/src/components/affine/page-properties/icons-selector.css.ts rename to packages/frontend/core/src/components/affine/page-properties/icons/icons-selector.css.ts diff --git a/packages/frontend/core/src/components/affine/page-properties/icons-selector.tsx b/packages/frontend/core/src/components/affine/page-properties/icons/icons-selector.tsx similarity index 57% rename from packages/frontend/core/src/components/affine/page-properties/icons-selector.tsx rename to packages/frontend/core/src/components/affine/page-properties/icons/icons-selector.tsx index 0db5e8d4a1..e92668f177 100644 --- a/packages/frontend/core/src/components/affine/page-properties/icons-selector.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/icons/icons-selector.tsx @@ -1,37 +1,23 @@ import { Menu, Scrollable } from '@affine/component'; import { useI18n } from '@affine/i18n'; +import type { DocCustomPropertyInfo } from '@toeverything/infra'; import { chunk } from 'lodash-es'; -import { useEffect, useRef } from 'react'; -import type { PagePropertyIcon } from './icons-mapping'; -import { iconNames, nameToIcon } from './icons-mapping'; +import { type DocPropertyIconName, DocPropertyIconNames } from './constant'; +import { DocPropertyIcon, iconNameToComponent } from './doc-property-icon'; import * as styles from './icons-selector.css'; const iconsPerRow = 6; -const iconRows = chunk(iconNames, iconsPerRow); +const iconRows = chunk(DocPropertyIconNames, iconsPerRow); -export const IconsSelectorPanel = ({ - selected, +const IconsSelectorPanel = ({ + selectedIcon, onSelectedChange, }: { - selected: PagePropertyIcon; - onSelectedChange: (icon: PagePropertyIcon) => void; + selectedIcon?: string | null; + onSelectedChange: (icon: DocPropertyIconName) => void; }) => { - const ref = useRef(null); - useEffect(() => { - if (!ref.current) { - return; - } - const iconButton = ref.current.querySelector( - `[data-name="${selected}"]` - ) as HTMLDivElement; - if (!iconButton) { - return; - } - iconButton.scrollIntoView({ block: 'center' }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); const t = useI18n(); return ( @@ -39,19 +25,19 @@ export const IconsSelectorPanel = ({ {t['com.affine.page-properties.icons']()}
-
+
{iconRows.map((iconRow, index) => { return (
{iconRow.map(iconName => { - const Icon = nameToIcon(iconName); + const Icon = iconNameToComponent(iconName); return (
onSelectedChange(iconName)} key={iconName} className={styles.iconButton} data-name={iconName} - data-active={selected === iconName} + data-active={iconName === selectedIcon} >
@@ -67,25 +53,24 @@ export const IconsSelectorPanel = ({ ); }; -export const IconsSelectorButton = ({ - selected, +export const DocPropertyIconSelector = ({ + propertyInfo, onSelectedChange, }: { - selected: PagePropertyIcon; - onSelectedChange: (icon: PagePropertyIcon) => void; + propertyInfo: DocCustomPropertyInfo; + onSelectedChange: (icon: DocPropertyIconName) => void; }) => { - const Icon = nameToIcon(selected); return ( } >
- +
); diff --git a/packages/frontend/core/src/components/affine/page-properties/index.ts b/packages/frontend/core/src/components/affine/page-properties/index.ts index 49e6ad80cf..3c5d854fc8 100644 --- a/packages/frontend/core/src/components/affine/page-properties/index.ts +++ b/packages/frontend/core/src/components/affine/page-properties/index.ts @@ -1,4 +1,2 @@ -export * from './icons-mapping'; export * from './info-modal/info-modal'; -export * from './page-properties-manager'; export * from './table'; diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.css.ts b/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.css.ts index 75ee3ea4e1..a858d161af 100644 --- a/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.css.ts +++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.css.ts @@ -1,7 +1,6 @@ import { cssVar } from '@toeverything/theme'; -import { style } from '@vanilla-extract/css'; - -import { rowHPadding } from '../styles.css'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { globalStyle, style } from '@vanilla-extract/css'; export const container = style({ maxWidth: 480, @@ -9,9 +8,6 @@ export const container = style({ padding: '20px 0', alignSelf: 'start', marginTop: '120px', - vars: { - [rowHPadding]: '6px', - }, }); export const titleContainer = style({ @@ -53,3 +49,36 @@ export const timeRow = style({ marginTop: 20, borderBottom: 4, }); + +export const tableBodyRoot = style({ + display: 'flex', + flexDirection: 'column', + position: 'relative', +}); + +export const addPropertyButton = style({ + alignSelf: 'flex-start', + fontSize: cssVar('fontSm'), + color: `${cssVarV2('text/secondary')}`, + padding: '0 4px', + height: 36, + fontWeight: 400, + gap: 6, + '@media': { + print: { + display: 'none', + }, + }, + selectors: { + [`[data-property-collapsed="true"] &`]: { + display: 'none', + }, + }, +}); +globalStyle(`${addPropertyButton} svg`, { + fontSize: 16, + color: cssVarV2('icon/secondary'), +}); +globalStyle(`${addPropertyButton}:hover svg`, { + color: cssVarV2('icon/primary'), +}); diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.tsx b/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.tsx index 1f52f51c0e..587f5f26c6 100644 --- a/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.tsx @@ -1,12 +1,16 @@ import { + Button, Divider, type InlineEditHandle, + Menu, Modal, + PropertyCollapsible, Scrollable, } from '@affine/component'; import { DocInfoService } from '@affine/core/modules/doc-info'; import { DocsSearchService } from '@affine/core/modules/docs-search'; import { useI18n } from '@affine/i18n'; +import { PlusIcon } from '@blocksuite/icons/rc'; import type { Doc } from '@toeverything/infra'; import { DocsService, @@ -16,27 +20,13 @@ import { useService, useServices, } from '@toeverything/infra'; -import { - Suspense, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BlocksuiteHeaderTitle } from '../../../blocksuite/block-suite-header/title'; -import { managerContext } from '../common'; -import { - PagePropertiesAddProperty, - PagePropertyRow, - SortableProperties, - usePagePropertiesManager, -} from '../table'; +import { CreatePropertyMenuItems } from '../menu/create-doc-property'; +import { PagePropertyRow } from '../table'; import * as styles from './info-modal.css'; import { LinksRow } from './links-row'; -import { TagsRow } from './tags-row'; import { TimeRow } from './time-row'; export const InfoModal = () => { @@ -68,15 +58,10 @@ const InfoModalOpened = ({ docId }: { docId: string }) => { const modal = useService(DocInfoService).modal; const titleInputHandleRef = useRef(null); - const manager = usePagePropertiesManager(docId ?? ''); const handleClose = useCallback(() => { modal.close(); }, [modal]); - if (!manager.page || manager.readonly) { - return null; - } - return ( { inputHandleRef={titleInputHandleRef} />
- - - - - + @@ -117,17 +94,17 @@ const InfoModalOpened = ({ docId }: { docId: string }) => { export const InfoTable = ({ onClose, docId, - readonly, }: { docId: string; onClose: () => void; - readonly: boolean; }) => { const t = useI18n(); - const manager = useContext(managerContext); - const { docsSearchService } = useServices({ + const { docsSearchService, docsService } = useServices({ DocsSearchService, + DocsService, }); + const [newPropertyId, setNewPropertyId] = useState(null); + const properties = useLiveData(docsService.propertyList.sortedProperties$); const links = useLiveData( useMemo( () => LiveData.from(docsSearchService.watchRefsFrom(docId), null), @@ -165,33 +142,50 @@ export const InfoTable = ({ ) : null} - - - {properties => - properties.length ? ( -
- {properties - .filter( - property => - manager.isPropertyRequired(property.id) || - (property.visibility !== 'hide' && - !( - property.visibility === 'hide-if-empty' && - !property.value - )) - ) - .map(property => ( - - ))} -
- ) : null + + isCollapsed + ? hide === 1 + ? t['com.affine.page-properties.more-property.one']({ + count: hide.toString(), + }) + : t['com.affine.page-properties.more-property.more']({ + count: hide.toString(), + }) + : hide === 1 + ? t['com.affine.page-properties.hide-property.one']({ + count: hide.toString(), + }) + : t['com.affine.page-properties.hide-property.more']({ + count: hide.toString(), + }) } -
- {manager.readonly ? null : } + > + {properties.map(property => ( + + ))} + } + contentOptions={{ + onClick(e) { + e.stopPropagation(); + }, + }} + > + + +
); }; diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.css.ts b/packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.css.ts deleted file mode 100644 index 29f50d305a..0000000000 --- a/packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.css.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { cssVar } from '@toeverything/theme'; -import { fallbackVar, style } from '@vanilla-extract/css'; - -import { rowHPadding } from '../styles.css'; - -export const icon = style({ - fontSize: 16, - color: cssVar('iconSecondary'), - display: 'flex', - alignItems: 'center', - justifyContent: 'center', -}); - -export const rowNameContainer = style({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - gap: 6, - padding: 6, - width: '160px', -}); - -export const rowName = style({ - flexGrow: 1, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - fontSize: cssVar('fontSm'), - color: cssVar('textSecondaryColor'), -}); - -export const time = style({ - display: 'flex', - alignItems: 'center', - padding: '6px 8px', - flexGrow: 1, - fontSize: cssVar('fontSm'), -}); - -export const rowCell = style({ - display: 'flex', - flexDirection: 'row', - alignItems: 'start', - gap: 4, -}); - -export const container = style({ - display: 'flex', - flexDirection: 'column', - marginTop: 20, - marginBottom: 4, -}); - -export const rowValueCell = style({ - display: 'flex', - flexDirection: 'row', - alignItems: 'flex-start', - position: 'relative', - borderRadius: 4, - fontSize: cssVar('fontSm'), - lineHeight: '22px', - userSelect: 'none', - ':focus-visible': { - outline: 'none', - }, - cursor: 'pointer', - ':hover': { - backgroundColor: cssVar('hoverColor'), - }, - padding: `6px ${fallbackVar(rowHPadding, '8px')} 6px 8px`, - border: `1px solid transparent`, - color: cssVar('textPrimaryColor'), - ':focus': { - backgroundColor: cssVar('hoverColor'), - }, - '::placeholder': { - color: cssVar('placeholderColor'), - }, - selectors: { - '&[data-empty="true"]': { - color: cssVar('placeholderColor'), - }, - '&[data-readonly=true]': { - pointerEvents: 'none', - }, - }, - flex: 1, -}); - -export const tagsMenu = style({ - padding: 0, - transform: - 'translate(-3.5px, calc(-3.5px + var(--radix-popper-anchor-height) * -1))', - width: 'calc(var(--radix-popper-anchor-width) + 16px)', - overflow: 'hidden', -}); - -export const tagsInlineEditor = style({ - selectors: { - '&[data-empty=true]': { - color: cssVar('placeholderColor'), - }, - }, -}); diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.tsx b/packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.tsx deleted file mode 100644 index 620bc1ab58..0000000000 --- a/packages/frontend/core/src/components/affine/page-properties/info-modal/tags-row.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Menu } from '@affine/component'; -import { TagService } from '@affine/core/modules/tag'; -import { useI18n } from '@affine/i18n'; -import { TagsIcon } from '@blocksuite/icons/rc'; -import { useLiveData, useService } from '@toeverything/infra'; -import clsx from 'clsx'; - -import { InlineTagsList, TagsEditor } from '../tags-inline-editor'; -import * as styles from './tags-row.css'; - -export const TagsRow = ({ - docId, - readonly, - className, -}: { - docId: string; - readonly: boolean; - className?: string; -}) => { - const t = useI18n(); - const tagList = useService(TagService).tagList; - const tagIds = useLiveData(tagList.tagIdsByPageId$(docId)); - const empty = !tagIds || tagIds.length === 0; - return ( -
-
-
- -
-
{t['Tags']()}
-
- } - > -
- {empty ? ( - t['com.affine.page-properties.property-value-placeholder']() - ) : ( - - )} -
-
-
- ); -}; diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.css.ts b/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.css.ts index 329588f103..628605828e 100644 --- a/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.css.ts +++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.css.ts @@ -1,48 +1,5 @@ -import { cssVar } from '@toeverything/theme'; import { style } from '@vanilla-extract/css'; -export const icon = style({ - fontSize: 16, - color: cssVar('iconSecondary'), - display: 'flex', - alignItems: 'center', - justifyContent: 'center', -}); - -export const rowNameContainer = style({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - - gap: 6, - padding: 6, - width: '160px', -}); - -export const rowName = style({ - flexGrow: 1, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - fontSize: cssVar('fontSm'), - color: cssVar('textSecondaryColor'), -}); - -export const time = style({ - display: 'flex', - alignItems: 'center', - padding: '6px 8px', - flexGrow: 1, - fontSize: cssVar('fontSm'), -}); - -export const rowCell = style({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - gap: 4, -}); - export const container = style({ display: 'flex', flexDirection: 'column', diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.tsx b/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.tsx index 88a1282182..11d62fd06d 100644 --- a/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/time-row.tsx @@ -1,34 +1,19 @@ +import { PropertyName, PropertyRoot, PropertyValue } from '@affine/component'; import { i18nTime, useI18n } from '@affine/i18n'; import { DateTimeIcon, HistoryIcon } from '@blocksuite/icons/rc'; -import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; +import { + DocsService, + useLiveData, + useService, + WorkspaceService, +} from '@toeverything/infra'; import clsx from 'clsx'; import type { ConfigType } from 'dayjs'; import { useDebouncedValue } from 'foxact/use-debounced-value'; -import { type ReactNode, useContext, useMemo } from 'react'; +import { useMemo } from 'react'; -import { managerContext } from '../common'; import * as styles from './time-row.css'; -const RowComponent = ({ - name, - icon, - time, -}: { - name: string; - icon: ReactNode; - time?: string | null; -}) => { - return ( -
-
-
{icon}
- {name} -
-
{time ? time : 'unknown'}
-
- ); -}; - export const TimeRow = ({ docId, className, @@ -37,11 +22,13 @@ export const TimeRow = ({ className?: string; }) => { const t = useI18n(); - const manager = useContext(managerContext); const workspaceService = useService(WorkspaceService); + const docsService = useService(DocsService); const { syncing, retrying, serverClock } = useLiveData( workspaceService.workspace.engine.doc.docState$(docId) ); + const docRecord = useLiveData(docsService.list.doc$(docId)); + const docMeta = useLiveData(docRecord?.meta$); const timestampElement = useMemo(() => { const formatI18nTime = (time: ConfigType) => @@ -54,44 +41,43 @@ export const TimeRow = ({ accuracy: 'day', }, }); - const localizedCreateTime = manager.createDate - ? formatI18nTime(manager.createDate) + const localizedCreateTime = docMeta + ? formatI18nTime(docMeta.createDate) : null; return ( <> - } - name={t['Created']()} - time={ - manager.createDate - ? formatI18nTime(manager.createDate) - : localizedCreateTime - } - /> + + } /> + + {docMeta ? formatI18nTime(docMeta.createDate) : localizedCreateTime} + + {serverClock ? ( - } - name={t[!syncing && !retrying ? 'Updated' : 'com.affine.syncing']()} - time={!syncing && !retrying ? formatI18nTime(serverClock) : null} - /> - ) : manager.updatedDate ? ( - } - name={t['Updated']()} - time={formatI18nTime(manager.updatedDate)} - /> + + } + /> + + {!syncing && !retrying + ? formatI18nTime(serverClock) + : docMeta?.updatedDate + ? formatI18nTime(docMeta.updatedDate) + : null} + + + ) : docMeta?.updatedDate ? ( + + } /> + {formatI18nTime(docMeta.updatedDate)} + ) : null} ); - }, [ - manager.createDate, - manager.updatedDate, - retrying, - serverClock, - syncing, - t, - ]); + }, [docMeta, retrying, serverClock, syncing, t]); const dTimestampElement = useDebouncedValue(timestampElement, 500); diff --git a/packages/frontend/core/src/components/affine/page-properties/manager/index.tsx b/packages/frontend/core/src/components/affine/page-properties/manager/index.tsx new file mode 100644 index 0000000000..1a8240c4eb --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-properties/manager/index.tsx @@ -0,0 +1,164 @@ +import { + DropIndicator, + IconButton, + Menu, + useDraggable, + useDropTarget, +} from '@affine/component'; +import type { AffineDNDData } from '@affine/core/types/dnd'; +import { useI18n } from '@affine/i18n'; +import { MoreHorizontalIcon } from '@blocksuite/icons/rc'; +import { + type DocCustomPropertyInfo, + DocsService, + useLiveData, + useService, + WorkspaceService, +} from '@toeverything/infra'; +import clsx from 'clsx'; +import { type HTMLProps, useCallback, useState } from 'react'; + +import { DocPropertyIcon } from '../icons/doc-property-icon'; +import { EditDocPropertyMenuItems } from '../menu/edit-doc-property'; +import { + DocPropertyTypes, + isSupportedDocPropertyType, +} from '../types/constant'; +import * as styles from './styles.css'; + +const PropertyItem = ({ + propertyInfo, + defaultOpenEditMenu, +}: { + propertyInfo: DocCustomPropertyInfo; + defaultOpenEditMenu?: boolean; +}) => { + const t = useI18n(); + const workspaceService = useService(WorkspaceService); + const docsService = useService(DocsService); + const [moreMenuOpen, setMoreMenuOpen] = useState(defaultOpenEditMenu); + + const typeInfo = isSupportedDocPropertyType(propertyInfo.type) + ? DocPropertyTypes[propertyInfo.type] + : undefined; + + const handleClick = useCallback(() => { + setMoreMenuOpen(true); + }, []); + + const { dragRef } = useDraggable( + () => ({ + data: { + entity: { + type: 'custom-property', + id: propertyInfo.id, + }, + from: { + at: 'doc-property:manager', + workspaceId: workspaceService.workspace.id, + }, + }, + }), + [propertyInfo, workspaceService] + ); + + const { dropTargetRef, closestEdge } = useDropTarget( + () => ({ + canDrop(data) { + return ( + data.source.data.entity?.type === 'custom-property' && + data.source.data.from?.at === 'doc-property:manager' && + data.source.data.from?.workspaceId === + workspaceService.workspace.id && + data.source.data.entity.id !== propertyInfo.id + ); + }, + closestEdge: { + allowedEdges: ['top', 'bottom'], + }, + isSticky: true, + onDrop(data) { + if (data.source.data.entity?.type !== 'custom-property') { + return; + } + const propertyId = data.source.data.entity.id; + const edge = data.closestEdge; + if (edge !== 'bottom' && edge !== 'top') { + return; + } + docsService.propertyList.updatePropertyInfo(propertyId, { + index: docsService.propertyList.indexAt( + edge === 'bottom' ? 'after' : 'before', + propertyInfo.id + ), + }); + }, + }), + [docsService, propertyInfo, workspaceService] + ); + + return ( +
{ + dropTargetRef.current = elem; + dragRef.current = elem; + }} + onClick={handleClick} + data-testid="doc-property-manager-item" + > + + + {propertyInfo.name || + (typeInfo?.name ? t.t(typeInfo.name) : t['unnamed']())} + + + {propertyInfo.show === 'hide-when-empty' + ? t['com.affine.page-properties.property.hide-when-empty']() + : propertyInfo.show === 'always-hide' + ? t['com.affine.page-properties.property.always-hide']() + : t['com.affine.page-properties.property.always-show']()} + + } + > + + + + + +
+ ); +}; + +export const DocPropertyManager = ({ + className, + defaultOpenEditMenuPropertyId, + ...props +}: HTMLProps & { defaultOpenEditMenuPropertyId?: string }) => { + const docsService = useService(DocsService); + + const properties = useLiveData(docsService.propertyList.sortedProperties$); + + return ( +
+ {properties.map(propertyInfo => ( + + ))} +
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/page-properties/manager/styles.css.ts b/packages/frontend/core/src/components/affine/page-properties/manager/styles.css.ts new file mode 100644 index 0000000000..214487bb67 --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-properties/manager/styles.css.ts @@ -0,0 +1,92 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + display: 'flex', + flexDirection: 'column', +}); + +export const itemContainer = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: '4px 8px', + gap: '8px', + color: cssVarV2('text/secondary'), + borderRadius: '6px', + lineHeight: '22px', + position: 'relative', + userSelect: 'none', + selectors: { + '&': { + cursor: 'pointer', + }, + '&:hover': { + backgroundColor: cssVarV2('layer/background/hoverOverlay'), + }, + '&[data-drag-preview="true"]': { + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + backgroundColor: 'transparent', + }, + '&[data-dragging="true"]': { + opacity: 0.5, + }, + '&[draggable="true"]:before': { + content: '""', + display: 'block', + position: 'absolute', + cursor: 'grab', + top: '50%', + left: 0, + borderRadius: '2px', + backgroundColor: cssVarV2('text/placeholder'), + transform: 'translate(-6px, -50%)', + transition: 'height 0.2s 0.1s, opacity 0.2s 0.1s', + opacity: 0, + height: '4px', + width: '4px', + willChange: 'height, opacity', + }, + '&[draggable="true"]:after': { + content: '""', + display: 'block', + position: 'absolute', + cursor: 'grab', + top: '50%', + left: 0, + borderRadius: '2px', + backgroundColor: 'transparent', + transform: 'translate(-8px, -50%)', + height: '100%', + width: '8px', + willChange: 'height, opacity', + }, + '&[draggable="true"]:hover:before': { + height: 12, + opacity: 1, + }, + }, +}); + +export const itemIcon = style({ + fontSize: '16px', +}); + +export const itemName = style({ + flex: 1, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + fontSize: cssVar('fontSm'), + color: cssVarV2('text/primary'), +}); + +export const itemVisibility = style({ + fontSize: cssVar('fontXs'), +}); + +export const itemMore = style({ + color: cssVarV2('text/secondary'), +}); diff --git a/packages/frontend/core/src/components/affine/page-properties/menu-items.tsx b/packages/frontend/core/src/components/affine/page-properties/menu-items.tsx deleted file mode 100644 index 0de30bb117..0000000000 --- a/packages/frontend/core/src/components/affine/page-properties/menu-items.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import type { MenuItemProps } from '@affine/component'; -import { Input, MenuItem, MenuSeparator, Scrollable } from '@affine/component'; -import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema'; -import { useI18n } from '@affine/i18n'; -import type { KeyboardEventHandler, MouseEventHandler } from 'react'; -import { cloneElement, isValidElement, useCallback } from 'react'; - -import type { PagePropertyIcon } from './icons-mapping'; -import { - getDefaultIconName, - getSafeIconName, - nameToIcon, -} from './icons-mapping'; -import { IconsSelectorButton } from './icons-selector'; -import * as styles from './styles.css'; -export type MenuItemOption = - | React.ReactElement - | '-' - | { - text: string; - onClick: MouseEventHandler; - key?: string; - icon?: React.ReactElement; - selected?: boolean; - checked?: boolean; - type?: MenuItemProps['type']; - } - | MenuItemOption[]; - -const isElementOption = (e: MenuItemOption): e is React.ReactElement => { - return isValidElement(e); -}; - -export const renderMenuItemOptions = (options: MenuItemOption[]) => { - return options.map((option, index) => { - if (option === '-') { - return ; - } else if (isElementOption(option)) { - return cloneElement(option, { key: index }); - } else if (Array.isArray(option)) { - // this is an area that needs scrollbar - return ( - - - {renderMenuItemOptions(option)} - - - - ); - } else { - const { text, icon, onClick, type, key, checked, selected } = option; - return ( - - {text} - - ); - } - }); -}; - -export const EditPropertyNameMenuItem = ({ - property, - onNameBlur: onBlur, - onNameChange, - onIconChange, -}: { - onNameBlur: (e: string) => void; - onNameChange: (e: string) => void; - onIconChange: (icon: PagePropertyIcon) => void; - property: PageInfoCustomPropertyMeta; -}) => { - const iconName = getSafeIconName(property.icon, property.type); - const onKeyDown: KeyboardEventHandler = useCallback( - e => { - if (e.key !== 'Escape') { - e.stopPropagation(); - } - if (e.key === 'Enter') { - e.preventDefault(); - onBlur(e.currentTarget.value); - } - }, - [onBlur] - ); - const handleBlur = useCallback( - (e: FocusEvent & { currentTarget: HTMLInputElement }) => { - onBlur(e.currentTarget.value); - }, - [onBlur] - ); - - const t = useI18n(); - return ( -
- - -
- ); -}; - -export const PropertyTypeMenuItem = ({ - property, -}: { - property: PageInfoCustomPropertyMeta; -}) => { - const Icon = nameToIcon(getDefaultIconName(property.type), property.type); - const t = useI18n(); - return ( -
- {t['com.affine.page-properties.create-property.menu.header']()} -
- - {t[`com.affine.page-properties.property.${property.type}`]()} -
-
- ); -}; diff --git a/packages/frontend/core/src/components/affine/page-properties/menu/create-doc-property.css.ts b/packages/frontend/core/src/components/affine/page-properties/menu/create-doc-property.css.ts new file mode 100644 index 0000000000..e32a90b8f6 --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-properties/menu/create-doc-property.css.ts @@ -0,0 +1,25 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const menuHeader = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: '8px', + fontSize: cssVar('fontXs'), + fontWeight: 500, + color: cssVarV2('text/secondary'), + padding: '8px 16px', + minWidth: 200, + textTransform: 'uppercase', +}); + +export const propertyItem = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: '8px', + minWidth: 200, +}); diff --git a/packages/frontend/core/src/components/affine/page-properties/menu/create-doc-property.tsx b/packages/frontend/core/src/components/affine/page-properties/menu/create-doc-property.tsx new file mode 100644 index 0000000000..8315bd157f --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-properties/menu/create-doc-property.tsx @@ -0,0 +1,84 @@ +import { MenuItem, MenuSeparator } from '@affine/component'; +import { generateUniqueNameInSequence } from '@affine/core/utils/unique-name'; +import { useI18n } from '@affine/i18n'; +import { DocsService, useLiveData, useService } from '@toeverything/infra'; +import { useCallback } from 'react'; + +import { + DocPropertyTypes, + isSupportedDocPropertyType, +} from '../types/constant'; +import * as styles from './create-doc-property.css'; + +export const CreatePropertyMenuItems = ({ + at = 'before', + onCreated, +}: { + at?: 'before' | 'after'; + onCreated?: (propertyId: string) => void; +}) => { + const t = useI18n(); + const docsService = useService(DocsService); + const propertyList = docsService.propertyList; + const properties = useLiveData(propertyList.properties$); + + const onAddProperty = useCallback( + (option: { type: string; name: string }) => { + if (!isSupportedDocPropertyType(option.type)) { + return; + } + const typeDefined = DocPropertyTypes[option.type]; + const nameExists = properties.some(meta => meta.name === option.name); + const allNames = properties + .map(meta => meta.name) + .filter((name): name is string => name !== null && name !== undefined); + const name = nameExists + ? generateUniqueNameInSequence(option.name, allNames) + : option.name; + const uniqueId = typeDefined.uniqueId; + const newPropertyId = propertyList.createProperty({ + id: uniqueId, + name, + type: option.type, + index: propertyList.indexAt(at), + }); + onCreated?.(newPropertyId); + }, + [at, onCreated, propertyList, properties] + ); + + return ( + <> +
+ {t['com.affine.page-properties.create-property.menu.header']()} +
+ + {Object.entries(DocPropertyTypes).map(([type, info]) => { + const name = t.t(info.name); + const uniqueId = info.uniqueId; + const isUniqueExist = properties.some(meta => meta.id === uniqueId); + const Icon = info.icon; + return ( + } + disabled={isUniqueExist} + onClick={() => { + onAddProperty({ + name: name, + type: type, + }); + }} + data-testid="create-property-menu-item" + data-property-type={type} + > +
+ {name} + {isUniqueExist && Added} +
+
+ ); + })} + + ); +}; diff --git a/packages/frontend/core/src/components/affine/page-properties/menu/edit-doc-property.css.ts b/packages/frontend/core/src/components/affine/page-properties/menu/edit-doc-property.css.ts new file mode 100644 index 0000000000..4cd1593108 --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-properties/menu/edit-doc-property.css.ts @@ -0,0 +1,42 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const propertyRowNamePopupRow = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: '8px', + fontSize: cssVar('fontSm'), + fontWeight: 500, + color: cssVarV2('text/secondary'), + padding: '8px 0px', + minWidth: 260, +}); + +export const propertyRowTypeItem = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: '8px', + fontSize: cssVar('fontSm'), + padding: '8px 4px', + minWidth: 260, +}); + +export const propertyTypeName = style({ + color: cssVarV2('text/secondary'), + fontSize: cssVar('fontSm'), + display: 'flex', + alignItems: 'center', + gap: 4, +}); + +export const propertyName = style({ + color: cssVarV2('text/primary'), + fontSize: cssVar('fontSm'), + padding: '0 8px', + display: 'flex', + alignItems: 'center', +}); diff --git a/packages/frontend/core/src/components/affine/page-properties/menu/edit-doc-property.tsx b/packages/frontend/core/src/components/affine/page-properties/menu/edit-doc-property.tsx new file mode 100644 index 0000000000..1659ac89b9 --- /dev/null +++ b/packages/frontend/core/src/components/affine/page-properties/menu/edit-doc-property.tsx @@ -0,0 +1,212 @@ +import { + Input, + MenuItem, + MenuSeparator, + useConfirmModal, +} from '@affine/component'; +import { Trans, useI18n } from '@affine/i18n'; +import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/rc'; +import { DocsService, useLiveData, useService } from '@toeverything/infra'; +import { + type KeyboardEventHandler, + type MouseEvent, + useCallback, + useEffect, + useState, +} from 'react'; + +import { DocPropertyIcon } from '../icons/doc-property-icon'; +import { DocPropertyIconSelector } from '../icons/icons-selector'; +import { + DocPropertyTypes, + isSupportedDocPropertyType, +} from '../types/constant'; +import * as styles from './edit-doc-property.css'; + +export const EditDocPropertyMenuItems = ({ + propertyId, +}: { + propertyId: string; +}) => { + const t = useI18n(); + const docsService = useService(DocsService); + const propertyInfo = useLiveData( + docsService.propertyList.propertyInfo$(propertyId) + ); + const propertyType = propertyInfo?.type; + const typeInfo = + propertyType && isSupportedDocPropertyType(propertyType) + ? DocPropertyTypes[propertyType] + : undefined; + const propertyName = + propertyInfo?.name || + (typeInfo?.name ? t.t(typeInfo.name) : t['unnamed']()); + const [name, setName] = useState(propertyName); + const confirmModal = useConfirmModal(); + + useEffect(() => { + setName(propertyName); + }, [propertyName]); + + const onKeyDown: KeyboardEventHandler = useCallback( + e => { + if (e.key !== 'Escape') { + e.stopPropagation(); + } + if (e.key === 'Enter') { + e.preventDefault(); + docsService.propertyList.updatePropertyInfo(propertyId, { + name: e.currentTarget.value, + }); + } + }, + [docsService.propertyList, propertyId] + ); + const handleBlur = useCallback( + (e: FocusEvent & { currentTarget: HTMLInputElement }) => { + docsService.propertyList.updatePropertyInfo(propertyId, { + name: e.currentTarget.value, + }); + }, + [docsService.propertyList, propertyId] + ); + + const handleIconChange = useCallback( + (iconName: string) => { + docsService.propertyList.updatePropertyInfo(propertyId, { + icon: iconName, + }); + }, + [docsService.propertyList, propertyId] + ); + + const handleNameChange = useCallback((e: string) => { + setName(e); + }, []); + + const handleClickAlwaysShow = useCallback( + (e: MouseEvent) => { + e.preventDefault(); // avoid radix-ui close the menu + docsService.propertyList.updatePropertyInfo(propertyId, { + show: 'always-show', + }); + }, + [docsService.propertyList, propertyId] + ); + + const handleClickHideWhenEmpty = useCallback( + (e: MouseEvent) => { + e.preventDefault(); // avoid radix-ui close the menu + docsService.propertyList.updatePropertyInfo(propertyId, { + show: 'hide-when-empty', + }); + }, + [docsService.propertyList, propertyId] + ); + + const handleClickAlwaysHide = useCallback( + (e: MouseEvent) => { + e.preventDefault(); // avoid radix-ui close the menu + docsService.propertyList.updatePropertyInfo(propertyId, { + show: 'always-hide', + }); + }, + [docsService.propertyList, propertyId] + ); + + if (!propertyInfo || !isSupportedDocPropertyType(propertyType)) { + return null; + } + + return ( + <> +
+ + {typeInfo?.renameable === false ? ( + {name} + ) : ( + + )} +
+
+ {t['com.affine.page-properties.create-property.menu.header']()} +
+ + {t[`com.affine.page-properties.property.${propertyType}`]()} +
+
+ + } + onClick={handleClickAlwaysShow} + selected={ + propertyInfo.show !== 'hide-when-empty' && + propertyInfo.show !== 'always-hide' + } + data-property-visibility="always-show" + > + {t['com.affine.page-properties.property.always-show']()} + + } + onClick={handleClickHideWhenEmpty} + selected={propertyInfo.show === 'hide-when-empty'} + data-property-visibility="hide-when-empty" + > + {t['com.affine.page-properties.property.hide-when-empty']()} + + } + onClick={handleClickAlwaysHide} + selected={propertyInfo.show === 'always-hide'} + data-property-visibility="always-hide" + > + {t['com.affine.page-properties.property.always-hide']()} + + + } + type="danger" + onClick={() => { + confirmModal.openConfirmModal({ + title: + t['com.affine.settings.workspace.properties.delete-property'](), + description: ( + + The {{ name: propertyInfo.name } as any}{' '} + property will be removed from count doc(s). This action cannot + be undone. + + ), + confirmText: t['Confirm'](), + onConfirm: () => { + docsService.propertyList.removeProperty(propertyId); + }, + confirmButtonOptions: { + variant: 'error', + }, + }); + }} + > + {t['com.affine.settings.workspace.properties.delete-property']()} + + + ); +}; diff --git a/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts b/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts deleted file mode 100644 index bf1d96befb..0000000000 --- a/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts +++ /dev/null @@ -1,303 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -import type { WorkspacePropertiesAdapter } from '@affine/core/modules/properties'; -import type { - PageInfoCustomProperty, - PageInfoCustomPropertyMeta, -} from '@affine/core/modules/properties/services/schema'; -import { PagePropertyType } from '@affine/core/modules/properties/services/schema'; -import { createFractionalIndexingSortableHelper } from '@affine/core/utils'; -import { DebugLogger } from '@affine/debug'; -import { nanoid } from 'nanoid'; - -import { getDefaultIconName } from './icons-mapping'; - -const logger = new DebugLogger('PagePropertiesManager'); - -function validatePropertyValue(type: PagePropertyType, value: any) { - switch (type) { - case PagePropertyType.Text: - return typeof value === 'string'; - case PagePropertyType.Number: - return typeof value === 'number' || !isNaN(+value); - case PagePropertyType.Checkbox: - return typeof value === 'boolean'; - case PagePropertyType.Date: - return value.match(/^\d{4}-\d{2}-\d{2}$/); - case PagePropertyType.Tags: - return Array.isArray(value) && value.every(v => typeof v === 'string'); - default: - return false; - } -} - -export const newPropertyTypes: PagePropertyType[] = [ - PagePropertyType.Text, - PagePropertyType.Number, - PagePropertyType.Checkbox, - PagePropertyType.Date, - PagePropertyType.CreatedBy, - PagePropertyType.UpdatedBy, - // TODO(@Peng): add more -]; - -export const readonlyPropertyTypes: PagePropertyType[] = [ - PagePropertyType.CreatedBy, - PagePropertyType.UpdatedBy, -]; - -export class PagePropertiesMetaManager { - constructor(private readonly adapter: WorkspacePropertiesAdapter) {} - - get propertiesSchema() { - return this.adapter.schema?.pageProperties ?? {}; - } - - get systemPropertiesSchema() { - return this.adapter.schema?.pageProperties.system ?? {}; - } - - get customPropertiesSchema() { - return this.adapter.schema?.pageProperties.custom ?? {}; - } - - getOrderedPropertiesSchema() { - return Object.values(this.customPropertiesSchema).sort( - (a, b) => a.order - b.order - ); - } - - checkPropertyExists(id: string) { - return !!this.customPropertiesSchema[id]; - } - - validatePropertyValue(id: string, value?: any) { - if (!value) { - // value is optional in all cases? - return true; - } - const type = this.customPropertiesSchema[id]?.type; - if (!type) { - logger.warn(`property ${id} not found`); - return false; - } - return validatePropertyValue(type, value); - } - - addPropertyMeta(schema: { - name: string; - type: PagePropertyType; - icon?: string; - }) { - const id = nanoid(); - const { type, icon } = schema; - const newOrder = - Math.max( - 0, - ...Object.values(this.customPropertiesSchema).map(p => p.order) - ) + 1; - const property = { - ...schema, - id, - source: 'custom', - type, - order: newOrder, - icon: icon ?? getDefaultIconName(type), - readonly: readonlyPropertyTypes.includes(type) || undefined, - } as const; - this.customPropertiesSchema[id] = property; - return property; - } - - updatePropertyMeta(id: string, opt: Partial) { - if (!this.checkPropertyExists(id)) { - logger.warn(`property ${id} not found`); - return; - } - Object.assign(this.customPropertiesSchema[id], opt); - } - - isPropertyRequired(id: string) { - return this.customPropertiesSchema[id]?.required; - } - - removePropertyMeta(id: string) { - // should warn if the property is in use - delete this.customPropertiesSchema[id]; - } - - // returns page schema properties -> related page - getPropertyStatistics() { - const mapping = new Map>(); - for (const page of this.adapter.workspace.docCollection.docs.values()) { - const properties = this.adapter.getPageProperties(page.id); - if (properties) { - for (const id of Object.keys(properties.custom)) { - if (!mapping.has(id)) mapping.set(id, new Set()); - mapping.get(id)?.add(page.id); - } - } - } - return mapping; - } - - getPropertyRelatedPages(id: string) { - return this.getPropertyStatistics().get(id); - } -} - -export class PagePropertiesManager { - public readonly metaManager: PagePropertiesMetaManager; - constructor( - private readonly adapter: WorkspacePropertiesAdapter, - public readonly pageId: string - ) { - this.metaManager = new PagePropertiesMetaManager(this.adapter); - this.ensureRequiredProperties(); - } - - readonly sorter = createFractionalIndexingSortableHelper< - PageInfoCustomProperty, - string | number - >(this); - - // prevent infinite loop - private ensuring = false; - ensureRequiredProperties() { - this.adapter.ensurePageProperties(this.pageId); - if (this.ensuring) return; - this.ensuring = true; - this.transact(() => { - this.metaManager.getOrderedPropertiesSchema().forEach(property => { - if (property.required && !this.hasCustomProperty(property.id)) { - this.addCustomProperty(property.id); - } - }); - }); - this.ensuring = false; - } - - getItems() { - return Object.values(this.getCustomProperties()); - } - - getItemOrder(item: PageInfoCustomProperty): string { - return item.order; - } - - setItemOrder(item: PageInfoCustomProperty, order: string) { - item.order = order; - } - - getItemId(item: PageInfoCustomProperty) { - return item.id; - } - - get workspace() { - return this.adapter.workspace; - } - - get page() { - return this.adapter.workspace.docCollection.getDoc(this.pageId); - } - - get intrinsicMeta() { - return this.page?.meta; - } - - get updatedDate() { - return this.intrinsicMeta?.updatedDate; - } - - get createDate() { - return this.intrinsicMeta?.createDate; - } - - get properties() { - return this.adapter.getPageProperties(this.pageId); - } - - get readonly() { - return !!this.page?.readonly; - } - - /** - * get custom properties (filter out properties that are not in schema) - */ - getCustomProperties(): Record { - return this.properties - ? Object.fromEntries( - Object.entries(this.properties.custom).filter(([id]) => - this.metaManager.checkPropertyExists(id) - ) - ) - : {}; - } - - getCustomPropertyMeta(id: string): PageInfoCustomPropertyMeta | undefined { - return this.metaManager.customPropertiesSchema[id]; - } - - getCustomProperty(id: string) { - return this.properties?.custom[id]; - } - - addCustomProperty(id: string, value?: any) { - this.ensureRequiredProperties(); - if (!this.metaManager.checkPropertyExists(id)) { - logger.warn(`property ${id} not found`); - return; - } - - if (!this.metaManager.validatePropertyValue(id, value)) { - logger.warn(`property ${id} value ${value} is invalid`); - return; - } - - const newOrder = this.sorter.getNewItemOrder(); - if (this.properties!.custom[id]) { - logger.warn(`custom property ${id} already exists`); - } - - this.properties!.custom[id] = { - id, - value, - order: newOrder, - visibility: 'visible', - }; - } - - hasCustomProperty(id: string) { - return !!this.properties?.custom[id]; - } - - removeCustomProperty(id: string) { - this.ensureRequiredProperties(); - delete this.properties!.custom[id]; - } - - updateCustomProperty(id: string, opt: Partial) { - this.ensureRequiredProperties(); - if (!this.properties?.custom[id]) { - logger.warn(`custom property ${id} not found`); - return; - } - if ( - opt.value !== undefined && - !this.metaManager.validatePropertyValue(id, opt.value) - ) { - logger.warn(`property ${id} value ${opt.value} is invalid`); - return; - } - Object.assign(this.properties.custom[id], opt); - } - - get updateCustomPropertyMeta() { - return this.metaManager.updatePropertyMeta.bind(this.metaManager); - } - - get isPropertyRequired() { - return this.metaManager.isPropertyRequired.bind(this.metaManager); - } - - transact = this.adapter.transact; -} diff --git a/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx b/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx deleted file mode 100644 index 94dc784dea..0000000000 --- a/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import { Avatar, Checkbox, DatePicker, Menu } from '@affine/component'; -import { CloudDocMetaService } from '@affine/core/modules/cloud/services/cloud-doc-meta'; -import type { - PageInfoCustomProperty, - PageInfoCustomPropertyMeta, - PagePropertyType, -} from '@affine/core/modules/properties/services/schema'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { i18nTime, useI18n } from '@affine/i18n'; -import { - DocService, - useLiveData, - useService, - WorkspaceService, -} from '@toeverything/infra'; -import { noop } from 'lodash-es'; -import type { ChangeEventHandler } from 'react'; -import { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; - -import { managerContext } from './common'; -import * as styles from './styles.css'; -import { TagsInlineEditor } from './tags-inline-editor'; - -interface PropertyRowValueProps { - property: PageInfoCustomProperty; - meta: PageInfoCustomPropertyMeta; -} - -export const DateValue = ({ property }: PropertyRowValueProps) => { - const displayValue = property.value - ? i18nTime(property.value, { absolute: { accuracy: 'day' } }) - : undefined; - const manager = useContext(managerContext); - - const handleClick = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - // show edit popup - }, []); - - const handleChange = useCallback( - (e: string) => { - manager.updateCustomProperty(property.id, { - value: e, - }); - }, - [manager, property.id] - ); - - const t = useI18n(); - - return ( - }> -
- {displayValue ?? - t['com.affine.page-properties.property-value-placeholder']()} -
-
- ); -}; - -export const CheckboxValue = ({ property }: PropertyRowValueProps) => { - const manager = useContext(managerContext); - const handleClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - manager.updateCustomProperty(property.id, { - value: !property.value, - }); - }, - [manager, property.id, property.value] - ); - return ( -
- -
- ); -}; - -export const TextValue = ({ property }: PropertyRowValueProps) => { - const manager = useContext(managerContext); - const [value, setValue] = useState(property.value); - const handleClick = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - }, []); - const ref = useRef(null); - const handleBlur = useCallback( - (e: FocusEvent) => { - manager.updateCustomProperty(property.id, { - value: (e.currentTarget as HTMLTextAreaElement).value.trim(), - }); - }, - [manager, property.id] - ); - // use native blur event to get event after unmount - // don't use useLayoutEffect here, cause the cleanup function will be called before unmount - useEffect(() => { - ref.current?.addEventListener('blur', handleBlur); - return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps - ref.current?.removeEventListener('blur', handleBlur); - }; - }, [handleBlur]); - const handleOnChange: ChangeEventHandler = useCallback( - e => { - setValue(e.target.value); - }, - [] - ); - const t = useI18n(); - useEffect(() => { - setValue(property.value); - }, [property.value]); - - return ( -
-