feat(core): doc database properties (#8520)

fix AF-1454

1. move inline tags editor to components
2. add progress component
3. adjust doc properties styles for desktop
4. subscribe bs database links and display in doc info
5. move update/create dates to doc info
6. a trivial e2e test

<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/T2klNLEk0wxLh4NRDzhk/eed266c1-fdac-4f0e-baa9-4aa00d14a2e8.mp4">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/T2klNLEk0wxLh4NRDzhk/eed266c1-fdac-4f0e-baa9-4aa00d14a2e8.mp4">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/eed266c1-fdac-4f0e-baa9-4aa00d14a2e8.mp4">10月23日.mp4</video>
This commit is contained in:
pengx17
2024-10-24 07:38:45 +00:00
parent f7dc65e170
commit 4b6c4ed546
67 changed files with 3166 additions and 941 deletions

View File

@@ -8,6 +8,7 @@ import { track } from '@affine/track';
import type { DocMode } from '@blocksuite/affine/blocks';
import type { DocCollection } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { nanoid } from 'nanoid';
import {
type PropsWithChildren,
@@ -24,10 +25,12 @@ export function AffinePageReference({
pageId,
wrapper: Wrapper,
params,
className,
}: {
pageId: string;
wrapper?: React.ComponentType<PropsWithChildren>;
params?: URLSearchParams;
className?: string;
}) {
const docDisplayMetaService = useService(DocDisplayMetaService);
const journalService = useService(JournalService);
@@ -108,7 +111,7 @@ export function AffinePageReference({
ref={ref}
to={`/${pageId}${query}`}
onClick={onClick}
className={styles.pageReferenceLink}
className={clsx(styles.pageReferenceLink, className)}
>
{Wrapper ? <Wrapper>{el}</Wrapper> : el}
</WorkbenchLink>

View File

@@ -14,11 +14,15 @@ export const titleContainer = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
marginBottom: 20,
padding: 2,
});
export const titleStyle = style({
fontSize: cssVar('fontH6'),
fontSize: cssVar('fontH2'),
fontWeight: '600',
minHeight: 42,
padding: 0,
});
export const rowNameContainer = style({

View File

@@ -4,10 +4,14 @@ import {
type InlineEditHandle,
Menu,
Modal,
PropertyCollapsible,
PropertyCollapsibleContent,
PropertyCollapsibleSection,
Scrollable,
} from '@affine/component';
import { DocInfoService } from '@affine/core/modules/doc-info';
import {
DocDatabaseBacklinkInfo,
DocInfoService,
} from '@affine/core/modules/doc-info';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
@@ -27,7 +31,6 @@ import { CreatePropertyMenuItems } from '../menu/create-doc-property';
import { DocPropertyRow } from '../table';
import * as styles from './info-modal.css';
import { LinksRow } from './links-row';
import { TimeRow } from './time-row';
export const InfoModal = () => {
const modal = useService(DocInfoService).modal;
@@ -119,9 +122,7 @@ export const InfoTable = ({
);
return (
<div>
<TimeRow className={styles.timeRow} docId={docId} />
<Divider size="thinner" />
<>
{backlinks && backlinks.length > 0 ? (
<>
<LinksRow
@@ -142,50 +143,56 @@ export const InfoTable = ({
<Divider size="thinner" />
</>
) : null}
<PropertyCollapsible
className={styles.tableBodyRoot}
collapseButtonText={({ hide, isCollapsed }) =>
isCollapsed
? hide === 1
? t['com.affine.page-properties.more-property.one']({
count: hide.toString(),
})
: t['com.affine.page-properties.more-property.more']({
count: hide.toString(),
})
: hide === 1
? t['com.affine.page-properties.hide-property.one']({
count: hide.toString(),
})
: t['com.affine.page-properties.hide-property.more']({
count: hide.toString(),
})
}
<PropertyCollapsibleSection
title={t.t('com.affine.workspace.properties')}
>
{properties.map(property => (
<DocPropertyRow
key={property.id}
propertyInfo={property}
defaultOpenEditMenu={newPropertyId === property.id}
/>
))}
<Menu
items={<CreatePropertyMenuItems onCreated={setNewPropertyId} />}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
<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(),
})
}
>
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.addPropertyButton}
{properties.map(property => (
<DocPropertyRow
key={property.id}
propertyInfo={property}
defaultOpenEditMenu={newPropertyId === property.id}
/>
))}
<Menu
items={<CreatePropertyMenuItems onCreated={setNewPropertyId} />}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
</PropertyCollapsible>
</div>
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.addPropertyButton}
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
</PropertyCollapsibleContent>
</PropertyCollapsibleSection>
<Divider size="thinner" />
<DocDatabaseBacklinkInfo />
</>
);
};

View File

@@ -1,13 +1,6 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
export const title = style({
fontSize: cssVar('fontSm'),
fontWeight: '500',
color: cssVar('textSecondaryColor'),
padding: '6px',
});
export const wrapper = style({
width: '100%',
borderRadius: 4,
@@ -15,11 +8,7 @@ export const wrapper = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 2,
padding: '6px',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
padding: 4,
});
globalStyle(`${wrapper} svg`, {

View File

@@ -1,3 +1,4 @@
import { PropertyCollapsibleSection } from '@affine/component';
import type { Backlink, Link } from '@affine/core/modules/doc-link';
import { AffinePageReference } from '../../affine/reference-link';
@@ -15,10 +16,10 @@ export const LinksRow = ({
onClick?: () => void;
}) => {
return (
<div className={className}>
<div className={styles.title}>
{label} · {references.length}
</div>
<PropertyCollapsibleSection
title={`${label} · ${references.length}`}
className={className}
>
{references.map((link, index) => (
<AffinePageReference
key={index}
@@ -29,6 +30,6 @@ export const LinksRow = ({
)}
/>
))}
</div>
</PropertyCollapsibleSection>
);
};

View File

@@ -74,7 +74,11 @@ export const CreatePropertyMenuItems = ({
>
<div className={styles.propertyItem}>
{name}
{isUniqueExist && <span>Added</span>}
{isUniqueExist && (
<span>
{t['com.affine.page-properties.create-property.added']()}
</span>
)}
</div>
</MenuItem>
);

View File

@@ -39,36 +39,12 @@ export const rootCentered = style({
export const tableHeader = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
});
export const tableHeaderInfoRow = style({
display: 'flex',
flexDirection: 'row',
height: 30,
padding: 4,
justifyContent: 'space-between',
alignItems: 'center',
color: cssVarV2('text/secondary'),
fontSize: fontSize,
fontWeight: 500,
minHeight: 34,
'@media': {
print: {
display: 'none',
},
},
});
export const tableHeaderSecondaryRow = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
color: cssVar('textPrimaryColor'),
fontSize: fontSize,
fontWeight: 500,
padding: `0 6px`,
gap: '8px',
height: 24,
'@media': {
print: {
display: 'none',
@@ -81,6 +57,7 @@ export const tableHeaderCollapseButtonWrapper = style({
flex: 1,
justifyContent: 'flex-end',
cursor: 'pointer',
fontSize: 20,
});
export const pageInfoDimmed = style({

View File

@@ -1,21 +1,19 @@
import {
Button,
IconButton,
Menu,
MenuItem,
PropertyCollapsible,
PropertyCollapsibleContent,
PropertyCollapsibleSection,
PropertyName,
PropertyRoot,
Tooltip,
useDraggable,
useDropTarget,
} from '@affine/component';
import { DocLinksService } from '@affine/core/modules/doc-link';
import { EditorSettingService } from '@affine/core/modules/editor-settting';
import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { i18nTime, useI18n } from '@affine/i18n';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { PlusIcon, PropertyIcon, ToggleExpandIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
@@ -25,14 +23,11 @@ import {
DocsService,
useLiveData,
useService,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { useDebouncedValue } from 'foxact/use-debounced-value';
import type React from 'react';
import type { HTMLProps, PropsWithChildren } from 'react';
import { forwardRef, useCallback, useMemo, useState } from 'react';
import { forwardRef, useCallback, useState } from 'react';
import { AffinePageReference } from '../affine/reference-link';
import { DocPropertyIcon } from './icons/doc-property-icon';
@@ -81,145 +76,38 @@ interface DocPropertiesTableHeaderProps {
onOpenChange: (open: boolean) => void;
}
// backlinks - #no Updated yyyy-mm-dd
// Info
// ─────────────────────────────────────────────────
// Page Info ...
export const DocPropertiesTableHeader = ({
className,
style,
open,
onOpenChange,
}: DocPropertiesTableHeaderProps) => {
const t = useI18n();
const {
docLinksService,
docService,
workspaceService,
editorSettingService,
} = useServices({
DocLinksService,
DocService,
WorkspaceService,
EditorSettingService,
});
const docBacklinks = docLinksService.backlinks;
const backlinks = useMemo(
() => docBacklinks.backlinks$.value,
[docBacklinks]
);
const displayDocInfo = useLiveData(
editorSettingService.editorSetting.settings$.selector(s => s.displayDocInfo)
);
const { syncing, retrying, serverClock } = useLiveData(
workspaceService.workspace.engine.doc.docState$(docService.doc.id)
);
const { createDate, updatedDate } = useLiveData(
docService.doc.meta$.selector(m => ({
createDate: m.createDate,
updatedDate: m.updatedDate,
}))
);
const timestampElement = useMemo(() => {
const localizedCreateTime = createDate ? i18nTime(createDate) : null;
const createTimeElement = (
<div className={styles.tableHeaderTimestamp}>
{t['Created']()} {localizedCreateTime}
</div>
);
return serverClock ? (
<Tooltip
side="right"
content={
<>
<div className={styles.tableHeaderTimestamp}>
{t['Updated']()} {i18nTime(serverClock)}
</div>
{createDate && (
<div className={styles.tableHeaderTimestamp}>
{t['Created']()} {i18nTime(createDate)}
</div>
)}
</>
}
>
<div className={styles.tableHeaderTimestamp}>
{!syncing && !retrying ? (
<>
{t['Updated']()}{' '}
{i18nTime(serverClock, {
relative: {
max: [1, 'day'],
accuracy: 'minute',
},
absolute: {
accuracy: 'day',
},
})}
</>
) : (
<>{t['com.affine.syncing']()}</>
)}
</div>
</Tooltip>
) : updatedDate ? (
<Tooltip side="right" content={createTimeElement}>
<div className={styles.tableHeaderTimestamp}>
{t['Updated']()} {i18nTime(updatedDate)}
</div>
</Tooltip>
) : (
createTimeElement
);
}, [createDate, updatedDate, retrying, serverClock, syncing, t]);
const dTimestampElement = useDebouncedValue(timestampElement, 500);
const handleCollapse = useCallback(() => {
track.doc.inlineDocInfo.$.toggle();
onOpenChange(!open);
}, [onOpenChange, open]);
const t = useI18n();
return (
<div className={clsx(styles.tableHeader, className)} style={style}>
{/* TODO(@Peng): add click handler to backlinks */}
<div className={styles.tableHeaderInfoRow}>
{backlinks.length > 0 ? (
<DocBacklinksPopup backlinks={backlinks}>
<div className={styles.tableHeaderBacklinksHint}>
{t['com.affine.page-properties.backlinks']()} · {backlinks.length}
</div>
</DocBacklinksPopup>
) : null}
{dTimestampElement}
</div>
<div className={styles.tableHeaderDivider} />
{displayDocInfo ? (
<div className={styles.tableHeaderSecondaryRow}>
<div className={clsx(!open ? styles.pageInfoDimmed : null)}>
{t['com.affine.page-properties.page-info']()}
</div>
<Collapsible.Trigger asChild role="button" onClick={handleCollapse}>
<div
className={styles.tableHeaderCollapseButtonWrapper}
data-testid="page-info-collapse"
>
<IconButton size="20">
<ToggleExpandIcon
className={styles.collapsedIcon}
data-collapsed={!open}
/>
</IconButton>
</div>
</Collapsible.Trigger>
<Collapsible.Trigger style={style} role="button" onClick={handleCollapse}>
<div className={clsx(styles.tableHeader, className)}>
<div className={clsx(!open ? styles.pageInfoDimmed : null)}>
{t['com.affine.page-properties.page-info']()}
</div>
) : null}
</div>
<div
className={styles.tableHeaderCollapseButtonWrapper}
data-testid="page-info-collapse"
>
<ToggleExpandIcon
className={styles.collapsedIcon}
data-collapsed={!open}
/>
</div>
</div>
<div className={styles.tableHeaderDivider} />
</Collapsible.Trigger>
);
};
@@ -364,13 +252,14 @@ export const DocPropertiesTableBody = forwardRef<
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
return (
<div
<PropertyCollapsibleSection
ref={ref}
className={clsx(styles.tableBodyRoot, className)}
style={style}
title={t.t('com.affine.workspace.properties')}
{...props}
>
<PropertyCollapsible
<PropertyCollapsibleContent
collapsible
collapsed={propertyCollapsed}
onCollapseChange={setPropertyCollapsed}
@@ -438,9 +327,8 @@ export const DocPropertiesTableBody = forwardRef<
{t['com.affine.page-properties.config-properties']()}
</Button>
</div>
</PropertyCollapsible>
<div className={styles.tableHeaderDivider} />
</div>
</PropertyCollapsibleContent>
</PropertyCollapsibleSection>
);
});
DocPropertiesTableBody.displayName = 'PagePropertiesTableBody';
@@ -455,8 +343,10 @@ const DocPropertiesTableInner = () => {
className={styles.rootCentered}
>
<DocPropertiesTableHeader open={expanded} onOpenChange={setExpanded} />
<Collapsible.Content asChild>
<Collapsible.Content>
<DocPropertiesTableBody />
<div className={styles.tableHeaderDivider} />
<DocDatabaseBacklinkInfo />
</Collapsible.Content>
</Collapsible.Root>
</div>

View File

@@ -1,147 +0,0 @@
import { cssVar } from '@toeverything/theme';
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 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,
});
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',
});
export const tagsEditorTagsSelectorHeader = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 8px',
fontSize: cssVar('fontXs'),
fontWeight: 500,
color: cssVar('textSecondaryColor'),
});
export const tagSelectorTagsScrollContainer = style({
overflowX: 'hidden',
position: 'relative',
maxHeight: '200px',
gap: '8px',
});
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'),
},
},
});
export const tagEditIcon = style({
opacity: 0,
selectors: {
[`${tagSelectorItem}:hover &`]: {
opacity: 1,
},
},
});
globalStyle(`${tagEditIcon}[data-state=open]`, {
opacity: 1,
});
export const spacer = style({
flexGrow: 1,
});
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 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

@@ -1,26 +1,16 @@
import type { MenuProps } from '@affine/component';
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 {
IconButton,
Input,
Menu,
MenuItem,
MenuSeparator,
RowInput,
Scrollable,
} from '@affine/component';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import type { Tag } from '@affine/core/modules/tag';
import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { DeleteIcon, MoreHorizontalIcon, TagsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import clsx from 'clsx';
import { clamp } from 'lodash-es';
import type { HTMLAttributes, PropsWithChildren } from 'react';
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
LiveData,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { TagItem, TempTagItem } from '../page-list';
import * as styles from './tags-inline-editor.css';
import { useAsyncCallback } from '../hooks/affine-async-hooks';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
interface TagsEditorProps {
pageId: string;
@@ -28,444 +18,113 @@ interface TagsEditorProps {
focusedIndex?: number;
}
interface InlineTagsListProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>,
Omit<TagsEditorProps, 'onOptionsChange'> {
onRemove?: () => void;
}
export const InlineTagsList = ({
pageId,
readonly,
children,
focusedIndex,
onRemove,
}: PropsWithChildren<InlineTagsListProps>) => {
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tags$);
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
return (
<div className={styles.inlineTagsContainer} data-testid="inline-tags-list">
{tagIds.map((tagId, idx) => {
const tag = tags.find(t => t.id === tagId);
if (!tag) {
return null;
}
const onRemoved = readonly
? undefined
: () => {
tag.untag(pageId);
onRemove?.();
};
return (
<TagItem
key={tagId}
idx={idx}
focused={focusedIndex === idx}
onRemoved={onRemoved}
mode="inline"
tag={tag}
/>
);
})}
{children}
</div>
);
};
export const EditTagMenu = ({
tagId,
onTagDelete,
children,
}: PropsWithChildren<{
tagId: string;
onTagDelete: (tagIds: string[]) => void;
}>) => {
const t = useI18n();
const workspaceService = useService(WorkspaceService);
const tagService = useService(TagService);
const tagList = tagService.tagList;
const tag = useLiveData(tagList.tagByTagId$(tagId));
const tagColor = useLiveData(tag?.color$);
const tagValue = useLiveData(tag?.value$);
const navigate = useNavigateHelper();
const menuProps = useMemo(() => {
const updateTagName = (name: string) => {
if (name.trim() === '') {
return;
}
tag?.rename(name);
};
return {
contentOptions: {
onClick(e) {
e.stopPropagation();
},
},
items: (
<>
<Input
defaultValue={tagValue}
onBlur={e => {
updateTagName(e.currentTarget.value);
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
updateTagName(e.currentTarget.value);
}
e.stopPropagation();
}}
placeholder={t['Untitled']()}
/>
<MenuSeparator />
<MenuItem
prefixIcon={<DeleteIcon />}
type="danger"
onClick={() => {
onTagDelete([tag?.id || '']);
}}
>
{t['Delete']()}
</MenuItem>
<MenuItem
prefixIcon={<TagsIcon />}
onClick={() => {
navigate.jumpToTag(workspaceService.workspace.id, tag?.id || '');
}}
>
{t['com.affine.page-properties.tags.open-tags-page']()}
</MenuItem>
<MenuSeparator />
<Scrollable.Root>
<Scrollable.Viewport className={styles.menuItemList}>
{tagService.tagColors.map(([name, color], i) => (
<MenuItem
key={i}
checked={tagColor === color}
prefixIcon={
<div key={i} className={styles.tagColorIconWrapper}>
<div
className={styles.tagColorIcon}
style={{
backgroundColor: color,
}}
/>
</div>
}
onClick={() => {
tag?.changeColor(color);
}}
>
{name}
</MenuItem>
))}
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
</Scrollable.Viewport>
</Scrollable.Root>
</>
),
} satisfies Partial<MenuProps>;
}, [
workspaceService,
navigate,
onTagDelete,
t,
tag,
tagColor,
tagService.tagColors,
tagValue,
]);
return <Menu {...menuProps}>{children}</Menu>;
};
type TagOption = Tag | { 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 = ({ pageId, readonly }: TagsEditorProps) => {
const t = useI18n();
const tagService = useService(TagService);
const tagList = tagService.tagList;
const tags = useLiveData(tagList.tags$);
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
const [inputValue, setInputValue] = useState('');
const filteredTags = useLiveData(
inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$
);
const [open, setOpen] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const exactMatch = filteredTags.find(tag => tag.value$.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>(
tagIds.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, tagIds.length);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const handleCloseModal = useCallback(
(open: boolean) => {
setOpen(open);
setSelectedTagIds([]);
},
[setOpen]
);
const onTagDelete = useCallback(
(tagIds: string[]) => {
setOpen(true);
setSelectedTagIds(tagIds);
},
[setOpen, setSelectedTagIds]
);
const onInputChange = useCallback((value: string) => {
setInputValue(value);
}, []);
const onToggleTag = useCallback(
(id: string) => {
const tagEntity = tagList.tags$.value.find(o => o.id === id);
if (!tagEntity) {
return;
}
if (!tagIds.includes(id)) {
tagEntity.tag(pageId);
} else {
tagEntity.untag(pageId);
}
},
[pageId, tagIds, tagList.tags$.value]
);
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, []);
const [nextColor, rotateNextColor] = useReducer(
color => {
const idx = tagService.tagColors.findIndex(c => c[1] === color);
return tagService.tagColors[(idx + 1) % tagService.tagColors.length][1];
},
tagService.tagColors[
Math.floor(Math.random() * tagService.tagColors.length)
][1]
);
const onCreateTag = useCallback(
(name: string) => {
rotateNextColor();
const newTag = tagList.createTag(name.trim(), nextColor);
return newTag.id;
},
[nextColor, tagList]
);
const onSelectTagOption = useCallback(
(tagOption: TagOption) => {
const id = isCreateNewTag(tagOption)
? onCreateTag(tagOption.value)
: tagOption.id;
onToggleTag(id);
setInputValue('');
focusInput();
setFocusedIndex(-1);
setFocusedInlineIndex(tagIds.length + 1);
},
[onCreateTag, onToggleTag, focusInput, tagIds.length]
);
const onEnter = useCallback(() => {
if (safeFocusedIndex >= 0) {
onSelectTagOption(tagOptions[safeFocusedIndex]);
}
}, [onSelectTagOption, safeFocusedIndex, tagOptions]);
const onInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && inputValue === '' && tagIds.length) {
const tagToRemove =
safeInlineFocusedIndex < 0 || safeInlineFocusedIndex >= tagIds.length
? tagIds.length - 1
: safeInlineFocusedIndex;
tags.find(item => item.id === tagIds.at(tagToRemove))?.untag(pageId);
} 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(tagIds.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,
tagIds,
safeFocusedIndex,
tagOptions,
safeInlineFocusedIndex,
tags,
pageId,
]
);
return (
<div data-testid="tags-editor-popup" className={styles.tagsEditorRoot}>
<div className={styles.tagsEditorSelectedTags}>
<InlineTagsList
pageId={pageId}
readonly={readonly}
focusedIndex={safeInlineFocusedIndex}
onRemove={focusInput}
>
<RowInput
ref={inputRef}
value={inputValue}
onChange={onInputChange}
onKeyDown={onInputKeyDown}
onEnter={onEnter}
autoFocus
className={styles.searchInput}
placeholder="Type here ..."
/>
</InlineTagsList>
</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.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']()}{' '}
<TempTagItem value={inputValue} color={nextColor} />
</div>
);
} else {
return (
<div
key={tag.id}
{...commonProps}
data-tag-id={tag.id}
data-tag-value={tag.value$.value}
>
<TagItem maxWidth="100%" tag={tag} mode="inline" />
<div className={styles.spacer} />
<EditTagMenu tagId={tag.id} onTagDelete={onTagDelete}>
<IconButton className={styles.tagEditIcon}>
<MoreHorizontalIcon />
</IconButton>
</EditTagMenu>
</div>
);
}
})}
</Scrollable.Viewport>
<Scrollable.Scrollbar style={{ transform: 'translateX(6px)' }} />
</Scrollable.Root>
</div>
<DeleteTagConfirmModal
open={open}
onOpenChange={handleCloseModal}
selectedTagIds={selectedTagIds}
/>
</div>
);
};
interface TagsInlineEditorProps extends TagsEditorProps {
placeholder?: string;
className?: string;
}
// this tags value renderer right now only renders the legacy tags for now
export const TagsInlineEditor = ({
pageId,
readonly,
placeholder,
className,
}: TagsInlineEditorProps) => {
const tagList = useService(TagService).tagList;
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
const empty = !tagIds || tagIds.length === 0;
const workspace = useService(WorkspaceService);
const tagService = useService(TagService);
const tagIds = useLiveData(tagService.tagList.tagIdsByPageId$(pageId));
const tags = useLiveData(tagService.tagList.tags$);
const tagColors = tagService.tagColors;
const onCreateTag = useCallback(
(name: string, color: string) => {
const newTag = tagService.tagList.createTag(name, color);
return {
id: newTag.id,
value: newTag.value$.value,
color: newTag.color$.value,
};
},
[tagService.tagList]
);
const onSelectTag = useCallback(
(tagId: string) => {
tagService.tagList.tagByTagId$(tagId).value?.tag(pageId);
},
[pageId, tagService.tagList]
);
const onDeselectTag = useCallback(
(tagId: string) => {
tagService.tagList.tagByTagId$(tagId).value?.untag(pageId);
},
[pageId, tagService.tagList]
);
const onTagChange = useCallback(
(id: string, property: keyof TagLike, value: string) => {
if (property === 'value') {
tagService.tagList.tagByTagId$(id).value?.rename(value);
} else if (property === 'color') {
tagService.tagList.tagByTagId$(id).value?.changeColor(value);
}
},
[tagService.tagList]
);
const deleteTags = useDeleteTagConfirmModal();
const onTagDelete = useAsyncCallback(
async (id: string) => {
await deleteTags([id]);
},
[deleteTags]
);
const adaptedTags = useLiveData(
useMemo(() => {
return LiveData.computed(get => {
return tags.map(tag => ({
id: tag.id,
value: get(tag.value$),
color: get(tag.color$),
}));
});
}, [tags])
);
const adaptedTagColors = useMemo(() => {
return tagColors.map(color => ({
id: color[0],
value: color[1],
name: color[0],
}));
}, [tagColors]);
const navigator = useNavigateHelper();
const jumpToTag = useCallback(
(id: string) => {
navigator.jumpToTag(workspace.workspace.id, id);
},
[navigator, workspace.workspace.id]
);
return (
<Menu
contentOptions={{
side: 'bottom',
align: 'start',
sideOffset: 0,
avoidCollisions: false,
className: styles.tagsMenu,
onClick(e) {
e.stopPropagation();
},
}}
items={<TagsEditor pageId={pageId} readonly={readonly} />}
>
<div
className={clsx(styles.tagsInlineEditor, className)}
data-empty={empty}
data-readonly={readonly}
>
{empty ? placeholder : <InlineTagsList pageId={pageId} readonly />}
</div>
</Menu>
<TagsInlineEditorComponent
tagMode="inline-tag"
jumpToTag={jumpToTag}
readonly={readonly}
placeholder={placeholder}
className={className}
tags={adaptedTags}
selectedTags={tagIds}
onCreateTag={onCreateTag}
onSelectTag={onSelectTag}
onDeselectTag={onDeselectTag}
tagColors={adaptedTagColors}
onTagChange={onTagChange}
onDeleteTag={onTagDelete}
/>
);
};

View File

@@ -4,6 +4,7 @@ import {
CreatedEditedIcon,
DateTimeIcon,
FileIcon,
HistoryIcon,
NumberIcon,
TagIcon,
TextIcon,
@@ -12,7 +13,7 @@ import {
import { CheckboxValue } from './checkbox';
import { CreatedByValue, UpdatedByValue } from './created-updated-by';
import { DateValue } from './date';
import { CreateDateValue, DateValue, UpdatedDateValue } from './date';
import { DocPrimaryModeValue } from './doc-primary-mode';
import { JournalValue } from './journal';
import { NumberValue } from './number';
@@ -65,6 +66,20 @@ export const DocPropertyTypes = {
name: 'com.affine.page-properties.property.updatedBy',
description: 'com.affine.page-properties.property.updatedBy.tooltips',
},
updatedAt: {
icon: DateTimeIcon,
value: UpdatedDateValue,
name: 'com.affine.page-properties.property.updatedAt',
renameable: false,
uniqueId: 'updatedAt',
},
createdAt: {
icon: HistoryIcon,
value: CreateDateValue,
name: 'com.affine.page-properties.property.createdAt',
renameable: false,
uniqueId: 'createdAt',
},
docPrimaryMode: {
icon: FileIcon,
value: DocPrimaryModeValue,

View File

@@ -1,10 +1,11 @@
import { DatePicker, Menu, PropertyValue } from '@affine/component';
import { DatePicker, Menu, PropertyValue, Tooltip } from '@affine/component';
import { i18nTime, useI18n } from '@affine/i18n';
import { DocService, useLiveData, useServices } from '@toeverything/infra';
import * as styles from './date.css';
import type { PropertyValueProps } from './types';
export const DateValue = ({ value, onChange }: PropertyValueProps) => {
const useParsedDate = (value: string) => {
const parsedValue =
typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}$/)
? value
@@ -12,8 +13,17 @@ export const DateValue = ({ value, onChange }: PropertyValueProps) => {
const displayValue = parsedValue
? i18nTime(parsedValue, { absolute: { accuracy: 'day' } })
: undefined;
const t = useI18n();
return {
parsedValue,
displayValue:
displayValue ??
t['com.affine.page-properties.property-value-placeholder'](),
};
};
export const DateValue = ({ value, onChange }: PropertyValueProps) => {
const { parsedValue, displayValue } = useParsedDate(value);
return (
<Menu items={<DatePicker value={parsedValue} onChange={onChange} />}>
@@ -21,9 +31,55 @@ export const DateValue = ({ value, onChange }: PropertyValueProps) => {
className={parsedValue ? '' : styles.empty}
isEmpty={!parsedValue}
>
{displayValue ??
t['com.affine.page-properties.property-value-placeholder']()}
{displayValue}
</PropertyValue>
</Menu>
);
};
const toRelativeDate = (time: string | number) => {
return i18nTime(time, {
relative: {
max: [1, 'day'],
},
absolute: {
accuracy: 'day',
},
});
};
const MetaDateValueFactory = ({
type,
}: {
type: 'createDate' | 'updatedDate';
}) =>
function ReadonlyDateValue() {
const { docService } = useServices({
DocService,
});
const docMeta = useLiveData(docService.doc.meta$);
const value = docMeta?.[type];
const relativeDate = value ? toRelativeDate(value) : null;
const date = value ? i18nTime(value) : null;
return (
<Tooltip content={date}>
<PropertyValue
className={relativeDate ? '' : styles.empty}
isEmpty={!relativeDate}
>
{relativeDate}
</PropertyValue>
</Tooltip>
);
};
export const CreateDateValue = MetaDateValueFactory({
type: 'createDate',
});
export const UpdatedDateValue = MetaDateValueFactory({
type: 'updatedDate',
});

View File

@@ -48,7 +48,7 @@ export const DocPrimaryModeValue = () => {
[doc, t]
);
return (
<PropertyValue className={styles.container}>
<PropertyValue className={styles.container} hoverable={false}>
<RadioGroup
width={194}
itemHeight={24}

View File

@@ -2,9 +2,10 @@ import { style } from '@vanilla-extract/css';
export const tagInlineEditor = style({
width: '100%',
minHeight: 34,
padding: `6px`,
});
export const container = style({
padding: `0px`,
padding: '0px !important',
});

View File

@@ -1,7 +1,7 @@
import type { DocCustomPropertyInfo } from '@toeverything/infra';
export interface PropertyValueProps {
propertyInfo: DocCustomPropertyInfo;
propertyInfo?: DocCustomPropertyInfo;
value: any;
onChange: (value: any) => void;
}

View File

@@ -1,8 +1,8 @@
import { Menu } from '@affine/component';
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
import { TagItem as TagItemComponent } from '@affine/component/ui/tags';
import type { Tag } from '@affine/core/modules/tag';
import { stopPropagation } from '@affine/core/utils';
import { CloseIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
@@ -27,77 +27,24 @@ interface TagItemProps {
style?: React.CSSProperties;
}
export const TempTagItem = ({
value,
color,
maxWidth = '100%',
}: {
value: string;
color: string;
maxWidth?: number | string;
}) => {
return (
<div className={styles.tag} title={value}>
<div style={{ maxWidth: maxWidth }} className={styles.tagInline}>
<div
className={styles.tagIndicator}
style={{
backgroundColor: color,
}}
/>
<div className={styles.tagLabel}>{value}</div>
</div>
</div>
);
};
export const TagItem = ({
tag,
idx,
mode,
focused,
onRemoved,
style,
maxWidth,
}: TagItemProps) => {
export const TagItem = ({ tag, ...props }: TagItemProps) => {
const value = useLiveData(tag?.value$);
const color = useLiveData(tag?.color$);
const handleRemove = useCatchEventCallback(() => {
onRemoved?.();
}, [onRemoved]);
if (!tag || !value || !color) {
return null;
}
return (
<div
data-testid="page-tag"
className={styles.tag}
data-idx={idx}
data-tag-id={tag?.id}
data-tag-value={value}
title={value}
style={style}
>
<div
style={{ maxWidth: maxWidth }}
data-focused={focused}
className={mode === 'inline' ? styles.tagInline : styles.tagListItem}
>
<div
className={styles.tagIndicator}
style={{
backgroundColor: color,
}}
/>
<div className={styles.tagLabel}>{value}</div>
{onRemoved ? (
<div
data-testid="remove-tag-button"
className={styles.tagRemove}
onClick={handleRemove}
>
<CloseIcon />
</div>
) : null}
</div>
</div>
<TagItemComponent
{...props}
mode={props.mode === 'inline' ? 'inline-tag' : 'list-tag'}
tag={{
id: tag?.id,
value: value,
color: color,
}}
/>
);
};