mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ export const iconsRow = style({
|
||||
fontWeight: 500,
|
||||
padding: '0 6px',
|
||||
gap: '8px',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
export const iconButton = style({
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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']()}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ export const date = style({
|
||||
lineHeight: '22px',
|
||||
padding: '0 4px',
|
||||
borderRadius: 4,
|
||||
whiteSpace: 'nowrap',
|
||||
':hover': {
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
});
|
||||
2
packages/frontend/core/src/components/mobile/index.ts
Normal file
2
packages/frontend/core/src/components/mobile/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './config-modal';
|
||||
export * from './page-header';
|
||||
@@ -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';
|
||||
|
||||
3
packages/frontend/core/src/components/tags/index.ts
Normal file
3
packages/frontend/core/src/components/tags/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './tag';
|
||||
export * from './tags-editor';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,8 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const inlineTagsContainer = style({
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
flexWrap: 'wrap',
|
||||
width: '100%',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
3
packages/frontend/core/src/components/tags/readme.md
Normal file
3
packages/frontend/core/src/components/tags/readme.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Tags Editor
|
||||
|
||||
A common module for both page and database tags editing (serviceless).
|
||||
184
packages/frontend/core/src/components/tags/styles.css.ts
Normal file
184
packages/frontend/core/src/components/tags/styles.css.ts
Normal 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',
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
220
packages/frontend/core/src/components/tags/tag-edit-menu.tsx
Normal file
220
packages/frontend/core/src/components/tags/tag-edit-menu.tsx
Normal 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;
|
||||
168
packages/frontend/core/src/components/tags/tag.css.ts
Normal file
168
packages/frontend/core/src/components/tags/tag.css.ts
Normal 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)',
|
||||
},
|
||||
});
|
||||
74
packages/frontend/core/src/components/tags/tag.tsx
Normal file
74
packages/frontend/core/src/components/tags/tag.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
390
packages/frontend/core/src/components/tags/tags-editor.tsx
Normal file
390
packages/frontend/core/src/components/tags/tags-editor.tsx
Normal 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;
|
||||
11
packages/frontend/core/src/components/tags/types.ts
Normal file
11
packages/frontend/core/src/components/tags/types.ts
Normal 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
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -77,7 +77,7 @@ const UsagePanel = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingGroup title="Storage">
|
||||
<SettingGroup title="Storage" contentStyle={{ padding: '10px 16px' }}>
|
||||
<CloudUsage />
|
||||
{serverFeatures?.copilot ? <AiUsage /> : null}
|
||||
</SettingGroup>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user