From 4b6c4ed54603bdd163a2c98620c8c20c1b1308d6 Mon Sep 17 00:00:00 2001 From: pengx17 Date: Thu, 24 Oct 2024 07:38:45 +0000 Subject: [PATCH] feat(core): doc database properties (#8520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
🎥 Video uploaded on Graphite:
--- .../common/infra/src/modules/doc/constants.ts | 10 + .../frontend/component/.storybook/preview.tsx | 23 +- packages/frontend/component/package.json | 2 + packages/frontend/component/src/index.ts | 1 + .../frontend/component/src/theme/global.css | 3 +- .../component/src/ui/progress/index.ts | 1 + .../component/src/ui/progress/progress.css.ts | 71 +++ .../src/ui/progress/progress.stories.tsx | 19 + .../component/src/ui/progress/progress.tsx | 51 ++ .../component/src/ui/property/property.css.ts | 76 ++- .../src/ui/property/property.stories.tsx | 24 +- .../component/src/ui/property/property.tsx | 150 ++++- .../frontend/component/src/ui/tags/index.ts | 3 + .../src/ui/tags/inline-tag-list.css.ts | 8 + .../component/src/ui/tags/inline-tag-list.tsx | 47 ++ .../frontend/component/src/ui/tags/readme.md | 3 + .../src/ui/tags/styles.css.ts} | 14 - .../src/ui/tags/tag-edit-menu.css.ts | 32 ++ .../component/src/ui/tags/tag-edit-menu.tsx | 113 ++++ .../frontend/component/src/ui/tags/tag.css.ts | 168 ++++++ .../frontend/component/src/ui/tags/tag.tsx | 74 +++ .../component/src/ui/tags/tags-editor.tsx | 330 +++++++++++ .../component/src/ui/tags/tags.stories.tsx | 120 ++++ .../frontend/component/src/ui/tags/types.ts | 11 + .../affine/reference-link/index.tsx | 5 +- .../info-modal/info-modal.css.ts | 6 +- .../doc-properties/info-modal/info-modal.tsx | 103 ++-- .../info-modal/links-row.css.ts | 13 +- .../doc-properties/info-modal/links-row.tsx | 11 +- .../menu/create-doc-property.tsx | 6 +- .../components/doc-properties/table.css.ts | 29 +- .../src/components/doc-properties/table.tsx | 172 ++---- .../doc-properties/tags-inline-editor.tsx | 519 +++--------------- .../doc-properties/types/constant.tsx | 17 +- .../components/doc-properties/types/date.tsx | 66 ++- .../doc-properties/types/doc-primary-mode.tsx | 2 +- .../doc-properties/types/tags.css.ts | 3 +- .../components/doc-properties/types/types.ts | 2 +- .../components/page-list/docs/page-tags.tsx | 87 +-- .../desktop/pages/workspace/all-tag/index.tsx | 29 +- .../core/src/modules/doc-info/index.ts | 16 +- .../services/doc-database-backlinks.ts | 149 +++++ .../core/src/modules/doc-info/types.ts | 35 ++ .../core/src/modules/doc-info/utils.ts | 56 ++ .../database-properties/cells/checkbox.tsx | 22 + .../views/database-properties/cells/date.tsx | 40 ++ .../database-properties/cells/link.css.ts | 56 ++ .../views/database-properties/cells/link.tsx | 145 +++++ .../database-properties/cells/number.tsx | 20 + .../database-properties/cells/progress.tsx | 34 ++ .../database-properties/cells/rich-text.tsx | 62 +++ .../database-properties/cells/select.css.ts | 11 + .../database-properties/cells/select.tsx | 259 +++++++++ .../views/database-properties/constant.tsx | 74 +++ .../doc-database-backlink-info.css.ts | 47 ++ .../doc-database-backlink-info.tsx | 167 ++++++ .../docs-search/services/docs-search.ts | 73 ++- .../core/src/modules/tag/entities/tag.ts | 4 +- .../core/src/modules/tag/entities/utils.ts | 37 +- .../frontend/core/src/modules/tag/index.ts | 4 +- .../src/modules/tag/view/delete-tag-modal.tsx | 117 ++-- .../i18n/src/i18n-completenesses.json | 2 +- packages/frontend/i18n/src/resources/en.json | 20 +- .../e2e/blocksuite/editor.spec.ts | 11 +- .../affine-local/e2e/page-properties.spec.ts | 95 +++- tests/kit/utils/page-logic.ts | 24 + yarn.lock | 63 ++- 67 files changed, 3146 insertions(+), 921 deletions(-) create mode 100644 packages/frontend/component/src/ui/progress/index.ts create mode 100644 packages/frontend/component/src/ui/progress/progress.css.ts create mode 100644 packages/frontend/component/src/ui/progress/progress.stories.tsx create mode 100644 packages/frontend/component/src/ui/progress/progress.tsx create mode 100644 packages/frontend/component/src/ui/tags/index.ts create mode 100644 packages/frontend/component/src/ui/tags/inline-tag-list.css.ts create mode 100644 packages/frontend/component/src/ui/tags/inline-tag-list.tsx create mode 100644 packages/frontend/component/src/ui/tags/readme.md rename packages/frontend/{core/src/components/doc-properties/tags-inline-editor.css.ts => component/src/ui/tags/styles.css.ts} (91%) create mode 100644 packages/frontend/component/src/ui/tags/tag-edit-menu.css.ts create mode 100644 packages/frontend/component/src/ui/tags/tag-edit-menu.tsx create mode 100644 packages/frontend/component/src/ui/tags/tag.css.ts create mode 100644 packages/frontend/component/src/ui/tags/tag.tsx create mode 100644 packages/frontend/component/src/ui/tags/tags-editor.tsx create mode 100644 packages/frontend/component/src/ui/tags/tags.stories.tsx create mode 100644 packages/frontend/component/src/ui/tags/types.ts create mode 100644 packages/frontend/core/src/modules/doc-info/services/doc-database-backlinks.ts create mode 100644 packages/frontend/core/src/modules/doc-info/types.ts create mode 100644 packages/frontend/core/src/modules/doc-info/utils.ts create mode 100644 packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx create mode 100644 packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx create mode 100644 packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.css.ts create mode 100644 packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.tsx create mode 100644 packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx create mode 100644 packages/frontend/core/src/modules/doc-info/views/database-properties/cells/progress.tsx create mode 100644 packages/frontend/core/src/modules/doc-info/views/database-properties/cells/rich-text.tsx create mode 100644 packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.css.ts create mode 100644 packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.tsx create mode 100644 packages/frontend/core/src/modules/doc-info/views/database-properties/constant.tsx create mode 100644 packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.css.ts create mode 100644 packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.tsx 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()} + + ) + ) : ( + <> +