mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-30 00:29:46 +08:00
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`.  Added a sidebar view to manage properties  new property ui in workspace settings  Property lists can be collapsed 
This commit is contained in:
@@ -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<PagePropertiesManager>();
|
||||
-57
@@ -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 (
|
||||
<ConfirmModal
|
||||
open={show}
|
||||
closeButtonOptions={{
|
||||
onClick: onCancel,
|
||||
}}
|
||||
title={t['com.affine.settings.workspace.properties.delete-property']()}
|
||||
description={
|
||||
<Trans
|
||||
values={{
|
||||
name: property.name,
|
||||
count,
|
||||
}}
|
||||
i18nKey="com.affine.settings.workspace.properties.delete-property-prompt"
|
||||
>
|
||||
The <strong>{{ name: property.name } as any}</strong> property will be
|
||||
removed from count doc(s). This action cannot be undone.
|
||||
</Trans>
|
||||
}
|
||||
confirmText={t['Confirm']()}
|
||||
onConfirm={onConfirm}
|
||||
cancelButtonOptions={{
|
||||
onClick: onCancel,
|
||||
}}
|
||||
confirmButtonOptions={{
|
||||
variant: 'error',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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<SVGSVGElement>) => JSX.Element;
|
||||
|
||||
// assume all exports in icons are icon Components
|
||||
type LibIconComponentName = keyof typeof icons;
|
||||
|
||||
type fromLibIconName<T extends string> = T extends `${infer N}Icon`
|
||||
? Uncapitalize<N>
|
||||
: 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<LibIconComponentName>[];
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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 string> = T extends `${infer N}Icon`
|
||||
? Uncapitalize<N>
|
||||
: 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<LibIconComponentName>[];
|
||||
|
||||
export type DocPropertyIconName = (typeof DocPropertyIconNames)[number];
|
||||
+39
@@ -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<SVGSVGElement>) => {
|
||||
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 <Icon {...props} />;
|
||||
};
|
||||
+17
-32
@@ -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<HTMLDivElement>(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 (
|
||||
<Scrollable.Root>
|
||||
@@ -39,19 +25,19 @@ export const IconsSelectorPanel = ({
|
||||
{t['com.affine.page-properties.icons']()}
|
||||
</div>
|
||||
<Scrollable.Viewport className={styles.iconsContainerScrollable}>
|
||||
<div className={styles.iconsContainer} ref={ref}>
|
||||
<div className={styles.iconsContainer}>
|
||||
{iconRows.map((iconRow, index) => {
|
||||
return (
|
||||
<div key={index} className={styles.iconsRow}>
|
||||
{iconRow.map(iconName => {
|
||||
const Icon = nameToIcon(iconName);
|
||||
const Icon = iconNameToComponent(iconName);
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelectedChange(iconName)}
|
||||
key={iconName}
|
||||
className={styles.iconButton}
|
||||
data-name={iconName}
|
||||
data-active={selected === iconName}
|
||||
data-active={iconName === selectedIcon}
|
||||
>
|
||||
<Icon key={iconName} />
|
||||
</div>
|
||||
@@ -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 (
|
||||
<Menu
|
||||
items={
|
||||
<IconsSelectorPanel
|
||||
selected={selected}
|
||||
selectedIcon={propertyInfo.icon}
|
||||
onSelectedChange={onSelectedChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={styles.iconSelectorButton}>
|
||||
<Icon />
|
||||
<DocPropertyIcon propertyInfo={propertyInfo} />
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
@@ -1,4 +1,2 @@
|
||||
export * from './icons-mapping';
|
||||
export * from './info-modal/info-modal';
|
||||
export * from './page-properties-manager';
|
||||
export * from './table';
|
||||
|
||||
+35
-6
@@ -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'),
|
||||
});
|
||||
|
||||
+55
-61
@@ -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<InlineEditHandle>(null);
|
||||
const manager = usePagePropertiesManager(docId ?? '');
|
||||
const handleClose = useCallback(() => {
|
||||
modal.close();
|
||||
}, [modal]);
|
||||
|
||||
if (!manager.page || manager.readonly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
contentOptions={{
|
||||
@@ -98,15 +83,7 @@ const InfoModalOpened = ({ docId }: { docId: string }) => {
|
||||
inputHandleRef={titleInputHandleRef}
|
||||
/>
|
||||
</div>
|
||||
<managerContext.Provider value={manager}>
|
||||
<Suspense>
|
||||
<InfoTable
|
||||
docId={docId}
|
||||
onClose={handleClose}
|
||||
readonly={manager.readonly}
|
||||
/>
|
||||
</Suspense>
|
||||
</managerContext.Provider>
|
||||
<InfoTable docId={docId} onClose={handleClose} />
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar className={styles.scrollBar} />
|
||||
</Scrollable.Root>
|
||||
@@ -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<string | null>(null);
|
||||
const properties = useLiveData(docsService.propertyList.sortedProperties$);
|
||||
const links = useLiveData(
|
||||
useMemo(
|
||||
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
||||
@@ -165,33 +142,50 @@ export const InfoTable = ({
|
||||
<Divider size="thinner" />
|
||||
</>
|
||||
) : null}
|
||||
<TagsRow docId={docId} readonly={readonly} />
|
||||
<SortableProperties>
|
||||
{properties =>
|
||||
properties.length ? (
|
||||
<div>
|
||||
{properties
|
||||
.filter(
|
||||
property =>
|
||||
manager.isPropertyRequired(property.id) ||
|
||||
(property.visibility !== 'hide' &&
|
||||
!(
|
||||
property.visibility === 'hide-if-empty' &&
|
||||
!property.value
|
||||
))
|
||||
)
|
||||
.map(property => (
|
||||
<PagePropertyRow
|
||||
key={property.id}
|
||||
property={property}
|
||||
rowNameClassName={styles.rowNameContainer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
<PropertyCollapsible
|
||||
className={styles.tableBodyRoot}
|
||||
collapseButtonText={({ hide, isCollapsed }) =>
|
||||
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(),
|
||||
})
|
||||
}
|
||||
</SortableProperties>
|
||||
{manager.readonly ? null : <PagePropertiesAddProperty />}
|
||||
>
|
||||
{properties.map(property => (
|
||||
<PagePropertyRow
|
||||
key={property.id}
|
||||
propertyInfo={property}
|
||||
defaultOpenEditMenu={newPropertyId === property.id}
|
||||
/>
|
||||
))}
|
||||
<Menu
|
||||
items={<CreatePropertyMenuItems onCreated={setNewPropertyId} />}
|
||||
contentOptions={{
|
||||
onClick(e) {
|
||||
e.stopPropagation();
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="plain"
|
||||
prefix={<PlusIcon />}
|
||||
className={styles.addPropertyButton}
|
||||
>
|
||||
{t['com.affine.page-properties.add-property']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
</PropertyCollapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
-104
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
className={clsx(styles.rowCell, className)}
|
||||
data-testid="info-modal-tags-row"
|
||||
>
|
||||
<div className={styles.rowNameContainer}>
|
||||
<div className={styles.icon}>
|
||||
<TagsIcon />
|
||||
</div>
|
||||
<div className={styles.rowName}>{t['Tags']()}</div>
|
||||
</div>
|
||||
<Menu
|
||||
contentOptions={{
|
||||
side: 'bottom',
|
||||
align: 'start',
|
||||
sideOffset: 0,
|
||||
avoidCollisions: false,
|
||||
className: styles.tagsMenu,
|
||||
onClick(e) {
|
||||
e.stopPropagation();
|
||||
},
|
||||
}}
|
||||
items={<TagsEditor pageId={docId} readonly={readonly} />}
|
||||
>
|
||||
<div
|
||||
className={clsx(styles.tagsInlineEditor, styles.rowValueCell)}
|
||||
data-empty={empty}
|
||||
data-readonly={readonly}
|
||||
data-testid="info-modal-tags-value"
|
||||
>
|
||||
{empty ? (
|
||||
t['com.affine.page-properties.property-value-placeholder']()
|
||||
) : (
|
||||
<InlineTagsList pageId={docId} readonly />
|
||||
)}
|
||||
</div>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-43
@@ -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',
|
||||
|
||||
+40
-54
@@ -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 (
|
||||
<div className={styles.rowCell}>
|
||||
<div className={styles.rowNameContainer}>
|
||||
<div className={styles.icon}>{icon}</div>
|
||||
<span className={styles.rowName}>{name}</span>
|
||||
</div>
|
||||
<div className={styles.time}>{time ? time : 'unknown'}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<RowComponent
|
||||
icon={<DateTimeIcon />}
|
||||
name={t['Created']()}
|
||||
time={
|
||||
manager.createDate
|
||||
? formatI18nTime(manager.createDate)
|
||||
: localizedCreateTime
|
||||
}
|
||||
/>
|
||||
<PropertyRoot>
|
||||
<PropertyName name={t['Created']()} icon={<DateTimeIcon />} />
|
||||
<PropertyValue>
|
||||
{docMeta ? formatI18nTime(docMeta.createDate) : localizedCreateTime}
|
||||
</PropertyValue>
|
||||
</PropertyRoot>
|
||||
{serverClock ? (
|
||||
<RowComponent
|
||||
icon={<HistoryIcon />}
|
||||
name={t[!syncing && !retrying ? 'Updated' : 'com.affine.syncing']()}
|
||||
time={!syncing && !retrying ? formatI18nTime(serverClock) : null}
|
||||
/>
|
||||
) : manager.updatedDate ? (
|
||||
<RowComponent
|
||||
icon={<HistoryIcon />}
|
||||
name={t['Updated']()}
|
||||
time={formatI18nTime(manager.updatedDate)}
|
||||
/>
|
||||
<PropertyRoot>
|
||||
<PropertyName
|
||||
name={t[
|
||||
!syncing && !retrying ? 'Updated' : 'com.affine.syncing'
|
||||
]()}
|
||||
icon={<HistoryIcon />}
|
||||
/>
|
||||
<PropertyValue>
|
||||
{!syncing && !retrying
|
||||
? formatI18nTime(serverClock)
|
||||
: docMeta?.updatedDate
|
||||
? formatI18nTime(docMeta.updatedDate)
|
||||
: null}
|
||||
</PropertyValue>
|
||||
</PropertyRoot>
|
||||
) : docMeta?.updatedDate ? (
|
||||
<PropertyRoot>
|
||||
<PropertyName name={t['Updated']()} icon={<HistoryIcon />} />
|
||||
<PropertyValue>{formatI18nTime(docMeta.updatedDate)}</PropertyValue>
|
||||
</PropertyRoot>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
manager.createDate,
|
||||
manager.updatedDate,
|
||||
retrying,
|
||||
serverClock,
|
||||
syncing,
|
||||
t,
|
||||
]);
|
||||
}, [docMeta, retrying, serverClock, syncing, t]);
|
||||
|
||||
const dTimestampElement = useDebouncedValue(timestampElement, 500);
|
||||
|
||||
|
||||
@@ -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<AffineDNDData>(
|
||||
() => ({
|
||||
data: {
|
||||
entity: {
|
||||
type: 'custom-property',
|
||||
id: propertyInfo.id,
|
||||
},
|
||||
from: {
|
||||
at: 'doc-property:manager',
|
||||
workspaceId: workspaceService.workspace.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
[propertyInfo, workspaceService]
|
||||
);
|
||||
|
||||
const { dropTargetRef, closestEdge } = useDropTarget<AffineDNDData>(
|
||||
() => ({
|
||||
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 (
|
||||
<div
|
||||
className={styles.itemContainer}
|
||||
ref={elem => {
|
||||
dropTargetRef.current = elem;
|
||||
dragRef.current = elem;
|
||||
}}
|
||||
onClick={handleClick}
|
||||
data-testid="doc-property-manager-item"
|
||||
>
|
||||
<DocPropertyIcon
|
||||
className={styles.itemIcon}
|
||||
propertyInfo={propertyInfo}
|
||||
/>
|
||||
<span className={styles.itemName}>
|
||||
{propertyInfo.name ||
|
||||
(typeInfo?.name ? t.t(typeInfo.name) : t['unnamed']())}
|
||||
</span>
|
||||
<span className={styles.itemVisibility}>
|
||||
{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']()}
|
||||
</span>
|
||||
<Menu
|
||||
rootOptions={{
|
||||
open: moreMenuOpen,
|
||||
onOpenChange: setMoreMenuOpen,
|
||||
modal: true,
|
||||
}}
|
||||
items={<EditDocPropertyMenuItems propertyId={propertyInfo.id} />}
|
||||
>
|
||||
<IconButton size={20} iconClassName={styles.itemMore}>
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</Menu>
|
||||
<DropIndicator edge={closestEdge} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocPropertyManager = ({
|
||||
className,
|
||||
defaultOpenEditMenuPropertyId,
|
||||
...props
|
||||
}: HTMLProps<HTMLDivElement> & { defaultOpenEditMenuPropertyId?: string }) => {
|
||||
const docsService = useService(DocsService);
|
||||
|
||||
const properties = useLiveData(docsService.propertyList.sortedProperties$);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.container, className)} {...props}>
|
||||
{properties.map(propertyInfo => (
|
||||
<PropertyItem
|
||||
propertyInfo={propertyInfo}
|
||||
defaultOpenEditMenu={
|
||||
defaultOpenEditMenuPropertyId === propertyInfo.id
|
||||
}
|
||||
key={propertyInfo.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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 <MenuSeparator key={index} />;
|
||||
} else if (isElementOption(option)) {
|
||||
return cloneElement(option, { key: index });
|
||||
} else if (Array.isArray(option)) {
|
||||
// this is an area that needs scrollbar
|
||||
return (
|
||||
<Scrollable.Root key={index} className={styles.menuItemListScrollable}>
|
||||
<Scrollable.Viewport className={styles.menuItemList}>
|
||||
{renderMenuItemOptions(option)}
|
||||
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
|
||||
</Scrollable.Viewport>
|
||||
</Scrollable.Root>
|
||||
);
|
||||
} else {
|
||||
const { text, icon, onClick, type, key, checked, selected } = option;
|
||||
return (
|
||||
<MenuItem
|
||||
key={key ?? index}
|
||||
type={type}
|
||||
selected={selected}
|
||||
checked={checked}
|
||||
prefixIcon={icon}
|
||||
onClick={onClick}
|
||||
>
|
||||
{text}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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<HTMLInputElement> = 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 (
|
||||
<div className={styles.propertyRowNamePopupRow}>
|
||||
<IconsSelectorButton
|
||||
selected={iconName}
|
||||
onSelectedChange={onIconChange}
|
||||
/>
|
||||
<Input
|
||||
defaultValue={property.name}
|
||||
onBlur={handleBlur}
|
||||
onChange={onNameChange}
|
||||
placeholder={t['unnamed']()}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PropertyTypeMenuItem = ({
|
||||
property,
|
||||
}: {
|
||||
property: PageInfoCustomPropertyMeta;
|
||||
}) => {
|
||||
const Icon = nameToIcon(getDefaultIconName(property.type), property.type);
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div className={styles.propertyRowTypeItem}>
|
||||
{t['com.affine.page-properties.create-property.menu.header']()}
|
||||
<div className={styles.propertyTypeName}>
|
||||
<Icon />
|
||||
{t[`com.affine.page-properties.property.${property.type}`]()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+25
@@ -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,
|
||||
});
|
||||
+84
@@ -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 (
|
||||
<>
|
||||
<div role="heading" className={styles.menuHeader}>
|
||||
{t['com.affine.page-properties.create-property.menu.header']()}
|
||||
</div>
|
||||
<MenuSeparator />
|
||||
{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 (
|
||||
<MenuItem
|
||||
key={type}
|
||||
prefixIcon={<Icon />}
|
||||
disabled={isUniqueExist}
|
||||
onClick={() => {
|
||||
onAddProperty({
|
||||
name: name,
|
||||
type: type,
|
||||
});
|
||||
}}
|
||||
data-testid="create-property-menu-item"
|
||||
data-property-type={type}
|
||||
>
|
||||
<div className={styles.propertyItem}>
|
||||
{name}
|
||||
{isUniqueExist && <span>Added</span>}
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+42
@@ -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',
|
||||
});
|
||||
+212
@@ -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<HTMLInputElement> = 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 (
|
||||
<>
|
||||
<div
|
||||
className={styles.propertyRowNamePopupRow}
|
||||
data-testid="edit-property-menu-item"
|
||||
>
|
||||
<DocPropertyIconSelector
|
||||
propertyInfo={propertyInfo}
|
||||
onSelectedChange={handleIconChange}
|
||||
/>
|
||||
{typeInfo?.renameable === false ? (
|
||||
<span className={styles.propertyName}>{name}</span>
|
||||
) : (
|
||||
<Input
|
||||
value={name}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleNameChange}
|
||||
placeholder={t['unnamed']()}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.propertyRowTypeItem}>
|
||||
{t['com.affine.page-properties.create-property.menu.header']()}
|
||||
<div className={styles.propertyTypeName}>
|
||||
<DocPropertyIcon propertyInfo={propertyInfo} />
|
||||
{t[`com.affine.page-properties.property.${propertyType}`]()}
|
||||
</div>
|
||||
</div>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
prefixIcon={<ViewIcon />}
|
||||
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']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
prefixIcon={<InvisibleIcon />}
|
||||
onClick={handleClickHideWhenEmpty}
|
||||
selected={propertyInfo.show === 'hide-when-empty'}
|
||||
data-property-visibility="hide-when-empty"
|
||||
>
|
||||
{t['com.affine.page-properties.property.hide-when-empty']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
prefixIcon={<InvisibleIcon />}
|
||||
onClick={handleClickAlwaysHide}
|
||||
selected={propertyInfo.show === 'always-hide'}
|
||||
data-property-visibility="always-hide"
|
||||
>
|
||||
{t['com.affine.page-properties.property.always-hide']()}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
prefixIcon={<DeleteIcon />}
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
confirmModal.openConfirmModal({
|
||||
title:
|
||||
t['com.affine.settings.workspace.properties.delete-property'](),
|
||||
description: (
|
||||
<Trans
|
||||
values={{
|
||||
name: propertyInfo.name,
|
||||
}}
|
||||
i18nKey="com.affine.settings.workspace.properties.delete-property-desc"
|
||||
>
|
||||
The <strong>{{ name: propertyInfo.name } as any}</strong>{' '}
|
||||
property will be removed from count doc(s). This action cannot
|
||||
be undone.
|
||||
</Trans>
|
||||
),
|
||||
confirmText: t['Confirm'](),
|
||||
onConfirm: () => {
|
||||
docsService.propertyList.removeProperty(propertyId);
|
||||
},
|
||||
confirmButtonOptions: {
|
||||
variant: 'error',
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t['com.affine.settings.workspace.properties.delete-property']()}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-303
@@ -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<PageInfoCustomPropertyMeta>) {
|
||||
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<string, Set<string>>();
|
||||
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<string, PageInfoCustomProperty> {
|
||||
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<PageInfoCustomProperty>) {
|
||||
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;
|
||||
}
|
||||
-320
@@ -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 (
|
||||
<Menu items={<DatePicker value={property.value} onChange={handleChange} />}>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={styles.propertyRowValueCell}
|
||||
data-empty={!property.value}
|
||||
>
|
||||
{displayValue ??
|
||||
t['com.affine.page-properties.property-value-placeholder']()}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={styles.propertyRowValueCell}
|
||||
data-empty={!property.value}
|
||||
>
|
||||
<Checkbox
|
||||
className={styles.checkboxProperty}
|
||||
checked={!!property.value}
|
||||
onChange={noop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TextValue = ({ property }: PropertyRowValueProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
const [value, setValue] = useState<string>(property.value);
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
const ref = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement> = useCallback(
|
||||
e => {
|
||||
setValue(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const t = useI18n();
|
||||
useEffect(() => {
|
||||
setValue(property.value);
|
||||
}, [property.value]);
|
||||
|
||||
return (
|
||||
<div onClick={handleClick} className={styles.propertyRowValueTextCell}>
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={styles.propertyRowValueTextarea}
|
||||
value={value || ''}
|
||||
onChange={handleOnChange}
|
||||
onClick={handleClick}
|
||||
data-empty={!value}
|
||||
placeholder={t[
|
||||
'com.affine.page-properties.property-value-placeholder'
|
||||
]()}
|
||||
/>
|
||||
<div className={styles.propertyRowValueTextareaInvisible}>
|
||||
{value}
|
||||
{value?.endsWith('\n') || !value ? <br /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NumberValue = ({ property }: PropertyRowValueProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
const [value, setValue] = useState(property.value);
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
const handleBlur = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
manager.updateCustomProperty(property.id, {
|
||||
value: e.target.value.trim(),
|
||||
});
|
||||
},
|
||||
[manager, property.id]
|
||||
);
|
||||
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
e => {
|
||||
setValue(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const t = useI18n();
|
||||
useEffect(() => {
|
||||
setValue(property.value);
|
||||
}, [property.value]);
|
||||
return (
|
||||
<input
|
||||
className={styles.propertyRowValueNumberCell}
|
||||
type={'number'}
|
||||
value={value || ''}
|
||||
onChange={handleOnChange}
|
||||
onClick={handleClick}
|
||||
onBlur={handleBlur}
|
||||
data-empty={!value}
|
||||
placeholder={t['com.affine.page-properties.property-value-placeholder']()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagsValue = () => {
|
||||
const doc = useService(DocService).doc;
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<TagsInlineEditor
|
||||
className={styles.propertyRowValueCell}
|
||||
placeholder={t['com.affine.page-properties.property-value-placeholder']()}
|
||||
pageId={doc.id}
|
||||
readonly={doc.blockSuiteDoc.readonly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CloudUserAvatar = (props: { type: 'CreatedBy' | 'UpdatedBy' }) => {
|
||||
const cloudDocMetaService = useService(CloudDocMetaService);
|
||||
const cloudDocMeta = useLiveData(cloudDocMetaService.cloudDocMeta.meta$);
|
||||
const isRevalidating = useLiveData(
|
||||
cloudDocMetaService.cloudDocMeta.isRevalidating$
|
||||
);
|
||||
const error = useLiveData(cloudDocMetaService.cloudDocMeta.error$);
|
||||
|
||||
useEffect(() => {
|
||||
cloudDocMetaService.cloudDocMeta.revalidate();
|
||||
}, [cloudDocMetaService]);
|
||||
|
||||
const user = useMemo(() => {
|
||||
if (!cloudDocMeta) return null;
|
||||
if (props.type === 'CreatedBy' && cloudDocMeta.createdBy) {
|
||||
return {
|
||||
name: cloudDocMeta.createdBy.name,
|
||||
avatarUrl: cloudDocMeta.createdBy.avatarUrl,
|
||||
};
|
||||
} else if (props.type === 'UpdatedBy' && cloudDocMeta.updatedBy) {
|
||||
return {
|
||||
name: cloudDocMeta.updatedBy.name,
|
||||
avatarUrl: cloudDocMeta.updatedBy.avatarUrl,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [cloudDocMeta, props.type]);
|
||||
|
||||
if (!cloudDocMeta) {
|
||||
if (isRevalidating) {
|
||||
// TODO: loading ui
|
||||
return null;
|
||||
}
|
||||
if (error) {
|
||||
// error ui
|
||||
return;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (user) {
|
||||
return (
|
||||
<>
|
||||
<Avatar url={user.avatarUrl || ''} name={user.name} size={20} />
|
||||
<span>{user.name}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <NoRecordValue />;
|
||||
};
|
||||
|
||||
const NoRecordValue = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<span>
|
||||
{t['com.affine.page-properties.property-user-avatar-no-record']()}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const LocalUserValue = () => {
|
||||
const t = useI18n();
|
||||
return <span>{t['com.affine.page-properties.local-user']()}</span>;
|
||||
};
|
||||
|
||||
export const CreatedUserValue = () => {
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const isCloud =
|
||||
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
||||
|
||||
if (!isCloud) {
|
||||
return (
|
||||
<div className={styles.propertyRowValueUserCell}>
|
||||
<LocalUserValue />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.propertyRowValueUserCell}>
|
||||
<CloudUserAvatar type="CreatedBy" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const UpdatedUserValue = () => {
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const isCloud =
|
||||
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
||||
|
||||
if (!isCloud) {
|
||||
return <LocalUserValue />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.propertyRowValueUserCell}>
|
||||
<CloudUserAvatar type="UpdatedBy" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const propertyValueRenderers: Record<
|
||||
PagePropertyType,
|
||||
typeof DateValue
|
||||
> = {
|
||||
date: DateValue,
|
||||
checkbox: CheckboxValue,
|
||||
text: TextValue,
|
||||
number: NumberValue,
|
||||
createdBy: CreatedUserValue,
|
||||
updatedBy: UpdatedUserValue,
|
||||
// TODO(@Peng): fix following
|
||||
tags: TagsValue,
|
||||
progress: TextValue,
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Divider, IconButton } from '@affine/component';
|
||||
import { generateUniqueNameInSequence } from '@affine/core/utils/unique-name';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
Content as CollapsibleContent,
|
||||
Root as CollapsibleRoot,
|
||||
} from '@radix-ui/react-collapsible';
|
||||
import { DocsService, useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { DocPropertyManager } from '../manager';
|
||||
import {
|
||||
DocPropertyTypes,
|
||||
isSupportedDocPropertyType,
|
||||
} from '../types/constant';
|
||||
import {
|
||||
AddDocPropertySidebarSection,
|
||||
DocPropertyListSidebarSection,
|
||||
} from './section';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const DocPropertySidebar = () => {
|
||||
const t = useI18n();
|
||||
const [newPropertyId, setNewPropertyId] = useState<string>();
|
||||
|
||||
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 newPropertyId = propertyList.createProperty({
|
||||
id: typeDefined.uniqueId,
|
||||
name,
|
||||
type: option.type,
|
||||
index: propertyList.indexAt('after'),
|
||||
});
|
||||
setNewPropertyId(newPropertyId);
|
||||
},
|
||||
[propertyList, properties]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<CollapsibleRoot defaultOpen>
|
||||
<DocPropertyListSidebarSection />
|
||||
<CollapsibleContent>
|
||||
<DocPropertyManager
|
||||
className={styles.manager}
|
||||
defaultOpenEditMenuPropertyId={newPropertyId}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
<div className={styles.divider}>
|
||||
<Divider />
|
||||
</div>
|
||||
<CollapsibleRoot defaultOpen>
|
||||
<AddDocPropertySidebarSection />
|
||||
<CollapsibleContent>
|
||||
<div className={styles.AddListContainer}>
|
||||
{Object.entries(DocPropertyTypes).map(([key, value]) => {
|
||||
const Icon = value.icon;
|
||||
const name = t.t(value.name);
|
||||
const isUniqueExist = properties.some(
|
||||
meta => meta.id === value.uniqueId
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.itemContainer}
|
||||
key={key}
|
||||
onClick={() => {
|
||||
onAddProperty({
|
||||
type: key,
|
||||
name,
|
||||
});
|
||||
}}
|
||||
data-disabled={isUniqueExist}
|
||||
>
|
||||
<Icon className={styles.itemIcon} />
|
||||
<span className={styles.itemName}>{t.t(value.name)}</span>
|
||||
{isUniqueExist ? (
|
||||
<span className={styles.itemAdded}>Added</span>
|
||||
) : (
|
||||
<IconButton size={20} iconClassName={styles.itemAdd}>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const headerRoot = style({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
boxSizing: 'border-box',
|
||||
padding: '8px 16px',
|
||||
});
|
||||
|
||||
export const headerTitle = style({
|
||||
height: '22px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
lineHeight: '22px',
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
|
||||
export const collapseIcon = style({
|
||||
vars: { '--y': '1px', '--r': '90deg' },
|
||||
color: cssVarV2('icon/secondary'),
|
||||
transform: 'translateY(var(--y)) rotate(var(--r))',
|
||||
transition: 'transform 0.2s',
|
||||
selectors: {
|
||||
[`button[data-state="closed"] &`]: {
|
||||
vars: { '--r': '0deg' },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ToggleCollapseIcon } from '@blocksuite/icons/rc';
|
||||
import { Trigger as CollapsibleTrigger } from '@radix-ui/react-collapsible';
|
||||
|
||||
import * as styles from './section.css';
|
||||
|
||||
export const DocPropertyListSidebarSection = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div className={styles.headerRoot}>
|
||||
<span className={styles.headerTitle}>
|
||||
{t['com.affine.propertySidebar.property-list.section']()}
|
||||
</span>
|
||||
<CollapsibleTrigger asChild>
|
||||
<IconButton>
|
||||
<ToggleCollapseIcon className={styles.collapseIcon} />
|
||||
</IconButton>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddDocPropertySidebarSection = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div className={styles.headerRoot}>
|
||||
<span className={styles.headerTitle}>
|
||||
{t['com.affine.propertySidebar.add-more.section']()}
|
||||
</span>
|
||||
<CollapsibleTrigger asChild>
|
||||
<IconButton>
|
||||
<ToggleCollapseIcon className={styles.collapseIcon} />
|
||||
</IconButton>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const container = style({
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
height: '100%',
|
||||
paddingTop: '8px',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const manager = style({
|
||||
padding: '0 8px',
|
||||
});
|
||||
|
||||
export const divider = style({
|
||||
padding: '0 8px',
|
||||
});
|
||||
|
||||
export const AddListContainer = style({
|
||||
padding: '0 8px',
|
||||
});
|
||||
|
||||
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-disabled="true"]': {
|
||||
opacity: 0.5,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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 itemAdded = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
|
||||
export const itemAdd = style({
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
@@ -1,545 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { createVar, globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
const propertyNameCellWidth = createVar();
|
||||
export const rowHPadding = createVar();
|
||||
export const fontSize = createVar();
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
fontFamily: cssVar('fontSansFamily'),
|
||||
vars: {
|
||||
[propertyNameCellWidth]: '160px',
|
||||
[rowHPadding]: '6px',
|
||||
[fontSize]: cssVar('fontSm'),
|
||||
},
|
||||
'@container': {
|
||||
[`viewport (width <= 640px)`]: {
|
||||
vars: {
|
||||
[rowHPadding]: '0px',
|
||||
[fontSize]: cssVar('fontXs'),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const rootCentered = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
maxWidth: cssVar('editorWidth'),
|
||||
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
|
||||
'@container': {
|
||||
[`viewport (width <= 640px)`]: {
|
||||
padding: '0 16px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableHeader = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
export const tableHeaderInfoRow = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontSize: fontSize,
|
||||
fontWeight: 500,
|
||||
minHeight: 34,
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableHeaderSecondaryRow = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontSize: fontSize,
|
||||
fontWeight: 500,
|
||||
padding: `0 ${rowHPadding}`,
|
||||
gap: '8px',
|
||||
height: 24,
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableHeaderCollapseButtonWrapper = style({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const pageInfoDimmed = style({
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const spacer = style({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
export const tableHeaderBacklinksHint = style({
|
||||
padding: `0 ${rowHPadding}`,
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
':hover': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
});
|
||||
|
||||
export const backlinksList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
});
|
||||
|
||||
export const tableHeaderTimestamp = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'start',
|
||||
gap: '8px',
|
||||
cursor: 'default',
|
||||
padding: `0 ${rowHPadding}`,
|
||||
});
|
||||
|
||||
export const tableHeaderDivider = style({
|
||||
height: 0,
|
||||
borderTop: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
width: '100%',
|
||||
margin: '8px 0',
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableBodyRoot = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
'@media': {
|
||||
print: {
|
||||
selectors: {
|
||||
'&[data-state="open"]': {
|
||||
marginBottom: 32,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableBodySortable = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
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',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${addPropertyButton} svg`, {
|
||||
fontSize: 16,
|
||||
color: cssVarV2('icon/secondary'),
|
||||
});
|
||||
globalStyle(`${addPropertyButton}:hover svg`, {
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const collapsedIcon = style({
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(90deg)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const propertyRow = style({
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
minHeight: 32,
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&[data-dragging=true]': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tagsPropertyRow = style([
|
||||
propertyRow,
|
||||
{
|
||||
marginBottom: -4,
|
||||
},
|
||||
]);
|
||||
|
||||
export const draggableItem = style({
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
borderRadius: '2px',
|
||||
backgroundColor: cssVarV2('text/placeholder'),
|
||||
transform: 'translate(-12px, -50%)',
|
||||
transition: 'all 0.2s 0.1s',
|
||||
opacity: 0,
|
||||
height: '4px',
|
||||
width: '4px',
|
||||
willChange: 'height, opacity',
|
||||
},
|
||||
'&[data-draggable=false]:before': {
|
||||
display: 'none',
|
||||
},
|
||||
'&:hover:before': {
|
||||
height: 12,
|
||||
opacity: 1,
|
||||
},
|
||||
'&:active:before': {
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
borderRadius: 0,
|
||||
opacity: 1,
|
||||
transform: 'translate(-6px, -50%)',
|
||||
},
|
||||
'&[data-other-dragging=true]:before': {
|
||||
opacity: 0,
|
||||
},
|
||||
'&[data-other-dragging=true]': {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const draggableRowSetting = style([
|
||||
draggableItem,
|
||||
{
|
||||
selectors: {
|
||||
'&:active:before': {
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
opacity: 1,
|
||||
transform: 'translate(-12px, -50%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertyRowCell = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
position: 'relative',
|
||||
borderRadius: 4,
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '22px',
|
||||
userSelect: 'none',
|
||||
padding: `6px ${rowHPadding} 6px 8px`,
|
||||
':focus-visible': {
|
||||
outline: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
export const editablePropertyRowCell = style([
|
||||
propertyRowCell,
|
||||
{
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertyRowNameCell = style([
|
||||
propertyRowCell,
|
||||
{
|
||||
padding: `6px ${rowHPadding}`,
|
||||
flexShrink: 0,
|
||||
color: cssVarV2('text/secondary'),
|
||||
width: propertyNameCellWidth,
|
||||
gap: 6,
|
||||
},
|
||||
]);
|
||||
|
||||
export const sortablePropertyRowNameCell = style([
|
||||
propertyRowNameCell,
|
||||
draggableItem,
|
||||
editablePropertyRowCell,
|
||||
]);
|
||||
|
||||
export const propertyRowIconContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '2px',
|
||||
fontSize: 16,
|
||||
color: cssVarV2('icon/secondary'),
|
||||
});
|
||||
|
||||
export const propertyRowNameContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
export const propertyRowName = style({
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: cssVar('fontSm'),
|
||||
});
|
||||
|
||||
export const propertyRowValueCell = style([
|
||||
propertyRowCell,
|
||||
editablePropertyRowCell,
|
||||
{
|
||||
border: `1px solid transparent`,
|
||||
color: cssVarV2('text/primary'),
|
||||
':focus': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
'::placeholder': {
|
||||
color: cssVarV2('text/placeholder'),
|
||||
},
|
||||
selectors: {
|
||||
'&[data-empty="true"]': {
|
||||
color: cssVarV2('text/placeholder'),
|
||||
},
|
||||
'&[data-readonly=true]': {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
flex: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertyRowValueTextCell = style([
|
||||
propertyRowValueCell,
|
||||
{
|
||||
padding: 0,
|
||||
position: 'relative',
|
||||
':focus-within': {
|
||||
border: `1px solid ${cssVar('blue700')}`,
|
||||
boxShadow: cssVar('activeShadow'),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertyRowValueUserCell = style([
|
||||
propertyRowValueCell,
|
||||
{
|
||||
border: 'none',
|
||||
overflow: 'hidden',
|
||||
columnGap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertyRowValueTextarea = style([
|
||||
propertyRowValueCell,
|
||||
{
|
||||
border: 'none',
|
||||
padding: `6px ${rowHPadding} 6px 8px`,
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertyRowValueTextareaInvisible = style([
|
||||
propertyRowValueCell,
|
||||
{
|
||||
border: 'none',
|
||||
padding: `6px ${rowHPadding} 6px 8px`,
|
||||
visibility: 'hidden',
|
||||
whiteSpace: 'break-spaces',
|
||||
wordBreak: 'break-all',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertyRowValueNumberCell = style([
|
||||
propertyRowValueTextCell,
|
||||
{
|
||||
padding: `6px ${rowHPadding} 6px 8px`,
|
||||
},
|
||||
]);
|
||||
|
||||
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 menuItemListScrollable = style({});
|
||||
|
||||
export const menuItemListScrollbar = style({
|
||||
transform: 'translateX(4px)',
|
||||
});
|
||||
|
||||
export const menuItemList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, {
|
||||
display: 'table !important',
|
||||
});
|
||||
|
||||
export const menuItemIconContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'inherit',
|
||||
});
|
||||
|
||||
export const menuItemName = style({
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const checkboxProperty = style({
|
||||
fontSize: cssVar('fontH5'),
|
||||
});
|
||||
|
||||
globalStyle(
|
||||
`${propertyRow}:is([data-dragging=true], [data-other-dragging=true])
|
||||
:is(${propertyRowValueCell}, ${propertyRowNameCell})`,
|
||||
{
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
);
|
||||
|
||||
export const propertyRowNamePopupRow = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
color: cssVarV2('text/secondary'),
|
||||
padding: '8px 16px',
|
||||
minWidth: 260,
|
||||
});
|
||||
|
||||
export const propertySettingRow = style([
|
||||
draggableRowSetting,
|
||||
{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
padding: '0 12px',
|
||||
height: 32,
|
||||
position: 'relative',
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertySettingRowName = style({
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 200,
|
||||
});
|
||||
|
||||
export const selectorButton = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
borderRadius: 4,
|
||||
gap: 8,
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 400,
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
selectors: {
|
||||
'&[data-required=true]': {
|
||||
color: cssVarV2('text/disable'),
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const propertyRowTypeItem = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
padding: '8px 16px',
|
||||
minWidth: 260,
|
||||
});
|
||||
|
||||
export const propertyTypeName = style({
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
});
|
||||
|
||||
export const backLinksMenu = style({
|
||||
background: cssVarV2('layer/white'),
|
||||
maxWidth: 'calc(var(--affine-editor-width) / 2)',
|
||||
width: '100%',
|
||||
maxHeight: '30vh',
|
||||
overflowY: 'auto',
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { createVar, globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
const propertyNameCellWidth = createVar();
|
||||
export const fontSize = createVar();
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
fontFamily: cssVar('fontSansFamily'),
|
||||
vars: {
|
||||
[propertyNameCellWidth]: '160px',
|
||||
[fontSize]: cssVar('fontSm'),
|
||||
},
|
||||
'@container': {
|
||||
[`viewport (width <= 640px)`]: {
|
||||
vars: {
|
||||
[fontSize]: cssVar('fontXs'),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const rootCentered = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
maxWidth: cssVar('editorWidth'),
|
||||
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
|
||||
'@container': {
|
||||
[`viewport (width <= 640px)`]: {
|
||||
padding: '0 16px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableHeader = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
export const tableHeaderInfoRow = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontSize: fontSize,
|
||||
fontWeight: 500,
|
||||
minHeight: 34,
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableHeaderSecondaryRow = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontSize: fontSize,
|
||||
fontWeight: 500,
|
||||
padding: `0 6px`,
|
||||
gap: '8px',
|
||||
height: 24,
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableHeaderCollapseButtonWrapper = style({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const pageInfoDimmed = style({
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const tableHeaderBacklinksHint = style({
|
||||
padding: `0 6px`,
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
':hover': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
});
|
||||
|
||||
export const backlinksList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
});
|
||||
|
||||
export const tableHeaderTimestamp = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'start',
|
||||
gap: '8px',
|
||||
cursor: 'default',
|
||||
padding: `0 6px`,
|
||||
});
|
||||
|
||||
export const tableHeaderDivider = style({
|
||||
height: 0,
|
||||
borderTop: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
width: '100%',
|
||||
margin: '8px 0',
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableBodyRoot = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
'@media': {
|
||||
print: {
|
||||
selectors: {
|
||||
'&[data-state="open"]': {
|
||||
marginBottom: 32,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableBodySortable = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const actionContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
selectors: {
|
||||
[`[data-property-collapsed="true"] &`]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const propertyActionButton = style({
|
||||
alignSelf: 'flex-start',
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: `${cssVarV2('text/secondary')}`,
|
||||
padding: '0 6px',
|
||||
height: 36,
|
||||
fontWeight: 400,
|
||||
gap: 6,
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${propertyActionButton} svg`, {
|
||||
fontSize: 16,
|
||||
color: cssVarV2('icon/secondary'),
|
||||
});
|
||||
globalStyle(`${propertyActionButton}:hover svg`, {
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const propertyConfigButton = style({
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`${actionContainer}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const collapsedIcon = style({
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(90deg)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const backLinksMenu = style({
|
||||
background: cssVarV2('layer/white'),
|
||||
maxWidth: 'calc(var(--affine-editor-width) / 2)',
|
||||
width: '100%',
|
||||
maxHeight: '30vh',
|
||||
overflowY: 'auto',
|
||||
});
|
||||
|
||||
export const propertyRootHideEmpty = style({
|
||||
selectors: {
|
||||
'&:has([data-property-value][data-empty="true"])': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+17
@@ -128,3 +128,20 @@ export const tagColorIcon = style({
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
});
|
||||
|
||||
export const menuItemListScrollable = style({});
|
||||
|
||||
export const menuItemListScrollbar = style({
|
||||
transform: 'translateX(4px)',
|
||||
});
|
||||
|
||||
export const menuItemList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, {
|
||||
display: 'table !important',
|
||||
});
|
||||
|
||||
+65
-64
@@ -3,6 +3,8 @@ import {
|
||||
IconButton,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
RowInput,
|
||||
Scrollable,
|
||||
} from '@affine/component';
|
||||
@@ -19,8 +21,6 @@ import type { HTMLAttributes, PropsWithChildren } from 'react';
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
|
||||
|
||||
import { TagItem, TempTagItem } from '../../page-list';
|
||||
import type { MenuItemOption } from './menu-items';
|
||||
import { renderMenuItemOptions } from './menu-items';
|
||||
import * as styles from './tags-inline-editor.css';
|
||||
|
||||
interface TagsEditorProps {
|
||||
@@ -93,73 +93,12 @@ export const EditTagMenu = ({
|
||||
const navigate = useNavigateHelper();
|
||||
|
||||
const menuProps = useMemo(() => {
|
||||
const options: MenuItemOption[] = [];
|
||||
const updateTagName = (name: string) => {
|
||||
if (name.trim() === '') {
|
||||
return;
|
||||
}
|
||||
tag?.rename(name);
|
||||
};
|
||||
options.push(
|
||||
<Input
|
||||
defaultValue={tagValue}
|
||||
onBlur={e => {
|
||||
updateTagName(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
updateTagName(e.currentTarget.value);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder={t['Untitled']()}
|
||||
/>
|
||||
);
|
||||
|
||||
options.push('-');
|
||||
|
||||
options.push({
|
||||
text: t['Delete'](),
|
||||
icon: <DeleteIcon />,
|
||||
type: 'danger',
|
||||
onClick() {
|
||||
onTagDelete([tag?.id || '']);
|
||||
},
|
||||
});
|
||||
|
||||
options.push({
|
||||
text: t['com.affine.page-properties.tags.open-tags-page'](),
|
||||
icon: <TagsIcon />,
|
||||
onClick() {
|
||||
navigate.jumpToTag(legacyProperties.workspaceId, tag?.id || '');
|
||||
},
|
||||
});
|
||||
|
||||
options.push('-');
|
||||
|
||||
options.push(
|
||||
tagService.tagColors.map(([name, color], i) => {
|
||||
return {
|
||||
text: name,
|
||||
icon: (
|
||||
<div key={i} className={styles.tagColorIconWrapper}>
|
||||
<div
|
||||
className={styles.tagColorIcon}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
checked: tagColor === color,
|
||||
onClick() {
|
||||
tag?.changeColor(color);
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
const items = renderMenuItemOptions(options);
|
||||
|
||||
return {
|
||||
contentOptions: {
|
||||
@@ -167,7 +106,69 @@ export const EditTagMenu = ({
|
||||
e.stopPropagation();
|
||||
},
|
||||
},
|
||||
items,
|
||||
items: (
|
||||
<>
|
||||
<Input
|
||||
defaultValue={tagValue}
|
||||
onBlur={e => {
|
||||
updateTagName(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
updateTagName(e.currentTarget.value);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder={t['Untitled']()}
|
||||
/>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
prefixIcon={<DeleteIcon />}
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
onTagDelete([tag?.id || '']);
|
||||
}}
|
||||
>
|
||||
{t['Delete']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
prefixIcon={<TagsIcon />}
|
||||
onClick={() => {
|
||||
navigate.jumpToTag(legacyProperties.workspaceId, tag?.id || '');
|
||||
}}
|
||||
>
|
||||
{t['com.affine.page-properties.tags.open-tags-page']()}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport className={styles.menuItemList}>
|
||||
{tagService.tagColors.map(([name, color], i) => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
checked={tagColor === color}
|
||||
prefixIcon={
|
||||
<div key={i} className={styles.tagColorIconWrapper}>
|
||||
<div
|
||||
className={styles.tagColorIcon}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onClick={() => {
|
||||
tag?.changeColor(color);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
|
||||
</Scrollable.Viewport>
|
||||
</Scrollable.Root>
|
||||
</>
|
||||
),
|
||||
} satisfies Partial<MenuProps>;
|
||||
}, [
|
||||
legacyProperties.workspaceId,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const checkboxProperty = style({
|
||||
fontSize: cssVar('fontH5'),
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Checkbox, PropertyValue } from '@affine/component';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import * as styles from './checkbox.css';
|
||||
import type { PropertyValueProps } from './types';
|
||||
|
||||
export const CheckboxValue = ({ value, onChange }: PropertyValueProps) => {
|
||||
const parsedValue = value === 'true' ? true : false;
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange(parsedValue ? 'false' : 'true');
|
||||
},
|
||||
[onChange, parsedValue]
|
||||
);
|
||||
return (
|
||||
<PropertyValue onClick={handleClick}>
|
||||
<Checkbox
|
||||
className={styles.checkboxProperty}
|
||||
checked={parsedValue}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</PropertyValue>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { I18nString } from '@affine/i18n';
|
||||
import {
|
||||
CheckBoxCheckLinearIcon,
|
||||
CreatedEditedIcon,
|
||||
DateTimeIcon,
|
||||
NumberIcon,
|
||||
TagIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
|
||||
import { CheckboxValue } from './checkbox';
|
||||
import { CreatedByValue, UpdatedByValue } from './created-updated-by';
|
||||
import { DateValue } from './date';
|
||||
import { NumberValue } from './number';
|
||||
import { TagsValue } from './tags';
|
||||
import { TextValue } from './text';
|
||||
import type { PropertyValueProps } from './types';
|
||||
|
||||
export const DocPropertyTypes = {
|
||||
text: {
|
||||
icon: TextIcon,
|
||||
value: TextValue,
|
||||
name: 'com.affine.page-properties.property.text',
|
||||
},
|
||||
number: {
|
||||
icon: NumberIcon,
|
||||
value: NumberValue,
|
||||
name: 'com.affine.page-properties.property.number',
|
||||
},
|
||||
date: {
|
||||
icon: DateTimeIcon,
|
||||
value: DateValue,
|
||||
name: 'com.affine.page-properties.property.date',
|
||||
},
|
||||
checkbox: {
|
||||
icon: CheckBoxCheckLinearIcon,
|
||||
value: CheckboxValue,
|
||||
name: 'com.affine.page-properties.property.checkbox',
|
||||
},
|
||||
createdBy: {
|
||||
icon: CreatedEditedIcon,
|
||||
value: CreatedByValue,
|
||||
name: 'com.affine.page-properties.property.createdBy',
|
||||
},
|
||||
updatedBy: {
|
||||
icon: CreatedEditedIcon,
|
||||
value: UpdatedByValue,
|
||||
name: 'com.affine.page-properties.property.updatedBy',
|
||||
},
|
||||
tags: {
|
||||
icon: TagIcon,
|
||||
value: TagsValue,
|
||||
name: 'com.affine.page-properties.property.tags',
|
||||
uniqueId: 'tags',
|
||||
renameable: false,
|
||||
},
|
||||
} as Record<
|
||||
string,
|
||||
{
|
||||
icon: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||
value?: React.FC<PropertyValueProps>;
|
||||
/**
|
||||
* set a unique id for property type, make the property type can only be created once.
|
||||
*/
|
||||
uniqueId?: string;
|
||||
name: I18nString;
|
||||
renameable?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
export const isSupportedDocPropertyType = (type?: string): boolean => {
|
||||
return type ? type in DocPropertyTypes : false;
|
||||
};
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { Avatar, PropertyValue } from '@affine/component';
|
||||
import { CloudDocMetaService } from '@affine/core/modules/cloud/services/cloud-doc-meta';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
const CloudUserAvatar = (props: { type: 'CreatedBy' | 'UpdatedBy' }) => {
|
||||
const cloudDocMetaService = useService(CloudDocMetaService);
|
||||
const cloudDocMeta = useLiveData(cloudDocMetaService.cloudDocMeta.meta$);
|
||||
const isRevalidating = useLiveData(
|
||||
cloudDocMetaService.cloudDocMeta.isRevalidating$
|
||||
);
|
||||
const error = useLiveData(cloudDocMetaService.cloudDocMeta.error$);
|
||||
|
||||
useEffect(() => {
|
||||
cloudDocMetaService.cloudDocMeta.revalidate();
|
||||
}, [cloudDocMetaService]);
|
||||
|
||||
const user = useMemo(() => {
|
||||
if (!cloudDocMeta) return null;
|
||||
if (props.type === 'CreatedBy' && cloudDocMeta.createdBy) {
|
||||
return {
|
||||
name: cloudDocMeta.createdBy.name,
|
||||
avatarUrl: cloudDocMeta.createdBy.avatarUrl,
|
||||
};
|
||||
} else if (props.type === 'UpdatedBy' && cloudDocMeta.updatedBy) {
|
||||
return {
|
||||
name: cloudDocMeta.updatedBy.name,
|
||||
avatarUrl: cloudDocMeta.updatedBy.avatarUrl,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [cloudDocMeta, props.type]);
|
||||
|
||||
if (!cloudDocMeta) {
|
||||
if (isRevalidating) {
|
||||
// TODO: loading ui
|
||||
return null;
|
||||
}
|
||||
if (error) {
|
||||
// error ui
|
||||
return;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (user) {
|
||||
return (
|
||||
<>
|
||||
<Avatar url={user.avatarUrl || ''} name={user.name} size={20} />
|
||||
<span>{user.name}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <NoRecordValue />;
|
||||
};
|
||||
|
||||
const NoRecordValue = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<span>
|
||||
{t['com.affine.page-properties.property-user-avatar-no-record']()}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const LocalUserValue = () => {
|
||||
const t = useI18n();
|
||||
return <span>{t['com.affine.page-properties.local-user']()}</span>;
|
||||
};
|
||||
|
||||
export const CreatedByValue = () => {
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const isCloud =
|
||||
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
||||
|
||||
if (!isCloud) {
|
||||
return (
|
||||
<PropertyValue>
|
||||
<LocalUserValue />
|
||||
</PropertyValue>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyValue>
|
||||
<CloudUserAvatar type="CreatedBy" />
|
||||
</PropertyValue>
|
||||
);
|
||||
};
|
||||
|
||||
export const UpdatedByValue = () => {
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const isCloud =
|
||||
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
||||
|
||||
if (!isCloud) {
|
||||
return (
|
||||
<PropertyValue>
|
||||
<LocalUserValue />
|
||||
</PropertyValue>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyValue>
|
||||
<CloudUserAvatar type="UpdatedBy" />
|
||||
</PropertyValue>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const empty = style({
|
||||
color: cssVar('placeholderColor'),
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { DatePicker, Menu, PropertyValue } from '@affine/component';
|
||||
import { i18nTime, useI18n } from '@affine/i18n';
|
||||
|
||||
import * as styles from './date.css';
|
||||
import type { PropertyValueProps } from './types';
|
||||
|
||||
export const DateValue = ({ value, onChange }: PropertyValueProps) => {
|
||||
const parsedValue =
|
||||
typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||
? value
|
||||
: undefined;
|
||||
const displayValue = parsedValue
|
||||
? i18nTime(parsedValue, { absolute: { accuracy: 'day' } })
|
||||
: undefined;
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Menu items={<DatePicker value={parsedValue} onChange={onChange} />}>
|
||||
<PropertyValue
|
||||
className={parsedValue ? '' : styles.empty}
|
||||
isEmpty={!parsedValue}
|
||||
>
|
||||
{displayValue ??
|
||||
t['com.affine.page-properties.property-value-placeholder']()}
|
||||
</PropertyValue>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const numberPropertyValueInput = style({
|
||||
border: `1px solid transparent`,
|
||||
padding: `6px`,
|
||||
paddingLeft: '5px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
':focus': {
|
||||
border: `1px solid ${cssVar('blue700')}`,
|
||||
boxShadow: cssVar('activeShadow'),
|
||||
},
|
||||
selectors: {
|
||||
'&::placeholder': {
|
||||
color: cssVar('placeholderColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const numberPropertyValueContainer = style({
|
||||
padding: '0px',
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { PropertyValue } from '@affine/component';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
type ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './number.css';
|
||||
import type { PropertyValueProps } from './types';
|
||||
|
||||
export const NumberValue = ({ value, onChange }: PropertyValueProps) => {
|
||||
const parsedValue = isNaN(Number(value)) ? null : value;
|
||||
const [tempValue, setTempValue] = useState(parsedValue);
|
||||
const handleBlur = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value.trim());
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
e => {
|
||||
setTempValue(e.target.value.trim());
|
||||
},
|
||||
[]
|
||||
);
|
||||
const t = useI18n();
|
||||
useEffect(() => {
|
||||
setTempValue(parsedValue);
|
||||
}, [parsedValue]);
|
||||
return (
|
||||
<PropertyValue
|
||||
className={styles.numberPropertyValueContainer}
|
||||
isEmpty={!parsedValue}
|
||||
>
|
||||
<input
|
||||
className={styles.numberPropertyValueInput}
|
||||
type={'number'}
|
||||
value={tempValue || ''}
|
||||
onChange={handleOnChange}
|
||||
onBlur={handleBlur}
|
||||
data-empty={!tempValue}
|
||||
placeholder={t[
|
||||
'com.affine.page-properties.property-value-placeholder'
|
||||
]()}
|
||||
/>
|
||||
</PropertyValue>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const tagInlineEditor = style({
|
||||
width: '100%',
|
||||
padding: `6px`,
|
||||
});
|
||||
|
||||
export const container = style({
|
||||
padding: `0px`,
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { PropertyValue } from '@affine/component';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { DocService, useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
import { TagsInlineEditor } from '../tags-inline-editor';
|
||||
import * as styles from './tags.css';
|
||||
|
||||
export const TagsValue = () => {
|
||||
const t = useI18n();
|
||||
|
||||
const doc = useService(DocService).doc;
|
||||
|
||||
const tagList = useService(TagService).tagList;
|
||||
const tagIds = useLiveData(tagList.tagIdsByPageId$(doc.id));
|
||||
const empty = !tagIds || tagIds.length === 0;
|
||||
|
||||
return (
|
||||
<PropertyValue
|
||||
className={styles.container}
|
||||
isEmpty={empty}
|
||||
data-testid="property-tags-value"
|
||||
>
|
||||
<TagsInlineEditor
|
||||
className={styles.tagInlineEditor}
|
||||
placeholder={t[
|
||||
'com.affine.page-properties.property-value-placeholder'
|
||||
]()}
|
||||
pageId={doc.id}
|
||||
/>
|
||||
</PropertyValue>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const textarea = style({
|
||||
border: 'none',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
whiteSpace: 'break-spaces',
|
||||
wordBreak: 'break-word',
|
||||
padding: `6px`,
|
||||
paddingLeft: '5px',
|
||||
overflow: 'hidden',
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '22px',
|
||||
selectors: {
|
||||
'&::placeholder': {
|
||||
color: cssVar('placeholderColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const textPropertyValueContainer = style({
|
||||
outline: `1px solid transparent`,
|
||||
padding: `6px`,
|
||||
':focus-within': {
|
||||
outline: `1px solid ${cssVar('blue700')}`,
|
||||
boxShadow: cssVar('activeShadow'),
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
});
|
||||
|
||||
export const textInvisible = style({
|
||||
border: 'none',
|
||||
whiteSpace: 'break-spaces',
|
||||
wordBreak: 'break-word',
|
||||
overflow: 'hidden',
|
||||
visibility: 'hidden',
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '22px',
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { PropertyValue } from '@affine/component';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
type ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './text.css';
|
||||
import type { PropertyValueProps } from './types';
|
||||
|
||||
export const TextValue = ({ value, onChange }: PropertyValueProps) => {
|
||||
const [tempValue, setTempValue] = useState<string>(value);
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const handleBlur = useCallback(
|
||||
(e: FocusEvent) => {
|
||||
onChange((e.currentTarget as HTMLTextAreaElement).value.trim());
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
// 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<HTMLTextAreaElement> = useCallback(
|
||||
e => {
|
||||
setTempValue(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const t = useI18n();
|
||||
useEffect(() => {
|
||||
setTempValue(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<PropertyValue
|
||||
className={styles.textPropertyValueContainer}
|
||||
onClick={handleClick}
|
||||
isEmpty={!value}
|
||||
>
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={styles.textarea}
|
||||
value={tempValue || ''}
|
||||
onChange={handleOnChange}
|
||||
onClick={handleClick}
|
||||
data-empty={!tempValue}
|
||||
autoFocus={false}
|
||||
placeholder={t[
|
||||
'com.affine.page-properties.property-value-placeholder'
|
||||
]()}
|
||||
/>
|
||||
<div className={styles.textInvisible}>
|
||||
{tempValue}
|
||||
{tempValue?.endsWith('\n') || !tempValue ? <br /> : null}
|
||||
</div>
|
||||
</PropertyValue>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { DocCustomPropertyInfo } from '@toeverything/infra';
|
||||
|
||||
export interface PropertyValueProps {
|
||||
propertyInfo: DocCustomPropertyInfo;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
}
|
||||
+6
-388
@@ -1,413 +1,31 @@
|
||||
import { Button, IconButton, Menu } from '@affine/component';
|
||||
import { Button, Menu } from '@affine/component';
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
|
||||
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import {
|
||||
DeleteIcon,
|
||||
FilterIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { FrameworkScope, type WorkspaceMetadata } from '@toeverything/infra';
|
||||
import type { MouseEvent } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useCurrentWorkspacePropertiesAdapter } from '../../../../../components/hooks/use-affine-adapter';
|
||||
import { useWorkspace } from '../../../../../components/hooks/use-workspace';
|
||||
import type { PagePropertyIcon } from '../../../page-properties';
|
||||
import {
|
||||
nameToIcon,
|
||||
PagePropertiesCreatePropertyMenuItems,
|
||||
PagePropertiesMetaManager,
|
||||
} from '../../../page-properties';
|
||||
import { ConfirmDeletePropertyModal } from '../../../page-properties/confirm-delete-property-modal';
|
||||
import type { MenuItemOption } from '../../../page-properties/menu-items';
|
||||
import {
|
||||
EditPropertyNameMenuItem,
|
||||
PropertyTypeMenuItem,
|
||||
renderMenuItemOptions,
|
||||
} from '../../../page-properties/menu-items';
|
||||
import { DocPropertyManager } from '../../../page-properties/manager';
|
||||
import { CreatePropertyMenuItems } from '../../../page-properties/menu/create-doc-property';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
// @ts-expect-error this should always be set
|
||||
const managerContext = createContext<PagePropertiesMetaManager>();
|
||||
|
||||
const usePagePropertiesMetaManager = () => {
|
||||
// the workspace properties adapter adapter is reactive,
|
||||
// which means it's reference will change when any of the properties change
|
||||
// also it will trigger a re-render of the component
|
||||
const adapter = useCurrentWorkspacePropertiesAdapter();
|
||||
const manager = useMemo(() => {
|
||||
return new PagePropertiesMetaManager(adapter);
|
||||
}, [adapter]);
|
||||
return manager;
|
||||
};
|
||||
|
||||
const Divider = () => {
|
||||
return <div className={styles.divider} />;
|
||||
};
|
||||
|
||||
const EditPropertyButton = ({
|
||||
property,
|
||||
}: {
|
||||
property: PageInfoCustomPropertyMeta;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const manager = useContext(managerContext);
|
||||
const [localPropertyMeta, setLocalPropertyMeta] = useState(() => ({
|
||||
...property,
|
||||
}));
|
||||
useEffect(() => {
|
||||
setLocalPropertyMeta(property);
|
||||
}, [property]);
|
||||
const handleToggleRequired = useCallback(() => {
|
||||
manager.updatePropertyMeta(localPropertyMeta.id, {
|
||||
required: !localPropertyMeta.required,
|
||||
});
|
||||
}, [manager, localPropertyMeta.id, localPropertyMeta.required]);
|
||||
const handleDelete = useCallback(() => {
|
||||
manager.removePropertyMeta(localPropertyMeta.id);
|
||||
}, [manager, localPropertyMeta.id]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
const handleFinishEditing = useCallback(() => {
|
||||
setOpen(false);
|
||||
setEditing(false);
|
||||
manager.updatePropertyMeta(localPropertyMeta.id, localPropertyMeta);
|
||||
}, [localPropertyMeta, manager]);
|
||||
|
||||
const defaultMenuItems = useMemo(() => {
|
||||
const options: MenuItemOption[] = [];
|
||||
options.push({
|
||||
text: t['com.affine.settings.workspace.properties.set-as-required'](),
|
||||
onClick: handleToggleRequired,
|
||||
checked: localPropertyMeta.required,
|
||||
});
|
||||
options.push('-');
|
||||
options.push({
|
||||
text: t['com.affine.settings.workspace.properties.edit-property'](),
|
||||
onClick: e => {
|
||||
e.preventDefault();
|
||||
setEditing(true);
|
||||
},
|
||||
});
|
||||
options.push({
|
||||
text: t['com.affine.settings.workspace.properties.delete-property'](),
|
||||
onClick: () => setShowDeleteModal(true),
|
||||
type: 'danger',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
return renderMenuItemOptions(options);
|
||||
}, [handleToggleRequired, localPropertyMeta.required, t]);
|
||||
|
||||
const handleNameBlur = useCallback(
|
||||
(e: string) => {
|
||||
manager.updatePropertyMeta(localPropertyMeta.id, {
|
||||
name: e,
|
||||
});
|
||||
},
|
||||
[manager, localPropertyMeta.id]
|
||||
);
|
||||
const handleNameChange = useCallback((e: string) => {
|
||||
setLocalPropertyMeta(prev => ({
|
||||
...prev,
|
||||
name: e,
|
||||
}));
|
||||
}, []);
|
||||
const handleIconChange = useCallback(
|
||||
(icon: PagePropertyIcon) => {
|
||||
setLocalPropertyMeta(prev => ({
|
||||
...prev,
|
||||
icon,
|
||||
}));
|
||||
manager.updatePropertyMeta(localPropertyMeta.id, {
|
||||
icon,
|
||||
});
|
||||
},
|
||||
[localPropertyMeta.id, manager]
|
||||
);
|
||||
const editMenuItems = useMemo(() => {
|
||||
const options: MenuItemOption[] = [];
|
||||
options.push(
|
||||
<EditPropertyNameMenuItem
|
||||
property={localPropertyMeta}
|
||||
onIconChange={handleIconChange}
|
||||
onNameBlur={handleNameBlur}
|
||||
onNameChange={handleNameChange}
|
||||
/>
|
||||
);
|
||||
options.push(<PropertyTypeMenuItem property={localPropertyMeta} />);
|
||||
options.push('-');
|
||||
options.push({
|
||||
text: t['com.affine.settings.workspace.properties.delete-property'](),
|
||||
onClick: handleDelete,
|
||||
type: 'danger',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
return renderMenuItemOptions(options);
|
||||
}, [
|
||||
handleDelete,
|
||||
handleIconChange,
|
||||
handleNameBlur,
|
||||
handleNameChange,
|
||||
localPropertyMeta,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
rootOptions={{
|
||||
open,
|
||||
onOpenChange: handleFinishEditing,
|
||||
}}
|
||||
items={editing ? editMenuItems : defaultMenuItems}
|
||||
contentOptions={{
|
||||
align: 'end',
|
||||
sideOffset: 4,
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={() => setOpen(true)} size="20">
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</Menu>
|
||||
<ConfirmDeletePropertyModal
|
||||
onConfirm={() => {
|
||||
setShowDeleteModal(false);
|
||||
handleDelete();
|
||||
}}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
show={showDeleteModal}
|
||||
property={property}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomPropertyRow = ({
|
||||
property,
|
||||
relatedPages,
|
||||
}: {
|
||||
relatedPages: string[];
|
||||
property: PageInfoCustomPropertyMeta;
|
||||
}) => {
|
||||
const Icon = nameToIcon(property.icon, property.type);
|
||||
const required = property.required;
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div
|
||||
className={styles.propertyRow}
|
||||
data-property-id={property.id}
|
||||
data-testid="custom-property-row"
|
||||
>
|
||||
<Icon className={styles.propertyIcon} />
|
||||
<div data-unnamed={!property.name} className={styles.propertyName}>
|
||||
{property.name || t['unnamed']()}
|
||||
</div>
|
||||
{relatedPages.length > 0 ? (
|
||||
<div className={styles.propertyDocCount}>
|
||||
·{' '}
|
||||
<Trans
|
||||
i18nKey={
|
||||
relatedPages.length > 1
|
||||
? 'com.affine.settings.workspace.properties.doc_others'
|
||||
: 'com.affine.settings.workspace.properties.doc'
|
||||
}
|
||||
count={relatedPages.length}
|
||||
>
|
||||
<span>{{ count: relatedPages.length } as any}</span> doc
|
||||
</Trans>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.spacer} />
|
||||
{required ? (
|
||||
<div className={styles.propertyRequired}>
|
||||
{t['com.affine.page-properties.property.required']()}
|
||||
</div>
|
||||
) : null}
|
||||
<EditPropertyButton property={property} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const propertyFilterModes = ['all', 'in-use', 'unused'] as const;
|
||||
type PropertyFilterMode = (typeof propertyFilterModes)[number];
|
||||
|
||||
const CustomPropertyRows = ({
|
||||
properties,
|
||||
statistics,
|
||||
}: {
|
||||
properties: PageInfoCustomPropertyMeta[];
|
||||
statistics: Map<string, Set<string>>;
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.metaList}>
|
||||
{properties.map(property => {
|
||||
const pages = [...(statistics.get(property.id) ?? [])];
|
||||
return (
|
||||
<Fragment key={property.id}>
|
||||
<CustomPropertyRow property={property} relatedPages={pages} />
|
||||
<Divider />
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomPropertyRowsList = ({
|
||||
filterMode,
|
||||
}: {
|
||||
filterMode: PropertyFilterMode;
|
||||
}) => {
|
||||
const manager = useContext(managerContext);
|
||||
const properties = manager.getOrderedPropertiesSchema();
|
||||
const statistics = manager.getPropertyStatistics();
|
||||
const t = useI18n();
|
||||
|
||||
if (filterMode !== 'all') {
|
||||
const filtered = properties.filter(property => {
|
||||
const count = statistics.get(property.id)?.size ?? 0;
|
||||
return filterMode === 'in-use' ? count > 0 : count === 0;
|
||||
});
|
||||
|
||||
return <CustomPropertyRows properties={filtered} statistics={statistics} />;
|
||||
} else {
|
||||
const partition = Object.groupBy(properties, p =>
|
||||
p.required ? 'required' : p.readonly ? 'readonly' : 'optional'
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{partition.required && partition.required.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.subListHeader}>
|
||||
{t[
|
||||
'com.affine.settings.workspace.properties.required-properties'
|
||||
]()}
|
||||
</div>
|
||||
<CustomPropertyRows
|
||||
properties={partition.required}
|
||||
statistics={statistics}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{partition.optional && partition.optional.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.subListHeader}>
|
||||
{t[
|
||||
'com.affine.settings.workspace.properties.general-properties'
|
||||
]()}
|
||||
</div>
|
||||
<CustomPropertyRows
|
||||
properties={partition.optional}
|
||||
statistics={statistics}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{partition.readonly && partition.readonly.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.subListHeader}>
|
||||
{t[
|
||||
'com.affine.settings.workspace.properties.readonly-properties'
|
||||
]()}
|
||||
</div>
|
||||
<CustomPropertyRows
|
||||
properties={partition.readonly}
|
||||
statistics={statistics}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const WorkspaceSettingPropertiesMain = () => {
|
||||
const t = useI18n();
|
||||
const manager = useContext(managerContext);
|
||||
const [filterMode, setFilterMode] = useState<PropertyFilterMode>('all');
|
||||
const properties = manager.getOrderedPropertiesSchema();
|
||||
const filterMenuItems = useMemo(() => {
|
||||
const options: MenuItemOption[] = (
|
||||
['all', '-', 'in-use', 'unused'] as const
|
||||
).map(mode => {
|
||||
return mode === '-'
|
||||
? '-'
|
||||
: {
|
||||
text: t[`com.affine.settings.workspace.properties.${mode}`](),
|
||||
onClick: () => setFilterMode(mode),
|
||||
checked: filterMode === mode,
|
||||
};
|
||||
});
|
||||
return renderMenuItemOptions(options);
|
||||
}, [filterMode, t]);
|
||||
|
||||
const onPropertyCreated = useCallback((_e: MouseEvent, id: string) => {
|
||||
setTimeout(() => {
|
||||
const newRow = document.querySelector<HTMLDivElement>(
|
||||
`[data-testid="custom-property-row"][data-property-id="${id}"]`
|
||||
);
|
||||
if (newRow) {
|
||||
newRow.scrollIntoView({ behavior: 'smooth' });
|
||||
newRow.dataset.highlight = '';
|
||||
setTimeout(() => {
|
||||
delete newRow.dataset.highlight;
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<div className={styles.main}>
|
||||
<div className={styles.listHeader}>
|
||||
{properties.length > 0 ? (
|
||||
<Menu items={filterMenuItems}>
|
||||
<Button prefix={<FilterIcon />}>
|
||||
{filterMode === 'all'
|
||||
? t['com.affine.filter']()
|
||||
: t[`com.affine.settings.workspace.properties.${filterMode}`]()}
|
||||
</Button>
|
||||
</Menu>
|
||||
) : null}
|
||||
<Menu
|
||||
items={
|
||||
<PagePropertiesCreatePropertyMenuItems
|
||||
onCreated={onPropertyCreated}
|
||||
metaManager={manager}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Menu items={<CreatePropertyMenuItems />}>
|
||||
<Button variant="primary">
|
||||
{t['com.affine.settings.workspace.properties.add_property']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
</div>
|
||||
<CustomPropertyRowsList filterMode={filterMode} />
|
||||
<DocPropertyManager />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WorkspaceSettingPropertiesInner = () => {
|
||||
const manager = usePagePropertiesMetaManager();
|
||||
return (
|
||||
<managerContext.Provider value={manager}>
|
||||
<WorkspaceSettingPropertiesMain />
|
||||
</managerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorkspaceSettingProperties = ({
|
||||
workspaceMetadata,
|
||||
}: {
|
||||
@@ -437,7 +55,7 @@ export const WorkspaceSettingProperties = ({
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
<WorkspaceSettingPropertiesInner />
|
||||
<WorkspaceSettingPropertiesMain />
|
||||
</FrameworkScope>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export const main = style({
|
||||
export const listHeader = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
justifyItems: 'flex-end',
|
||||
alignItems: 'center',
|
||||
padding: '6px 0',
|
||||
marginBottom: 16,
|
||||
|
||||
@@ -34,7 +34,7 @@ import React, {
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { PagePropertiesTable } from '../../affine/page-properties';
|
||||
import { DocPropertiesTable } from '../../affine/page-properties';
|
||||
import {
|
||||
AffinePageReference,
|
||||
AffineSharedPageReference,
|
||||
@@ -232,7 +232,7 @@ export const BlocksuiteDocEditor = forwardRef<
|
||||
) : (
|
||||
<BlocksuiteEditorJournalDocTitle page={page} />
|
||||
)}
|
||||
{!shared ? <PagePropertiesTable docId={page.id} /> : null}
|
||||
{!shared ? <DocPropertiesTable /> : null}
|
||||
<adapted.DocEditor
|
||||
className={styles.docContainer}
|
||||
ref={onDocRef}
|
||||
|
||||
Reference in New Issue
Block a user