mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-02 01:49:51 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DocCustomPropertyInfo } from '@toeverything/infra';
|
||||
|
||||
export interface PropertyValueProps {
|
||||
propertyInfo: DocCustomPropertyInfo;
|
||||
propertyInfo?: DocCustomPropertyInfo;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import {
|
||||
TagListHeader,
|
||||
VirtualizedTagList,
|
||||
} from '@affine/core/components/page-list/tags';
|
||||
import { CreateOrEditTag } from '@affine/core/components/page-list/tags/create-tag';
|
||||
import type { TagMeta } from '@affine/core/components/page-list/types';
|
||||
import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag';
|
||||
import { TagService, useDeleteTagConfirmModal } from '@affine/core/modules/tag';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useState } from 'react';
|
||||
@@ -39,25 +40,14 @@ const EmptyTagListHeader = () => {
|
||||
export const AllTag = () => {
|
||||
const tagList = useService(TagService).tagList;
|
||||
const tags = useLiveData(tagList.tags$);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
|
||||
const tagMetas: TagMeta[] = useLiveData(tagList.tagMetas$);
|
||||
const handleDeleteTags = useDeleteTagConfirmModal();
|
||||
|
||||
const handleCloseModal = useCallback(
|
||||
(open: boolean) => {
|
||||
setOpen(open);
|
||||
setSelectedTagIds([]);
|
||||
const onTagDelete = useAsyncCallback(
|
||||
async (tagIds: string[]) => {
|
||||
await handleDeleteTags(tagIds);
|
||||
},
|
||||
[setOpen]
|
||||
);
|
||||
|
||||
const onTagDelete = useCallback(
|
||||
(tagIds: string[]) => {
|
||||
setOpen(true);
|
||||
setSelectedTagIds(tagIds);
|
||||
},
|
||||
[setOpen, setSelectedTagIds]
|
||||
[handleDeleteTags]
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
@@ -82,11 +72,6 @@ export const AllTag = () => {
|
||||
)}
|
||||
</div>
|
||||
</ViewBody>
|
||||
<DeleteTagConfirmModal
|
||||
open={open}
|
||||
onOpenChange={handleCloseModal}
|
||||
selectedTagIds={selectedTagIds}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { type Framework, WorkspaceScope } from '@toeverything/infra';
|
||||
import {
|
||||
DocsService,
|
||||
type Framework,
|
||||
WorkspaceScope,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { DocsSearchService } from '../docs-search';
|
||||
import { DocInfoModal } from './entities/modal';
|
||||
import { DocDatabaseBacklinksService } from './services/doc-database-backlinks';
|
||||
import { DocInfoService } from './services/doc-info';
|
||||
|
||||
export { DocDatabaseBacklinkInfo } from './views/database-properties/doc-database-backlink-info';
|
||||
|
||||
export { DocInfoService };
|
||||
|
||||
export function configureDocInfoModule(framework: Framework) {
|
||||
framework.scope(WorkspaceScope).service(DocInfoService).entity(DocInfoModal);
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(DocInfoService)
|
||||
.service(DocDatabaseBacklinksService, [DocsService, DocsSearchService])
|
||||
.entity(DocInfoModal);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
DatabaseBlockDataSource,
|
||||
type DatabaseBlockModel,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import type { DocsService } from '@toeverything/infra';
|
||||
import { Service } from '@toeverything/infra';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { combineLatest, distinctUntilChanged, map, Observable } from 'rxjs';
|
||||
|
||||
import type { DocsSearchService } from '../../docs-search';
|
||||
import type { DatabaseRow, DatabaseValueCell } from '../types';
|
||||
import { signalToLiveData, signalToObservable } from '../utils';
|
||||
|
||||
const equalComparator = <T>(a: T, b: T) => {
|
||||
return isEqual(a, b);
|
||||
};
|
||||
|
||||
export class DocDatabaseBacklinksService extends Service {
|
||||
constructor(
|
||||
private readonly docsService: DocsService,
|
||||
private readonly docsSearchService: DocsSearchService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private async ensureDocLoaded(docId: string) {
|
||||
const docRef = this.docsService.open(docId);
|
||||
if (!docRef.doc.blockSuiteDoc.ready) {
|
||||
docRef.doc.blockSuiteDoc.load();
|
||||
}
|
||||
docRef.doc.setPriorityLoad(10);
|
||||
await docRef.doc.waitForSyncReady();
|
||||
return docRef;
|
||||
}
|
||||
|
||||
private adaptRowCells(dbModel: DatabaseBlockModel, rowId: string) {
|
||||
const dataSource = new DatabaseBlockDataSource(dbModel);
|
||||
|
||||
const hydratedRows$ = combineLatest([
|
||||
signalToObservable(dataSource.rows$),
|
||||
signalToObservable(dataSource.properties$),
|
||||
]).pipe(
|
||||
map(([rowIds, propertyIds]) => {
|
||||
const rowExists = rowIds.some(id => id === rowId);
|
||||
if (!rowExists) {
|
||||
return undefined;
|
||||
}
|
||||
return propertyIds
|
||||
.map<DatabaseValueCell>(id => {
|
||||
return {
|
||||
id,
|
||||
value$: signalToLiveData(
|
||||
dataSource.cellValueGet$(rowId, id)
|
||||
).distinctUntilChanged(equalComparator),
|
||||
property: {
|
||||
id,
|
||||
type$: signalToLiveData(dataSource.propertyTypeGet$(id)),
|
||||
name$: signalToLiveData(dataSource.propertyNameGet$(id)),
|
||||
data$: signalToLiveData(dataSource.propertyDataGet$(id)),
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((p: any): p is DatabaseValueCell => !!p);
|
||||
})
|
||||
);
|
||||
|
||||
return [hydratedRows$, dataSource] as const;
|
||||
}
|
||||
|
||||
// for each db doc backlink,
|
||||
private watchDatabaseRow$(backlink: {
|
||||
docId: string;
|
||||
rowId: string;
|
||||
databaseBlockId: string;
|
||||
databaseName: string | undefined;
|
||||
}) {
|
||||
return new Observable<DatabaseRow | undefined>(subscriber => {
|
||||
let disposed = false;
|
||||
let unsubscribe = () => {};
|
||||
const docRef = this.docsService.open(backlink.docId);
|
||||
const run = async () => {
|
||||
await this.ensureDocLoaded(backlink.docId);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
const maybeDatabaseBlock = docRef.doc.blockSuiteDoc.getBlock(
|
||||
backlink.databaseBlockId
|
||||
);
|
||||
if (maybeDatabaseBlock?.flavour === 'affine:database') {
|
||||
const dbModel = maybeDatabaseBlock.model as DatabaseBlockModel;
|
||||
const [cells$, dataSource] = this.adaptRowCells(
|
||||
dbModel,
|
||||
backlink.rowId
|
||||
);
|
||||
const subscription = cells$.subscribe(cells => {
|
||||
if (cells) {
|
||||
subscriber.next({
|
||||
cells,
|
||||
id: backlink.rowId,
|
||||
doc: docRef.doc,
|
||||
docId: backlink.docId,
|
||||
databaseId: dbModel.id,
|
||||
databaseName: dbModel.title.yText.toString(),
|
||||
dataSource: dataSource,
|
||||
});
|
||||
} else {
|
||||
subscriber.next(undefined);
|
||||
}
|
||||
});
|
||||
unsubscribe = () => subscription.unsubscribe();
|
||||
} else {
|
||||
subscriber.next(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
run().catch(e => {
|
||||
console.error(`failed to get database info:`, e);
|
||||
docRef.release();
|
||||
});
|
||||
|
||||
return () => {
|
||||
docRef.release();
|
||||
disposed = true;
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// backlinks (docid:blockid:databaseBlockId)
|
||||
// -> related db rows (DatabaseRow[])
|
||||
watchDbBacklinkRows$(docId: string) {
|
||||
return this.docsSearchService.watchDatabasesTo(docId).pipe(
|
||||
distinctUntilChanged(equalComparator),
|
||||
map(rows =>
|
||||
rows.toSorted(
|
||||
(a, b) => a.databaseName?.localeCompare(b.databaseName ?? '') ?? 0
|
||||
)
|
||||
),
|
||||
map(backlinks =>
|
||||
backlinks.map(backlink => {
|
||||
return {
|
||||
...backlink,
|
||||
row$: this.watchDatabaseRow$(backlink),
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks';
|
||||
import type { Doc, LiveData } from '@toeverything/infra';
|
||||
|
||||
// make database property type to be compatible with DocCustomPropertyInfo
|
||||
export type DatabaseProperty<Data = Record<string, unknown>> = {
|
||||
id: string;
|
||||
name$: LiveData<string | undefined>;
|
||||
type$: LiveData<string | undefined>;
|
||||
data$: LiveData<Data | undefined>;
|
||||
};
|
||||
|
||||
export interface DatabaseValueCell<
|
||||
T = unknown,
|
||||
Data = Record<string, unknown>,
|
||||
> {
|
||||
value$: LiveData<T>;
|
||||
property: DatabaseProperty<Data>;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface DatabaseRow {
|
||||
cells: DatabaseValueCell[];
|
||||
id: string; // row id (block id)
|
||||
doc: Doc; // the doc that contains the database. required for editing etc.
|
||||
docId: string; // for rendering the doc reference
|
||||
dataSource: DatabaseBlockDataSource;
|
||||
databaseId: string;
|
||||
databaseName: string; // the title
|
||||
}
|
||||
|
||||
export interface DatabaseCellRendererProps {
|
||||
rowId: string;
|
||||
cell: DatabaseValueCell;
|
||||
dataSource: DatabaseBlockDataSource;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { BlockStdScope } from '@blocksuite/affine/block-std';
|
||||
import { PageEditorBlockSpecs } from '@blocksuite/affine/blocks';
|
||||
import type { Doc } from '@blocksuite/affine/store';
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import { useMemo } from 'react';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
const logger = new DebugLogger('doc-info');
|
||||
|
||||
interface ReadonlySignal<T> {
|
||||
subscribe: (fn: (value: T) => void) => () => void;
|
||||
}
|
||||
|
||||
export function signalToObservable<T>(
|
||||
signal: ReadonlySignal<T>
|
||||
): Observable<T> {
|
||||
return new Observable(subscriber => {
|
||||
const unsub = signal.subscribe(value => {
|
||||
subscriber.next(value);
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function signalToLiveData<T>(
|
||||
signal: ReadonlySignal<T>,
|
||||
defaultValue: T
|
||||
): LiveData<T>;
|
||||
|
||||
export function signalToLiveData<T>(
|
||||
signal: ReadonlySignal<T>
|
||||
): LiveData<T | undefined>;
|
||||
|
||||
export function signalToLiveData<T>(
|
||||
signal: ReadonlySignal<T>,
|
||||
defaultValue?: T
|
||||
) {
|
||||
return LiveData.from(signalToObservable(signal), defaultValue);
|
||||
}
|
||||
|
||||
// todo(pengx17): use rc pool?
|
||||
export function createBlockStdScope(doc: Doc) {
|
||||
logger.debug('createBlockStdScope', doc.id);
|
||||
const std = new BlockStdScope({
|
||||
doc,
|
||||
extensions: PageEditorBlockSpecs,
|
||||
});
|
||||
return std;
|
||||
}
|
||||
|
||||
export function useBlockStdScope(doc: Doc) {
|
||||
return useMemo(() => createBlockStdScope(doc), [doc]);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { CheckboxValue } from '@affine/core/components/doc-properties/types/checkbox';
|
||||
import type { LiveData } from '@toeverything/infra';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
|
||||
import type { DatabaseCellRendererProps } from '../../../types';
|
||||
|
||||
export const CheckboxCell = ({
|
||||
cell,
|
||||
rowId,
|
||||
dataSource,
|
||||
}: DatabaseCellRendererProps) => {
|
||||
const value = useLiveData(cell.value$ as LiveData<boolean>);
|
||||
return (
|
||||
<CheckboxValue
|
||||
// todo(pengx17): better internal impl
|
||||
value={value ? 'true' : 'false'}
|
||||
onChange={v => {
|
||||
dataSource.cellValueChange(rowId, cell.property.id, v === 'true');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { DateValue } from '@affine/core/components/doc-properties/types/date';
|
||||
import type { LiveData } from '@toeverything/infra';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import type { DatabaseCellRendererProps } from '../../../types';
|
||||
|
||||
const toInternalDateString = (date: unknown) => {
|
||||
if (typeof date !== 'string' && typeof date !== 'number') {
|
||||
return '';
|
||||
}
|
||||
return dayjs(date).format('YYYY-MM-DD');
|
||||
};
|
||||
|
||||
const fromInternalDateString = (date: string) => {
|
||||
return dayjs(date).toDate().getTime();
|
||||
};
|
||||
|
||||
export const DateCell = ({
|
||||
cell,
|
||||
rowId,
|
||||
dataSource,
|
||||
}: DatabaseCellRendererProps) => {
|
||||
const value = useLiveData(
|
||||
cell.value$ as LiveData<number | string | undefined>
|
||||
);
|
||||
const date = value ? toInternalDateString(value) : '';
|
||||
return (
|
||||
<DateValue
|
||||
value={date}
|
||||
onChange={v => {
|
||||
dataSource.cellValueChange(
|
||||
rowId,
|
||||
cell.property.id,
|
||||
fromInternalDateString(v)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const link = style({
|
||||
textDecoration: 'none',
|
||||
color: cssVarV2('text/link'),
|
||||
whiteSpace: 'wrap',
|
||||
wordBreak: 'break-all',
|
||||
display: 'inline',
|
||||
});
|
||||
|
||||
export const textarea = style({
|
||||
border: 'none',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
whiteSpace: 'wrap',
|
||||
wordBreak: 'break-all',
|
||||
padding: `6px`,
|
||||
paddingLeft: '5px',
|
||||
overflow: 'hidden',
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '22px',
|
||||
selectors: {
|
||||
'&::placeholder': {
|
||||
color: cssVar('placeholderColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const container = style({
|
||||
position: 'relative',
|
||||
outline: `1px solid transparent`,
|
||||
padding: `6px`,
|
||||
display: 'block',
|
||||
':focus-within': {
|
||||
outline: `1px solid ${cssVar('blue700')}`,
|
||||
boxShadow: cssVar('activeShadow'),
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
});
|
||||
|
||||
export const textInvisible = style({
|
||||
border: 'none',
|
||||
whiteSpace: 'wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflow: 'hidden',
|
||||
visibility: 'hidden',
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '22px',
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { PropertyValue } from '@affine/component';
|
||||
import { AffinePageReference } from '@affine/core/components/affine/reference-link';
|
||||
import { resolveLinkToDoc } from '@affine/core/modules/navigation';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { LiveData } from '@toeverything/infra';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
import {
|
||||
type ChangeEventHandler,
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { DatabaseCellRendererProps } from '../../../types';
|
||||
import * as styles from './link.css';
|
||||
|
||||
export const LinkCell = ({
|
||||
cell,
|
||||
dataSource,
|
||||
rowId,
|
||||
}: DatabaseCellRendererProps) => {
|
||||
const isEmpty = useLiveData(
|
||||
cell.value$.map(value => typeof value !== 'string' || !value)
|
||||
);
|
||||
const link = useLiveData(cell.value$ as LiveData<string | undefined>) || '';
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [tempValue, setTempValue] = useState<string>(link);
|
||||
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const commitChange = useCallback(() => {
|
||||
dataSource.cellValueChange(rowId, cell.id, tempValue.trim());
|
||||
setEditing(false);
|
||||
setTempValue(tempValue.trim());
|
||||
}, [dataSource, rowId, cell.id, tempValue]);
|
||||
|
||||
const handleOnChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
e => {
|
||||
setTempValue(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const resolvedDocLink = useMemo(() => {
|
||||
const docInfo = resolveLinkToDoc(link);
|
||||
|
||||
if (docInfo) {
|
||||
const params = new URLSearchParams();
|
||||
if (docInfo.mode) {
|
||||
params.set('mode', docInfo.mode);
|
||||
}
|
||||
if (docInfo.blockIds) {
|
||||
params.set('blockIds', docInfo.blockIds.join(','));
|
||||
}
|
||||
if (docInfo.elementIds) {
|
||||
params.set('elementIds', docInfo.elementIds.join(','));
|
||||
}
|
||||
return {
|
||||
docId: docInfo.docId,
|
||||
params,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [link]);
|
||||
|
||||
const onKeydown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
commitChange();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditing(false);
|
||||
setTempValue(link);
|
||||
}
|
||||
},
|
||||
[commitChange, link]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTempValue(link);
|
||||
}, [link]);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
setEditing(true);
|
||||
setTimeout(() => {
|
||||
ref.current?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onLinkClick = useCallback((e: React.MouseEvent) => {
|
||||
// prevent click event from propagating to parent (editing)
|
||||
e.stopPropagation();
|
||||
setEditing(false);
|
||||
}, []);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { NumberValue } from '@affine/core/components/doc-properties/types/number';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
|
||||
import type { DatabaseCellRendererProps } from '../../../types';
|
||||
|
||||
export const NumberCell = ({
|
||||
cell,
|
||||
rowId,
|
||||
dataSource,
|
||||
}: DatabaseCellRendererProps) => {
|
||||
const value = useLiveData(cell.value$);
|
||||
return (
|
||||
<NumberValue
|
||||
value={value}
|
||||
onChange={v => {
|
||||
dataSource.cellValueChange(rowId, cell.property.id, v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Progress, PropertyValue } from '@affine/component';
|
||||
import type { LiveData } from '@toeverything/infra';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { DatabaseCellRendererProps } from '../../../types';
|
||||
|
||||
export const ProgressCell = ({
|
||||
cell,
|
||||
dataSource,
|
||||
rowId,
|
||||
}: DatabaseCellRendererProps) => {
|
||||
const value = useLiveData(cell.value$ as LiveData<number>);
|
||||
const isEmpty = value === undefined;
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<PropertyValue isEmpty={isEmpty} hoverable={false}>
|
||||
<Progress
|
||||
value={localValue}
|
||||
onChange={v => {
|
||||
setLocalValue(v);
|
||||
}}
|
||||
onBlur={() => {
|
||||
dataSource.cellValueChange(rowId, cell.id, localValue);
|
||||
}}
|
||||
/>
|
||||
</PropertyValue>
|
||||
);
|
||||
};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { PropertyValue } from '@affine/component';
|
||||
import type { BlockStdScope } from '@blocksuite/affine/block-std';
|
||||
import {
|
||||
DefaultInlineManagerExtension,
|
||||
RichText,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import type { Doc } from '@blocksuite/affine/store';
|
||||
import { type LiveData, useLiveData } from '@toeverything/infra';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
import type { DatabaseCellRendererProps } from '../../../types';
|
||||
import { useBlockStdScope } from '../../../utils';
|
||||
|
||||
// todo(@pengx17): handle markdown/keyboard shortcuts
|
||||
const renderRichText = ({
|
||||
doc,
|
||||
std,
|
||||
text,
|
||||
}: {
|
||||
std: BlockStdScope;
|
||||
text: Y.Text;
|
||||
doc: Doc;
|
||||
}) => {
|
||||
const inlineManager = std.get(DefaultInlineManagerExtension.identifier);
|
||||
|
||||
if (!inlineManager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const richText = new RichText();
|
||||
richText.yText = text;
|
||||
richText.undoManager = doc.history;
|
||||
richText.readonly = doc.readonly;
|
||||
richText.attributesSchema = inlineManager.getSchema() as any;
|
||||
richText.attributeRenderer = inlineManager.getRenderer();
|
||||
return richText;
|
||||
};
|
||||
|
||||
export const RichTextCell = ({
|
||||
cell,
|
||||
dataSource,
|
||||
}: DatabaseCellRendererProps) => {
|
||||
const std = useBlockStdScope(dataSource.doc);
|
||||
const text = useLiveData(cell.value$ as LiveData<Y.Text>);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// todo(@pengx17): following is a workaround to y.Text that it is got renewed when the cell is updated externally. however it breaks the cursor position.
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.innerHTML = '';
|
||||
const richText = renderRichText({ doc: dataSource.doc, std, text });
|
||||
if (richText) {
|
||||
ref.current.append(richText);
|
||||
return () => {
|
||||
richText.remove();
|
||||
};
|
||||
}
|
||||
}
|
||||
return () => {};
|
||||
}, [dataSource.doc, std, text]);
|
||||
return <PropertyValue ref={ref}></PropertyValue>;
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const tagInlineEditor = style({
|
||||
width: '100%',
|
||||
padding: `6px`,
|
||||
minHeight: 34,
|
||||
});
|
||||
|
||||
export const container = style({
|
||||
padding: '0px !important',
|
||||
});
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
/* eslint-disable rxjs/finnish */
|
||||
|
||||
import { PropertyValue } from '@affine/component';
|
||||
import { type TagLike, TagsInlineEditor } from '@affine/component/ui/tags';
|
||||
import { paletteLineToTag, TagService } from '@affine/core/modules/tag';
|
||||
import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks';
|
||||
import type { SelectTag } from '@blocksuite/data-view';
|
||||
import { LiveData, useLiveData, useService } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type {
|
||||
DatabaseCellRendererProps,
|
||||
DatabaseValueCell,
|
||||
} from '../../../types';
|
||||
import * as styles from './select.css';
|
||||
|
||||
interface SelectPropertyData {
|
||||
options: SelectTag[];
|
||||
}
|
||||
|
||||
type SelectCellValue = string[] | string;
|
||||
|
||||
type SelectCell<T extends SelectCellValue> = DatabaseValueCell<
|
||||
T,
|
||||
SelectPropertyData
|
||||
>;
|
||||
|
||||
type SingleSelectCell = SelectCell<string>;
|
||||
type MultiSelectCell = SelectCell<string[]>;
|
||||
|
||||
const adapter = {
|
||||
getSelectedIds$(cell: SingleSelectCell | MultiSelectCell) {
|
||||
return cell.value$.map(ids => {
|
||||
if (!Array.isArray(ids)) {
|
||||
return typeof ids === 'string' ? [ids] : [];
|
||||
}
|
||||
return ids.filter(id => typeof id === 'string');
|
||||
});
|
||||
},
|
||||
|
||||
getSelectedTags$(cell: SingleSelectCell | MultiSelectCell) {
|
||||
return LiveData.computed(get => {
|
||||
const ids = get(adapter.getSelectedIds$(cell));
|
||||
const options = get(adapter.getTagOptions$(cell));
|
||||
return ids
|
||||
.map(
|
||||
id =>
|
||||
typeof id === 'string' && options.find(option => option.id === id)
|
||||
)
|
||||
.filter(option => !!option);
|
||||
});
|
||||
},
|
||||
|
||||
getTagOptions$(cell: SingleSelectCell | MultiSelectCell) {
|
||||
return LiveData.computed(get => {
|
||||
const data = get(cell.property.data$);
|
||||
return data?.options as SelectTag[];
|
||||
});
|
||||
},
|
||||
|
||||
updateOptions(
|
||||
cell: SingleSelectCell | MultiSelectCell,
|
||||
dataSource: DatabaseBlockDataSource,
|
||||
updater: (oldOptions: SelectTag[]) => SelectTag[]
|
||||
) {
|
||||
const oldData = dataSource.propertyDataGet(cell.property.id);
|
||||
return dataSource.propertyDataSet(cell.property.id, {
|
||||
...oldData,
|
||||
options: updater(oldData.options as SelectTag[]),
|
||||
});
|
||||
},
|
||||
|
||||
deselectTag(
|
||||
rowId: string,
|
||||
cell: SingleSelectCell | MultiSelectCell,
|
||||
dataSource: DatabaseBlockDataSource,
|
||||
tagId: string,
|
||||
multiple: boolean
|
||||
) {
|
||||
const ids = adapter.getSelectedIds$(cell).value;
|
||||
dataSource.cellValueChange(
|
||||
rowId,
|
||||
cell.property.id,
|
||||
multiple ? ids.filter(id => id !== tagId) : undefined
|
||||
);
|
||||
},
|
||||
|
||||
selectTag(
|
||||
rowId: string,
|
||||
cell: SingleSelectCell | MultiSelectCell,
|
||||
dataSource: DatabaseBlockDataSource,
|
||||
tagId: string,
|
||||
multiple: boolean
|
||||
) {
|
||||
const ids = adapter.getSelectedIds$(cell).value;
|
||||
dataSource.cellValueChange(
|
||||
rowId,
|
||||
cell.property.id,
|
||||
multiple ? [...ids, tagId] : tagId
|
||||
);
|
||||
},
|
||||
|
||||
createTag(
|
||||
cell: SingleSelectCell | MultiSelectCell,
|
||||
dataSource: DatabaseBlockDataSource,
|
||||
newTag: TagLike
|
||||
) {
|
||||
adapter.updateOptions(cell, dataSource, options => [
|
||||
...options,
|
||||
{
|
||||
id: newTag.id,
|
||||
value: newTag.value,
|
||||
color: newTag.color,
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
deleteTag(
|
||||
cell: SingleSelectCell | MultiSelectCell,
|
||||
dataSource: DatabaseBlockDataSource,
|
||||
tagId: string
|
||||
) {
|
||||
adapter.updateOptions(cell, dataSource, options =>
|
||||
options.filter(option => option.id !== tagId)
|
||||
);
|
||||
},
|
||||
|
||||
updateTag(
|
||||
cell: SingleSelectCell | MultiSelectCell,
|
||||
dataSource: DatabaseBlockDataSource,
|
||||
tagId: string,
|
||||
updater: (oldTag: SelectTag) => SelectTag
|
||||
) {
|
||||
adapter.updateOptions(cell, dataSource, options =>
|
||||
options.map(option => (option.id === tagId ? updater(option) : option))
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const BlocksuiteDatabaseSelector = ({
|
||||
cell,
|
||||
dataSource,
|
||||
rowId,
|
||||
multiple,
|
||||
}: DatabaseCellRendererProps & { multiple: boolean }) => {
|
||||
const tagService = useService(TagService);
|
||||
const selectCell = cell as any as SingleSelectCell | MultiSelectCell;
|
||||
const selectedIds = useLiveData(adapter.getSelectedIds$(selectCell));
|
||||
const tagOptions = useLiveData(adapter.getTagOptions$(selectCell));
|
||||
|
||||
const onCreateTag = useCallback(
|
||||
(name: string, color: string) => {
|
||||
// bs database uses --affine-tag-xxx colors
|
||||
const newTag = {
|
||||
id: nanoid(),
|
||||
value: name,
|
||||
color: color,
|
||||
};
|
||||
adapter.createTag(selectCell, dataSource, newTag);
|
||||
return newTag;
|
||||
},
|
||||
[dataSource, selectCell]
|
||||
);
|
||||
const onDeleteTag = useCallback(
|
||||
(tagId: string) => {
|
||||
adapter.deleteTag(selectCell, dataSource, tagId);
|
||||
},
|
||||
[dataSource, selectCell]
|
||||
);
|
||||
const onDeselectTag = useCallback(
|
||||
(tagId: string) => {
|
||||
adapter.deselectTag(rowId, selectCell, dataSource, tagId, multiple);
|
||||
},
|
||||
[selectCell, dataSource, rowId, multiple]
|
||||
);
|
||||
|
||||
const onSelectTag = useCallback(
|
||||
(tagId: string) => {
|
||||
adapter.selectTag(rowId, selectCell, dataSource, tagId, multiple);
|
||||
},
|
||||
[rowId, selectCell, dataSource, multiple]
|
||||
);
|
||||
|
||||
const tagColors = useMemo(() => {
|
||||
return tagService.tagColors.map(([name, color]) => ({
|
||||
id: name,
|
||||
value: paletteLineToTag(color), // map from palette line to tag color
|
||||
name,
|
||||
}));
|
||||
}, [tagService.tagColors]);
|
||||
|
||||
const onTagChange = useCallback(
|
||||
(tagId: string, property: string, value: string) => {
|
||||
adapter.updateTag(selectCell, dataSource, tagId, old => {
|
||||
return {
|
||||
...old,
|
||||
[property]: value,
|
||||
};
|
||||
});
|
||||
},
|
||||
[dataSource, selectCell]
|
||||
);
|
||||
|
||||
return (
|
||||
<TagsInlineEditor
|
||||
tagMode="db-label"
|
||||
className={styles.tagInlineEditor}
|
||||
tags={tagOptions}
|
||||
selectedTags={selectedIds}
|
||||
onCreateTag={onCreateTag}
|
||||
onDeleteTag={onDeleteTag}
|
||||
onDeselectTag={onDeselectTag}
|
||||
onSelectTag={onSelectTag}
|
||||
tagColors={tagColors}
|
||||
onTagChange={onTagChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectCell = ({
|
||||
cell,
|
||||
dataSource,
|
||||
rowId,
|
||||
}: DatabaseCellRendererProps) => {
|
||||
const isEmpty = useLiveData(
|
||||
cell.value$.map(value => Array.isArray(value) && value.length === 0)
|
||||
);
|
||||
return (
|
||||
<PropertyValue isEmpty={isEmpty} className={styles.container}>
|
||||
<BlocksuiteDatabaseSelector
|
||||
cell={cell}
|
||||
dataSource={dataSource}
|
||||
rowId={rowId}
|
||||
multiple={false}
|
||||
/>
|
||||
</PropertyValue>
|
||||
);
|
||||
};
|
||||
|
||||
export const MultiSelectCell = ({
|
||||
cell,
|
||||
dataSource,
|
||||
rowId,
|
||||
}: DatabaseCellRendererProps) => {
|
||||
const isEmpty = useLiveData(
|
||||
cell.value$.map(value => Array.isArray(value) && value.length === 0)
|
||||
);
|
||||
return (
|
||||
<PropertyValue isEmpty={isEmpty} className={styles.container}>
|
||||
<BlocksuiteDatabaseSelector
|
||||
cell={cell}
|
||||
dataSource={dataSource}
|
||||
rowId={rowId}
|
||||
multiple={true}
|
||||
/>
|
||||
</PropertyValue>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { I18nString } from '@affine/i18n';
|
||||
import {
|
||||
CheckBoxCheckLinearIcon,
|
||||
DateTimeIcon,
|
||||
LinkIcon,
|
||||
MultiSelectIcon,
|
||||
NumberIcon,
|
||||
ProgressIcon,
|
||||
SingleSelectIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
|
||||
import type { DatabaseCellRendererProps } from '../../types';
|
||||
import { CheckboxCell } from './cells/checkbox';
|
||||
import { DateCell } from './cells/date';
|
||||
import { LinkCell } from './cells/link';
|
||||
import { NumberCell } from './cells/number';
|
||||
import { ProgressCell } from './cells/progress';
|
||||
import { RichTextCell } from './cells/rich-text';
|
||||
import { MultiSelectCell, SelectCell } from './cells/select';
|
||||
|
||||
export const DatabaseRendererTypes = {
|
||||
'rich-text': {
|
||||
Icon: TextIcon,
|
||||
Renderer: RichTextCell,
|
||||
name: 'com.affine.page-properties.property.text',
|
||||
},
|
||||
checkbox: {
|
||||
Icon: CheckBoxCheckLinearIcon,
|
||||
Renderer: CheckboxCell,
|
||||
name: 'com.affine.page-properties.property.checkbox',
|
||||
},
|
||||
date: {
|
||||
Icon: DateTimeIcon,
|
||||
Renderer: DateCell,
|
||||
name: 'com.affine.page-properties.property.date',
|
||||
},
|
||||
number: {
|
||||
Icon: NumberIcon,
|
||||
Renderer: NumberCell,
|
||||
name: 'com.affine.page-properties.property.number',
|
||||
},
|
||||
link: {
|
||||
Icon: LinkIcon,
|
||||
Renderer: LinkCell,
|
||||
name: 'com.affine.page-properties.property.link',
|
||||
},
|
||||
progress: {
|
||||
Icon: ProgressIcon,
|
||||
Renderer: ProgressCell,
|
||||
name: 'com.affine.page-properties.property.progress',
|
||||
},
|
||||
select: {
|
||||
Icon: SingleSelectIcon,
|
||||
Renderer: SelectCell,
|
||||
name: 'com.affine.page-properties.property.select',
|
||||
},
|
||||
'multi-select': {
|
||||
Icon: MultiSelectIcon,
|
||||
Renderer: MultiSelectCell,
|
||||
name: 'com.affine.page-properties.property.multi-select',
|
||||
},
|
||||
} as Record<
|
||||
string,
|
||||
{
|
||||
Icon: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||
Renderer: React.FC<DatabaseCellRendererProps>;
|
||||
name: I18nString;
|
||||
}
|
||||
>;
|
||||
|
||||
export const isSupportedDatabaseRendererType = (type?: string): boolean => {
|
||||
return type ? type in DatabaseRendererTypes : false;
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const section = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
});
|
||||
|
||||
export const cell = style({
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
});
|
||||
|
||||
export const divider = style({
|
||||
margin: '8px 0',
|
||||
});
|
||||
|
||||
export const spacer = style({
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const docRefLink = style({
|
||||
maxWidth: '50%',
|
||||
fontSize: cssVar('fontSm'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: cssVarV2('text/tertiary'),
|
||||
});
|
||||
|
||||
export const cellList = style({
|
||||
padding: '0 2px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
});
|
||||
|
||||
globalStyle(`${docRefLink} .affine-reference-title`, {
|
||||
border: 'none',
|
||||
});
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
Divider,
|
||||
PropertyCollapsibleContent,
|
||||
PropertyCollapsibleSection,
|
||||
PropertyName,
|
||||
} from '@affine/component';
|
||||
import { AffinePageReference } from '@affine/core/components/affine/reference-link';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks';
|
||||
import { DatabaseTableViewIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
DocService,
|
||||
LiveData,
|
||||
useLiveData,
|
||||
useService,
|
||||
} from '@toeverything/infra';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
import { DocDatabaseBacklinksService } from '../../services/doc-database-backlinks';
|
||||
import type { DatabaseRow, DatabaseValueCell } from '../../types';
|
||||
import { DatabaseRendererTypes } from './constant';
|
||||
import * as styles from './doc-database-backlink-info.css';
|
||||
|
||||
type CellConfig =
|
||||
(typeof DatabaseRendererTypes)[keyof typeof DatabaseRendererTypes];
|
||||
|
||||
const DatabaseBacklinkCellName = ({
|
||||
cell,
|
||||
config,
|
||||
}: {
|
||||
cell: DatabaseValueCell;
|
||||
config: CellConfig;
|
||||
}) => {
|
||||
const propertyName = useLiveData(cell.property.name$);
|
||||
const t = useI18n();
|
||||
return (
|
||||
<PropertyName
|
||||
icon={<config.Icon />}
|
||||
name={propertyName ?? (config.name ? t.t(config.name) : t['unnamed']())}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const DatabaseBacklinkCell = ({
|
||||
cell,
|
||||
dataSource,
|
||||
rowId,
|
||||
}: {
|
||||
cell: DatabaseValueCell;
|
||||
dataSource: DatabaseBlockDataSource;
|
||||
rowId: string;
|
||||
}) => {
|
||||
const cellType = useLiveData(cell.property.type$);
|
||||
|
||||
const config = cellType ? DatabaseRendererTypes[cellType] : undefined;
|
||||
|
||||
// do not render title cell!
|
||||
if (!config || cellType === 'title') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={cell.id}
|
||||
className={styles.cell}
|
||||
data-testid="database-backlink-cell"
|
||||
>
|
||||
<DatabaseBacklinkCellName cell={cell} config={config} />
|
||||
<config.Renderer cell={cell} dataSource={dataSource} rowId={rowId} />
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A row in the database backlink info.
|
||||
* Note: it is being rendered in a list. The name might be confusing.
|
||||
*/
|
||||
const DatabaseBacklinkRow = ({
|
||||
defaultOpen = false,
|
||||
row$,
|
||||
}: {
|
||||
defaultOpen: boolean;
|
||||
row$: Observable<DatabaseRow | undefined>;
|
||||
}) => {
|
||||
const row = useLiveData(
|
||||
useMemo(() => LiveData.from(row$, undefined), [row$])
|
||||
);
|
||||
const sortedCells = useMemo(() => {
|
||||
return row?.cells.toSorted((a, b) => {
|
||||
return (a.property.name$.value ?? '').localeCompare(
|
||||
b.property.name$.value ?? ''
|
||||
);
|
||||
});
|
||||
}, [row?.cells]);
|
||||
const t = useI18n();
|
||||
|
||||
if (!row || !sortedCells) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyCollapsibleSection
|
||||
title={row.databaseName + ' ' + t['properties']()}
|
||||
defaultCollapsed={!defaultOpen}
|
||||
icon={<DatabaseTableViewIcon />}
|
||||
suffix={
|
||||
<AffinePageReference className={styles.docRefLink} pageId={row.docId} />
|
||||
}
|
||||
>
|
||||
<PropertyCollapsibleContent
|
||||
className={styles.cellList}
|
||||
collapsible={false}
|
||||
>
|
||||
{sortedCells.map(cell => {
|
||||
return (
|
||||
<DatabaseBacklinkCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
dataSource={row.dataSource}
|
||||
rowId={row.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</PropertyCollapsibleContent>
|
||||
</PropertyCollapsibleSection>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocDatabaseBacklinkInfo = ({
|
||||
defaultOpen = [],
|
||||
}: {
|
||||
defaultOpen?: {
|
||||
docId: string;
|
||||
blockId: string;
|
||||
}[];
|
||||
}) => {
|
||||
const doc = useService(DocService).doc;
|
||||
const docDatabaseBacklinks = useService(DocDatabaseBacklinksService);
|
||||
const rows = useLiveData(
|
||||
useMemo(
|
||||
() =>
|
||||
LiveData.from(docDatabaseBacklinks.watchDbBacklinkRows$(doc.id), []),
|
||||
[docDatabaseBacklinks, doc.id]
|
||||
)
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{rows.map(({ docId, rowId, row$ }) => (
|
||||
<Fragment key={`${docId}-${rowId}`}>
|
||||
<DatabaseBacklinkRow
|
||||
defaultOpen={defaultOpen?.some(
|
||||
backlink => backlink.docId === docId && backlink.blockId === rowId
|
||||
)}
|
||||
row$={row$}
|
||||
/>
|
||||
<Divider size="thinner" className={styles.divider} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
WorkspaceEngineBeforeStart,
|
||||
} from '@toeverything/infra';
|
||||
import { isEmpty, omit } from 'lodash-es';
|
||||
import { type Observable, switchMap } from 'rxjs';
|
||||
import { map, type Observable, switchMap } from 'rxjs';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocsIndexer } from '../entities/docs-indexer';
|
||||
|
||||
@@ -509,6 +510,76 @@ export class DocsSearchService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
watchDatabasesTo(docId: string) {
|
||||
const DatabaseAdditionalSchema = z.object({
|
||||
databaseName: z.string().optional(),
|
||||
});
|
||||
return this.indexer.blockIndex
|
||||
.search$(
|
||||
{
|
||||
type: 'boolean',
|
||||
occur: 'must',
|
||||
queries: [
|
||||
{
|
||||
type: 'match',
|
||||
field: 'refDocId',
|
||||
match: docId,
|
||||
},
|
||||
{
|
||||
type: 'match',
|
||||
field: 'parentFlavour',
|
||||
match: 'affine:database',
|
||||
},
|
||||
// Ignore if it is a link to the current document.
|
||||
{
|
||||
type: 'boolean',
|
||||
occur: 'must_not',
|
||||
queries: [
|
||||
{
|
||||
type: 'match',
|
||||
field: 'docId',
|
||||
match: docId,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: ['docId', 'blockId', 'parentBlockId', 'additional'],
|
||||
pagination: {
|
||||
limit: 100,
|
||||
},
|
||||
}
|
||||
)
|
||||
.pipe(
|
||||
map(({ nodes }) => {
|
||||
return nodes.map(node => {
|
||||
const additional =
|
||||
typeof node.fields.additional === 'string'
|
||||
? node.fields.additional
|
||||
: node.fields.additional[0];
|
||||
|
||||
return {
|
||||
docId:
|
||||
typeof node.fields.docId === 'string'
|
||||
? node.fields.docId
|
||||
: node.fields.docId[0],
|
||||
rowId:
|
||||
typeof node.fields.blockId === 'string'
|
||||
? node.fields.blockId
|
||||
: node.fields.blockId[0],
|
||||
databaseBlockId:
|
||||
typeof node.fields.parentBlockId === 'string'
|
||||
? node.fields.parentBlockId
|
||||
: node.fields.parentBlockId[0],
|
||||
databaseName: DatabaseAdditionalSchema.safeParse(additional).data
|
||||
?.databaseName as string | undefined,
|
||||
};
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async getDocTitle(docId: string) {
|
||||
const doc = await this.indexer.docIndex.get(docId);
|
||||
const title = doc?.get('title');
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DocsService } from '@toeverything/infra';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
|
||||
import type { TagStore } from '../stores/tag';
|
||||
import { tagColorMap } from './utils';
|
||||
import { tagToPaletteLine } from './utils';
|
||||
|
||||
export class Tag extends Entity<{ id: string }> {
|
||||
id = this.props.id;
|
||||
@@ -20,7 +20,7 @@ export class Tag extends Entity<{ id: string }> {
|
||||
|
||||
value$ = this.tagOption$.map(tag => tag?.value || '');
|
||||
|
||||
color$ = this.tagOption$.map(tag => tagColorMap(tag?.color ?? '') || '');
|
||||
color$ = this.tagOption$.map(tag => tagToPaletteLine(tag?.color ?? '') || '');
|
||||
|
||||
createDate$ = this.tagOption$.map(tag => tag?.createDate || Date.now());
|
||||
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
|
||||
export const tagColorMap = (color: string) => {
|
||||
const mapping: Record<string, string> = {
|
||||
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
|
||||
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
|
||||
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
|
||||
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
|
||||
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
|
||||
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
|
||||
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
|
||||
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
|
||||
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
|
||||
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
|
||||
};
|
||||
return mapping[color] || color;
|
||||
const tagToPaletteLineMap: Record<string, string> = {
|
||||
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
|
||||
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
|
||||
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
|
||||
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
|
||||
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
|
||||
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
|
||||
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
|
||||
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
|
||||
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
|
||||
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
|
||||
};
|
||||
|
||||
const paletteLineToTagMap: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(tagToPaletteLineMap).map(([key, value]) => [value, key])
|
||||
);
|
||||
|
||||
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
|
||||
export const tagToPaletteLine = (color: string) => {
|
||||
return tagToPaletteLineMap[color] || color;
|
||||
};
|
||||
|
||||
export const paletteLineToTag = (color: string) => {
|
||||
return paletteLineToTagMap[color] || color;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { Tag } from './entities/tag';
|
||||
export { tagColorMap } from './entities/utils';
|
||||
export { paletteLineToTag, tagToPaletteLine } from './entities/utils';
|
||||
export { TagService } from './service/tag';
|
||||
export { DeleteTagConfirmModal } from './view/delete-tag-modal';
|
||||
export { useDeleteTagConfirmModal } from './view/delete-tag-modal';
|
||||
|
||||
import {
|
||||
DocsService,
|
||||
|
||||
@@ -1,64 +1,77 @@
|
||||
import { ConfirmModal, toast } from '@affine/component';
|
||||
import { toast, useConfirmModal } from '@affine/component';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { TagService } from '../service/tag';
|
||||
|
||||
export const DeleteTagConfirmModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedTagIds,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedTagIds: string[];
|
||||
}) => {
|
||||
/**
|
||||
* Show a confirm modal AND delete the tags
|
||||
*/
|
||||
export const useDeleteTagConfirmModal = () => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const t = useI18n();
|
||||
const tagService = useService(TagService);
|
||||
const tags = useLiveData(tagService.tagList.tags$);
|
||||
const selectedTags = useMemo(() => {
|
||||
return tags.filter(tag => selectedTagIds.includes(tag.id));
|
||||
}, [selectedTagIds, tags]);
|
||||
const tagName = useLiveData(selectedTags[0]?.value$ || '');
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
selectedTagIds.forEach(tagId => {
|
||||
tagService.tagList.deleteTag(tagId);
|
||||
});
|
||||
const confirm = useCallback(
|
||||
(tagIdsToDelete: string[]) => {
|
||||
let closed = false;
|
||||
const { resolve, promise } = Promise.withResolvers<boolean>();
|
||||
const tagsToDelete = tags.filter(tag => tagIdsToDelete.includes(tag.id));
|
||||
const tagName = tagsToDelete[0]?.value$.value;
|
||||
const handleClose = (state: boolean) => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
resolve(state);
|
||||
|
||||
toast(
|
||||
selectedTagIds.length > 1
|
||||
? t['com.affine.delete-tags.count']({ count: selectedTagIds.length })
|
||||
: t['com.affine.tags.delete-tags.toast']()
|
||||
);
|
||||
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange, selectedTagIds, t, tagService]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t['com.affine.delete-tags.confirm.title']()}
|
||||
description={
|
||||
selectedTags.length === 1 ? (
|
||||
<Trans
|
||||
i18nKey={'com.affine.delete-tags.confirm.description'}
|
||||
values={{ tag: tagName }}
|
||||
components={{ 1: <strong /> }}
|
||||
/>
|
||||
) : (
|
||||
t['com.affine.delete-tags.confirm.multi-tag-description']({
|
||||
count: selectedTags.length.toString(),
|
||||
})
|
||||
)
|
||||
}
|
||||
confirmText={t['Delete']()}
|
||||
confirmButtonOptions={{
|
||||
variant: 'error',
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
if (state) {
|
||||
tagIdsToDelete.forEach(tagId => {
|
||||
tagService.tagList.deleteTag(tagId);
|
||||
});
|
||||
toast(
|
||||
tagIdsToDelete.length > 1
|
||||
? t['com.affine.delete-tags.count']({
|
||||
count: tagIdsToDelete.length,
|
||||
})
|
||||
: t['com.affine.tags.delete-tags.toast']()
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
openConfirmModal({
|
||||
title: t['com.affine.delete-tags.confirm.title'](),
|
||||
description:
|
||||
tagIdsToDelete.length === 1 ? (
|
||||
<Trans
|
||||
i18nKey={'com.affine.delete-tags.confirm.description'}
|
||||
values={{ tag: tagName }}
|
||||
components={{ 1: <strong /> }}
|
||||
/>
|
||||
) : (
|
||||
t['com.affine.delete-tags.confirm.multi-tag-description']({
|
||||
count: tagIdsToDelete.length.toString(),
|
||||
})
|
||||
),
|
||||
confirmText: t['Delete'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'error',
|
||||
},
|
||||
onConfirm: () => {
|
||||
handleClose(true);
|
||||
},
|
||||
onCancel: () => {
|
||||
handleClose(true);
|
||||
},
|
||||
onOpenChange: state => {
|
||||
handleClose(state);
|
||||
},
|
||||
});
|
||||
return promise;
|
||||
},
|
||||
[openConfirmModal, t, tagService.tagList, tags]
|
||||
);
|
||||
|
||||
return confirm;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user