fix(mobile): doc property styles (#8760)

fix AF-1582
fix AF-1671

- mobile doc info dialog styles
- added ConfigModal for editing property values in modal, including:
  - workspace properties: text, number, tags
  - db properties: text, number, label, link
This commit is contained in:
pengx17
2024-11-12 07:11:00 +00:00
parent 2ee2cbfe36
commit fa82842cd7
67 changed files with 1460 additions and 492 deletions

View File

@@ -280,12 +280,15 @@ export const BlocksuiteDocEditor = forwardRef<
<BlocksuiteEditorJournalDocTitle page={page} />
)}
{!shared && displayDocInfo ? (
<DocPropertiesTable
onDatabasePropertyChange={onDatabasePropertyChange}
onPropertyChange={onPropertyChange}
onPropertyAdded={onPropertyAdded}
defaultOpenProperty={defaultOpenProperty}
/>
<div className={styles.docPropertiesTableContainer}>
<DocPropertiesTable
className={styles.docPropertiesTable}
onDatabasePropertyChange={onDatabasePropertyChange}
onPropertyChange={onPropertyChange}
onPropertyAdded={onPropertyAdded}
defaultOpenProperty={defaultOpenProperty}
/>
</div>
) : null}
<adapted.DocEditor
className={styles.docContainer}

View File

@@ -60,3 +60,23 @@ export const pageReferenceIcon = style({
fontSize: '1.1em',
transform: 'translate(2px, -1px)',
});
export const docPropertiesTableContainer = style({
display: 'flex',
width: '100%',
justifyContent: 'center',
});
export const docPropertiesTable = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
width: '100%',
maxWidth: cssVar('editorWidth'),
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
'@container': {
[`viewport (width <= 640px)`]: {
padding: '0 16px',
},
},
});

View File

@@ -16,6 +16,7 @@ export const iconsRow = style({
fontWeight: 500,
padding: '0 6px',
gap: '8px',
justifyContent: 'space-between',
});
export const iconButton = style({

View File

@@ -14,6 +14,13 @@ export const propertyRowNamePopupRow = style({
minWidth: 260,
});
export const mobilePropertyRowNamePopupRow = style([
propertyRowNamePopupRow,
{
padding: '8px 20px',
},
]);
export const propertyRowTypeItem = style({
display: 'flex',
flexDirection: 'row',
@@ -25,6 +32,13 @@ export const propertyRowTypeItem = style({
minWidth: 260,
});
export const mobilePropertyRowTypeItem = style([
propertyRowTypeItem,
{
padding: '8px 20px',
},
]);
export const propertyTypeName = style({
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontSm'),
@@ -40,3 +54,7 @@ export const propertyName = style({
display: 'flex',
alignItems: 'center',
});
export const mobileRootWrapper = style({
padding: '10px 16px',
});

View File

@@ -121,7 +121,11 @@ export const EditDocPropertyMenuItems = ({
return (
<>
<div
className={styles.propertyRowNamePopupRow}
className={
BUILD_CONFIG.isMobileEdition
? styles.mobilePropertyRowNamePopupRow
: styles.propertyRowNamePopupRow
}
data-testid="edit-property-menu-item"
>
<DocPropertyIconSelector
@@ -140,7 +144,13 @@ export const EditDocPropertyMenuItems = ({
/>
)}
</div>
<div className={styles.propertyRowTypeItem}>
<div
className={
BUILD_CONFIG.isMobileEdition
? styles.mobilePropertyRowTypeItem
: styles.propertyRowTypeItem
}
>
{t['com.affine.page-properties.create-property.menu.header']()}
<div className={styles.propertyTypeName}>
<DocPropertyIcon propertyInfo={propertyInfo} />

View File

@@ -6,9 +6,7 @@ const propertyNameCellWidth = createVar();
export const fontSize = createVar();
export const root = style({
display: 'flex',
width: '100%',
justifyContent: 'center',
fontFamily: cssVar('fontSansFamily'),
paddingBottom: '18px',
vars: {
@@ -24,20 +22,6 @@ export const root = style({
},
});
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',
height: 30,

View File

@@ -49,6 +49,7 @@ export type DefaultOpenProperty =
};
export interface DocPropertiesTableProps {
className?: string;
defaultOpenProperty?: DefaultOpenProperty;
onPropertyAdded?: (property: DocCustomPropertyInfo) => void;
onPropertyChange?: (property: DocCustomPropertyInfo, value: unknown) => void;
@@ -351,16 +352,17 @@ const DocPropertiesTableInner = ({
onPropertyAdded,
onPropertyChange,
onDatabasePropertyChange,
className,
}: DocPropertiesTableProps) => {
const [expanded, setExpanded] = useState(!!defaultOpenProperty);
return (
<div className={styles.root}>
<Collapsible.Root
open={expanded}
onOpenChange={setExpanded}
className={styles.rootCentered}
>
<DocPropertiesTableHeader open={expanded} onOpenChange={setExpanded} />
<div className={clsx(styles.root, className)}>
<Collapsible.Root open={expanded} onOpenChange={setExpanded}>
<DocPropertiesTableHeader
style={{ width: '100%' }}
open={expanded}
onOpenChange={setExpanded}
/>
<Collapsible.Content>
<DocWorkspacePropertiesTableBody
defaultOpen={

View File

@@ -1,7 +1,7 @@
import type { TagLike } from '@affine/component/ui/tags';
import { TagsInlineEditor as TagsInlineEditorComponent } from '@affine/component/ui/tags';
import { TagService, useDeleteTagConfirmModal } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { TagsIcon } from '@blocksuite/icons/rc';
import {
LiveData,
useLiveData,
@@ -12,6 +12,10 @@ import { useCallback, useMemo } from 'react';
import { useAsyncCallback } from '../hooks/affine-async-hooks';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
import {
type TagLike,
TagsInlineEditor as TagsInlineEditorComponent,
} from '../tags';
interface TagsEditorProps {
pageId: string;
@@ -126,6 +130,8 @@ export const TagsInlineEditor = ({
[navigator, workspace.workspace.id]
);
const t = useI18n();
return (
<TagsInlineEditorComponent
tagMode="inline-tag"
@@ -141,6 +147,12 @@ export const TagsInlineEditor = ({
tagColors={adaptedTagColors}
onTagChange={onTagChange}
onDeleteTag={onTagDelete}
title={
<>
<TagsIcon />
{t['Tags']()}
</>
}
/>
);
};

View File

@@ -24,6 +24,7 @@ export const date = style({
lineHeight: '22px',
padding: '0 4px',
borderRadius: 4,
whiteSpace: 'nowrap',
':hover': {
background: cssVarV2('layer/background/hoverOverlay'),
},

View File

@@ -10,7 +10,7 @@ import {
useServiceOptional,
} from '@toeverything/infra';
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as styles from './journal.css';
@@ -58,6 +58,7 @@ export const JournalValue = () => {
(_: unknown, v: boolean) => {
if (!v) {
journalService.removeJournalDate(doc.id);
setShowDatePicker(false);
} else {
handleDateSelect(selectedDate);
}
@@ -71,6 +72,10 @@ export const JournalValue = () => {
const handleOpenDuplicate = useCallback(
(e: React.MouseEvent) => {
// todo: open duplicate dialog for mobile
if (BUILD_CONFIG.isMobileEdition) {
return;
}
e.stopPropagation();
workbench.openSidebar();
view.activeSidebarTab('journal');
@@ -78,12 +83,23 @@ export const JournalValue = () => {
[view, workbench]
);
const toggle = useCallback(() => {
handleCheck(null, !checked);
}, [checked, handleCheck]);
const propertyRef = useRef<HTMLDivElement>(null);
const toggle = useCallback(
(e: React.MouseEvent) => {
if (propertyRef.current?.contains(e.target as Node)) {
handleCheck(null, !checked);
}
},
[checked, handleCheck]
);
return (
<PropertyValue className={styles.property} onClick={toggle}>
<PropertyValue
ref={propertyRef}
className={styles.property}
onClick={toggle}
>
<div className={styles.root}>
<Checkbox
className={styles.checkbox}
@@ -113,7 +129,13 @@ export const JournalValue = () => {
/>
}
>
<div data-testid="date-selector" className={styles.date}>
<div
data-testid="date-selector"
className={styles.date}
onClick={e => {
e.stopPropagation();
}}
>
{displayDate}
</div>
</Menu>

View File

@@ -1,4 +1,5 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const numberPropertyValueInput = style({
@@ -23,3 +24,18 @@ export const numberPropertyValueInput = style({
export const numberPropertyValueContainer = style({
padding: '0px',
});
export const numberIcon = style({
color: cssVarV2('icon/primary'),
});
export const mobileNumberPropertyValueInput = style([
numberPropertyValueInput,
{
selectors: {
'input&': {
border: `1px solid ${cssVar('blue700')}`,
},
},
},
]);

View File

@@ -1,5 +1,6 @@
import { PropertyValue } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { NumberIcon } from '@blocksuite/icons/rc';
import {
type ChangeEventHandler,
useCallback,
@@ -7,10 +8,11 @@ import {
useState,
} from 'react';
import { ConfigModal } from '../../mobile';
import * as styles from './number.css';
import type { PropertyValueProps } from './types';
export const NumberValue = ({ value, onChange }: PropertyValueProps) => {
const DesktopNumberValue = ({ value, onChange }: PropertyValueProps) => {
const parsedValue = isNaN(Number(value)) ? null : value;
const [tempValue, setTempValue] = useState(parsedValue);
const handleBlur = useCallback(
@@ -48,3 +50,79 @@ export const NumberValue = ({ value, onChange }: PropertyValueProps) => {
</PropertyValue>
);
};
const MobileNumberValue = ({
value,
onChange,
propertyInfo,
}: 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]);
const [open, setOpen] = useState(false);
const onClose = useCallback(() => {
setOpen(false);
onChange(tempValue);
}, [onChange, tempValue]);
return (
<>
<PropertyValue
className={styles.numberPropertyValueContainer}
isEmpty={!parsedValue}
onClick={() => {
setOpen(true);
}}
>
<div className={styles.mobileNumberPropertyValueInput}>
{value ||
t['com.affine.page-properties.property-value-placeholder']()}
</div>
</PropertyValue>
<ConfigModal
open={open}
variant="popup"
onDone={onClose}
onOpenChange={setOpen}
title={
<>
<NumberIcon className={styles.numberIcon} />
{propertyInfo?.name}
</>
}
>
<input
className={styles.mobileNumberPropertyValueInput}
type={'number'}
value={tempValue || ''}
onChange={handleOnChange}
onBlur={handleBlur}
data-empty={!tempValue}
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
/>
</ConfigModal>
</>
);
};
export const NumberValue = BUILD_CONFIG.isMobileEdition
? MobileNumberValue
: DesktopNumberValue;

View File

@@ -25,6 +25,32 @@ export const textarea = style({
},
});
export const mobileTextareaWrapper = style({
position: 'relative',
background: cssVarV2('layer/background/primary'),
borderRadius: 12,
});
const mobileTextareaBase = {
fontSize: 17,
lineHeight: '26px',
padding: 12,
};
export const mobileTextareaPlain = style([
textarea,
mobileTextareaBase,
{
position: 'relative',
fontSize: 14,
lineHeight: '22px',
height: 'auto',
padding: 0,
},
]);
export const mobileTextarea = style([textarea, mobileTextareaBase]);
export const textPropertyValueContainer = style({
outline: `1px solid transparent`,
padding: `6px`,
@@ -43,4 +69,7 @@ export const textInvisible = style({
visibility: 'hidden',
fontSize: cssVar('fontSm'),
lineHeight: '22px',
padding: `6px`,
});
export const mobileTextInvisible = style([textInvisible, mobileTextareaBase]);

View File

@@ -1,5 +1,6 @@
import { PropertyValue } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { TextIcon } from '@blocksuite/icons/rc';
import {
type ChangeEventHandler,
useCallback,
@@ -8,10 +9,11 @@ import {
useState,
} from 'react';
import { ConfigModal } from '../../mobile';
import * as styles from './text.css';
import type { PropertyValueProps } from './types';
export const TextValue = ({ value, onChange }: PropertyValueProps) => {
const DesktopTextValue = ({ value, onChange }: PropertyValueProps) => {
const [tempValue, setTempValue] = useState<string>(value);
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@@ -68,3 +70,94 @@ export const TextValue = ({ value, onChange }: PropertyValueProps) => {
</PropertyValue>
);
};
const MobileTextValue = ({
value,
onChange,
propertyInfo,
}: PropertyValueProps) => {
const [open, setOpen] = useState(false);
const [tempValue, setTempValue] = useState<string>(value);
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setOpen(true);
}, []);
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 onClose = useCallback(() => {
setOpen(false);
onChange(tempValue.trim());
}, [onChange, tempValue]);
const t = useI18n();
useEffect(() => {
setTempValue(value);
}, [value]);
return (
<PropertyValue
className={styles.textPropertyValueContainer}
onClick={handleClick}
isEmpty={!value}
>
<div className={styles.mobileTextareaPlain} data-empty={!tempValue}>
{tempValue ||
t['com.affine.page-properties.property-value-placeholder']()}
</div>
<ConfigModal
open={open}
onOpenChange={setOpen}
onBack={onClose}
title={
<>
<TextIcon />
{propertyInfo?.name}
</>
}
>
<div className={styles.mobileTextareaWrapper}>
<textarea
ref={ref}
className={styles.mobileTextarea}
value={tempValue || ''}
onChange={handleOnChange}
data-empty={!tempValue}
autoFocus
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
/>
<div className={styles.mobileTextInvisible}>
{tempValue}
{tempValue?.endsWith('\n') || !tempValue ? <br /> : null}
</div>
</div>
</ConfigModal>
</PropertyValue>
);
};
export const TextValue = BUILD_CONFIG.isMobileWeb
? MobileTextValue
: DesktopTextValue;

View File

@@ -0,0 +1,139 @@
import { Button, Modal } from '@affine/component';
import { useI18n } from '@affine/i18n';
import clsx from 'clsx';
import {
type CSSProperties,
forwardRef,
type HTMLProps,
type ReactNode,
} from 'react';
import { PageHeader } from '../page-header';
import * as styles from './styles.css';
interface ConfigModalProps {
onBack?: () => void;
onDone?: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
title?: ReactNode;
children: ReactNode;
variant?: 'popup' | 'page';
}
/**
* A modal with a page header for configuring something on mobile (preferable to be fullscreen)
*/
export const ConfigModal = ({
onBack,
onDone,
open,
onOpenChange,
title,
children,
variant = 'page',
}: ConfigModalProps) => {
const t = useI18n();
return (
<Modal
onOpenChange={onOpenChange}
open={open}
fullScreen={variant === 'page'}
width="100%"
minHeight={0}
animation="slideBottom"
withoutCloseButton
contentOptions={{
onClick: e => {
e.stopPropagation();
},
className:
variant === 'page'
? styles.pageModalContent
: styles.popupModalContent,
}}
>
{variant === 'page' ? (
<PageHeader
back={!!onBack}
backAction={onBack}
suffix={
onDone ? (
<Button
style={{
fontSize: 17,
fontWeight: 600,
}}
className={styles.doneButton}
variant="plain"
onClick={onDone}
>
{t['Done']()}
</Button>
) : undefined
}
>
<div className={styles.pageTitle}>{title}</div>
</PageHeader>
) : null}
<div
className={
variant === 'page' ? styles.pageContent : styles.popupContent
}
>
{variant === 'page' ? null : (
<div className={styles.popupTitle}>{title}</div>
)}
{children}
{variant === 'popup' && onDone ? (
<Button
variant="primary"
className={styles.bottomDoneButton}
onClick={onDone}
>
{t['Done']()}
</Button>
) : null}
</div>
</Modal>
);
};
export const ConfigRow = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
function ConfigRow({ children, className, ...attrs }, ref) {
return (
<div className={clsx(styles.rowItem, className)} ref={ref} {...attrs}>
{children}
</div>
);
}
);
export interface SettingGroupProps
extends Omit<HTMLProps<HTMLDivElement>, 'title'> {
title?: ReactNode;
contentClassName?: string;
contentStyle?: CSSProperties;
}
export const ConfigRowGroup = forwardRef<HTMLDivElement, SettingGroupProps>(
function ConfigRowGroup(
{ children, title, className, contentClassName, contentStyle, ...attrs },
ref
) {
return (
<div className={clsx(styles.group, className)} ref={ref} {...attrs}>
{title ? <div className={styles.groupTitle}>{title}</div> : null}
<div
className={clsx(styles.groupContent, contentClassName)}
style={contentStyle}
>
{children}
</div>
</div>
);
}
);
ConfigModal.RowGroup = ConfigRowGroup;
ConfigModal.Row = ConfigRow;

View File

@@ -0,0 +1,89 @@
import {
bodyEmphasized,
footnoteRegular,
} from '@toeverything/theme/typography';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const pageModalContent = style({
padding: 0,
overflowY: 'auto',
backgroundColor: cssVarV2('layer/background/secondary'),
});
export const popupModalContent = style({});
export const pageTitle = style([
bodyEmphasized,
{
color: cssVarV2('text/primary'),
display: 'inline-flex',
alignItems: 'center',
gap: 8,
},
]);
export const popupTitle = style([
bodyEmphasized,
{
display: 'flex',
alignItems: 'center',
gap: 8,
color: cssVarV2('text/primary'),
},
]);
export const pageContent = style({
padding: '24px 16px',
display: 'flex',
flexDirection: 'column',
gap: 16,
});
export const popupContent = style({
display: 'flex',
flexDirection: 'column',
gap: 16,
});
export const doneButton = style([
bodyEmphasized,
{
color: cssVarV2('button/primary'),
},
]);
export const bottomDoneButton = style({
width: '100%',
});
export const group = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
width: '100%',
});
export const groupTitle = style([
footnoteRegular,
{
color: cssVarV2('text/tertiary'),
padding: 4,
},
]);
export const groupContent = style({
background: cssVarV2('layer/background/primary'),
borderRadius: 12,
padding: 4,
display: 'flex',
flexDirection: 'column',
gap: 8,
});
export const rowItem = style({
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 4px',
});

View File

@@ -0,0 +1,2 @@
export * from './config-modal';
export * from './page-header';

View File

@@ -1,5 +1,5 @@
import { Menu } from '@affine/component';
import { TagItem as TagItemComponent } from '@affine/component/ui/tags';
import { TagItem as TagItemComponent } from '@affine/core/components/tags';
import type { Tag } from '@affine/core/modules/tag';
import { stopPropagation } from '@affine/core/utils';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';

View File

@@ -0,0 +1,3 @@
export * from './tag';
export * from './tags-editor';
export * from './types';

View File

@@ -0,0 +1,8 @@
import { style } from '@vanilla-extract/css';
export const inlineTagsContainer = style({
display: 'flex',
gap: '6px',
flexWrap: 'wrap',
width: '100%',
});

View File

@@ -0,0 +1,47 @@
import type { HTMLAttributes, PropsWithChildren } from 'react';
import * as styles from './inline-tag-list.css';
import { TagItem } from './tag';
import type { TagLike } from './types';
interface InlineTagListProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
onRemoved?: (id: string) => void;
tags: TagLike[];
tagMode: 'inline-tag' | 'db-label';
focusedIndex?: number;
}
export const InlineTagList = ({
children,
focusedIndex,
tags,
onRemoved,
tagMode,
}: PropsWithChildren<InlineTagListProps>) => {
return (
<div className={styles.inlineTagsContainer} data-testid="inline-tags-list">
{tags.map((tag, idx) => {
if (!tag) {
return null;
}
const handleRemoved = onRemoved
? () => {
onRemoved?.(tag.id);
}
: undefined;
return (
<TagItem
key={tag.id}
idx={idx}
focused={focusedIndex === idx}
onRemoved={handleRemoved}
mode={tagMode}
tag={tag}
/>
);
})}
{children}
</div>
);
};

View File

@@ -0,0 +1,3 @@
# Tags Editor
A common module for both page and database tags editing (serviceless).

View File

@@ -0,0 +1,184 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const tagsInlineEditor = style({
selectors: {
'&[data-empty=true]': {
color: cssVar('placeholderColor'),
},
},
});
export const tagsEditorRoot = style({
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: '12px',
});
export const tagsEditorRootMobile = style([
tagsEditorRoot,
{
gap: 20,
},
]);
export const inlineTagsContainer = style({
display: 'flex',
gap: '6px',
flexWrap: 'wrap',
width: '100%',
});
export const tagsMenu = style({
padding: 0,
position: 'relative',
top: 'calc(-3.5px + var(--radix-popper-anchor-height) * -1)',
left: '-3.5px',
width: 'calc(var(--radix-popper-anchor-width) + 16px)',
overflow: 'hidden',
});
export const tagsEditorSelectedTags = style({
display: 'flex',
gap: '4px',
flexWrap: 'wrap',
padding: '10px 12px',
backgroundColor: cssVar('hoverColor'),
minHeight: 42,
selectors: {
[`${tagsEditorRootMobile} &`]: {
borderRadius: 12,
backgroundColor: cssVarV2('layer/background/primary'),
},
},
});
export const searchInput = style({
flexGrow: 1,
padding: '10px 0',
margin: '-10px 0',
border: 'none',
outline: 'none',
fontSize: '14px',
fontFamily: 'inherit',
color: 'inherit',
backgroundColor: 'transparent',
'::placeholder': {
color: cssVar('placeholderColor'),
},
});
export const tagsEditorTagsSelector = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '0 8px 8px 8px',
maxHeight: '400px',
overflow: 'auto',
selectors: {
[`${tagsEditorRootMobile} &`]: {
padding: 0,
},
},
});
export const tagsEditorTagsSelectorHeader = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 8px',
fontSize: cssVar('fontXs'),
fontWeight: 500,
color: cssVar('textSecondaryColor'),
selectors: {
[`${tagsEditorRootMobile} &`]: {
fontSize: cssVar('fontSm'),
},
},
});
export const tagSelectorTagsScrollContainer = style({
overflowX: 'hidden',
position: 'relative',
maxHeight: '200px',
gap: '8px',
selectors: {
[`${tagsEditorRootMobile} &`]: {
borderRadius: 12,
backgroundColor: cssVarV2('layer/background/primary'),
gap: 0,
padding: 4,
},
},
});
export const tagSelectorItem = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: '0 8px',
height: '34px',
gap: 8,
cursor: 'pointer',
borderRadius: '4px',
selectors: {
'&[data-focused=true]': {
backgroundColor: cssVar('hoverColor'),
},
[`${tagsEditorRootMobile} &`]: {
height: 44,
},
[`${tagsEditorRootMobile} &[data-focused="true"]`]: {
height: 44,
backgroundColor: 'transparent',
},
},
});
export const tagEditIcon = style({
opacity: 0,
selectors: {
[`:is(${tagSelectorItem}:hover, ${tagsEditorRootMobile}) &`]: {
opacity: 1,
},
},
});
globalStyle(`${tagEditIcon}[data-state=open]`, {
opacity: 1,
});
export const spacer = style({
flexGrow: 1,
});
export const tagSelectorEmpty = style({
padding: '10px 8px',
fontSize: cssVar('fontSm'),
color: cssVar('textSecondaryColor'),
height: '34px',
selectors: {
[`${tagsEditorRootMobile} &`]: {
height: 44,
},
},
});
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',
});

View File

@@ -0,0 +1,64 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
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 tagColorIconWrapper = style({
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const tagColorIcon = style({
width: 16,
height: 16,
borderRadius: '50%',
});
export const mobileTagColorIcon = style({
width: 20,
height: 20,
borderRadius: '50%',
});
export const mobileTagEditInput = style({
height: 'auto',
borderRadius: 12,
});
export const mobileTagEditDeleteRow = style({
color: cssVarV2('button/error'),
});
export const spacer = style({
flex: 1,
});
export const tagColorIconSelect = style({
opacity: 0,
color: cssVarV2('button/primary'),
width: 20,
height: 20,
selectors: {
'&[data-selected="true"]': {
opacity: 1,
},
},
});

View File

@@ -0,0 +1,220 @@
import {
Input,
Menu,
MenuItem,
type MenuProps,
MenuSeparator,
Scrollable,
} from '@affine/component';
import { useI18n } from '@affine/i18n';
import { DeleteIcon, DoneIcon, TagsIcon } from '@blocksuite/icons/rc';
import type { MouseEventHandler, PropsWithChildren } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ConfigModal } from '../mobile';
import { TagItem } from './tag';
import * as styles from './tag-edit-menu.css';
import type { TagColor, TagLike } from './types';
type TagEditMenuProps = PropsWithChildren<{
onTagDelete: (tagId: string) => void;
colors: TagColor[];
tag: TagLike;
onTagChange: (property: keyof TagLike, value: string) => void;
jumpToTag?: (tagId: string) => void;
}>;
const DesktopTagEditMenu = ({
tag,
onTagDelete,
children,
jumpToTag,
colors,
onTagChange,
}: TagEditMenuProps) => {
const t = useI18n();
const menuProps = useMemo(() => {
const updateTagName = (name: string) => {
if (name.trim() === '') {
return;
}
onTagChange('value', name);
};
return {
contentOptions: {
onClick(e) {
e.stopPropagation();
},
},
items: (
<>
<Input
defaultValue={tag.value}
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={() => {
tag?.id ? onTagDelete(tag.id) : null;
}}
>
{t['Delete']()}
</MenuItem>
{jumpToTag ? (
<MenuItem
prefixIcon={<TagsIcon />}
onClick={() => {
jumpToTag(tag.id);
}}
>
{t['com.affine.page-properties.tags.open-tags-page']()}
</MenuItem>
) : null}
<MenuSeparator />
<Scrollable.Root>
<Scrollable.Viewport className={styles.menuItemList}>
{colors.map(({ name, value: color }, i) => (
<MenuItem
key={i}
checked={tag.color === color}
prefixIcon={
<div key={i} className={styles.tagColorIconWrapper}>
<div
className={styles.tagColorIcon}
style={{
backgroundColor: color,
}}
/>
</div>
}
onClick={() => {
onTagChange('color', color);
}}
>
{name}
</MenuItem>
))}
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
</Scrollable.Viewport>
</Scrollable.Root>
</>
),
} satisfies Partial<MenuProps>;
}, [tag, t, jumpToTag, colors, onTagChange, onTagDelete]);
return <Menu {...menuProps}>{children}</Menu>;
};
const MobileTagEditMenu = ({
tag,
onTagDelete,
children,
colors,
onTagChange,
}: TagEditMenuProps) => {
const [open, setOpen] = useState(false);
const t = useI18n();
const [localTag, setLocalTag] = useState({ ...tag });
useEffect(() => {
setLocalTag({ ...tag });
}, [tag]);
const handleTriggerClick: MouseEventHandler<HTMLDivElement> = useCallback(
e => {
e.stopPropagation();
setOpen(true);
},
[]
);
const handleOnDone = () => {
setOpen(false);
if (localTag.value.trim() !== tag.value) {
onTagChange('value', localTag.value);
}
if (localTag.color !== tag.color) {
onTagChange('color', localTag.color);
}
};
return (
<>
<ConfigModal
open={open}
onOpenChange={setOpen}
title={<TagItem mode="list-tag" tag={tag} />}
onDone={handleOnDone}
>
<Input
inputStyle={{
height: 46,
padding: '12px',
}}
autoSelect={false}
className={styles.mobileTagEditInput}
value={localTag.value}
onChange={e => {
setLocalTag({ ...localTag, value: e });
}}
placeholder={t['Untitled']()}
/>
<ConfigModal.RowGroup title={t['Colors']()}>
{colors.map(({ name, value: color }, i) => (
<ConfigModal.Row
key={i}
onClick={() => {
setLocalTag({ ...localTag, color });
}}
>
<div key={i} className={styles.tagColorIconWrapper}>
<div
className={styles.tagColorIcon}
style={{
backgroundColor: color,
}}
/>
</div>
{name}
<div className={styles.spacer} />
<DoneIcon
className={styles.tagColorIconSelect}
data-selected={localTag.color === color}
/>
</ConfigModal.Row>
))}
</ConfigModal.RowGroup>
<ConfigModal.RowGroup>
<ConfigModal.Row
className={styles.mobileTagEditDeleteRow}
onClick={() => {
onTagDelete(tag.id);
}}
>
<DeleteIcon />
{t['Delete']()}
</ConfigModal.Row>
</ConfigModal.RowGroup>
</ConfigModal>
<div onClick={handleTriggerClick}>{children}</div>
</>
);
};
export const TagEditMenu = BUILD_CONFIG.isMobileEdition
? MobileTagEditMenu
: DesktopTagEditMenu;

View File

@@ -0,0 +1,168 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, style } from '@vanilla-extract/css';
export const hoverMaxWidth = createVar();
export const tagColorVar = createVar();
export const root = style({
position: 'relative',
width: '100%',
height: '100%',
minHeight: '32px',
});
export const tagsContainer = style({
display: 'flex',
alignItems: 'center',
});
export const tagsScrollContainer = style([
tagsContainer,
{
overflowX: 'hidden',
position: 'relative',
height: '100%',
gap: '8px',
},
]);
export const tagsListContainer = style([
tagsContainer,
{
flexWrap: 'wrap',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '4px',
},
]);
export const innerContainer = style({
display: 'flex',
columnGap: '8px',
alignItems: 'center',
position: 'absolute',
height: '100%',
maxWidth: '100%',
transition: 'all 0.2s 0.3s ease-in-out',
selectors: {
[`${root}:hover &`]: {
maxWidth: hoverMaxWidth,
},
},
});
// background with linear gradient hack
export const innerBackdrop = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '100%',
opacity: 0,
transition: 'all 0.2s',
background: `linear-gradient(90deg, transparent 0%, ${cssVar(
'hoverColorFilled'
)} 40%)`,
selectors: {
[`${root}:hover &`]: {
opacity: 1,
},
},
});
export const tag = style({
height: '22px',
display: 'flex',
minWidth: 0,
alignItems: 'center',
justifyContent: 'space-between',
':last-child': {
minWidth: 'max-content',
},
});
export const tagInnerWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 8px',
color: cssVar('textPrimaryColor'),
borderColor: cssVar('borderColor'),
selectors: {
'&[data-focused=true]': {
borderColor: cssVar('primaryColor'),
},
},
});
export const tagInlineMode = style([
tagInnerWrapper,
{
fontSize: 'inherit',
borderRadius: '10px',
columnGap: '4px',
borderWidth: '1px',
borderStyle: 'solid',
background: cssVar('backgroundPrimaryColor'),
maxWidth: '128px',
height: '100%',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
]);
export const tagListItemMode = style([
tag,
{
fontSize: 'inherit',
padding: '4px 12px',
columnGap: '8px',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: '30px',
},
]);
export const tagLabelMode = style([
tag,
{
fontSize: cssVar('fontSm'),
background: tagColorVar,
padding: '0 8px',
borderRadius: 4,
border: `1px solid ${cssVarV2('database/border')}`,
gap: 4,
selectors: {
'&[data-focused=true]': {
borderColor: cssVar('primaryColor'),
},
},
},
]);
export const showMoreTag = style({
fontSize: cssVar('fontH5'),
right: 0,
position: 'sticky',
display: 'inline-flex',
});
export const tagIndicator = style({
width: '8px',
height: '8px',
borderRadius: '50%',
flexShrink: 0,
background: tagColorVar,
});
export const tagLabel = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
userSelect: 'none',
});
export const tagRemove = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 12,
height: 12,
borderRadius: '50%',
flexShrink: 0,
cursor: 'pointer',
':hover': {
background: 'var(--affine-hover-color)',
},
});

View File

@@ -0,0 +1,74 @@
import { CloseIcon } from '@blocksuite/icons/rc';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { type MouseEventHandler, useCallback } from 'react';
import * as styles from './tag.css';
import type { TagLike } from './types';
export interface TagItemProps {
tag: TagLike;
idx?: number;
maxWidth?: number | string;
// @todo(pengx17): better naming
mode: 'inline-tag' | 'list-tag' | 'db-label';
focused?: boolean;
onRemoved?: () => void;
style?: React.CSSProperties;
}
export const TagItem = ({
tag,
idx,
mode,
focused,
onRemoved,
style,
maxWidth,
}: TagItemProps) => {
const { value, color, id } = tag;
const handleRemove: MouseEventHandler<HTMLDivElement> = useCallback(
e => {
e.stopPropagation();
onRemoved?.();
},
[onRemoved]
);
return (
<div
className={styles.tag}
data-idx={idx}
data-tag-id={id}
data-tag-value={value}
title={value}
style={{
...style,
...assignInlineVars({
[styles.tagColorVar]: color,
}),
}}
>
<div
style={{ maxWidth: maxWidth }}
data-focused={focused}
className={clsx({
[styles.tagInlineMode]: mode === 'inline-tag',
[styles.tagListItemMode]: mode === 'list-tag',
[styles.tagLabelMode]: mode === 'db-label',
})}
>
{mode !== 'db-label' ? <div className={styles.tagIndicator} /> : null}
<div className={styles.tagLabel}>{value}</div>
{onRemoved ? (
<div
data-testid="remove-tag-button"
className={styles.tagRemove}
onClick={handleRemove}
>
<CloseIcon />
</div>
) : null}
</div>
</div>
);
};

View File

@@ -0,0 +1,390 @@
import { IconButton, Menu, RowInput, Scrollable } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import { clamp } from 'lodash-es';
import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
import { ConfigModal } from '../mobile';
import { InlineTagList } from './inline-tag-list';
import * as styles from './styles.css';
import { TagItem } from './tag';
import { TagEditMenu } from './tag-edit-menu';
import type { TagColor, TagLike } from './types';
export interface TagsEditorProps {
tags: TagLike[]; // candidates to show in the tag dropdown
selectedTags: string[];
onCreateTag: (name: string, color: string) => TagLike;
onSelectTag: (tagId: string) => void; // activate tag
onDeselectTag: (tagId: string) => void; // deactivate tag
tagColors: TagColor[];
onTagChange: (id: string, property: keyof TagLike, value: string) => void;
onDeleteTag: (id: string) => void; // a candidate to be deleted
jumpToTag?: (id: string) => void;
tagMode: 'inline-tag' | 'db-label';
}
export interface TagsInlineEditorProps extends TagsEditorProps {
placeholder?: string;
className?: string;
readonly?: boolean;
title?: ReactNode; // only used for mobile
}
type TagOption = TagLike | { readonly create: true; readonly value: string };
const isCreateNewTag = (
tagOption: TagOption
): tagOption is { readonly create: true; readonly value: string } => {
return 'create' in tagOption;
};
export const TagsEditor = ({
tags,
selectedTags,
onSelectTag,
onDeselectTag,
onCreateTag,
tagColors,
onDeleteTag: onTagDelete,
onTagChange,
jumpToTag,
tagMode,
}: TagsEditorProps) => {
const t = useI18n();
const [inputValue, setInputValue] = useState('');
const filteredTags = tags.filter(tag => tag.value.includes(inputValue));
const inputRef = useRef<HTMLInputElement>(null);
const exactMatch = filteredTags.find(tag => tag.value === inputValue);
const showCreateTag = !exactMatch && inputValue.trim();
// tag option candidates to show in the tag dropdown
const tagOptions: TagOption[] = useMemo(() => {
if (showCreateTag) {
return [{ create: true, value: inputValue } as const, ...filteredTags];
} else {
return filteredTags;
}
}, [filteredTags, inputValue, showCreateTag]);
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
const [focusedInlineIndex, setFocusedInlineIndex] = useState<number>(
selectedTags.length
);
// -1: no focus
const safeFocusedIndex = clamp(focusedIndex, -1, tagOptions.length - 1);
// inline tags focus index can go beyond the length of tagIds
// using -1 and tagIds.length to make keyboard navigation easier
const safeInlineFocusedIndex = clamp(
focusedInlineIndex,
-1,
selectedTags.length
);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const onInputChange = useCallback((value: string) => {
setInputValue(value);
}, []);
const onToggleTag = useCallback(
(id: string) => {
if (!selectedTags.includes(id)) {
onSelectTag(id);
} else {
onDeselectTag(id);
}
},
[selectedTags, onSelectTag, onDeselectTag]
);
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, []);
const [nextColor, rotateNextColor] = useReducer(
color => {
const idx = tagColors.findIndex(c => c.value === color);
return tagColors[(idx + 1) % tagColors.length].value;
},
tagColors[Math.floor(Math.random() * tagColors.length)].value
);
const handleCreateTag = useCallback(
(name: string) => {
rotateNextColor();
const newTag = onCreateTag(name.trim(), nextColor);
return newTag.id;
},
[onCreateTag, nextColor]
);
const onSelectTagOption = useCallback(
(tagOption: TagOption) => {
const id = isCreateNewTag(tagOption)
? handleCreateTag(tagOption.value)
: tagOption.id;
onToggleTag(id);
setInputValue('');
focusInput();
setFocusedIndex(-1);
setFocusedInlineIndex(selectedTags.length + 1);
},
[handleCreateTag, onToggleTag, focusInput, selectedTags.length]
);
const onEnter = useCallback(() => {
if (safeFocusedIndex >= 0) {
onSelectTagOption(tagOptions[safeFocusedIndex]);
}
}, [onSelectTagOption, safeFocusedIndex, tagOptions]);
const handleUntag = useCallback(
(id: string) => {
onToggleTag(id);
focusInput();
},
[onToggleTag, focusInput]
);
const onInputKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && inputValue === '' && selectedTags.length) {
const index =
safeInlineFocusedIndex < 0 ||
safeInlineFocusedIndex >= selectedTags.length
? selectedTags.length - 1
: safeInlineFocusedIndex;
const tagToRemove = selectedTags.at(index);
if (tagToRemove) {
onDeselectTag(tagToRemove);
}
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const newFocusedIndex = clamp(
safeFocusedIndex + (e.key === 'ArrowUp' ? -1 : 1),
0,
tagOptions.length - 1
);
scrollContainerRef.current
?.querySelector(
`.${styles.tagSelectorItem}:nth-child(${newFocusedIndex + 1})`
)
?.scrollIntoView({ block: 'nearest' });
setFocusedIndex(newFocusedIndex);
// reset inline focus
setFocusedInlineIndex(selectedTags.length + 1);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const newItemToFocus =
e.key === 'ArrowLeft'
? safeInlineFocusedIndex - 1
: safeInlineFocusedIndex + 1;
e.preventDefault();
setFocusedInlineIndex(newItemToFocus);
// reset tag list focus
setFocusedIndex(-1);
}
},
[
inputValue,
safeInlineFocusedIndex,
selectedTags,
onDeselectTag,
safeFocusedIndex,
tagOptions.length,
]
);
return (
<div
data-testid="tags-editor-popup"
className={
BUILD_CONFIG.isMobileEdition
? styles.tagsEditorRootMobile
: styles.tagsEditorRoot
}
>
<div className={styles.tagsEditorSelectedTags}>
<InlineTagList
tagMode={tagMode}
tags={tags.filter(tag => selectedTags.includes(tag.id))}
focusedIndex={safeInlineFocusedIndex}
onRemoved={handleUntag}
>
<RowInput
ref={inputRef}
value={inputValue}
onChange={onInputChange}
onKeyDown={onInputKeyDown}
onEnter={onEnter}
autoFocus
className={styles.searchInput}
placeholder="Type here ..."
/>
</InlineTagList>
</div>
<div className={styles.tagsEditorTagsSelector}>
<div className={styles.tagsEditorTagsSelectorHeader}>
{t['com.affine.page-properties.tags.selector-header-title']()}
</div>
<Scrollable.Root>
<Scrollable.Viewport
ref={scrollContainerRef}
className={styles.tagSelectorTagsScrollContainer}
>
{tagOptions.length === 0 && (
<div className={styles.tagSelectorEmpty}>Nothing here yet</div>
)}
{tagOptions.map((tag, idx) => {
const commonProps = {
...(safeFocusedIndex === idx ? { focused: 'true' } : {}),
onClick: () => onSelectTagOption(tag),
onMouseEnter: () => setFocusedIndex(idx),
['data-testid']: 'tag-selector-item',
['data-focused']: safeFocusedIndex === idx,
className: styles.tagSelectorItem,
};
if (isCreateNewTag(tag)) {
return (
<div key={tag.value + '.' + idx} {...commonProps}>
{t['Create']()}{' '}
<TagItem
mode={tagMode}
tag={{
id: 'create-new-tag',
value: inputValue,
color: nextColor,
}}
/>
</div>
);
} else {
return (
<div
key={tag.id}
{...commonProps}
data-tag-id={tag.id}
data-tag-value={tag.value}
>
<TagItem maxWidth="100%" tag={tag} mode={tagMode} />
<div className={styles.spacer} />
<TagEditMenu
tag={tag}
onTagDelete={onTagDelete}
onTagChange={(property, value) => {
onTagChange(tag.id, property, value);
}}
jumpToTag={jumpToTag}
colors={tagColors}
>
<IconButton className={styles.tagEditIcon}>
<MoreHorizontalIcon />
</IconButton>
</TagEditMenu>
</div>
);
}
})}
</Scrollable.Viewport>
<Scrollable.Scrollbar style={{ transform: 'translateX(6px)' }} />
</Scrollable.Root>
</div>
</div>
);
};
const MobileInlineEditor = ({
readonly,
placeholder,
className,
title,
...props
}: TagsInlineEditorProps) => {
const [editing, setEditing] = useState(false);
const empty = !props.selectedTags || props.selectedTags.length === 0;
const selectedTags = useMemo(() => {
return props.selectedTags
.map(id => props.tags.find(tag => tag.id === id))
.filter(tag => tag !== undefined);
}, [props.selectedTags, props.tags]);
return (
<>
<ConfigModal
title={title}
open={editing}
onOpenChange={setEditing}
onBack={() => setEditing(false)}
>
<TagsEditor {...props} />
</ConfigModal>
<div
className={clsx(styles.tagsInlineEditor, className)}
data-empty={empty}
data-readonly={readonly}
onClick={() => setEditing(true)}
>
{empty ? (
placeholder
) : (
<InlineTagList {...props} tags={selectedTags} onRemoved={undefined} />
)}
</div>
</>
);
};
const DesktopTagsInlineEditor = ({
readonly,
placeholder,
className,
...props
}: TagsInlineEditorProps) => {
const empty = !props.selectedTags || props.selectedTags.length === 0;
const selectedTags = useMemo(() => {
return props.selectedTags
.map(id => props.tags.find(tag => tag.id === id))
.filter(tag => tag !== undefined);
}, [props.selectedTags, props.tags]);
return (
<Menu
contentOptions={{
side: 'bottom',
align: 'start',
sideOffset: 0,
avoidCollisions: false,
className: styles.tagsMenu,
onClick(e) {
e.stopPropagation();
},
}}
items={<TagsEditor {...props} />}
>
<div
className={clsx(styles.tagsInlineEditor, className)}
data-empty={empty}
data-readonly={readonly}
>
{empty ? (
placeholder
) : (
<InlineTagList
{...props}
title=""
tags={selectedTags}
onRemoved={undefined}
/>
)}
</div>
</Menu>
);
};
export const TagsInlineEditor = BUILD_CONFIG.isMobileEdition
? MobileInlineEditor
: DesktopTagsInlineEditor;

View File

@@ -0,0 +1,11 @@
export interface TagLike {
id: string;
value: string; // value is the tag name
color: string; // css color value
}
export interface TagColor {
id: string;
value: string; // css color value
name?: string; // display name
}

View File

@@ -1,6 +1,5 @@
export * from './app-tabs';
export * from './doc-card';
export * from './page-header';
export * from './rename';
export * from './search-input';
export * from './search-result';

View File

@@ -5,6 +5,7 @@ import {
Scrollable,
useThemeColorMeta,
} from '@affine/component';
import { PageHeader } from '@affine/core/components/mobile';
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
@@ -18,7 +19,6 @@ import {
useState,
} from 'react';
import { PageHeader } from '../../components/page-header';
import * as styles from './generic.css';
export interface GenericSelectorProps {

View File

@@ -1,5 +1,3 @@
import { footnoteRegular } from '@toeverything/theme/typography';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const group = style({
@@ -8,20 +6,3 @@ export const group = style({
gap: 4,
width: '100%',
});
export const title = style([
footnoteRegular,
{
padding: '0px 8px',
color: cssVarV2('text/tertiary'),
},
]);
export const content = style({
background: cssVarV2('layer/background/primary'),
borderRadius: 12,
padding: '10px 16px',
display: 'flex',
flexDirection: 'column',
gap: 8,
});

View File

@@ -1,3 +1,4 @@
import { ConfigModal } from '@affine/core/components/mobile';
import clsx from 'clsx';
import {
type CSSProperties,
@@ -21,15 +22,16 @@ export const SettingGroup = forwardRef<HTMLDivElement, SettingGroupProps>(
ref
) {
return (
<div className={clsx(styles.group, className)} ref={ref} {...attrs}>
{title ? <h6 className={styles.title}>{title}</h6> : null}
<div
className={clsx(styles.content, contentClassName)}
style={contentStyle}
>
{children}
</div>
</div>
<ConfigModal.RowGroup
{...attrs}
ref={ref}
title={title}
className={clsx(styles.group, className)}
contentClassName={contentClassName}
contentStyle={contentStyle}
>
{children}
</ConfigModal.RowGroup>
);
}
);

View File

@@ -1,4 +1,4 @@
import { Modal } from '@affine/component';
import { ConfigModal } from '@affine/core/components/mobile';
import { AuthService } from '@affine/core/modules/cloud';
import type {
DialogComponentProps,
@@ -6,10 +6,8 @@ import type {
} from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useEffect } from 'react';
import { PageHeader } from '../../components';
import { AboutGroup } from './about';
import { AppearanceGroup } from './appearance';
import { OthersGroup } from './others';
@@ -17,50 +15,34 @@ import * as styles from './style.css';
import { UserProfile } from './user-profile';
import { UserUsage } from './user-usage';
const MobileSetting = ({ onClose }: { onClose: () => void }) => {
const t = useI18n();
const MobileSetting = () => {
const session = useService(AuthService).session;
useEffect(() => session.revalidate(), [session]);
return (
<>
<PageHeader back backAction={onClose}>
<span className={styles.pageTitle}>
{t['com.affine.mobile.setting.header-title']()}
</span>
</PageHeader>
<div className={styles.root}>
<UserProfile />
<UserUsage />
<AppearanceGroup />
<AboutGroup />
<OthersGroup />
</div>
</>
<div className={styles.root}>
<UserProfile />
<UserUsage />
<AppearanceGroup />
<AboutGroup />
<OthersGroup />
</div>
);
};
export const SettingDialog = ({
close,
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['setting']>) => {
const t = useI18n();
return (
<Modal
fullScreen
animation="slideBottom"
<ConfigModal
title={t['com.affine.mobile.setting.header-title']()}
open
onOpenChange={() => close()}
contentOptions={{
style: {
padding: 0,
overflowY: 'auto',
backgroundColor: cssVarV2('layer/background/secondary'),
},
}}
withoutCloseButton
onBack={close}
>
<MobileSetting onClose={close} />
</Modal>
<MobileSetting />
</ConfigModal>
);
};

View File

@@ -1,3 +1,4 @@
import { ConfigModal } from '@affine/core/components/mobile';
import { DualLinkIcon } from '@blocksuite/icons/rc';
import type { PropsWithChildren, ReactNode } from 'react';
@@ -9,13 +10,13 @@ export const RowLayout = ({
href,
}: PropsWithChildren<{ label: ReactNode; href?: string }>) => {
const content = (
<div className={styles.baseSettingItem}>
<ConfigModal.Row className={styles.baseSettingItem}>
<div className={styles.baseSettingItemName}>{label}</div>
<div className={styles.baseSettingItemAction}>
{children ||
(href ? <DualLinkIcon className={styles.linkIcon} /> : null)}
</div>
</div>
</ConfigModal.Row>
);
return href ? (

View File

@@ -5,7 +5,6 @@ import { style } from '@vanilla-extract/css';
export const pageTitle = style([bodyEmphasized]);
export const root = style({
padding: '24px 16px',
display: 'flex',
flexDirection: 'column',
gap: 16,
@@ -16,8 +15,9 @@ export const baseSettingItem = style({
justifyContent: 'space-between',
alignItems: 'center',
gap: 32,
padding: '8px 0',
padding: 8,
});
export const baseSettingItemName = style([
bodyRegular,
{
@@ -26,6 +26,7 @@ export const baseSettingItemName = style([
whiteSpace: 'nowrap',
},
]);
export const baseSettingItemAction = style([
baseSettingItemName,
{

View File

@@ -77,7 +77,7 @@ const UsagePanel = () => {
);
return (
<SettingGroup title="Storage">
<SettingGroup title="Storage" contentStyle={{ padding: '10px 16px' }}>
<CloudUsage />
{serverFeatures?.copilot ? <AiUsage /> : null}
</SettingGroup>

View File

@@ -2,9 +2,7 @@ import { bodyRegular, caption1Regular } from '@toeverything/theme/typography';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const progressRoot = style({
paddingBottom: 8,
});
export const progressRoot = style({});
export const progressInfoRow = style({
display: 'flex',
justifyContent: 'space-between',

View File

@@ -7,6 +7,7 @@ import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-
import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-state';
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { PageHeader } from '@affine/core/components/mobile';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper';
import { EditorService } from '@affine/core/modules/editor';
@@ -42,7 +43,7 @@ import dayjs from 'dayjs';
import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { AppTabs, PageHeader } from '../../../components';
import { AppTabs } from '../../../components';
import { JournalDatePicker } from './journal-date-picker';
import * as styles from './mobile-detail-page.css';
import { PageHeaderMenuButton } from './page-header-more-button';
@@ -215,12 +216,12 @@ const notFound = (
</>
);
const JournalDetailPage = ({
const MobileDetailPage = ({
pageId,
date,
}: {
pageId: string;
date: string;
date?: string;
}) => {
const journalService = useService(JournalService);
const { openJournal } = useJournalRouteHelper();
@@ -250,40 +251,23 @@ const JournalDetailPage = ({
</>
}
>
<span className={bodyEmphasized}>
{i18nTime(dayjs(date), { absolute: { accuracy: 'month' } })}
</span>
{date ? (
<span className={bodyEmphasized}>
{i18nTime(dayjs(date), { absolute: { accuracy: 'month' } })}
</span>
) : null}
</PageHeader>
<JournalDatePicker
date={date}
onChange={handleDateChange}
withDotDates={allJournalDates}
/>
<DetailPageImpl />
<AppTabs background={cssVarV2('layer/background/primary')} />
</DetailPageWrapper>
</div>
);
};
const NormalDetailPage = ({ pageId }: { pageId: string }) => {
return (
<div className={styles.root}>
<DetailPageWrapper
skeleton={skeleton}
notFound={notFound}
pageId={pageId}
>
<PageHeader
back
className={styles.header}
suffix={
<>
<PageHeaderShareButton />
<PageHeaderMenuButton />
</>
}
/>
{date ? (
<JournalDatePicker
date={date}
onChange={handleDateChange}
withDotDates={allJournalDates}
/>
) : null}
<DetailPageImpl />
{date ? (
<AppTabs background={cssVarV2('layer/background/primary')} />
) : null}
</DetailPageWrapper>
</div>
);
@@ -300,9 +284,5 @@ export const Component = () => {
return null;
}
return journalDate ? (
<JournalDetailPage pageId={pageId} date={journalDate} />
) : (
<NormalDetailPage pageId={pageId} />
);
return <MobileDetailPage pageId={pageId} date={journalDate} />;
};

View File

@@ -1,6 +1,7 @@
import { IconButton, notify } from '@affine/component';
import {
MenuSeparator,
MenuSub,
MobileMenu,
MobileMenuItem,
} from '@affine/component/ui/menu';
@@ -42,6 +43,7 @@ export const PageHeaderMenuButton = () => {
editorService.editor.doc.meta$.map(meta => meta.trash)
);
const primaryMode = useLiveData(editorService.editor.doc.primaryMode$);
const title = useLiveData(editorService.editor.doc.title$);
const { favorite, toggleFavorite } = useFavorite(docId);
@@ -104,14 +106,16 @@ export const PageHeaderMenuButton = () => {
: t['com.affine.favoritePageOperation.add']()}
</MobileMenuItem>
<MenuSeparator />
<MobileMenu items={<DocInfoSheet docId={docId} />}>
<MobileMenuItem
prefixIcon={<InformationIcon />}
onClick={preventDefault}
>
<span>{t['com.affine.page-properties.page-info.view']()}</span>
</MobileMenuItem>
</MobileMenu>
<MenuSub
triggerOptions={{
prefixIcon: <InformationIcon />,
onClick: preventDefault,
}}
title={title ?? t['unnamed']()}
items={<DocInfoSheet docId={docId} />}
>
<span>{t['com.affine.page-properties.page-info.view']()}</span>
</MenuSub>
<MobileMenu
items={
<div className={styles.outlinePanel}>

View File

@@ -1,13 +1,49 @@
import { style } from '@vanilla-extract/css';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const linksRow = style({
export const scrollableRoot = style({
padding: '0 16px',
display: 'flex',
flexDirection: 'column',
});
export const timeRow = style({
padding: '0 16px',
});
export const linksRow = style({});
export const timeRow = style({});
export const scrollBar = style({
width: 6,
transform: 'translateX(-4px)',
});
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'),
});

View File

@@ -1,25 +1,43 @@
import { Divider, Scrollable } from '@affine/component';
import {
Button,
Divider,
Menu,
PropertyCollapsibleContent,
PropertyCollapsibleSection,
Scrollable,
} from '@affine/component';
import {
type DefaultOpenProperty,
DocPropertiesTable,
DocPropertyRow,
} from '@affine/core/components/doc-properties';
import { CreatePropertyMenuItems } from '@affine/core/components/doc-properties/menu/create-doc-property';
import { LinksRow } from '@affine/core/desktop/dialogs/doc-info/links-row';
import { TimeRow } from '@affine/core/desktop/dialogs/doc-info/time-row';
import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { useI18n } from '@affine/i18n';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { Suspense, useMemo } from 'react';
import { PlusIcon } from '@blocksuite/icons/rc';
import {
type DocCustomPropertyInfo,
DocsService,
LiveData,
useLiveData,
useServices,
} from '@toeverything/infra';
import { Suspense, useCallback, useMemo, useState } from 'react';
import * as styles from './doc-info.css';
export const DocInfoSheet = ({
docId,
defaultOpenProperty,
}: {
docId: string;
defaultOpenProperty?: DefaultOpenProperty;
}) => {
const docsSearchService = useService(DocsSearchService);
const { docsSearchService, docsService } = useServices({
DocsSearchService,
DocsService,
});
const t = useI18n();
const links = useLiveData(
@@ -35,8 +53,16 @@ export const DocInfoSheet = ({
)
);
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
const onPropertyAdded = useCallback((property: DocCustomPropertyInfo) => {
setNewPropertyId(property.id);
}, []);
const properties = useLiveData(docsService.propertyList.sortedProperties$);
return (
<Scrollable.Root>
<Scrollable.Root className={styles.scrollableRoot}>
<Scrollable.Viewport data-testid="doc-info-menu">
<Suspense>
<TimeRow docId={docId} className={styles.timeRow} />
@@ -61,7 +87,58 @@ export const DocInfoSheet = ({
<Divider size="thinner" />
</>
) : null}
<DocPropertiesTable defaultOpenProperty={defaultOpenProperty} />
<PropertyCollapsibleSection
title={t.t('com.affine.workspace.properties')}
>
<PropertyCollapsibleContent
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(),
})
}
>
{properties.map(property => (
<DocPropertyRow
key={property.id}
propertyInfo={property}
defaultOpenEditMenu={newPropertyId === property.id}
/>
))}
<Menu
items={<CreatePropertyMenuItems onCreated={onPropertyAdded} />}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.addPropertyButton}
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
</PropertyCollapsibleContent>
</PropertyCollapsibleSection>
<Divider size="thinner" />
<DocDatabaseBacklinkInfo />
</Suspense>
</Scrollable.Viewport>
<Scrollable.Scrollbar className={styles.scrollBar} />

View File

@@ -1,10 +1,10 @@
import { IconButton, MobileMenu } from '@affine/component';
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty';
import { PageHeader } from '@affine/core/components/mobile';
import { isEmptyCollection } from '@affine/core/desktop/pages/workspace/collection';
import type { Collection } from '@affine/env/filter';
import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
import { PageHeader } from '../../../components';
import { AllDocList } from '../doc/list';
import { AllDocsMenu } from '../doc/menu';
import * as styles from './detail.css';

View File

@@ -1,9 +1,9 @@
import { IconButton, MobileMenu } from '@affine/component';
import { PageHeader } from '@affine/core/components/mobile';
import type { Tag } from '@affine/core/modules/tag';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';
import { PageHeader } from '../../../components';
import { AllDocsMenu } from '../doc';
import * as styles from './detail.css';

View File

@@ -33,6 +33,26 @@ export const textarea = style({
},
});
const mobileTextareaBase = {
fontSize: 17,
lineHeight: '26px',
padding: 12,
};
export const mobileTextareaPlain = style([
textarea,
mobileTextareaBase,
{
position: 'relative',
fontSize: 14,
lineHeight: '22px',
height: 'auto',
padding: 0,
},
]);
export const mobileTextarea = style([textarea, mobileTextareaBase]);
export const container = style({
position: 'relative',
outline: `1px solid transparent`,
@@ -54,3 +74,11 @@ export const textInvisible = style({
fontSize: cssVar('fontSm'),
lineHeight: '22px',
});
export const mobileTextInvisible = style([textInvisible, mobileTextareaBase]);
export const mobileTextareaWrapper = style({
position: 'relative',
background: cssVarV2('layer/background/primary'),
borderRadius: 12,
});

View File

@@ -1,7 +1,9 @@
import { PropertyValue } from '@affine/component';
import { AffinePageReference } from '@affine/core/components/affine/reference-link';
import { ConfigModal } from '@affine/core/components/mobile';
import { resolveLinkToDoc } from '@affine/core/modules/navigation';
import { useI18n } from '@affine/i18n';
import { LinkIcon } from '@blocksuite/icons/rc';
import type { LiveData } from '@toeverything/infra';
import { useLiveData } from '@toeverything/infra';
import {
@@ -99,49 +101,84 @@ export const LinkCell = ({
const t = useI18n();
const editingElement = (
<>
<textarea
ref={ref}
onKeyDown={onKeydown}
className={
!BUILD_CONFIG.isMobileEdition
? styles.textarea
: styles.mobileTextarea
}
onBlur={commitChange}
value={tempValue || ''}
onChange={handleOnChange}
data-empty={!tempValue}
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
/>
<div
className={
!BUILD_CONFIG.isMobileEdition
? styles.textInvisible
: styles.mobileTextInvisible
}
>
{tempValue}
{tempValue?.endsWith('\n') || !tempValue ? <br /> : null}
</div>
</>
);
const name = useLiveData(cell.property.name$);
return (
<PropertyValue
className={styles.container}
isEmpty={isEmpty}
onClick={onClick}
>
{!editing ? (
resolvedDocLink ? (
<AffinePageReference
pageId={resolvedDocLink.docId}
params={resolvedDocLink.params}
/>
) : (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
onClick={onLinkClick}
className={styles.link}
>
{link?.replace(/^https?:\/\//, '').trim()}
</a>
)
) : (
<>
<textarea
ref={ref}
onKeyDown={onKeydown}
className={styles.textarea}
onBlur={commitChange}
value={tempValue || ''}
onChange={handleOnChange}
data-empty={!tempValue}
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
/>
<div className={styles.textInvisible}>
{tempValue}
{tempValue?.endsWith('\n') || !tempValue ? <br /> : null}
</div>
</>
)}
</PropertyValue>
<>
<PropertyValue
className={styles.container}
isEmpty={isEmpty}
onClick={onClick}
>
{!editing ? (
resolvedDocLink ? (
<AffinePageReference
pageId={resolvedDocLink.docId}
params={resolvedDocLink.params}
/>
) : (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
onClick={onLinkClick}
className={styles.link}
>
{link?.replace(/^https?:\/\//, '').trim()}
</a>
)
) : !BUILD_CONFIG.isMobileEdition ? (
editingElement
) : null}
</PropertyValue>
{BUILD_CONFIG.isMobileEdition ? (
<ConfigModal
open={editing}
onOpenChange={setEditing}
onBack={() => {
setEditing(false);
}}
title={
<>
<LinkIcon />
{name}
</>
}
>
<div className={styles.mobileTextareaWrapper}>{editingElement}</div>
</ConfigModal>
) : null}
</>
);
};

View File

@@ -1,11 +1,13 @@
import { Progress, PropertyValue } from '@affine/component';
import { ConfigModal } from '@affine/core/components/mobile';
import { ProgressIcon } from '@blocksuite/icons/rc';
import type { LiveData } from '@toeverything/infra';
import { useLiveData } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import type { DatabaseCellRendererProps } from '../../../types';
export const ProgressCell = ({
const DesktopProgressCell = ({
cell,
dataSource,
rowId,
@@ -34,3 +36,62 @@ export const ProgressCell = ({
</PropertyValue>
);
};
const MobileProgressCell = ({
cell,
dataSource,
rowId,
onChange,
}: DatabaseCellRendererProps) => {
const value = useLiveData(cell.value$ as LiveData<number>);
const isEmpty = value === undefined;
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
setLocalValue(value);
}, [value]);
const [open, setOpen] = useState(false);
const name = useLiveData(cell.property.name$);
const commitChange = () => {
dataSource.cellValueChange(rowId, cell.id, localValue);
onChange?.(localValue);
setOpen(false);
};
return (
<>
<PropertyValue
isEmpty={isEmpty}
hoverable={false}
onClick={() => setOpen(true)}
>
<Progress value={value} />
</PropertyValue>
<ConfigModal
variant="popup"
open={open}
onOpenChange={setOpen}
onDone={commitChange}
title={
<>
<ProgressIcon />
{name}
</>
}
>
<Progress
value={localValue}
onChange={v => {
setLocalValue(v);
}}
/>
</ConfigModal>
</>
);
};
export const ProgressCell = BUILD_CONFIG.isMobileEdition
? MobileProgressCell
: DesktopProgressCell;

View File

@@ -1,12 +1,14 @@
import { PropertyValue } from '@affine/component';
import { ConfigModal } from '@affine/core/components/mobile';
import type { BlockStdScope } from '@blocksuite/affine/block-std';
import {
DefaultInlineManagerExtension,
RichText,
} from '@blocksuite/affine/blocks';
import type { Doc } from '@blocksuite/affine/store';
import { TextIcon } from '@blocksuite/icons/rc';
import { type LiveData, useLiveData } from '@toeverything/infra';
import { useEffect, useRef } from 'react';
import { type CSSProperties, useEffect, useRef, useState } from 'react';
import type * as Y from 'yjs';
import type { DatabaseCellRendererProps } from '../../../types';
@@ -37,11 +39,12 @@ const renderRichText = ({
return richText;
};
export const RichTextCell = ({
const RichTextInput = ({
cell,
dataSource,
onChange,
}: DatabaseCellRendererProps) => {
style,
}: DatabaseCellRendererProps & { style?: CSSProperties }) => {
const std = useBlockStdScope(dataSource.doc);
const text = useLiveData(cell.value$ as LiveData<Y.Text>);
const ref = useRef<HTMLDivElement>(null);
@@ -64,5 +67,68 @@ export const RichTextCell = ({
}
return () => {};
}, [dataSource.doc, onChange, std, text]);
return <PropertyValue ref={ref}></PropertyValue>;
return <div ref={ref} style={style} />;
};
const DesktopRichTextCell = ({
cell,
dataSource,
onChange,
rowId,
}: DatabaseCellRendererProps) => {
return (
<PropertyValue>
<RichTextInput
cell={cell}
dataSource={dataSource}
onChange={onChange}
rowId={rowId}
/>
</PropertyValue>
);
};
const MobileRichTextCell = ({
cell,
dataSource,
onChange,
rowId,
}: DatabaseCellRendererProps) => {
const [open, setOpen] = useState(false);
const name = useLiveData(cell.property.name$);
return (
<PropertyValue onClick={() => setOpen(true)}>
<ConfigModal
onBack={() => setOpen(false)}
open={open}
onOpenChange={setOpen}
title={
<>
<TextIcon />
{name}
</>
}
>
<ConfigModal.RowGroup>
<RichTextInput
cell={cell}
dataSource={dataSource}
onChange={onChange}
rowId={rowId}
style={{ padding: 12 }}
/>
</ConfigModal.RowGroup>
</ConfigModal>
<RichTextInput
cell={cell}
dataSource={dataSource}
onChange={onChange}
rowId={rowId}
/>
</PropertyValue>
);
};
export const RichTextCell = BUILD_CONFIG.isMobileEdition
? MobileRichTextCell
: DesktopRichTextCell;

View File

@@ -1,7 +1,7 @@
/* eslint-disable rxjs/finnish */
import { PropertyValue } from '@affine/component';
import { type TagLike, TagsInlineEditor } from '@affine/component/ui/tags';
import { type TagLike, TagsInlineEditor } from '@affine/core/components/tags';
import { TagService } from '@affine/core/modules/tag';
import {
affineLabelToDatabaseTagColor,
@@ -9,6 +9,7 @@ import {
} from '@affine/core/modules/tag/entities/utils';
import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks';
import type { SelectTag } from '@blocksuite/data-view';
import { MultiSelectIcon, SingleSelectIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback, useMemo } from 'react';
@@ -221,6 +222,8 @@ const BlocksuiteDatabaseSelector = ({
[dataSource, selectCell]
);
const propertyName = useLiveData(cell.property.name$);
return (
<TagsInlineEditor
tagMode="db-label"
@@ -233,6 +236,12 @@ const BlocksuiteDatabaseSelector = ({
onSelectTag={onSelectTag}
tagColors={tagColors}
onTagChange={onTagChange}
title={
<>
{multiple ? <MultiSelectIcon /> : <SingleSelectIcon />}
{propertyName}
</>
}
/>
);
};

View File

@@ -34,9 +34,31 @@ export const docRefLink = style({
color: cssVarV2('text/tertiary'),
});
export const mobileDocRefLink = style([
docRefLink,
{
maxWidth: '110px',
minWidth: '60px',
},
]);
export const cellList = style({
padding: '0 2px',
display: 'flex',
flexDirection: 'column',
gap: 4,
});
export const databaseNameWrapper = style({
display: 'flex',
alignItems: 'center',
gap: 4,
overflow: 'hidden',
});
export const databaseName = style({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
display: 'inline-block',
});

View File

@@ -122,12 +122,24 @@ const DatabaseBacklinkRow = ({
return (
<PropertyCollapsibleSection
title={(row.databaseName || t['unnamed']()) + ' ' + t['properties']()}
// @ts-expect-error fix type
title={
<span className={styles.databaseNameWrapper}>
<span className={styles.databaseName}>
{row.databaseName || t['unnamed']()}
</span>
{t['properties']()}
</span>
}
defaultCollapsed={!defaultOpen}
icon={<DatabaseTableViewIcon />}
suffix={
<AffinePageReference
className={styles.docRefLink}
className={
BUILD_CONFIG.isMobileEdition
? styles.mobileDocRefLink
: styles.docRefLink
}
pageId={row.docId}
params={pageRefParams}
Icon={PageIcon}