diff --git a/packages/frontend/component/.storybook/preview.tsx b/packages/frontend/component/.storybook/preview.tsx index e39b285accb0e..5f1d975c22d73 100644 --- a/packages/frontend/component/.storybook/preview.tsx +++ b/packages/frontend/component/.storybook/preview.tsx @@ -2,6 +2,7 @@ import './polyfill'; import '../src/theme'; import './preview.css'; import { ThemeProvider } from 'next-themes'; +import { getOrCreateI18n, I18nextProvider } from '@affine/i18n'; import type { ComponentType } from 'react'; import type { Preview } from '@storybook/react'; @@ -42,14 +43,18 @@ const useTheme = context => { }, [theme]); }; +const i18n = getOrCreateI18n(); + export const decorators = [ (Story: ComponentType, context) => { useTheme(context); return ( - - - + + + + + ); }, diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index 548b71666705b..d36a900194ef9 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -30,6 +30,7 @@ "@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/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..69c6da6f5f8c5 --- /dev/null +++ b/packages/frontend/component/src/ui/tags/inline-tag-list.tsx @@ -0,0 +1,45 @@ +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[]; + focusedIndex?: number; +} + +export const InlineTagList = ({ + children, + focusedIndex, + tags, + onRemoved, +}: 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..e4487e0e303a3 --- /dev/null +++ b/packages/frontend/component/src/ui/tags/tag.css.ts @@ -0,0 +1,145 @@ +import { cssVar } from '@toeverything/theme'; +import { createVar, style } from '@vanilla-extract/css'; +export const hoverMaxWidth = 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 tagInline = 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 tagListItem = style([ + tag, + { + fontSize: cssVar('fontSm'), + padding: '4px 12px', + columnGap: '8px', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + height: '30px', + }, +]); +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, +}); +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..816ca1b0db174 --- /dev/null +++ b/packages/frontend/component/src/ui/tags/tag.tsx @@ -0,0 +1,68 @@ +import { CloseIcon } from '@blocksuite/icons/rc'; +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; + mode: 'inline' | 'list-item'; + 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 ( +
+
+
+
{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..6ee096e59917e --- /dev/null +++ b/packages/frontend/component/src/ui/tags/tags-editor.tsx @@ -0,0 +1,327 @@ +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; +} + +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, +}: 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..f850bb57799fb --- /dev/null +++ b/packages/frontend/component/src/ui/tags/tags.stories.tsx @@ -0,0 +1,105 @@ +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'); + }} + /> + + + +
+ ); +}; + +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/doc-properties/table.tsx b/packages/frontend/core/src/components/doc-properties/table.tsx index 4edc0f26e907e..0116bd6b56bab 100644 --- a/packages/frontend/core/src/components/doc-properties/table.tsx +++ b/packages/frontend/core/src/components/doc-properties/table.tsx @@ -10,6 +10,7 @@ import { useDraggable, useDropTarget, } from '@affine/component'; +import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info'; import { DocLinksService } from '@affine/core/modules/doc-link'; import { EditorSettingService } from '@affine/core/modules/editor-settting'; import { WorkbenchService } from '@affine/core/modules/workbench'; @@ -448,18 +449,24 @@ DocPropertiesTableBody.displayName = 'PagePropertiesTableBody'; const DocPropertiesTableInner = () => { const [expanded, setExpanded] = useState(false); return ( -
- - - - - - -
+ <> +
+ + + + + + +
+ + ); }; 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..1039547be6283 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,114 @@ 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]); + console.log(tagIds); - 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, - ] + [navigator, workspace.workspace.id] ); 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 ( -
- -
- - - - - -
- ); - } - })} - - - -
- -
- ); -}; - -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/types.ts b/packages/frontend/core/src/components/doc-properties/types/types.ts index 5eda8c8c89f56..93cbd7190f957 100644 --- a/packages/frontend/core/src/components/doc-properties/types/types.ts +++ b/packages/frontend/core/src/components/doc-properties/types/types.ts @@ -1,7 +1,7 @@ import type { DocCustomPropertyInfo } from '@toeverything/infra'; export interface PropertyValueProps { - propertyInfo: DocCustomPropertyInfo; + propertyInfo?: DocCustomPropertyInfo; value: any; onChange: (value: any) => 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..c40d40c28a892 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,23 @@ 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..e672409a44429 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/services/doc-database-backlinks.ts @@ -0,0 +1,146 @@ +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, + switchMap, +} from 'rxjs'; + +import type { Backlink } from '../../doc-link'; +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 backlink, + // 1. check if it is in a database block + // 2. if it is, return the related database row + // 3. if it is not, return undefined + private watchDatabaseRow$(backlink: Backlink) { + 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 block = docRef.doc.blockSuiteDoc.getBlock(backlink.blockId); + const parent = block?.model.parent; + if (parent?.flavour === 'affine:database') { + const dbModel = parent as DatabaseBlockModel; + const [cells$, dataSource] = this.adaptRowCells( + dbModel, + backlink.blockId + ); + const subscription = cells$.subscribe(cells => { + if (cells) { + subscriber.next({ + cells, + id: backlink.blockId, + doc: docRef.doc, + docId: backlink.docId, + databaseId: dbModel.id, + databaseName: dbModel.title.yText.toString(), + dataSource: dataSource, + }); + } else { + subscriber.next(undefined); + } + }); + unsubscribe = () => subscription.unsubscribe(); + } + }; + + run().catch(e => { + console.error(`failed to get database info:`, e); + docRef.release(); + }); + + return () => { + docRef.release(); + disposed = true; + unsubscribe(); + }; + }); + } + + // backlinks (docid:blockid) -> related db rows (DatabaseRow[]) + // todo: use LiveData per-CELL, instead of using a single LiveData for all rows + watchDbBacklinkRows$(docId: string) { + return this.docsSearchService.watchRefsTo(docId).pipe( + distinctUntilChanged(equalComparator), + switchMap(backlinks => { + return combineLatest( + backlinks.map(backlink => { + return this.watchDatabaseRow$(backlink); + }) + ); + }), + map(rows => rows.filter((row): row is DatabaseRow => Boolean(row))) + ); + } +} 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/constant.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/constant.tsx new file mode 100644 index 0000000000000..0a9a84e1b3f48 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/constant.tsx @@ -0,0 +1,73 @@ +import type { I18nString } from '@affine/i18n'; +import { + CheckBoxCheckLinearIcon, + DateTimeIcon, + LinkIcon, + NumberIcon, + ProgressIcon, + SelectIcon, + TextIcon, +} from '@blocksuite/icons/rc'; + +import type { DatabaseCellRendererProps } from '../../../types'; +import { CheckboxCell } from './checkbox'; +import { DateCell } from './date'; +import { LinkCell } from './link'; +import { NumberCell } from './number'; +import { ProgressCell } from './progress'; +import { RichTextCell } from './rich-text'; +import { MultiSelectCell, SelectCell } from './select'; + +export const DatabaseRendererTypes = { + 'rich-text': { + Icon: TextIcon, + Renderer: RichTextCell, + name: 'com.affine.page-properties.property.text', + }, + checkbox: { + Icon: CheckBoxCheckLinearIcon, + Renderer: CheckboxCell, + name: 'com.affine.page-properties.property.checkbox', + }, + date: { + Icon: DateTimeIcon, + Renderer: DateCell, + name: 'com.affine.page-properties.property.date', + }, + number: { + Icon: NumberIcon, + Renderer: NumberCell, + name: 'com.affine.page-properties.property.number', + }, + link: { + Icon: LinkIcon, + Renderer: LinkCell, + name: 'com.affine.page-properties.property.link', + }, + progress: { + Icon: ProgressIcon, + Renderer: ProgressCell, + name: 'com.affine.page-properties.property.progress', + }, + select: { + Icon: SelectIcon, + Renderer: SelectCell, + name: 'com.affine.page-properties.property.select', + }, + 'multi-select': { + Icon: SelectIcon, + Renderer: MultiSelectCell, + name: 'com.affine.page-properties.property.multi-select', + }, +} as Record< + string, + { + Icon: React.FC>; + Renderer: React.FC; + name: I18nString; + } +>; + +export const isSupportedDatabaseRendererType = (type?: string): boolean => { + return type ? type in DatabaseRendererTypes : false; +}; 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.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.tsx new file mode 100644 index 0000000000000..acd54143f40ac --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.tsx @@ -0,0 +1,20 @@ +import { PropertyValue } from '@affine/component'; +import type { LiveData } from '@toeverything/infra'; +import { useLiveData } from '@toeverything/infra'; + +import type { DatabaseCellRendererProps } from '../../../types'; + +export const LinkCell = ({ cell }: DatabaseCellRendererProps) => { + const isEmpty = useLiveData( + cell.value$.map(value => typeof value !== 'string' || !value) + ); + const link = useLiveData(cell.value$ as LiveData); + // todo(pengx17): support edit + return ( + + + {link?.replace(/^https?:\/\//, '')} + + + ); +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx new file mode 100644 index 0000000000000..7415274bce95c --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx @@ -0,0 +1,20 @@ +import { NumberValue } from '@affine/core/components/doc-properties/types/number'; +import { useLiveData } from '@toeverything/infra'; + +import type { DatabaseCellRendererProps } from '../../../types'; + +export const NumberCell = ({ + cell, + rowId, + dataSource, +}: DatabaseCellRendererProps) => { + const value = useLiveData(cell.value$); + return ( + { + dataSource.cellValueChange(rowId, cell.property.id, v); + }} + /> + ); +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/progress.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/progress.tsx new file mode 100644 index 0000000000000..d4f1110137673 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/progress.tsx @@ -0,0 +1,34 @@ +import { Progress, PropertyValue } from '@affine/component'; +import type { LiveData } from '@toeverything/infra'; +import { useLiveData } from '@toeverything/infra'; +import { useEffect, useState } from 'react'; + +import type { DatabaseCellRendererProps } from '../../../types'; + +export const ProgressCell = ({ + cell, + dataSource, + rowId, +}: DatabaseCellRendererProps) => { + const value = useLiveData(cell.value$ as LiveData); + const isEmpty = value === undefined; + const [localValue, setLocalValue] = useState(value); + + useEffect(() => { + setLocalValue(value); + }, [value]); + + return ( + + { + setLocalValue(v); + }} + onBlur={() => { + dataSource.cellValueChange(rowId, cell.id, localValue); + }} + /> + + ); +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/rich-text.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/rich-text.tsx new file mode 100644 index 0000000000000..9d0529af7d78a --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/rich-text.tsx @@ -0,0 +1,61 @@ +import { PropertyValue } from '@affine/component'; +import type { BlockStdScope } from '@blocksuite/affine/block-std'; +import { + DefaultInlineManagerExtension, + RichText, +} from '@blocksuite/affine/blocks'; +import type { Doc } from '@blocksuite/affine/store'; +import { type LiveData, useLiveData } from '@toeverything/infra'; +import { useEffect, useRef } from 'react'; +import type * as Y from 'yjs'; + +import type { DatabaseCellRendererProps } from '../../../types'; +import { useBlockStdScope } from '../../../utils'; + +const renderRichText = ({ + doc, + std, + text, +}: { + std: BlockStdScope; + text: Y.Text; + doc: Doc; +}) => { + const inlineManager = std.get(DefaultInlineManagerExtension.identifier); + + if (!inlineManager) { + return null; + } + + const richText = new RichText(); + richText.yText = text; + richText.undoManager = doc.history; + richText.readonly = doc.readonly; + richText.attributesSchema = inlineManager.getSchema() as any; + richText.attributeRenderer = inlineManager.getRenderer(); + return richText; +}; + +export const RichTextCell = ({ + cell, + dataSource, +}: DatabaseCellRendererProps) => { + const std = useBlockStdScope(dataSource.doc); + const text = useLiveData(cell.value$ as LiveData); + const ref = useRef(null); + // todo(@pengx17): following is a workaround to y.Text that it is got renewed when the cell is updated externally. however it breaks the cursor position. + useEffect(() => { + if (ref.current) { + ref.current.innerHTML = ''; + const richText = renderRichText({ doc: dataSource.doc, std, text }); + if (richText) { + ref.current.append(richText); + return () => { + richText.remove(); + }; + } + } + return () => {}; + }, [dataSource.doc, std, text]); + return ; +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.css.ts b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.css.ts new file mode 100644 index 0000000000000..be1572158f21e --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.css.ts @@ -0,0 +1,10 @@ +import { style } from '@vanilla-extract/css'; + +export const tagInlineEditor = style({ + width: '100%', + padding: `6px`, +}); + +export const container = style({ + padding: `0px`, +}); diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.tsx new file mode 100644 index 0000000000000..b09f809f670f9 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.tsx @@ -0,0 +1,257 @@ +/* eslint-disable rxjs/finnish */ + +import { PropertyValue } from '@affine/component'; +import { type TagLike, TagsInlineEditor } from '@affine/component/ui/tags'; +import { TagService } from '@affine/core/modules/tag'; +import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks'; +import type { SelectTag } from '@blocksuite/data-view'; +import { LiveData, useLiveData, useService } from '@toeverything/infra'; +import { nanoid } from 'nanoid'; +import { useCallback, useMemo } from 'react'; + +import type { + DatabaseCellRendererProps, + DatabaseValueCell, +} from '../../../types'; +import * as styles from './select.css'; + +interface SelectPropertyData { + options: SelectTag[]; +} + +type SelectCellValue = string[] | string; + +type SelectCell = DatabaseValueCell< + T, + SelectPropertyData +>; + +type SingleSelectCell = SelectCell; +type MultiSelectCell = SelectCell; + +const adapter = { + getSelectedIds$(cell: SingleSelectCell | MultiSelectCell) { + return cell.value$.map(ids => { + if (!Array.isArray(ids)) { + return typeof ids === 'string' ? [ids] : []; + } + return ids.filter(id => typeof id === 'string'); + }); + }, + + getSelectedTags$(cell: SingleSelectCell | MultiSelectCell) { + return LiveData.computed(get => { + const ids = get(adapter.getSelectedIds$(cell)); + const options = get(adapter.getTagOptions$(cell)); + return ids + .map( + id => + typeof id === 'string' && options.find(option => option.id === id) + ) + .filter(option => !!option); + }); + }, + + getTagOptions$(cell: SingleSelectCell | MultiSelectCell) { + return LiveData.computed(get => { + const data = get(cell.property.data$); + return data?.options as SelectTag[]; + }); + }, + + updateOptions( + cell: SingleSelectCell | MultiSelectCell, + dataSource: DatabaseBlockDataSource, + updater: (oldOptions: SelectTag[]) => SelectTag[] + ) { + const oldData = dataSource.propertyDataGet(cell.property.id); + return dataSource.propertyDataSet(cell.property.id, { + ...oldData, + options: updater(oldData.options as SelectTag[]), + }); + }, + + deselectTag( + rowId: string, + cell: SingleSelectCell | MultiSelectCell, + dataSource: DatabaseBlockDataSource, + tagId: string, + multiple: boolean + ) { + const ids = adapter.getSelectedIds$(cell).value; + dataSource.cellValueChange( + rowId, + cell.property.id, + multiple ? ids.filter(id => id !== tagId) : undefined + ); + }, + + selectTag( + rowId: string, + cell: SingleSelectCell | MultiSelectCell, + dataSource: DatabaseBlockDataSource, + tagId: string, + multiple: boolean + ) { + const ids = adapter.getSelectedIds$(cell).value; + dataSource.cellValueChange( + rowId, + cell.property.id, + multiple ? [...ids, tagId] : tagId + ); + }, + + createTag( + cell: SingleSelectCell | MultiSelectCell, + dataSource: DatabaseBlockDataSource, + newTag: TagLike + ) { + adapter.updateOptions(cell, dataSource, options => [ + ...options, + { + id: newTag.id, + value: newTag.value, + color: newTag.color, + }, + ]); + }, + + deleteTag( + cell: SingleSelectCell | MultiSelectCell, + dataSource: DatabaseBlockDataSource, + tagId: string + ) { + adapter.updateOptions(cell, dataSource, options => + options.filter(option => option.id !== tagId) + ); + }, + + updateTag( + cell: SingleSelectCell | MultiSelectCell, + dataSource: DatabaseBlockDataSource, + tagId: string, + updater: (oldTag: SelectTag) => SelectTag + ) { + adapter.updateOptions(cell, dataSource, options => + options.map(option => (option.id === tagId ? updater(option) : option)) + ); + }, +}; + +const BlocksuiteDatabaseSelector = ({ + cell, + dataSource, + rowId, + multiple, +}: DatabaseCellRendererProps & { multiple: boolean }) => { + const tagService = useService(TagService); + const selectCell = cell as any as SingleSelectCell | MultiSelectCell; + const selectedIds = useLiveData(adapter.getSelectedIds$(selectCell)); + const tagOptions = useLiveData(adapter.getTagOptions$(selectCell)); + + const onCreateTag = useCallback( + (name: string, color: string) => { + const newTag = { + id: nanoid(), + value: name, + color, + }; + adapter.createTag(selectCell, dataSource, newTag); + return newTag; + }, + [dataSource, selectCell] + ); + const onDeleteTag = useCallback( + (tagId: string) => { + adapter.deleteTag(selectCell, dataSource, tagId); + }, + [dataSource, selectCell] + ); + const onDeselectTag = useCallback( + (tagId: string) => { + adapter.deselectTag(rowId, selectCell, dataSource, tagId, multiple); + }, + [selectCell, dataSource, rowId, multiple] + ); + + const onSelectTag = useCallback( + (tagId: string) => { + adapter.selectTag(rowId, selectCell, dataSource, tagId, multiple); + }, + [rowId, selectCell, dataSource, multiple] + ); + + const tagColors = useMemo(() => { + return tagService.tagColors.map(([name, color]) => ({ + id: name, + value: color, + name, + })); + }, [tagService.tagColors]); + + const onTagChange = useCallback( + (tagId: string, property: string, value: string) => { + adapter.updateTag(selectCell, dataSource, tagId, old => { + return { + ...old, + [property]: value, + }; + }); + }, + [dataSource, selectCell] + ); + + return ( + + ); +}; + +export const SelectCell = ({ + cell, + dataSource, + rowId, +}: DatabaseCellRendererProps) => { + const isEmpty = useLiveData( + cell.value$.map(value => Array.isArray(value) && value.length === 0) + ); + return ( + + + + ); +}; + +export const MultiSelectCell = ({ + cell, + dataSource, + rowId, +}: DatabaseCellRendererProps) => { + const isEmpty = useLiveData( + cell.value$.map(value => Array.isArray(value) && value.length === 0) + ); + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.tsx new file mode 100644 index 0000000000000..d9561b12f5cc4 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.tsx @@ -0,0 +1,115 @@ +import { PropertyName } from '@affine/component'; +import { AffinePageReference } from '@affine/core/components/affine/reference-link'; +import { useI18n } from '@affine/i18n'; +import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks'; +import { + DocService, + LiveData, + useLiveData, + useService, +} from '@toeverything/infra'; +import { useMemo } from 'react'; + +import { DocDatabaseBacklinksService } from '../../services/doc-database-backlinks'; +import type { DatabaseRow, DatabaseValueCell } from '../../types'; +import { DatabaseRendererTypes } from './cells/constant'; + +type CellConfig = + (typeof DatabaseRendererTypes)[keyof typeof DatabaseRendererTypes]; + +const DatabaseBacklinkCellName = ({ + cell, + config, +}: { + cell: DatabaseValueCell; + config: CellConfig; +}) => { + const propertyName = useLiveData(cell.property.name$); + const t = useI18n(); + return ( + } + name={propertyName ?? (config.name ? t.t(config.name) : t['unnamed']())} + /> + ); +}; + +const DatabaseBacklinkCell = ({ + cell, + dataSource, + rowId, +}: { + cell: DatabaseValueCell; + dataSource: DatabaseBlockDataSource; + rowId: string; +}) => { + const cellType = useLiveData(cell.property.type$); + + const config = cellType ? DatabaseRendererTypes[cellType] : undefined; + + // do not render title cell! + if (!config || cellType === 'title') { + return null; + } + + return ( +
  • + + +
  • + ); +}; + +const DatabaseBacklinkRow = ({ row }: { row: DatabaseRow }) => { + const sortedCells = useMemo(() => { + return row.cells.toSorted((a, b) => { + return (a.property.name$.value ?? '').localeCompare( + b.property.name$.value ?? '' + ); + }); + }, [row.cells]); + const t = useI18n(); + return ( +
      + {row.databaseName || t['unnamed']()} + + {sortedCells.map(cell => { + return ( + + ); + })} +
    + ); +}; + +export const DocDatabaseBacklinkInfo = () => { + const doc = useService(DocService).doc; + const docDatabaseBacklinks = useService(DocDatabaseBacklinksService); + const rows = useLiveData( + useMemo( + () => + LiveData.from( + docDatabaseBacklinks.watchDbBacklinkRows$(doc.id), + [] + ).map(rows => { + return rows.toSorted((a, b) => { + return a.databaseName.localeCompare(b.databaseName); + }); + }), + [docDatabaseBacklinks, doc.id] + ) + ); + return ( +
    +
    Database Backlinks
    + {rows.map(row => ( + + ))} +
    + ); +}; diff --git a/packages/frontend/core/src/modules/tag/index.ts b/packages/frontend/core/src/modules/tag/index.ts index 69179f7aa0987..56b3ec0543791 100644 --- a/packages/frontend/core/src/modules/tag/index.ts +++ b/packages/frontend/core/src/modules/tag/index.ts @@ -1,7 +1,7 @@ export { Tag } from './entities/tag'; export { tagColorMap } from './entities/utils'; export { TagService } from './service/tag'; -export { DeleteTagConfirmModal } from './view/delete-tag-modal'; +export { useDeleteTagConfirmModal } from './view/delete-tag-modal'; import { DocsService, diff --git a/packages/frontend/core/src/modules/tag/view/delete-tag-modal.tsx b/packages/frontend/core/src/modules/tag/view/delete-tag-modal.tsx index c607a30c8b487..80898523565dd 100644 --- a/packages/frontend/core/src/modules/tag/view/delete-tag-modal.tsx +++ b/packages/frontend/core/src/modules/tag/view/delete-tag-modal.tsx @@ -1,64 +1,77 @@ -import { ConfirmModal, toast } from '@affine/component'; +import { toast, useConfirmModal } from '@affine/component'; import { Trans, useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { TagService } from '../service/tag'; -export const DeleteTagConfirmModal = ({ - open, - onOpenChange, - selectedTagIds, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedTagIds: string[]; -}) => { +/** + * Show a confirm modal AND delete the tags + */ +export const useDeleteTagConfirmModal = () => { + const { openConfirmModal } = useConfirmModal(); + const t = useI18n(); const tagService = useService(TagService); const tags = useLiveData(tagService.tagList.tags$); - const selectedTags = useMemo(() => { - return tags.filter(tag => selectedTagIds.includes(tag.id)); - }, [selectedTagIds, tags]); - const tagName = useLiveData(selectedTags[0]?.value$ || ''); - - const handleDelete = useCallback(() => { - selectedTagIds.forEach(tagId => { - tagService.tagList.deleteTag(tagId); - }); - toast( - selectedTagIds.length > 1 - ? t['com.affine.delete-tags.count']({ count: selectedTagIds.length }) - : t['com.affine.tags.delete-tags.toast']() - ); + const confirm = useCallback( + (tagIdsToDelete: string[]) => { + let closed = false; + const { resolve, promise } = Promise.withResolvers(); + const tagsToDelete = tags.filter(tag => tagIdsToDelete.includes(tag.id)); + const tagName = tagsToDelete[0]?.value$.value; + const handleClose = (state: boolean) => { + if (!closed) { + closed = true; + resolve(state); - onOpenChange(false); - }, [onOpenChange, selectedTagIds, t, tagService]); - - return ( - }} - /> - ) : ( - t['com.affine.delete-tags.confirm.multi-tag-description']({ - count: selectedTags.length.toString(), - }) - ) - } - confirmText={t['Delete']()} - confirmButtonOptions={{ - variant: 'error', - }} - onConfirm={handleDelete} - /> + if (state) { + tagIdsToDelete.forEach(tagId => { + tagService.tagList.deleteTag(tagId); + }); + toast( + tagIdsToDelete.length > 1 + ? t['com.affine.delete-tags.count']({ + count: tagIdsToDelete.length, + }) + : t['com.affine.tags.delete-tags.toast']() + ); + } + } + }; + openConfirmModal({ + title: t['com.affine.delete-tags.confirm.title'](), + description: + tagIdsToDelete.length === 1 ? ( + }} + /> + ) : ( + t['com.affine.delete-tags.confirm.multi-tag-description']({ + count: tagIdsToDelete.length.toString(), + }) + ), + confirmText: t['Delete'](), + confirmButtonOptions: { + variant: 'error', + }, + onConfirm: () => { + handleClose(true); + }, + onCancel: () => { + handleClose(true); + }, + onOpenChange: state => { + handleClose(state); + }, + }); + return promise; + }, + [openConfirmModal, t, tagService.tagList, tags] ); + + return confirm; }; diff --git a/yarn.lock b/yarn.lock index 78dbdabfd51eb..8b9e26d7918b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -326,6 +326,7 @@ __metadata: "@radix-ui/react-dialog": "npm:^1.1.1" "@radix-ui/react-dropdown-menu": "npm:^2.1.1" "@radix-ui/react-popover": "npm:^1.0.7" + "@radix-ui/react-progress": "npm:^1.1.0" "@radix-ui/react-radio-group": "npm:^1.1.3" "@radix-ui/react-scroll-area": "npm:^1.0.5" "@radix-ui/react-slider": "npm:^1.2.0"