diff --git a/packages/common/infra/src/modules/doc/constants.ts b/packages/common/infra/src/modules/doc/constants.ts index 5e476554ecc9e..1aaf11f8379c1 100644 --- a/packages/common/infra/src/modules/doc/constants.ts +++ b/packages/common/infra/src/modules/doc/constants.ts @@ -23,4 +23,14 @@ export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [ show: 'always-hide', index: 'a0000003', }, + { + id: 'createdAt', + type: 'createdAt', + index: 'a0000004', + }, + { + id: 'updatedAt', + type: 'updatedAt', + index: 'a0000005', + }, ] as DocCustomPropertyInfo[]; diff --git a/packages/frontend/component/.storybook/preview.tsx b/packages/frontend/component/.storybook/preview.tsx index 5215e4e59d271..30ebdef4f18af 100644 --- a/packages/frontend/component/.storybook/preview.tsx +++ b/packages/frontend/component/.storybook/preview.tsx @@ -1,13 +1,16 @@ -import './polyfill'; +import { getOrCreateI18n, I18nextProvider } from '@affine/i18n'; +import { ThemeProvider } from 'next-themes'; +import type { ComponentType } from 'react'; import '../src/theme'; +import './polyfill'; import './preview.css'; -import { ConfirmModalProvider } from '../src/ui/modal/confirm-modal'; -import { ThemeProvider, useTheme as useNextTheme } from 'next-themes'; -import type { ComponentType } from 'react'; -import React, { useEffect } from 'react'; import type { Preview } from '@storybook/react'; +import React, { useEffect } from 'react'; +import { ConfirmModalProvider } from '../src/ui/modal/confirm-modal'; + import { setupGlobal } from '@affine/env/global'; +import { useTheme as useNextTheme } from 'next-themes'; setupGlobal(); @@ -45,14 +48,18 @@ const ThemeToggle = ({ context }) => { return null; }; +const i18n = getOrCreateI18n(); + export const decorators = [ (Story: ComponentType, context) => { return ( - - - + + + + + ); }, diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index afbb99b843b45..0fbe970148921 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -27,9 +27,11 @@ "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-slider": "^1.2.0", diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index ffef1814dfbd7..09a579d864733 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -20,6 +20,7 @@ export * from './ui/menu'; export * from './ui/modal'; export * from './ui/notification'; export * from './ui/popover'; +export * from './ui/progress'; export * from './ui/property'; export * from './ui/radio'; export * from './ui/safe-area'; diff --git a/packages/frontend/component/src/theme/global.css b/packages/frontend/component/src/theme/global.css index bf67aaeb41346..636a0cba77d03 100644 --- a/packages/frontend/component/src/theme/global.css +++ b/packages/frontend/component/src/theme/global.css @@ -282,7 +282,8 @@ body { /** * A hack to make the anchor wrapper not affect the layout of the page. */ -[data-lit-react-wrapper] { +[data-lit-react-wrapper], +affine-lit-template-wrapper { display: contents; } diff --git a/packages/frontend/component/src/ui/progress/index.ts b/packages/frontend/component/src/ui/progress/index.ts new file mode 100644 index 0000000000000..2ee57814ba3fb --- /dev/null +++ b/packages/frontend/component/src/ui/progress/index.ts @@ -0,0 +1 @@ +export * from './progress'; diff --git a/packages/frontend/component/src/ui/progress/progress.css.ts b/packages/frontend/component/src/ui/progress/progress.css.ts new file mode 100644 index 0000000000000..508480327562c --- /dev/null +++ b/packages/frontend/component/src/ui/progress/progress.css.ts @@ -0,0 +1,71 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { createVar, style } from '@vanilla-extract/css'; + +const progressHeight = createVar(); + +export const root = style({ + height: '20px', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: 240, + gap: 12, + vars: { + [progressHeight]: '10px', + }, +}); + +export const progress = style({ + height: progressHeight, + flex: 1, + background: cssVarV2('layer/background/hoverOverlay'), + borderRadius: 5, + position: 'relative', +}); + +export const sliderRoot = style({ + height: progressHeight, + width: '100%', + position: 'absolute', + top: 0, + left: 0, +}); + +export const thumb = style({ + width: '4px', + height: `calc(${progressHeight} + 2px)`, + transform: 'translateY(-1px)', + borderRadius: '2px', + display: 'block', + background: cssVarV2('layer/insideBorder/primaryBorder'), + opacity: 0, + selectors: { + [`${root}:hover &, &:is(:focus-visible, :focus-within)`]: { + opacity: 1, + }, + }, +}); + +export const label = style({ + width: '40px', + fontSize: cssVar('fontSm'), +}); + +export const indicator = style({ + height: '100%', + width: '100%', + borderRadius: 5, + background: cssVarV2('toast/iconState/regular'), + transition: 'background 0.2s ease-in-out', + selectors: { + [`${root}:hover &, &:has(${thumb}:is(:focus-visible, :focus-within, :active))`]: + { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + }, + [`[data-state="complete"]&`]: { + background: cssVarV2('status/success'), + }, + }, +}); diff --git a/packages/frontend/component/src/ui/progress/progress.stories.tsx b/packages/frontend/component/src/ui/progress/progress.stories.tsx new file mode 100644 index 0000000000000..615c4bdb2bf98 --- /dev/null +++ b/packages/frontend/component/src/ui/progress/progress.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryFn } from '@storybook/react'; +import { useState } from 'react'; + +import type { ProgressProps } from './progress'; +import { Progress } from './progress'; + +export default { + title: 'UI/Progress', + component: Progress, +} satisfies Meta; + +const Template: StoryFn = () => { + const [value, setValue] = useState(30); + return ( + + ); +}; + +export const Default: StoryFn = Template.bind(undefined); diff --git a/packages/frontend/component/src/ui/progress/progress.tsx b/packages/frontend/component/src/ui/progress/progress.tsx new file mode 100644 index 0000000000000..debf3656e5997 --- /dev/null +++ b/packages/frontend/component/src/ui/progress/progress.tsx @@ -0,0 +1,51 @@ +import * as RadixProgress from '@radix-ui/react-progress'; +import * as RadixSlider from '@radix-ui/react-slider'; +import clsx from 'clsx'; + +import * as styles from './progress.css'; + +export interface ProgressProps { + /** + * The value of the progress bar. + * A value between 0 and 100. + */ + value: number; + onChange?: (value: number) => void; + onBlur?: () => void; + readonly?: boolean; + className?: string; + style?: React.CSSProperties; +} + +export const Progress = ({ + value, + onChange, + onBlur, + readonly, + className, + style, +}: ProgressProps) => { + return ( +
+ + + {!readonly ? ( + onChange?.(values[0])} + onBlur={onBlur} + > + + + ) : null} + +
{value}%
+
+ ); +}; diff --git a/packages/frontend/component/src/ui/property/property.css.ts b/packages/frontend/component/src/ui/property/property.css.ts index cbe69e7e5492f..71a4debf7f679 100644 --- a/packages/frontend/component/src/ui/property/property.css.ts +++ b/packages/frontend/component/src/ui/property/property.css.ts @@ -129,15 +129,13 @@ export const propertyValueContainer = style({ color: cssVarV2('text/placeholder'), }, selectors: { - '&[data-readonly="false"]': { + '&[data-readonly="false"][data-hoverable="true"]': { cursor: 'pointer', }, - '&[data-readonly="false"]:hover': { - backgroundColor: cssVarV2('layer/background/hoverOverlay'), - }, - '&[data-readonly="false"]:focus-within': { - backgroundColor: cssVarV2('layer/background/hoverOverlay'), - }, + '&[data-readonly="false"][data-hoverable="true"]:is(:hover, :focus-within)': + { + backgroundColor: cssVarV2('layer/background/hoverOverlay'), + }, }, }); @@ -162,3 +160,67 @@ globalStyle(`${tableButton} svg`, { globalStyle(`${tableButton}:hover svg`, { color: cssVarV2('icon/primary'), }); + +export const section = style({ + display: 'flex', + flexDirection: 'column', + gap: 8, +}); + +export const sectionHeader = style({ + display: 'flex', + alignItems: 'center', + gap: 4, + padding: '4px 6px', + minHeight: 30, +}); + +export const sectionHeaderTrigger = style({ + display: 'flex', + alignItems: 'center', + gap: 4, + flex: 1, +}); + +export const sectionHeaderIcon = style({ + width: 16, + height: 16, + fontSize: 16, + color: cssVarV2('icon/primary'), +}); + +export const sectionHeaderName = style({ + display: 'flex', + alignItems: 'center', + fontSize: cssVar('fontSm'), + fontWeight: 500, + whiteSpace: 'nowrap', + selectors: { + '&[data-collapsed="true"]': { + color: cssVarV2('text/secondary'), + }, + }, +}); + +export const sectionCollapsedIcon = style({ + transition: 'all 0.2s ease-in-out', + color: cssVarV2('icon/primary'), + fontSize: 20, + selectors: { + '&[data-collapsed="true"]': { + transform: 'rotate(90deg)', + color: cssVarV2('icon/secondary'), + }, + }, +}); + +export const sectionContent = style({ + display: 'flex', + flexDirection: 'column', + gap: 4, + selectors: { + '&[hidden]': { + display: 'none', + }, + }, +}); diff --git a/packages/frontend/component/src/ui/property/property.stories.tsx b/packages/frontend/component/src/ui/property/property.stories.tsx index 89a0b0091bb3e..4c464fbddea03 100644 --- a/packages/frontend/component/src/ui/property/property.stories.tsx +++ b/packages/frontend/component/src/ui/property/property.stories.tsx @@ -3,7 +3,8 @@ import { FrameIcon } from '@blocksuite/icons/rc'; import { useDraggable, useDropTarget } from '../dnd'; import { MenuItem } from '../menu'; import { - PropertyCollapsible, + PropertyCollapsibleContent, + PropertyCollapsibleSection, PropertyName, PropertyRoot, PropertyValue, @@ -100,9 +101,9 @@ export const HideEmptyProperty = () => { ); }; -export const BasicPropertyCollapsible = () => { +export const BasicPropertyCollapsibleContent = () => { return ( - + } /> Value @@ -115,13 +116,24 @@ export const BasicPropertyCollapsible = () => { } /> Value - + + ); +}; + +export const BasicPropertyCollapsibleSection = () => { + return ( + } + title="Collapsible Section" + > + + ); }; export const PropertyCollapsibleCustomButton = () => { return ( - `${isCollapsed ? 'Show' : 'Hide'} ${hide} properties` @@ -139,6 +151,6 @@ export const PropertyCollapsibleCustomButton = () => { } /> Value - + ); }; diff --git a/packages/frontend/component/src/ui/property/property.tsx b/packages/frontend/component/src/ui/property/property.tsx index e87e43454f483..e69c8bc588154 100644 --- a/packages/frontend/component/src/ui/property/property.tsx +++ b/packages/frontend/component/src/ui/property/property.tsx @@ -1,5 +1,10 @@ import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; -import { ArrowDownSmallIcon, ArrowUpSmallIcon } from '@blocksuite/icons/rc'; +import { + ArrowDownSmallIcon, + ArrowUpSmallIcon, + ToggleExpandIcon, +} from '@blocksuite/icons/rc'; +import * as Collapsible from '@radix-ui/react-collapsible'; import clsx from 'clsx'; import { createContext, @@ -24,7 +29,92 @@ const PropertyTableContext = createContext<{ showAllHide: boolean; } | null>(null); -export const PropertyCollapsible = forwardRef< +export const PropertyCollapsibleSection = forwardRef< + HTMLDivElement, + PropsWithChildren<{ + defaultCollapsed?: boolean; + icon?: ReactNode; + title: ReactNode; + suffix?: ReactNode; + collapsed?: boolean; + onCollapseChange?: (collapsed: boolean) => void; + }> & + HTMLProps +>( + ( + { + children, + defaultCollapsed = false, + collapsed, + onCollapseChange, + icon, + title, + suffix, + className, + ...props + }, + ref + ) => { + const [internalCollapsed, setInternalCollapsed] = + useState(defaultCollapsed); + + const handleCollapse = useCallback( + (open: boolean) => { + setInternalCollapsed(!open); + onCollapseChange?.(!open); + }, + [onCollapseChange] + ); + + const finalCollapsed = + collapsed !== undefined ? collapsed : internalCollapsed; + + return ( + +
+ + {icon &&
{icon}
} +
+ {title} +
+ +
+ {suffix} +
+ + {children} + +
+ ); + } +); + +PropertyCollapsibleSection.displayName = 'PropertyCollapsibleSection'; + +export const PropertyCollapsibleContent = forwardRef< HTMLDivElement, PropsWithChildren<{ collapsible?: boolean; @@ -124,7 +214,7 @@ export const PropertyCollapsible = forwardRef< } ); -PropertyCollapsible.displayName = 'PropertyCollapsible'; +PropertyCollapsibleContent.displayName = 'PropertyCollapsible'; const PropertyRootContext = createContext<{ mountValue: (payload: { isEmpty: boolean }) => () => void; @@ -249,28 +339,38 @@ export const PropertyName = ({ export const PropertyValue = forwardRef< HTMLDivElement, - { readonly?: boolean; isEmpty?: boolean } & HTMLProps ->(({ children, className, readonly, isEmpty, ...props }, ref) => { - const context = useContext(PropertyRootContext); + { + readonly?: boolean; + isEmpty?: boolean; + hoverable?: boolean; + } & HTMLProps +>( + ( + { children, className, readonly, isEmpty, hoverable = true, ...props }, + ref + ) => { + const context = useContext(PropertyRootContext); - useLayoutEffect(() => { - if (context) { - return context.mountValue({ isEmpty: !!isEmpty }); - } - return; - }, [context, isEmpty]); + useLayoutEffect(() => { + if (context) { + return context.mountValue({ isEmpty: !!isEmpty }); + } + return; + }, [context, isEmpty]); - return ( -
- {children} -
- ); -}); + return ( +
+ {children} +
+ ); + } +); PropertyValue.displayName = 'PropertyValue'; diff --git a/packages/frontend/component/src/ui/tags/index.ts b/packages/frontend/component/src/ui/tags/index.ts new file mode 100644 index 0000000000000..ba3a0e7af0e3b --- /dev/null +++ b/packages/frontend/component/src/ui/tags/index.ts @@ -0,0 +1,3 @@ +export * from './tag'; +export * from './tags-editor'; +export * from './types'; diff --git a/packages/frontend/component/src/ui/tags/inline-tag-list.css.ts b/packages/frontend/component/src/ui/tags/inline-tag-list.css.ts new file mode 100644 index 0000000000000..75359852cbb36 --- /dev/null +++ b/packages/frontend/component/src/ui/tags/inline-tag-list.css.ts @@ -0,0 +1,8 @@ +import { style } from '@vanilla-extract/css'; + +export const inlineTagsContainer = style({ + display: 'flex', + gap: '6px', + flexWrap: 'wrap', + width: '100%', +}); diff --git a/packages/frontend/component/src/ui/tags/inline-tag-list.tsx b/packages/frontend/component/src/ui/tags/inline-tag-list.tsx new file mode 100644 index 0000000000000..09b32fb60e7c7 --- /dev/null +++ b/packages/frontend/component/src/ui/tags/inline-tag-list.tsx @@ -0,0 +1,47 @@ +import type { HTMLAttributes, PropsWithChildren } from 'react'; + +import * as styles from './inline-tag-list.css'; +import { TagItem } from './tag'; +import type { TagLike } from './types'; + +interface InlineTagListProps + extends Omit, 'onChange'> { + onRemoved?: (id: string) => void; + tags: TagLike[]; + tagMode: 'inline-tag' | 'db-label'; + focusedIndex?: number; +} + +export const InlineTagList = ({ + children, + focusedIndex, + tags, + onRemoved, + tagMode, +}: PropsWithChildren) => { + return ( +
+ {tags.map((tag, idx) => { + if (!tag) { + return null; + } + const handleRemoved = onRemoved + ? () => { + onRemoved?.(tag.id); + } + : undefined; + return ( + + ); + })} + {children} +
+ ); +}; diff --git a/packages/frontend/component/src/ui/tags/readme.md b/packages/frontend/component/src/ui/tags/readme.md new file mode 100644 index 0000000000000..f3afd311223aa --- /dev/null +++ b/packages/frontend/component/src/ui/tags/readme.md @@ -0,0 +1,3 @@ +# Tags Editor + +A common module for both page and database tags editing (serviceless). diff --git a/packages/frontend/core/src/components/doc-properties/tags-inline-editor.css.ts b/packages/frontend/component/src/ui/tags/styles.css.ts similarity index 91% rename from packages/frontend/core/src/components/doc-properties/tags-inline-editor.css.ts rename to packages/frontend/component/src/ui/tags/styles.css.ts index f1585756f074e..2166a3e68a01e 100644 --- a/packages/frontend/core/src/components/doc-properties/tags-inline-editor.css.ts +++ b/packages/frontend/component/src/ui/tags/styles.css.ts @@ -115,20 +115,6 @@ 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({ diff --git a/packages/frontend/component/src/ui/tags/tag-edit-menu.css.ts b/packages/frontend/component/src/ui/tags/tag-edit-menu.css.ts new file mode 100644 index 0000000000000..5ffe1d1ab42a4 --- /dev/null +++ b/packages/frontend/component/src/ui/tags/tag-edit-menu.css.ts @@ -0,0 +1,32 @@ +import { globalStyle, style } from '@vanilla-extract/css'; + +export const menuItemListScrollable = style({}); + +export const menuItemListScrollbar = style({ + transform: 'translateX(4px)', +}); + +export const menuItemList = style({ + display: 'flex', + flexDirection: 'column', + maxHeight: 200, + overflow: 'auto', +}); + +globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, { + display: 'table !important', +}); + +export const tagColorIconWrapper = style({ + width: 20, + height: 20, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +export const tagColorIcon = style({ + width: 16, + height: 16, + borderRadius: '50%', +}); diff --git a/packages/frontend/component/src/ui/tags/tag-edit-menu.tsx b/packages/frontend/component/src/ui/tags/tag-edit-menu.tsx new file mode 100644 index 0000000000000..96c650be686c2 --- /dev/null +++ b/packages/frontend/component/src/ui/tags/tag-edit-menu.tsx @@ -0,0 +1,113 @@ +import { useI18n } from '@affine/i18n'; +import { DeleteIcon, TagsIcon } from '@blocksuite/icons/rc'; +import type { PropsWithChildren } from 'react'; +import { useMemo } from 'react'; + +import Input from '../input'; +import { Menu, MenuItem, type MenuProps, MenuSeparator } from '../menu'; +import { Scrollable } from '../scrollbar'; +import * as styles from './tag-edit-menu.css'; +import type { TagColor, TagLike } from './types'; + +type TagEditMenuProps = PropsWithChildren<{ + onTagDelete: (tagId: string) => void; + colors: TagColor[]; + tag: TagLike; + onTagChange: (property: keyof TagLike, value: string) => void; + jumpToTag?: (tagId: string) => void; +}>; + +export const TagEditMenu = ({ + tag, + onTagDelete, + children, + jumpToTag, + colors, + onTagChange, +}: TagEditMenuProps) => { + const t = useI18n(); + + const menuProps = useMemo(() => { + const updateTagName = (name: string) => { + if (name.trim() === '') { + return; + } + onTagChange('value', name); + }; + + return { + contentOptions: { + onClick(e) { + e.stopPropagation(); + }, + }, + items: ( + <> + { + updateTagName(e.currentTarget.value); + }} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + updateTagName(e.currentTarget.value); + } + e.stopPropagation(); + }} + placeholder={t['Untitled']()} + /> + + } + type="danger" + onClick={() => { + tag?.id ? onTagDelete(tag.id) : null; + }} + > + {t['Delete']()} + + {jumpToTag ? ( + } + onClick={() => { + jumpToTag(tag.id); + }} + > + {t['com.affine.page-properties.tags.open-tags-page']()} + + ) : null} + + + + {colors.map(({ name, value: color }, i) => ( + +
+
+ } + onClick={() => { + onTagChange('color', color); + }} + > + {name} +
+ ))} + +
+
+ + ), + } satisfies Partial; + }, [tag, t, jumpToTag, colors, onTagChange, onTagDelete]); + + return {children}; +}; diff --git a/packages/frontend/component/src/ui/tags/tag.css.ts b/packages/frontend/component/src/ui/tags/tag.css.ts new file mode 100644 index 0000000000000..1616a71c4d3d4 --- /dev/null +++ b/packages/frontend/component/src/ui/tags/tag.css.ts @@ -0,0 +1,168 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { createVar, style } from '@vanilla-extract/css'; +export const hoverMaxWidth = createVar(); + +export const tagColorVar = createVar(); + +export const root = style({ + position: 'relative', + width: '100%', + height: '100%', + minHeight: '32px', +}); + +export const tagsContainer = style({ + display: 'flex', + alignItems: 'center', +}); +export const tagsScrollContainer = style([ + tagsContainer, + { + overflowX: 'hidden', + position: 'relative', + height: '100%', + gap: '8px', + }, +]); +export const tagsListContainer = style([ + tagsContainer, + { + flexWrap: 'wrap', + flexDirection: 'column', + alignItems: 'flex-start', + gap: '4px', + }, +]); +export const innerContainer = style({ + display: 'flex', + columnGap: '8px', + alignItems: 'center', + position: 'absolute', + height: '100%', + maxWidth: '100%', + transition: 'all 0.2s 0.3s ease-in-out', + selectors: { + [`${root}:hover &`]: { + maxWidth: hoverMaxWidth, + }, + }, +}); + +// background with linear gradient hack +export const innerBackdrop = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '100%', + opacity: 0, + transition: 'all 0.2s', + background: `linear-gradient(90deg, transparent 0%, ${cssVar( + 'hoverColorFilled' + )} 40%)`, + selectors: { + [`${root}:hover &`]: { + opacity: 1, + }, + }, +}); +export const tag = style({ + height: '22px', + display: 'flex', + minWidth: 0, + alignItems: 'center', + justifyContent: 'space-between', + ':last-child': { + minWidth: 'max-content', + }, +}); +export const tagInnerWrapper = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 8px', + color: cssVar('textPrimaryColor'), + borderColor: cssVar('borderColor'), + selectors: { + '&[data-focused=true]': { + borderColor: cssVar('primaryColor'), + }, + }, +}); +export const tagInlineMode = style([ + tagInnerWrapper, + { + fontSize: 'inherit', + borderRadius: '10px', + columnGap: '4px', + borderWidth: '1px', + borderStyle: 'solid', + background: cssVar('backgroundPrimaryColor'), + maxWidth: '128px', + height: '100%', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, +]); +export const tagListItemMode = style([ + tag, + { + fontSize: cssVar('fontSm'), + padding: '4px 12px', + columnGap: '8px', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + height: '30px', + }, +]); +export const tagLabelMode = style([ + tag, + { + fontSize: cssVar('fontSm'), + background: tagColorVar, + padding: '0 8px', + borderRadius: 4, + border: `1px solid ${cssVarV2('database/border')}`, + gap: 4, + selectors: { + '&[data-focused=true]': { + borderColor: cssVar('primaryColor'), + }, + }, + }, +]); + +export const showMoreTag = style({ + fontSize: cssVar('fontH5'), + right: 0, + position: 'sticky', + display: 'inline-flex', +}); +export const tagIndicator = style({ + width: '8px', + height: '8px', + borderRadius: '50%', + flexShrink: 0, + background: tagColorVar, +}); +export const tagLabel = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + userSelect: 'none', +}); + +export const tagRemove = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 12, + height: 12, + borderRadius: '50%', + flexShrink: 0, + cursor: 'pointer', + ':hover': { + background: 'var(--affine-hover-color)', + }, +}); diff --git a/packages/frontend/component/src/ui/tags/tag.tsx b/packages/frontend/component/src/ui/tags/tag.tsx new file mode 100644 index 0000000000000..6b0de484b9f36 --- /dev/null +++ b/packages/frontend/component/src/ui/tags/tag.tsx @@ -0,0 +1,74 @@ +import { CloseIcon } from '@blocksuite/icons/rc'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import clsx from 'clsx'; +import { type MouseEventHandler, useCallback } from 'react'; + +import * as styles from './tag.css'; +import type { TagLike } from './types'; + +export interface TagItemProps { + tag: TagLike; + idx?: number; + maxWidth?: number | string; + // @todo(pengx17): better naming + mode: 'inline-tag' | 'list-tag' | 'db-label'; + focused?: boolean; + onRemoved?: () => void; + style?: React.CSSProperties; +} + +export const TagItem = ({ + tag, + idx, + mode, + focused, + onRemoved, + style, + maxWidth, +}: TagItemProps) => { + const { value, color, id } = tag; + const handleRemove: MouseEventHandler = useCallback( + e => { + e.stopPropagation(); + onRemoved?.(); + }, + [onRemoved] + ); + return ( +
+
+ {mode !== 'db-label' ?
: null} +
{value}
+ {onRemoved ? ( +
+ +
+ ) : null} +
+
+ ); +}; diff --git a/packages/frontend/component/src/ui/tags/tags-editor.tsx b/packages/frontend/component/src/ui/tags/tags-editor.tsx new file mode 100644 index 0000000000000..507e17f5be80d --- /dev/null +++ b/packages/frontend/component/src/ui/tags/tags-editor.tsx @@ -0,0 +1,330 @@ +import { useI18n } from '@affine/i18n'; +import { MoreHorizontalIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; +import { clamp } from 'lodash-es'; +import type { KeyboardEvent } from 'react'; +import { useCallback, useMemo, useReducer, useRef, useState } from 'react'; + +import { IconButton } from '../button'; +import { RowInput } from '../input'; +import { Menu } from '../menu'; +import { Scrollable } from '../scrollbar'; +import { InlineTagList } from './inline-tag-list'; +import * as styles from './styles.css'; +import { TagItem } from './tag'; +import { TagEditMenu } from './tag-edit-menu'; +import type { TagColor, TagLike } from './types'; + +export interface TagsEditorProps { + tags: TagLike[]; // candidates to show in the tag dropdown + selectedTags: string[]; + onCreateTag: (name: string, color: string) => TagLike; + onSelectTag: (tagId: string) => void; // activate tag + onDeselectTag: (tagId: string) => void; // deactivate tag + tagColors: TagColor[]; + onTagChange: (id: string, property: keyof TagLike, value: string) => void; + onDeleteTag: (id: string) => void; // a candidate to be deleted + jumpToTag?: (id: string) => void; + tagMode: 'inline-tag' | 'db-label'; +} + +export interface TagsInlineEditorProps extends TagsEditorProps { + placeholder?: string; + className?: string; + readonly?: boolean; +} + +type TagOption = TagLike | { readonly create: true; readonly value: string }; + +const isCreateNewTag = ( + tagOption: TagOption +): tagOption is { readonly create: true; readonly value: string } => { + return 'create' in tagOption; +}; + +export const TagsEditor = ({ + tags, + selectedTags, + onSelectTag, + onDeselectTag, + onCreateTag, + tagColors, + onDeleteTag: onTagDelete, + onTagChange, + jumpToTag, + tagMode, +}: TagsEditorProps) => { + const t = useI18n(); + const [inputValue, setInputValue] = useState(''); + const filteredTags = tags.filter(tag => tag.value.includes(inputValue)); + const inputRef = useRef(null); + + const exactMatch = filteredTags.find(tag => tag.value === inputValue); + const showCreateTag = !exactMatch && inputValue.trim(); + + // tag option candidates to show in the tag dropdown + const tagOptions: TagOption[] = useMemo(() => { + if (showCreateTag) { + return [{ create: true, value: inputValue } as const, ...filteredTags]; + } else { + return filteredTags; + } + }, [filteredTags, inputValue, showCreateTag]); + + const [focusedIndex, setFocusedIndex] = useState(-1); + const [focusedInlineIndex, setFocusedInlineIndex] = useState( + selectedTags.length + ); + + // -1: no focus + const safeFocusedIndex = clamp(focusedIndex, -1, tagOptions.length - 1); + // inline tags focus index can go beyond the length of tagIds + // using -1 and tagIds.length to make keyboard navigation easier + const safeInlineFocusedIndex = clamp( + focusedInlineIndex, + -1, + selectedTags.length + ); + + const scrollContainerRef = useRef(null); + + const onInputChange = useCallback((value: string) => { + setInputValue(value); + }, []); + + const onToggleTag = useCallback( + (id: string) => { + if (!selectedTags.includes(id)) { + onSelectTag(id); + } else { + onDeselectTag(id); + } + }, + [selectedTags, onSelectTag, onDeselectTag] + ); + + const focusInput = useCallback(() => { + inputRef.current?.focus(); + }, []); + + const [nextColor, rotateNextColor] = useReducer( + color => { + const idx = tagColors.findIndex(c => c.value === color); + return tagColors[(idx + 1) % tagColors.length].value; + }, + tagColors[Math.floor(Math.random() * tagColors.length)].value + ); + + const handleCreateTag = useCallback( + (name: string) => { + rotateNextColor(); + const newTag = onCreateTag(name.trim(), nextColor); + return newTag.id; + }, + [onCreateTag, nextColor] + ); + + const onSelectTagOption = useCallback( + (tagOption: TagOption) => { + const id = isCreateNewTag(tagOption) + ? handleCreateTag(tagOption.value) + : tagOption.id; + onToggleTag(id); + setInputValue(''); + focusInput(); + setFocusedIndex(-1); + setFocusedInlineIndex(selectedTags.length + 1); + }, + [handleCreateTag, onToggleTag, focusInput, selectedTags.length] + ); + const onEnter = useCallback(() => { + if (safeFocusedIndex >= 0) { + onSelectTagOption(tagOptions[safeFocusedIndex]); + } + }, [onSelectTagOption, safeFocusedIndex, tagOptions]); + + const handleUntag = useCallback( + (id: string) => { + onToggleTag(id); + focusInput(); + }, + [onToggleTag, focusInput] + ); + + const onInputKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Backspace' && inputValue === '' && selectedTags.length) { + const index = + safeInlineFocusedIndex < 0 || + safeInlineFocusedIndex >= selectedTags.length + ? selectedTags.length - 1 + : safeInlineFocusedIndex; + const tagToRemove = selectedTags.at(index); + if (tagToRemove) { + onDeselectTag(tagToRemove); + } + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + const newFocusedIndex = clamp( + safeFocusedIndex + (e.key === 'ArrowUp' ? -1 : 1), + 0, + tagOptions.length - 1 + ); + scrollContainerRef.current + ?.querySelector( + `.${styles.tagSelectorItem}:nth-child(${newFocusedIndex + 1})` + ) + ?.scrollIntoView({ block: 'nearest' }); + setFocusedIndex(newFocusedIndex); + // reset inline focus + setFocusedInlineIndex(selectedTags.length + 1); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + const newItemToFocus = + e.key === 'ArrowLeft' + ? safeInlineFocusedIndex - 1 + : safeInlineFocusedIndex + 1; + + e.preventDefault(); + setFocusedInlineIndex(newItemToFocus); + // reset tag list focus + setFocusedIndex(-1); + } + }, + [ + inputValue, + safeInlineFocusedIndex, + selectedTags, + onDeselectTag, + safeFocusedIndex, + tagOptions.length, + ] + ); + + return ( +
+
+ selectedTags.includes(tag.id))} + focusedIndex={safeInlineFocusedIndex} + onRemoved={handleUntag} + > + + +
+
+
+ {t['com.affine.page-properties.tags.selector-header-title']()} +
+ + + {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 ( +
+ {t['Create']()}{' '} + +
+ ); + } else { + return ( +
+ +
+ { + onTagChange(tag.id, property, value); + }} + jumpToTag={jumpToTag} + colors={tagColors} + > + + + + +
+ ); + } + })} + + + +
+
+ ); +}; + +export const TagsInlineEditor = ({ + readonly, + placeholder, + className, + ...props +}: TagsInlineEditorProps) => { + const empty = !props.selectedTags || props.selectedTags.length === 0; + const selectedTags = useMemo(() => { + return props.selectedTags + .map(id => props.tags.find(tag => tag.id === id)) + .filter(tag => tag !== undefined); + }, [props.selectedTags, props.tags]); + return ( + } + > +
+ {empty ? ( + placeholder + ) : ( + + )} +
+
+ ); +}; diff --git a/packages/frontend/component/src/ui/tags/tags.stories.tsx b/packages/frontend/component/src/ui/tags/tags.stories.tsx new file mode 100644 index 0000000000000..e81a3903fa9cf --- /dev/null +++ b/packages/frontend/component/src/ui/tags/tags.stories.tsx @@ -0,0 +1,120 @@ +import type { Meta, StoryFn } from '@storybook/react'; +import { useState } from 'react'; + +import { InlineTagList } from './inline-tag-list'; +import { TagItem } from './tag'; +import { TagEditMenu } from './tag-edit-menu'; +import { TagsInlineEditor } from './tags-editor'; +import type { TagColor, TagLike } from './types'; + +export default { + title: 'UI/Tags', +} satisfies Meta; + +const tags: TagLike[] = [ + { id: '1', value: 'tag', color: 'red' }, + { id: '2', value: 'tag2', color: 'blue' }, + { id: '3', value: 'tag3', color: 'green' }, +]; + +const tagColors: TagColor[] = [ + { id: '1', value: 'red', name: 'Red' }, + { id: '2', value: 'blue', name: 'Blue' }, + { id: '3', value: 'green', name: 'Green' }, +]; + +export const Tags: StoryFn = () => { + return ( +
+ { + console.log('removed'); + }} + /> + + + { + console.log('removed'); + }} + /> + +
+ ); +}; + +export const InlineTagListStory: StoryFn = () => { + return ; +}; + +export const TagEditMenuStory: StoryFn = () => { + return ( + {}} + onTagDelete={() => {}} + jumpToTag={() => {}} + > +
Trigger Edit Tag Menu
+
+ ); +}; + +export const TagsInlineEditorStory: StoryFn = () => { + const [options, setOptions] = useState(tags); + const [selectedTags, setSelectedTags] = useState( + options.slice(0, 1).map(item => item.id) + ); + + return ( + { + const newTag = { + id: (options.at(-1)!.id ?? 0) + 1, + value: name, + color, + }; + setOptions(prev => [...prev, newTag]); + return newTag; + }} + tagColors={tagColors} + onTagChange={(id, property, value) => { + setOptions(prev => { + const index = prev.findIndex(item => item.id === id); + if (index === -1) { + return prev; + } + return options.toSpliced(index, 1, { + ...options[index], + [property]: value, + }); + }); + }} + onDeleteTag={tagId => { + setOptions(prev => prev.filter(item => item.id !== tagId)); + }} + onSelectTag={tagId => { + setSelectedTags(prev => [...prev, tagId]); + }} + onDeselectTag={tagId => { + setSelectedTags(prev => prev.filter(id => id !== tagId)); + }} + /> + ); +}; diff --git a/packages/frontend/component/src/ui/tags/types.ts b/packages/frontend/component/src/ui/tags/types.ts new file mode 100644 index 0000000000000..e25a05b31849b --- /dev/null +++ b/packages/frontend/component/src/ui/tags/types.ts @@ -0,0 +1,11 @@ +export interface TagLike { + id: string; + value: string; // value is the tag name + color: string; // css color value +} + +export interface TagColor { + id: string; + value: string; // css color value + name?: string; // display name +} diff --git a/packages/frontend/core/src/components/affine/reference-link/index.tsx b/packages/frontend/core/src/components/affine/reference-link/index.tsx index f44a876a7c2b4..38bf78dd851cb 100644 --- a/packages/frontend/core/src/components/affine/reference-link/index.tsx +++ b/packages/frontend/core/src/components/affine/reference-link/index.tsx @@ -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; 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 ? {el} : el} diff --git a/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.css.ts b/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.css.ts index a858d161af78a..9c6903a34c84d 100644 --- a/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.css.ts +++ b/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.css.ts @@ -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({ diff --git a/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.tsx b/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.tsx index 47e28125d1634..e96009461386f 100644 --- a/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.tsx +++ b/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.tsx @@ -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 ( -
- - + <> {backlinks && backlinks.length > 0 ? ( <> ) : null} - - isCollapsed - ? hide === 1 - ? t['com.affine.page-properties.more-property.one']({ - count: hide.toString(), - }) - : t['com.affine.page-properties.more-property.more']({ - count: hide.toString(), - }) - : hide === 1 - ? t['com.affine.page-properties.hide-property.one']({ - count: hide.toString(), - }) - : t['com.affine.page-properties.hide-property.more']({ - count: hide.toString(), - }) - } + - {properties.map(property => ( - - ))} - } - contentOptions={{ - onClick(e) { - e.stopPropagation(); - }, - }} + + 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(), + }) + } > - - - -
+ + + + + + + ); }; diff --git a/packages/frontend/core/src/components/doc-properties/info-modal/links-row.css.ts b/packages/frontend/core/src/components/doc-properties/info-modal/links-row.css.ts index 4f093464f629c..026f6cb393dc8 100644 --- a/packages/frontend/core/src/components/doc-properties/info-modal/links-row.css.ts +++ b/packages/frontend/core/src/components/doc-properties/info-modal/links-row.css.ts @@ -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`, { diff --git a/packages/frontend/core/src/components/doc-properties/info-modal/links-row.tsx b/packages/frontend/core/src/components/doc-properties/info-modal/links-row.tsx index e59a050c3ab93..f6f52f1fb5c73 100644 --- a/packages/frontend/core/src/components/doc-properties/info-modal/links-row.tsx +++ b/packages/frontend/core/src/components/doc-properties/info-modal/links-row.tsx @@ -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 ( -
-
- {label} · {references.length} -
+ {references.map((link, index) => ( ))} -
+ ); }; diff --git a/packages/frontend/core/src/components/doc-properties/menu/create-doc-property.tsx b/packages/frontend/core/src/components/doc-properties/menu/create-doc-property.tsx index 8315bd157fb49..de235de977d1f 100644 --- a/packages/frontend/core/src/components/doc-properties/menu/create-doc-property.tsx +++ b/packages/frontend/core/src/components/doc-properties/menu/create-doc-property.tsx @@ -74,7 +74,11 @@ export const CreatePropertyMenuItems = ({ >
{name} - {isUniqueExist && Added} + {isUniqueExist && ( + + {t['com.affine.page-properties.create-property.added']()} + + )}
); diff --git a/packages/frontend/core/src/components/doc-properties/table.css.ts b/packages/frontend/core/src/components/doc-properties/table.css.ts index 00a2bc948c2bb..5bb035845302c 100644 --- a/packages/frontend/core/src/components/doc-properties/table.css.ts +++ b/packages/frontend/core/src/components/doc-properties/table.css.ts @@ -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({ diff --git a/packages/frontend/core/src/components/doc-properties/table.tsx b/packages/frontend/core/src/components/doc-properties/table.tsx index 4edc0f26e907e..ac768c7484f57 100644 --- a/packages/frontend/core/src/components/doc-properties/table.tsx +++ b/packages/frontend/core/src/components/doc-properties/table.tsx @@ -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 = ( -
- {t['Created']()} {localizedCreateTime} -
- ); - - return serverClock ? ( - -
- {t['Updated']()} {i18nTime(serverClock)} -
- {createDate && ( -
- {t['Created']()} {i18nTime(createDate)} -
- )} - - } - > -
- {!syncing && !retrying ? ( - <> - {t['Updated']()}{' '} - {i18nTime(serverClock, { - relative: { - max: [1, 'day'], - accuracy: 'minute', - }, - absolute: { - accuracy: 'day', - }, - })} - - ) : ( - <>{t['com.affine.syncing']()} - )} -
-
- ) : updatedDate ? ( - -
- {t['Updated']()} {i18nTime(updatedDate)} -
-
- ) : ( - 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 ( -
- {/* TODO(@Peng): add click handler to backlinks */} -
- {backlinks.length > 0 ? ( - -
- {t['com.affine.page-properties.backlinks']()} · {backlinks.length} -
-
- ) : null} - {dTimestampElement} + +
+
+ {t['com.affine.page-properties.page-info']()} +
+
+ +
+
- {displayDocInfo ? ( -
-
- {t['com.affine.page-properties.page-info']()} -
- -
- - - -
-
-
- ) : null} -
+
); }; @@ -364,13 +252,14 @@ export const DocPropertiesTableBody = forwardRef< const [newPropertyId, setNewPropertyId] = useState(null); return ( -
-
- -
-
+ + ); }); DocPropertiesTableBody.displayName = 'PagePropertiesTableBody'; @@ -455,8 +343,10 @@ const DocPropertiesTableInner = () => { className={styles.rootCentered} > - + +
+
diff --git a/packages/frontend/core/src/components/doc-properties/tags-inline-editor.tsx b/packages/frontend/core/src/components/doc-properties/tags-inline-editor.tsx index 07e84b5137caf..2517748d592cc 100644 --- a/packages/frontend/core/src/components/doc-properties/tags-inline-editor.tsx +++ b/packages/frontend/core/src/components/doc-properties/tags-inline-editor.tsx @@ -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, 'onChange'>, - Omit { - onRemove?: () => void; +interface TagsInlineEditorProps extends TagsEditorProps { + placeholder?: string; + className?: string; } -export const InlineTagsList = ({ +export const TagsInlineEditor = ({ pageId, readonly, - children, - focusedIndex, - onRemove, -}: PropsWithChildren) => { - const tagList = useService(TagService).tagList; - const tags = useLiveData(tagList.tags$); - const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId)); - - return ( -
- {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 ( - - ); - })} - {children} -
- ); -}; - -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: ( - <> - { - updateTagName(e.currentTarget.value); - }} - onKeyDown={e => { - if (e.key === 'Enter') { - e.preventDefault(); - updateTagName(e.currentTarget.value); - } - e.stopPropagation(); - }} - placeholder={t['Untitled']()} - /> - - } - type="danger" - onClick={() => { - onTagDelete([tag?.id || '']); - }} - > - {t['Delete']()} - - } - onClick={() => { - navigate.jumpToTag(workspaceService.workspace.id, tag?.id || ''); - }} - > - {t['com.affine.page-properties.tags.open-tags-page']()} - - - - - {tagService.tagColors.map(([name, color], i) => ( - -
-
- } - onClick={() => { - tag?.changeColor(color); - }} - > - {name} -
- ))} - -
-
- - ), - } satisfies Partial; - }, [ - workspaceService, - navigate, - onTagDelete, - t, - tag, - tagColor, - tagService.tagColors, - tagValue, - ]); - - return {children}; -}; - -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(); + placeholder, + className, +}: TagsInlineEditorProps) => { + const workspace = useService(WorkspaceService); 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([]); - const inputRef = useRef(null); - - const exactMatch = filteredTags.find(tag => tag.value$.value === inputValue); - const showCreateTag = !exactMatch && inputValue.trim(); + const tagIds = useLiveData(tagService.tagList.tagIdsByPageId$(pageId)); + const tags = useLiveData(tagService.tagList.tags$); + const tagColors = tagService.tagColors; - // 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(-1); - const [focusedInlineIndex, setFocusedInlineIndex] = useState( - tagIds.length + 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] ); - // -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(null); - - const handleCloseModal = useCallback( - (open: boolean) => { - setOpen(open); - setSelectedTagIds([]); + const onSelectTag = useCallback( + (tagId: string) => { + tagService.tagList.tagByTagId$(tagId).value?.tag(pageId); }, - [setOpen] + [pageId, tagService.tagList] ); - const onTagDelete = useCallback( - (tagIds: string[]) => { - setOpen(true); - setSelectedTagIds(tagIds); + const onDeselectTag = useCallback( + (tagId: string) => { + tagService.tagList.tagByTagId$(tagId).value?.untag(pageId); }, - [setOpen, setSelectedTagIds] + [pageId, tagService.tagList] ); - 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); + 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); } }, - [pageId, tagIds, tagList.tags$.value] + [tagService.tagList] ); - const focusInput = useCallback(() => { - inputRef.current?.focus(); - }, []); + const deleteTags = useDeleteTagConfirmModal(); - const [nextColor, rotateNextColor] = useReducer( - color => { - const idx = tagService.tagColors.findIndex(c => c[1] === color); - return tagService.tagColors[(idx + 1) % tagService.tagColors.length][1]; + const onTagDelete = useAsyncCallback( + async (id: string) => { + await deleteTags([id]); }, - tagService.tagColors[ - Math.floor(Math.random() * tagService.tagColors.length) - ][1] + [deleteTags] ); - const onCreateTag = useCallback( - (name: string) => { - rotateNextColor(); - const newTag = tagList.createTag(name.trim(), nextColor); - return newTag.id; - }, - [nextColor, tagList] + 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 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 adaptedTagColors = useMemo(() => { + return tagColors.map(color => ({ + id: color[0], + value: color[1], + name: color[0], + })); + }, [tagColors]); - const onInputKeyDown = useCallback( - (e: React.KeyboardEvent) => { - 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; + const navigator = useNavigateHelper(); - e.preventDefault(); - setFocusedInlineIndex(newItemToFocus); - // reset tag list focus - setFocusedIndex(-1); - } + const jumpToTag = useCallback( + (id: string) => { + navigator.jumpToTag(workspace.workspace.id, id); }, - [ - inputValue, - tagIds, - safeFocusedIndex, - tagOptions, - safeInlineFocusedIndex, - tags, - pageId, - ] - ); - - return ( -
-
- - - -
-
-
- {t['com.affine.page-properties.tags.selector-header-title']()} -
- - - {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 ( -
- {t['Create']()}{' '} - -
- ); - } else { - return ( -
- -
- - - - - -
- ); - } - })} - - - -
- -
+ [navigator, workspace.workspace.id] ); -}; - -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; return ( - } - > -
- {empty ? placeholder : } -
-
+ ); }; diff --git a/packages/frontend/core/src/components/doc-properties/types/constant.tsx b/packages/frontend/core/src/components/doc-properties/types/constant.tsx index 3247ee6d1453b..89833eae7b64f 100644 --- a/packages/frontend/core/src/components/doc-properties/types/constant.tsx +++ b/packages/frontend/core/src/components/doc-properties/types/constant.tsx @@ -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, diff --git a/packages/frontend/core/src/components/doc-properties/types/date.tsx b/packages/frontend/core/src/components/doc-properties/types/date.tsx index 57e9c8ce3669c..6da2b2726cb27 100644 --- a/packages/frontend/core/src/components/doc-properties/types/date.tsx +++ b/packages/frontend/core/src/components/doc-properties/types/date.tsx @@ -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 ( }> @@ -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} ); }; + +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 ( + + + {relativeDate} + + + ); + }; + +export const CreateDateValue = MetaDateValueFactory({ + type: 'createDate', +}); + +export const UpdatedDateValue = MetaDateValueFactory({ + type: 'updatedDate', +}); diff --git a/packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.tsx b/packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.tsx index 8eae4f1895d5e..d175c40f70c49 100644 --- a/packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.tsx +++ b/packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.tsx @@ -48,7 +48,7 @@ export const DocPrimaryModeValue = () => { [doc, t] ); return ( - + void; } diff --git a/packages/frontend/core/src/components/page-list/docs/page-tags.tsx b/packages/frontend/core/src/components/page-list/docs/page-tags.tsx index 91aeda5a5b6a9..ab5a9ddc1dc90 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-tags.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-tags.tsx @@ -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 ( -
-
-
-
{value}
-
-
- ); -}; - -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 ( -
-
-
-
{value}
- {onRemoved ? ( -
- -
- ) : null} -
-
+ ); }; diff --git a/packages/frontend/core/src/desktop/pages/workspace/all-tag/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/all-tag/index.tsx index d6e98ed2a249e..4b6099f104507 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/all-tag/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/all-tag/index.tsx @@ -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([]); - 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 = () => { )}
- ); }; diff --git a/packages/frontend/core/src/modules/doc-info/index.ts b/packages/frontend/core/src/modules/doc-info/index.ts index 14d8afa3df581..01805077da64d 100644 --- a/packages/frontend/core/src/modules/doc-info/index.ts +++ b/packages/frontend/core/src/modules/doc-info/index.ts @@ -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); } diff --git a/packages/frontend/core/src/modules/doc-info/services/doc-database-backlinks.ts b/packages/frontend/core/src/modules/doc-info/services/doc-database-backlinks.ts new file mode 100644 index 0000000000000..f261a2e196e69 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/services/doc-database-backlinks.ts @@ -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 = (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(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(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), + }; + }) + ) + ); + } +} diff --git a/packages/frontend/core/src/modules/doc-info/types.ts b/packages/frontend/core/src/modules/doc-info/types.ts new file mode 100644 index 0000000000000..9fa8b9325a914 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/types.ts @@ -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> = { + id: string; + name$: LiveData; + type$: LiveData; + data$: LiveData; +}; + +export interface DatabaseValueCell< + T = unknown, + Data = Record, +> { + value$: LiveData; + property: DatabaseProperty; + 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; +} diff --git a/packages/frontend/core/src/modules/doc-info/utils.ts b/packages/frontend/core/src/modules/doc-info/utils.ts new file mode 100644 index 0000000000000..34e4705b49178 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/utils.ts @@ -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 { + subscribe: (fn: (value: T) => void) => () => void; +} + +export function signalToObservable( + signal: ReadonlySignal +): Observable { + return new Observable(subscriber => { + const unsub = signal.subscribe(value => { + subscriber.next(value); + }); + return () => { + unsub(); + }; + }); +} + +export function signalToLiveData( + signal: ReadonlySignal, + defaultValue: T +): LiveData; + +export function signalToLiveData( + signal: ReadonlySignal +): LiveData; + +export function signalToLiveData( + signal: ReadonlySignal, + 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]); +} diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx new file mode 100644 index 0000000000000..2892f4d86ebd6 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx @@ -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); + return ( + { + dataSource.cellValueChange(rowId, cell.property.id, v === 'true'); + }} + /> + ); +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx new file mode 100644 index 0000000000000..02b5f65eb13e4 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx @@ -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 + ); + const date = value ? toInternalDateString(value) : ''; + return ( + { + dataSource.cellValueChange( + rowId, + cell.property.id, + fromInternalDateString(v) + ); + }} + /> + ); +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.css.ts b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.css.ts new file mode 100644 index 0000000000000..eefa8961d526a --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.tsx new file mode 100644 index 0000000000000..46cedacb7adee --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.tsx @@ -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) || ''; + + const [editing, setEditing] = useState(false); + const [tempValue, setTempValue] = useState(link); + + const ref = useRef(null); + const commitChange = useCallback(() => { + dataSource.cellValueChange(rowId, cell.id, tempValue.trim()); + setEditing(false); + setTempValue(tempValue.trim()); + }, [dataSource, rowId, cell.id, tempValue]); + + const handleOnChange: ChangeEventHandler = 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) => { + 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 ( + + {!editing ? ( + resolvedDocLink ? ( + + ) : ( + + {link?.replace(/^https?:\/\//, '').trim()} + + ) + ) : ( + <> +