feat(core): workspace properties setting (#5739)

the property settings in workspace settings
This commit is contained in:
Peng Xiao 2024-02-22 09:37:46 +00:00
parent 55b8082d3a
commit 546d96c5c9
No known key found for this signature in database
GPG Key ID: 23F23D9E8B3971ED
17 changed files with 1231 additions and 318 deletions

View File

@ -9,7 +9,7 @@ export const button = style({
flexShrink: 0,
outline: '0',
border: '1px solid',
padding: '0 18px',
padding: '0 8px',
borderRadius: '8px',
fontSize: cssVar('fontXs'),
fontWeight: 500,

View File

@ -4,7 +4,7 @@ export const inputWrapper = style({
width: '100%',
height: 28,
lineHeight: '22px',
padding: '0 10px',
gap: '10px',
color: cssVar('textPrimaryColor'),
border: '1px solid',
backgroundColor: cssVar('white'),
@ -53,6 +53,7 @@ export const input = style({
width: '0',
flex: 1,
boxSizing: 'border-box',
padding: '0 12px',
// prevent default style
WebkitAppearance: 'none',
WebkitTapHighlightColor: 'transparent',

View File

@ -13,7 +13,6 @@ export const menuContent = style({
userSelect: 'none',
});
export const menuItem = style({
maxWidth: '296px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
@ -50,6 +49,9 @@ export const menuItem = style({
color: cssVar('warningColor'),
backgroundColor: cssVar('backgroundWarningColor'),
},
'&.checked': {
color: cssVar('primaryColor'),
},
},
});
export const menuSpan = style({

View File

@ -1,40 +1,47 @@
import type { PagePropertyType } from '@affine/core/modules/workspace/properties/schema';
import { PagePropertyType } from '@affine/core/modules/workspace/properties/schema';
import * as icons from '@blocksuite/icons';
import type { SVGProps } from 'react';
type IconType = (props: SVGProps<SVGSVGElement>) => JSX.Element;
// todo: this breaks tree-shaking, and we should fix it (using dynamic imports?)
const IconsMapping = {
text: icons.TextIcon,
tag: icons.TagIcon,
dateTime: icons.DateTimeIcon,
progress: icons.ProgressIcon,
checkbox: icons.CheckBoxCheckLinearIcon,
number: icons.NumberIcon,
// todo: add more icons
} satisfies Record<string, IconType>;
const IconsMapping = icons;
export type PagePropertyIcon = keyof typeof IconsMapping;
const excludedIcons: PagePropertyIcon[] = [
'YoutubeDuotoneIcon',
'LinearLogoIcon',
'RedditDuotoneIcon',
'Logo2Icon',
'Logo3Icon',
'Logo4Icon',
'InstagramDuotoneIcon',
'TelegramDuotoneIcon',
'TextBackgroundDuotoneIcon',
];
export const iconNames = Object.keys(IconsMapping).filter(
icon => !excludedIcons.includes(icon as PagePropertyIcon)
) as PagePropertyIcon[];
export const getDefaultIconName = (
type: PagePropertyType
): PagePropertyIcon => {
switch (type) {
case 'text':
return 'text';
return 'TextIcon';
case 'tags':
return 'tag';
return 'TagIcon';
case 'date':
return 'dateTime';
return 'DateTimeIcon';
case 'progress':
return 'progress';
return 'ProgressIcon';
case 'checkbox':
return 'checkbox';
return 'CheckBoxCheckLinearIcon';
case 'number':
return 'number';
return 'NumberIcon';
default:
return 'text';
return 'TextIcon';
}
};
@ -46,12 +53,18 @@ export const IconToIconName = (icon: IconType) => {
return iconKey;
};
export const getSafeIconName = (
iconName: string,
type?: PagePropertyType
): PagePropertyIcon => {
return Object.hasOwn(IconsMapping, iconName)
? (iconName as PagePropertyIcon)
: getDefaultIconName(type || PagePropertyType.Text);
};
export const nameToIcon = (
iconName: string,
type: PagePropertyType
type?: PagePropertyType
): IconType => {
return (
IconsMapping[iconName as keyof typeof IconsMapping] ??
getDefaultIconName(type)
);
return IconsMapping[getSafeIconName(iconName, type)];
};

View File

@ -0,0 +1,79 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const iconsContainer = style({
display: 'flex',
flexDirection: 'column',
gap: '2px',
});
export const iconsRow = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
color: cssVar('iconColor'),
fontSize: cssVar('fontSm'),
fontWeight: 500,
padding: '0 6px',
gap: '8px',
});
export const iconButton = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
borderRadius: '4px',
width: 28,
height: 28,
cursor: 'pointer',
transition: 'background-color 0.2s',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
selectors: {
'&[data-active=true]': {
color: cssVar('primaryColor'),
},
},
});
export const iconSelectorButton = style({
fontSize: cssVar('fontH5'),
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
flexShrink: 0,
border: `1px solid ${cssVar('borderColor')}`,
background: cssVar('backgroundSecondaryColor'),
cursor: 'pointer',
':hover': {
backgroundColor: cssVar('backgroundTertiaryColor'),
},
});
export const iconsContainerScrollable = style({
maxHeight: 320,
display: 'flex',
flexDirection: 'column',
});
export const iconsContainerScroller = style({
transform: 'translateX(4px)',
});
export const menuHeader = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '8px',
fontSize: cssVar('fontXs'),
fontWeight: 500,
color: cssVar('textSecondaryColor'),
padding: '8px 12px',
minWidth: 200,
textTransform: 'uppercase',
});

View File

@ -0,0 +1,93 @@
import { Menu, Scrollable } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { chunk } from 'lodash-es';
import { useEffect, useRef } from 'react';
import { iconNames, nameToIcon, type PagePropertyIcon } from './icons-mapping';
import * as styles from './icons-selector.css';
const iconsPerRow = 10;
const iconRows = chunk(iconNames, iconsPerRow);
export const IconsSelectorPanel = ({
selected,
onSelectedChange,
}: {
selected: PagePropertyIcon;
onSelectedChange: (icon: PagePropertyIcon) => 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 = useAFFiNEI18N();
return (
<Scrollable.Root>
<div className={styles.menuHeader}>
{t['com.affine.page-properties.icons']()}
</div>
<Scrollable.Viewport className={styles.iconsContainerScrollable}>
<div className={styles.iconsContainer} ref={ref}>
{iconRows.map((iconRow, index) => {
return (
<div key={index} className={styles.iconsRow}>
{iconRow.map(iconName => {
const Icon = nameToIcon(iconName);
return (
<div
key={iconName}
className={styles.iconButton}
data-name={iconName}
data-active={selected === iconName}
>
<Icon
key={iconName}
onClick={() => onSelectedChange(iconName)}
/>
</div>
);
})}
</div>
);
})}
</div>
<Scrollable.Scrollbar className={styles.iconsContainerScroller} />
</Scrollable.Viewport>
</Scrollable.Root>
);
};
export const IconsSelectorButton = ({
selected,
onSelectedChange,
}: {
selected: PagePropertyIcon;
onSelectedChange: (icon: PagePropertyIcon) => void;
}) => {
const Icon = nameToIcon(selected);
return (
<Menu
items={
<IconsSelectorPanel
selected={selected}
onSelectedChange={onSelectedChange}
/>
}
>
<div className={styles.iconSelectorButton}>
<Icon />
</div>
</Menu>
);
};

View File

@ -0,0 +1,125 @@
import {
Input,
MenuIcon,
MenuItem,
type MenuItemProps,
MenuSeparator,
Scrollable,
} from '@affine/component';
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/workspace/properties/schema';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
type ChangeEventHandler,
cloneElement,
isValidElement,
type MouseEventHandler,
} from 'react';
import {
getDefaultIconName,
getSafeIconName,
nameToIcon,
type PagePropertyIcon,
} 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}
preFix={icon ? <MenuIcon>{icon}</MenuIcon> : null}
onClick={onClick}
>
{text}
</MenuItem>
);
}
});
};
export const EditPropertyNameMenuItem = ({
property,
onNameBlur: onBlur,
onNameChange,
onIconChange,
}: {
onNameBlur: ChangeEventHandler;
onNameChange: (name: string) => void;
onIconChange: (icon: PagePropertyIcon) => void;
property: PageInfoCustomPropertyMeta;
}) => {
const iconName = getSafeIconName(property.icon, property.type);
const t = useAFFiNEI18N();
return (
<div className={styles.propertyRowNamePopupRow}>
<IconsSelectorButton
selected={iconName}
onSelectedChange={onIconChange}
/>
<Input
defaultValue={property.name}
onBlur={onBlur}
size="large"
style={{ borderRadius: 4 }}
onChange={onNameChange}
placeholder={t['unnamed']()}
/>
</div>
);
};
export const PropertyTypeMenuItem = ({
property,
}: {
property: PageInfoCustomPropertyMeta;
}) => {
const Icon = nameToIcon(getDefaultIconName(property.type), property.type);
const t = useAFFiNEI18N();
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>
);
};

View File

@ -29,29 +29,11 @@ function validatePropertyValue(type: PagePropertyType, value: any) {
}
}
export interface NewPropertyOption {
name: string;
type: PagePropertyType;
}
export const newPropertyOptions: NewPropertyOption[] = [
// todo: name i18n?
{
name: 'Text',
type: PagePropertyType.Text,
},
{
name: 'Number',
type: PagePropertyType.Number,
},
{
name: 'Checkbox',
type: PagePropertyType.Checkbox,
},
{
name: 'Date',
type: PagePropertyType.Date,
},
export const newPropertyTypes: PagePropertyType[] = [
PagePropertyType.Text,
PagePropertyType.Number,
PagePropertyType.Checkbox,
PagePropertyType.Date,
// todo: add more
];
@ -121,6 +103,21 @@ export class PagePropertiesMetaManager {
return property;
}
updateCustomPropertyMeta(
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;
}
removeCustomPropertyMeta(id: string) {
// should warn if the property is in use
delete this.customPropertiesSchema[id];
@ -136,6 +133,7 @@ export class PagePropertiesMetaManager {
mapping.get(id)?.add(page.id);
}
}
return mapping;
}
}
@ -149,6 +147,20 @@ export class PagePropertiesManager {
this.metaManager = new PagePropertiesMetaManager(this.adapter);
}
private ensuring = false;
ensureRequiredProperties() {
if (this.ensuring) return;
this.ensuring = true;
this.transact(() => {
this.metaManager.getOrderedCustomPropertiesSchema().forEach(property => {
if (property.required && !this.hasCustomProperty(property.id)) {
this.addCustomProperty(property.id);
}
});
});
this.ensuring = false;
}
get workspace() {
return this.adapter.workspace;
}
@ -174,6 +186,7 @@ export class PagePropertiesManager {
}
get properties() {
this.ensureRequiredProperties();
return this.adapter.getPageProperties(this.pageId);
}
@ -192,7 +205,7 @@ export class PagePropertiesManager {
/**
* get custom properties (filter out properties that are not in schema)
*/
getCustomProperties() {
getCustomProperties(): Record<string, PageInfoCustomProperty> {
return Object.fromEntries(
Object.entries(this.properties.custom).filter(([id]) =>
this.metaManager.checkPropertyExists(id)
@ -213,13 +226,6 @@ export class PagePropertiesManager {
);
}
leastOrder() {
return Math.min(
...Object.values(this.properties.custom).map(p => p.order),
0
);
}
getCustomPropertyMeta(id: string): PageInfoCustomPropertyMeta | undefined {
return this.metaManager.customPropertiesSchema[id];
}
@ -275,15 +281,12 @@ export class PagePropertiesManager {
Object.assign(this.properties.custom[id], opt);
}
updateCustomPropertyMeta(
id: string,
opt: Partial<PageInfoCustomPropertyMeta>
) {
if (!this.metaManager.checkPropertyExists(id)) {
logger.warn(`property ${id} not found`);
return;
}
Object.assign(this.metaManager.customPropertiesSchema[id], opt);
get updateCustomPropertyMeta() {
return this.metaManager.updateCustomPropertyMeta.bind(this.metaManager);
}
get isPropertyRequired() {
return this.metaManager.isPropertyRequired.bind(this.metaManager);
}
transact = this.adapter.transact;

View File

@ -19,6 +19,11 @@ export const rootCentered = style({
width: '100%',
maxWidth: cssVar('editorWidth'),
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
'@container': {
[`viewport (width <= 640px)`]: {
padding: '0 24px',
},
},
});
export const tableHeader = style({
@ -226,7 +231,6 @@ export const propertyRowIconContainer = style({
justifyContent: 'center',
borderRadius: '2px',
fontSize: 16,
transition: 'transform 0.2s',
color: 'inherit',
});
@ -277,13 +281,11 @@ export const menuHeader = style({
fontWeight: 500,
color: cssVar('textSecondaryColor'),
padding: '8px 16px',
minWidth: 320,
minWidth: 200,
textTransform: 'uppercase',
});
export const menuItemListScrollable = style({
maxHeight: 300,
});
export const menuItemListScrollable = style({});
export const menuItemListScrollbar = style({
transform: 'translateX(4px)',
@ -296,7 +298,7 @@ export const menuItemList = style({
overflow: 'auto',
});
globalStyle(`${menuItemList}${menuItemList} > div`, {
globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, {
display: 'table !important',
});
@ -318,30 +320,6 @@ export const checkboxProperty = style({
fontSize: cssVar('fontH5'),
});
export const propertyNameIconEditable = style({
fontSize: cssVar('fontH5'),
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
flexShrink: 0,
border: `1px solid ${cssVar('borderColor')}`,
background: cssVar('backgroundSecondaryColor'),
});
export const propertyNameInput = style({
fontSize: cssVar('fontSm'),
borderRadius: 4,
color: cssVar('textPrimaryColor'),
background: 'none',
border: `1px solid ${cssVar('borderColor')}`,
outline: 'none',
width: '100%',
padding: 6,
});
globalStyle(
`${propertyRow}:is([data-dragging=true], [data-other-dragging=true])
:is(${propertyRowValueCell}, ${propertyRowNameCell})`,
@ -387,7 +365,7 @@ export const propertySettingRowName = style({
export const selectorButton = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
justifyContent: 'flex-end',
borderRadius: 4,
gap: 8,
fontSize: cssVar('fontSm'),
@ -397,4 +375,29 @@ export const selectorButton = style({
':hover': {
backgroundColor: cssVar('hoverColor'),
},
selectors: {
'&[data-required=true]': {
color: cssVar('textDisableColor'),
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: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
display: 'flex',
alignItems: 'center',
gap: 4,
});

View File

@ -4,7 +4,6 @@ import {
Menu,
MenuIcon,
MenuItem,
Scrollable,
Tooltip,
} from '@affine/component';
import { useCurrentWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
@ -12,6 +11,7 @@ import { useBlockSuitePageBacklinks } from '@affine/core/hooks/use-block-suite-p
import type {
PageInfoCustomProperty,
PageInfoCustomPropertyMeta,
PagePropertyType,
} from '@affine/core/modules/workspace/properties/schema';
import { timestampToLocalDate } from '@affine/core/utils';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@ -54,19 +54,28 @@ import {
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react';
import { AffinePageReference } from '../reference-link';
import { managerContext, pageInfoCollapsedAtom } from './common';
import { getDefaultIconName, nameToIcon } from './icons-mapping';
import {
type NewPropertyOption,
newPropertyOptions,
getDefaultIconName,
nameToIcon,
type PagePropertyIcon,
} from './icons-mapping';
import {
EditPropertyNameMenuItem,
type MenuItemOption,
PropertyTypeMenuItem,
renderMenuItemOptions,
} from './menu-items';
import type { PagePropertiesMetaManager } from './page-properties-manager';
import {
newPropertyTypes,
PagePropertiesManager,
} from './page-properties-manager';
import { propertyValueRenderers } from './property-row-values';
import { propertyValueRenderers } from './property-row-value-renderer';
import * as styles from './styles.css';
type PagePropertiesSettingsPopupProps = PropsWithChildren<{
@ -220,37 +229,36 @@ const VisibilityModeSelector = ({
const manager = useContext(managerContext);
const t = useAFFiNEI18N();
const meta = manager.getCustomPropertyMeta(property.id);
const visibility = property.visibility || 'visible';
const menuItems = useMemo(() => {
const options: MenuItemOption[] = [];
options.push(
visibilities.map(v => {
const text = visibilityMenuText(v);
return {
text: t[text](),
selected: visibility === v,
onClick: () => {
manager.updateCustomProperty(property.id, {
visibility: v,
});
},
};
})
);
return renderMenuItemOptions(options);
}, [manager, property.id, t, visibility]);
if (!meta) {
return null;
}
const required = meta.required;
const visibility = property.visibility || 'visible';
return (
<Menu
items={
<>
{visibilities.map(v => {
const text = visibilitySelectorText(v);
return (
<MenuItem
key={v}
checked={visibility === v}
data-testid="page-properties-visibility-menu-item"
onClick={() => {
manager.updateCustomProperty(property.id, {
visibility: v,
});
}}
>
{t[text]()}
</MenuItem>
);
})}
</>
}
items={menuItems}
rootOptions={{
open: required ? false : undefined,
}}
@ -276,46 +284,42 @@ export const PagePropertiesSettingsPopup = ({
const t = useAFFiNEI18N();
const properties = manager.getOrderedCustomProperties();
return (
<Menu
items={
<>
<div className={styles.menuHeader}>
{t['com.affine.page-properties.settings.title']()}
</div>
<Divider />
<Scrollable.Root className={styles.menuItemListScrollable}>
<Scrollable.Viewport className={styles.menuItemList}>
<SortableProperties>
{properties.map(property => {
const meta = manager.getCustomPropertyMeta(property.id);
assertExists(meta, 'meta should exist for property');
const Icon = nameToIcon(meta.icon, meta.type);
const name = meta.name;
return (
<SortablePropertyRow
key={meta.id}
property={property}
className={styles.propertySettingRow}
data-testid="page-properties-settings-menu-item"
>
<MenuIcon>
<Icon />
</MenuIcon>
<div className={styles.propertyRowName}>{name}</div>
<VisibilityModeSelector property={property} />
</SortablePropertyRow>
);
})}
</SortableProperties>
</Scrollable.Viewport>
</Scrollable.Root>
</>
}
>
{children}
</Menu>
);
const menuItems = useMemo(() => {
const options: MenuItemOption[] = [];
options.push(
<div className={styles.menuHeader}>
{t['com.affine.page-properties.settings.title']()}
</div>
);
options.push('-');
options.push([
<SortableProperties key="sortable-settings">
{properties.map(property => {
const meta = manager.getCustomPropertyMeta(property.id);
assertExists(meta, 'meta should exist for property');
const Icon = nameToIcon(meta.icon, meta.type);
const name = meta.name;
return (
<SortablePropertyRow
key={meta.id}
property={property}
className={styles.propertySettingRow}
data-testid="page-properties-settings-menu-item"
>
<MenuIcon>
<Icon />
</MenuIcon>
<div className={styles.propertyRowName}>{name}</div>
<VisibilityModeSelector property={property} />
</SortablePropertyRow>
);
})}
</SortableProperties>,
]);
return renderMenuItemOptions(options);
}, [manager, properties, t]);
return <Menu items={menuItems}>{children}</Menu>;
};
type PageBacklinksPopupProps = PropsWithChildren<{
@ -363,18 +367,24 @@ export const PagePropertyRowName = ({
children,
}: PropsWithChildren<PagePropertyRowNameProps>) => {
const manager = useContext(managerContext);
const Icon = nameToIcon(meta.icon, meta.type);
const localPropertyMetaRef = useRef({ ...meta });
const localPropertyRef = useRef({ ...property });
const [nextVisibility, setNextVisibility] = useState(property.visibility);
const toHide =
nextVisibility === 'hide' || nextVisibility === 'hide-if-empty';
const [localPropertyMeta, setLocalPropertyMeta] = useState(() => ({
...meta,
}));
const [localProperty, setLocalProperty] = useState(() => ({ ...property }));
const nextVisibility = rotateVisibility(localProperty.visibility);
const handleFinishEditing = useCallback(() => {
onFinishEditing();
manager.updateCustomPropertyMeta(meta.id, localPropertyMetaRef.current);
manager.updateCustomProperty(property.id, localPropertyRef.current);
}, [manager, meta.id, onFinishEditing, property.id]);
manager.updateCustomPropertyMeta(meta.id, localPropertyMeta);
manager.updateCustomProperty(property.id, localProperty);
}, [
localProperty,
localPropertyMeta,
manager,
meta.id,
onFinishEditing,
property.id,
]);
const t = useAFFiNEI18N();
const handleNameBlur: ChangeEventHandler<HTMLInputElement> = useCallback(
e => {
@ -385,21 +395,23 @@ export const PagePropertyRowName = ({
},
[manager, meta.id]
);
const handleNameChange: ChangeEventHandler<HTMLInputElement> = useCallback(
e => {
localPropertyMetaRef.current.name = e.target.value;
},
[]
);
const toggleHide = useCallback((e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const nextVisibility = rotateVisibility(
localPropertyRef.current.visibility
);
setNextVisibility(nextVisibility);
localPropertyRef.current.visibility = nextVisibility;
const handleNameChange: (name: string) => void = useCallback(name => {
setLocalPropertyMeta(prev => ({
...prev,
name: name,
}));
}, []);
const toggleHide = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setLocalProperty(prev => ({
...prev,
visibility: nextVisibility,
}));
},
[nextVisibility]
);
const handleDelete = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
@ -409,6 +421,64 @@ export const PagePropertyRowName = ({
[manager, property.id]
);
const handleIconChange = useCallback(
(icon: PagePropertyIcon) => {
setLocalPropertyMeta(prev => ({
...prev,
icon,
}));
manager.updateCustomPropertyMeta(meta.id, {
icon: icon,
});
},
[manager, meta.id]
);
const menuItems = useMemo(() => {
const options: MenuItemOption[] = [];
options.push(
<EditPropertyNameMenuItem
property={localPropertyMeta}
onIconChange={handleIconChange}
onNameBlur={handleNameBlur}
onNameChange={handleNameChange}
/>
);
options.push(<PropertyTypeMenuItem property={localPropertyMeta} />);
if (!localPropertyMeta.required) {
options.push('-');
options.push({
icon:
nextVisibility === 'hide' || nextVisibility === 'hide-if-empty' ? (
<InvisibleIcon />
) : (
<ViewIcon />
),
text: t[
visibilityMenuText(rotateVisibility(localProperty.visibility))
](),
onClick: toggleHide,
});
options.push({
type: 'danger',
icon: <DeleteIcon />,
text: t['com.affine.page-properties.property.remove-property'](),
onClick: handleDelete,
});
}
return renderMenuItemOptions(options);
}, [
handleDelete,
handleIconChange,
handleNameBlur,
handleNameChange,
localProperty.visibility,
localPropertyMeta,
nextVisibility,
t,
toggleHide,
]);
return (
<Menu
rootOptions={{
@ -417,43 +487,7 @@ export const PagePropertyRowName = ({
contentOptions={{
onInteractOutside: handleFinishEditing,
}}
items={
<>
<div className={styles.propertyRowNamePopupRow}>
<div className={styles.propertyNameIconEditable}>
<Icon />
</div>
<input
className={styles.propertyNameInput}
defaultValue={meta.name}
onBlur={handleNameBlur}
onChange={handleNameChange}
/>
</div>
<Divider />
<MenuItem
preFix={
<MenuIcon>{!toHide ? <ViewIcon /> : <InvisibleIcon />}</MenuIcon>
}
data-testid="page-property-row-name-hide-menu-item"
onClick={toggleHide}
>
{t[visibilityMenuText(nextVisibility)]()}
</MenuItem>
<MenuItem
type="danger"
preFix={
<MenuIcon>
<DeleteIcon />
</MenuIcon>
}
data-testid="page-property-row-name-delete-menu-item"
onClick={handleDelete}
>
{t['com.affine.page-properties.property.remove-property']()}
</MenuItem>
</>
}
items={menuItems}
>
{children}
</Menu>
@ -641,7 +675,10 @@ export const PagePropertiesTableBody = ({
style,
}: PagePropertiesTableBodyProps) => {
const manager = useContext(managerContext);
const properties = manager.getOrderedCustomProperties();
const properties = useMemo(() => {
return manager.getOrderedCustomProperties();
}, [manager]);
return (
<Collapsible.Content
className={clsx(styles.tableBodyRoot, className)}
@ -652,8 +689,9 @@ export const PagePropertiesTableBody = ({
{properties
.filter(
property =>
property.visibility !== 'hide' &&
!(property.visibility === 'hide-if-empty' && !property.value)
manager.isPropertyRequired(property.id) ||
(property.visibility !== 'hide' &&
!(property.visibility === 'hide-if-empty' && !property.value))
)
.map(property => (
<PagePropertyRow key={property.id} property={property} />
@ -668,6 +706,7 @@ export const PagePropertiesTableBody = ({
interface PagePropertiesCreatePropertyMenuItemsProps {
onCreated?: (e: React.MouseEvent, id: string) => void;
metaManager: PagePropertiesMetaManager;
}
const findNextDefaultName = (name: string, allNames: string[]): string => {
@ -688,63 +727,62 @@ const findNextDefaultName = (name: string, allNames: string[]): string => {
export const PagePropertiesCreatePropertyMenuItems = ({
onCreated,
metaManager,
}: PagePropertiesCreatePropertyMenuItemsProps) => {
const manager = useContext(managerContext);
const t = useAFFiNEI18N();
const onAddProperty = useCallback(
(e: React.MouseEvent, option: NewPropertyOption & { icon: string }) => {
const nameExists = manager.metaManager
.getOrderedCustomPropertiesSchema()
.some(meta => meta.name === option.name);
const allNames = manager.metaManager
.getOrderedCustomPropertiesSchema()
.map(meta => meta.name);
(
e: React.MouseEvent,
option: { type: PagePropertyType; name: string; icon: string }
) => {
const schemaList = metaManager.getOrderedCustomPropertiesSchema();
const nameExists = schemaList.some(meta => meta.name === option.name);
const allNames = schemaList.map(meta => meta.name);
const name = nameExists
? findNextDefaultName(option.name, allNames)
: option.name;
const { id } = manager.metaManager.addCustomPropertyMeta({
const { id } = metaManager.addCustomPropertyMeta({
name,
icon: option.icon,
type: option.type,
});
onCreated?.(e, id);
},
[manager.metaManager, onCreated]
[metaManager, onCreated]
);
return (
<>
return useMemo(() => {
const options: MenuItemOption[] = [];
options.push(
<div className={styles.menuHeader}>
{t['com.affine.page-properties.create-property.menu.header']()}
</div>
<Divider />
<div className={styles.menuItemList}>
{newPropertyOptions.map(({ name, type }) => {
const iconName = getDefaultIconName(type);
const Icon = nameToIcon(iconName, type);
return (
<MenuItem
key={type}
preFix={
<MenuIcon>
<Icon />
</MenuIcon>
}
data-testid="page-properties-create-property-menu-item"
onClick={e => {
onAddProperty(e, { icon: iconName, name, type });
}}
>
{name}
</MenuItem>
);
})}
</div>
</>
);
);
options.push('-');
options.push(
newPropertyTypes.map(type => {
const iconName = getDefaultIconName(type);
const Icon = nameToIcon(iconName, type);
const name = t[`com.affine.page-properties.property.${type}`]();
return {
icon: <Icon />,
text: name,
onClick: (e: React.MouseEvent) => {
onAddProperty(e, {
icon: iconName,
name: name,
type: type,
});
},
};
})
);
return renderMenuItemOptions(options);
}, [onAddProperty, t]);
};
interface PagePropertiesAddPropertyMenuItemsProps {
onCreateClicked?: (e: React.MouseEvent) => void;
onCreateClicked: (e: React.MouseEvent) => void;
}
const PagePropertiesAddPropertyMenuItems = ({
@ -754,6 +792,7 @@ const PagePropertiesAddPropertyMenuItems = ({
const t = useAFFiNEI18N();
const metaList = manager.metaManager.getOrderedCustomPropertiesSchema();
const nonRequiredMetaList = metaList.filter(meta => !meta.required);
const isChecked = useCallback(
(m: string) => {
return manager.hasCustomProperty(m);
@ -774,59 +813,41 @@ const PagePropertiesAddPropertyMenuItems = ({
[isChecked, manager]
);
return (
<>
const menuItems = useMemo(() => {
const options: MenuItemOption[] = [];
options.push(
<div className={styles.menuHeader}>
{t['com.affine.page-properties.add-property.menu.header']()}
</div>
{/* hide available properties if there are none */}
{metaList.length > 0 ? (
<>
<Divider />
<Scrollable.Root className={styles.menuItemListScrollable}>
<Scrollable.Viewport className={styles.menuItemList}>
{metaList.map(meta => {
const Icon = nameToIcon(meta.icon, meta.type);
const name = meta.name;
return (
<MenuItem
key={meta.id}
preFix={
<MenuIcon>
<Icon />
</MenuIcon>
}
data-testid="page-properties-add-property-menu-item"
data-property={meta.id}
checked={isChecked(meta.id)}
onClick={(e: React.MouseEvent) =>
onClickProperty(e, meta.id)
}
>
{name}
</MenuItem>
);
})}
</Scrollable.Viewport>
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
</Scrollable.Root>
</>
) : null}
<Divider />
<MenuItem
onClick={onCreateClicked}
preFix={
<MenuIcon>
<PlusIcon />
</MenuIcon>
);
if (nonRequiredMetaList.length > 0) {
options.push('-');
const nonRequiredMetaOptions: MenuItemOption = nonRequiredMetaList.map(
meta => {
const Icon = nameToIcon(meta.icon, meta.type);
const name = meta.name;
return {
icon: <Icon />,
text: name,
selected: isChecked(meta.id),
onClick: (e: React.MouseEvent) => onClickProperty(e, meta.id),
};
}
>
<div className={styles.menuItemName}>
{t['com.affine.page-properties.add-property.menu.create']()}
</div>
</MenuItem>
</>
);
);
options.push(nonRequiredMetaOptions);
}
options.push('-');
options.push({
icon: <PlusIcon />,
text: t['com.affine.page-properties.add-property.menu.create'](),
onClick: onCreateClicked,
});
return renderMenuItemOptions(options);
}, [isChecked, nonRequiredMetaList, onClickProperty, onCreateClicked, t]);
return menuItems;
};
export const PagePropertiesAddProperty = () => {
@ -848,7 +869,10 @@ export const PagePropertiesAddProperty = () => {
const items = adding ? (
<PagePropertiesAddPropertyMenuItems onCreateClicked={toggleAdding} />
) : (
<PagePropertiesCreatePropertyMenuItems onCreated={handleCreated} />
<PagePropertiesCreatePropertyMenuItems
metaManager={manager.metaManager}
onCreated={handleCreated}
/>
);
return (
<Menu rootOptions={{ onOpenChange: () => setAdding(true) }} items={items}>
@ -882,6 +906,13 @@ const PagePropertiesTableInner = () => {
export const PagePropertiesTable = ({ page }: { page: Page }) => {
const manager = usePagePropertiesManager(page);
// if the given page is not in the current workspace, then we don't render anything
// eg. when it is in history modal
if (!manager.page) {
return null;
}
return (
<managerContext.Provider value={manager}>
<Suspense>

View File

@ -223,6 +223,10 @@ const subTabConfigs = [
key: 'experimental-features',
title: 'com.affine.settings.workspace.experimental-features',
},
{
key: 'properties',
title: 'com.affine.settings.workspace.properties',
},
] satisfies {
key: WorkspaceSubTab;
title: keyof ReturnType<typeof useAFFiNEI18N>;

View File

@ -9,6 +9,7 @@ export const GeneralSettingKeys = [
export const WorkspaceSubTabs = [
'preference',
'experimental-features',
'properties',
] as const;
export type GeneralSettingKey = (typeof GeneralSettingKeys)[number];

View File

@ -3,13 +3,14 @@ import type { WorkspaceMetadata } from '@toeverything/infra';
import { useIsWorkspaceOwner } from '../../../../hooks/affine/use-is-workspace-owner';
import { ExperimentalFeatures } from './experimental-features';
import { WorkspaceSettingDetail } from './new-workspace-setting-detail';
import { WorkspaceSettingProperties } from './properties';
export const WorkspaceSetting = ({
workspaceMetadata,
subTab,
}: {
workspaceMetadata: WorkspaceMetadata;
subTab: 'preference' | 'experimental-features';
subTab: 'preference' | 'experimental-features' | 'properties';
}) => {
const isOwner = useIsWorkspaceOwner(workspaceMetadata);
@ -23,5 +24,9 @@ export const WorkspaceSetting = ({
);
case 'experimental-features':
return <ExperimentalFeatures workspaceMetadata={workspaceMetadata} />;
case 'properties':
return (
<WorkspaceSettingProperties workspaceMetadata={workspaceMetadata} />
);
}
};

View File

@ -0,0 +1,421 @@
import { Button, IconButton, Menu } from '@affine/component';
import { SettingHeader } from '@affine/component/setting-components';
import { useWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
import { useWorkspace } from '@affine/core/hooks/use-workspace';
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/workspace/properties/schema';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { DeleteIcon, FilterIcon, MoreHorizontalIcon } from '@blocksuite/icons';
import type {
Workspace,
WorkspaceMetadata,
} from '@toeverything/infra/workspace';
import {
type ChangeEventHandler,
createContext,
Fragment,
type MouseEvent,
type MouseEventHandler,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {
nameToIcon,
PagePropertiesCreatePropertyMenuItems,
PagePropertiesMetaManager,
type PagePropertyIcon,
} from '../../../page-properties';
import {
EditPropertyNameMenuItem,
type MenuItemOption,
PropertyTypeMenuItem,
renderMenuItemOptions,
} from '../../../page-properties/menu-items';
import * as styles from './styles.css';
// @ts-expect-error this should always be set
const managerContext = createContext<PagePropertiesMetaManager>();
const usePagePropertiesMetaManager = (workspace: Workspace) => {
// 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 = useWorkspacePropertiesAdapter(workspace);
const manager = useMemo(() => {
return new PagePropertiesMetaManager(adapter);
}, [adapter]);
return manager;
};
const Divider = () => {
return <div className={styles.divider} />;
};
const EditPropertyButton = ({
property,
}: {
property: PageInfoCustomPropertyMeta;
}) => {
const t = useAFFiNEI18N();
const manager = useContext(managerContext);
const [localPropertyMeta, setLocalPropertyMeta] = useState(() => ({
...property,
}));
useEffect(() => {
setLocalPropertyMeta(property);
}, [property]);
const handleToggleRequired: MouseEventHandler = useCallback(
e => {
e.stopPropagation();
e.preventDefault();
manager.updateCustomPropertyMeta(localPropertyMeta.id, {
required: !localPropertyMeta.required,
});
},
[manager, localPropertyMeta.id, localPropertyMeta.required]
);
const handleDelete: MouseEventHandler = useCallback(
e => {
e.stopPropagation();
e.preventDefault();
manager.removeCustomPropertyMeta(localPropertyMeta.id);
},
[manager, localPropertyMeta.id]
);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const handleFinishEditing = useCallback(() => {
setOpen(false);
setEditing(false);
manager.updateCustomPropertyMeta(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: handleDelete,
type: 'danger',
icon: <DeleteIcon />,
});
return renderMenuItemOptions(options);
}, [handleDelete, handleToggleRequired, localPropertyMeta.required, t]);
const handleNameBlur: ChangeEventHandler<HTMLInputElement> = useCallback(
e => {
e.stopPropagation();
manager.updateCustomPropertyMeta(localPropertyMeta.id, {
name: e.target.value,
});
},
[manager, localPropertyMeta.id]
);
const handleNameChange: (name: string) => void = useCallback(name => {
setLocalPropertyMeta(prev => ({
...prev,
name: name,
}));
}, []);
const handleIconChange = useCallback(
(icon: PagePropertyIcon) => {
setLocalPropertyMeta(prev => ({
...prev,
icon,
}));
manager.updateCustomPropertyMeta(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}
>
<IconButton
onClick={() => setOpen(true)}
type="plain"
icon={<MoreHorizontalIcon />}
/>
</Menu>
);
};
const CustomPropertyRow = ({
property,
relatedPages,
}: {
relatedPages: string[];
property: PageInfoCustomPropertyMeta;
}) => {
const Icon = nameToIcon(property.icon, property.type);
const required = property.required;
const t = useAFFiNEI18N();
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.getOrderedCustomPropertiesSchema();
const statistics = manager.getCustomPropertyStatistics();
const t = useAFFiNEI18N();
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 required = properties.filter(property => property.required);
const optional = properties.filter(property => !property.required);
return (
<>
{required.length > 0 ? (
<>
<div className={styles.subListHeader}>
{t[
'com.affine.settings.workspace.properties.required-properties'
]()}
</div>
<CustomPropertyRows properties={required} statistics={statistics} />
</>
) : null}
{optional.length > 0 ? (
<>
<div className={styles.subListHeader}>
{t[
'com.affine.settings.workspace.properties.general-properties'
]()}
</div>
<CustomPropertyRows properties={optional} statistics={statistics} />
</>
) : null}
</>
);
}
};
const WorkspaceSettingPropertiesMain = () => {
const t = useAFFiNEI18N();
const manager = useContext(managerContext);
const [filterMode, setFilterMode] = useState<PropertyFilterMode>('all');
const properties = manager.getOrderedCustomPropertiesSchema();
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 type="default" icon={<FilterIcon />}>
{filterMode === 'all'
? t['com.affine.filter']()
: t[`com.affine.settings.workspace.properties.${filterMode}`]()}
</Button>
</Menu>
) : null}
<Menu
items={
<PagePropertiesCreatePropertyMenuItems
onCreated={onPropertyCreated}
metaManager={manager}
/>
}
>
<Button type="primary">
{t['com.affine.settings.workspace.properties.add_property']()}
</Button>
</Menu>
</div>
<CustomPropertyRowsList filterMode={filterMode} />
</div>
);
};
const WorkspaceSettingPropertiesInner = ({
workspace,
}: {
workspace: Workspace;
}) => {
const manager = usePagePropertiesMetaManager(workspace);
return (
<managerContext.Provider value={manager}>
<WorkspaceSettingPropertiesMain />
</managerContext.Provider>
);
};
export const WorkspaceSettingProperties = ({
workspaceMetadata,
}: {
workspaceMetadata: WorkspaceMetadata;
}) => {
const t = useAFFiNEI18N();
const workspace = useWorkspace(workspaceMetadata);
const workspaceInfo = useWorkspaceInfo(workspaceMetadata);
const title = workspaceInfo.name || 'untitled';
return (
<>
<SettingHeader
title={t['com.affine.settings.workspace.properties.header.title']()}
subtitle={
<Trans
values={{
name: title,
}}
i18nKey="com.affine.settings.workspace.properties.header.subtitle"
>
Manage workspace <strong>name</strong> properties
</Trans>
}
/>
{workspace ? (
<WorkspaceSettingPropertiesInner workspace={workspace} />
) : null}
</>
);
};

View File

@ -0,0 +1,110 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const main = style({
display: 'flex',
flexDirection: 'column',
width: '100%',
justifyContent: 'center',
});
export const listHeader = style({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: '6px 0',
marginBottom: 16,
});
export const propertyRow = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: '6px 0',
gap: 6,
outline: 'none',
transition: 'background 0.2s ease',
selectors: {
'&[data-highlight]': {
background: cssVar('backgroundSecondaryColor'),
},
},
});
export const metaList = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
marginBottom: 16,
});
export const propertyIcon = style({
color: cssVar('iconColor'),
fontSize: 16,
});
export const propertyName = style({
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontSm'),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
selectors: {
'&[data-unnamed=true]': {
color: cssVar('placeholderColor'),
},
},
});
export const propertyDocCount = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontSm'),
whiteSpace: 'nowrap',
});
export const divider = style({
width: '100%',
height: 1,
backgroundColor: cssVar('dividerColor'),
});
export const spacer = style({
flexGrow: 1,
});
export const propertyRequired = style({
color: cssVar('textDisableColor'),
fontSize: cssVar('fontXs'),
});
export const subListHeader = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontSm'),
marginBottom: 8,
});
export const propertyRowNamePopupRow = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '8px',
fontSize: cssVar('fontSm'),
fontWeight: 500,
color: cssVar('textSecondaryColor'),
padding: '8px 16px',
minWidth: 260,
});
export const propertyNameIconEditable = style({
fontSize: cssVar('fontH5'),
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
flexShrink: 0,
border: `1px solid ${cssVar('borderColor')}`,
background: cssVar('backgroundSecondaryColor'),
});

View File

@ -308,6 +308,7 @@
"Unpublished hint": "Once published to the web, visitors can view the contents through the provided link.",
"Untitled": "Untitled",
"Untitled Collection": "Untitled Collection",
"unnamed": "unnamed",
"Update Available": "Update available",
"Update Collection": "Update Collection",
"Update workspace name success": "Update workspace name success",
@ -901,8 +902,22 @@
"com.affine.settings.workspace.experimental-features.prompt-disclaimer": "I am aware of the risks, and I am willing to continue to use it.",
"com.affine.settings.workspace.experimental-features.get-started": "Get Started",
"com.affine.settings.workspace.experimental-features.header.plugins": "Experimental Features",
"com.affine.settings.workspace.properties.header.title": "Properties",
"com.affine.settings.workspace.properties.header.subtitle": "Manage workspace <1>{{name}}</1> properties",
"com.affine.settings.workspace.properties.doc": "<0>{{count}}</0> doc",
"com.affine.settings.workspace.properties.doc_others": "<0>{{count}}</0> docs",
"com.affine.settings.workspace.properties.add_property": "Add property",
"com.affine.settings.workspace.properties.all": "All",
"com.affine.settings.workspace.properties.in-use": "In use",
"com.affine.settings.workspace.properties.unused": "Unused",
"com.affine.settings.workspace.properties.set-as-required": "Set as required property",
"com.affine.settings.workspace.properties.edit-property": "Edit property",
"com.affine.settings.workspace.properties.delete-property": "Delete property",
"com.affine.settings.workspace.properties.required-properties": "Required properties",
"com.affine.settings.workspace.properties.general-properties": "General properties",
"com.affine.settings.workspace.preferences": "Preference",
"com.affine.settings.workspace.experimental-features": "Plugins",
"com.affine.settings.workspace.properties": "Properties",
"com.affine.share-menu.EnableCloudDescription": "Sharing doc requires AFFiNE Cloud.",
"com.affine.share-menu.ShareMode": "Share mode",
"com.affine.share-menu.SharePage": "Share Doc",
@ -1103,5 +1118,12 @@
"com.affine.page-properties.property.always-show": "Always show",
"com.affine.page-properties.property.hide-when-empty": "Hide when empty",
"com.affine.page-properties.property.required": "Required",
"com.affine.page-properties.property.remove-property": "Remove property"
"com.affine.page-properties.property.remove-property": "Remove property",
"com.affine.page-properties.property.text": "Text",
"com.affine.page-properties.property.number": "Number",
"com.affine.page-properties.property.date": "Date",
"com.affine.page-properties.property.checkbox": "Checkbox",
"com.affine.page-properties.property.progress": "Progress",
"com.affine.page-properties.property.tags": "Tags",
"com.affine.page-properties.icons": "Icons"
}