feat(core): tags inline editor (#5748)

tags inline editor and some refactor

<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/T2klNLEk0wxLh4NRDzhk/439da1e3-30a9-462a-b7b4-c8e7c3b5ef17.mp4">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/T2klNLEk0wxLh4NRDzhk/439da1e3-30a9-462a-b7b4-c8e7c3b5ef17.mp4">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/439da1e3-30a9-462a-b7b4-c8e7c3b5ef17.mp4">Kapture 2024-01-31 at 23.29.11.mp4</video>

fix AFF-467
fix AFF-468
fix AFF-472
fix AFF-466
This commit is contained in:
Peng Xiao
2024-02-22 09:37:50 +00:00
parent 546d96c5c9
commit bb8e601f82
19 changed files with 1121 additions and 192 deletions

View File

@@ -17,8 +17,8 @@ export interface ModalProps extends DialogProps {
width?: CSSProperties['width']; width?: CSSProperties['width'];
height?: CSSProperties['height']; height?: CSSProperties['height'];
minHeight?: CSSProperties['minHeight']; minHeight?: CSSProperties['minHeight'];
title?: string; title?: React.ReactNode;
description?: string; description?: React.ReactNode;
withoutCloseButton?: boolean; withoutCloseButton?: boolean;
portalOptions?: DialogPortalProps; portalOptions?: DialogPortalProps;

View File

@@ -1,3 +1,4 @@
import { cssVar } from '@toeverything/theme';
import { atom } from 'jotai'; import { atom } from 'jotai';
import { createContext } from 'react'; import { createContext } from 'react';
@@ -6,3 +7,22 @@ import type { PagePropertiesManager } from './page-properties-manager';
// @ts-expect-error this should always be set // @ts-expect-error this should always be set
export const managerContext = createContext<PagePropertiesManager>(); export const managerContext = createContext<PagePropertiesManager>();
export const pageInfoCollapsedAtom = atom(false); export const pageInfoCollapsedAtom = atom(false);
type TagColorHelper<T> = T extends `paletteLine${infer Color}` ? Color : never;
type TagColorName = TagColorHelper<Parameters<typeof cssVar>[0]>;
const tagColorIds: TagColorName[] = [
'Red',
'Magenta',
'Orange',
'Yellow',
'Green',
'Teal',
'Blue',
'Purple',
'Grey',
];
export const tagColors = tagColorIds.map(
color => [color, cssVar(`paletteLine${color}`)] as const
);

View File

@@ -4,67 +4,139 @@ import type { SVGProps } from 'react';
type IconType = (props: SVGProps<SVGSVGElement>) => JSX.Element; type IconType = (props: SVGProps<SVGSVGElement>) => JSX.Element;
// todo: this breaks tree-shaking, and we should fix it (using dynamic imports?) // assume all exports in icons are icon Components
const IconsMapping = icons; type LibIconComponentName = keyof typeof icons;
export type PagePropertyIcon = keyof typeof IconsMapping;
const excludedIcons: PagePropertyIcon[] = [ type fromLibIconName<T extends string> = T extends `${infer N}Icon`
'YoutubeDuotoneIcon', ? Uncapitalize<N>
'LinearLogoIcon', : never;
'RedditDuotoneIcon',
'Logo2Icon',
'Logo3Icon',
'Logo4Icon',
'InstagramDuotoneIcon',
'TelegramDuotoneIcon',
'TextBackgroundDuotoneIcon',
];
export const iconNames = Object.keys(IconsMapping).filter( export const iconNames = [
icon => !excludedIcons.includes(icon as PagePropertyIcon) 'ai',
) as PagePropertyIcon[]; 'email',
'text',
'dateTime',
'keyboard',
'pen',
'account',
'embedWeb',
'layer',
'pin',
'appearance',
'eraser',
'layout',
'presentation',
'bookmark',
'exportToHtml',
'lightMode',
'progress',
'bulletedList',
'exportToMarkdown',
'link',
'publish',
'camera',
'exportToPdf',
'linkedEdgeless',
'quote',
'checkBoxCheckLinear',
'exportToPng',
'linkedPage',
'save',
'cloudWorkspace',
'exportToSvg',
'localData',
'shape',
'code',
'favorite',
'localWorkspace',
'style',
'codeBlock',
'file',
'lock',
'tag',
'collaboration',
'folder',
'multiSelect',
'tags',
'colorPicker',
'frame',
'new',
'today',
'contactWithUs',
'grid',
'now',
'upgrade',
'darkMode',
'grouping',
'number',
'userGuide',
'databaseKanbanView',
'image',
'numberedList',
'view',
'databaseListView',
'inbox',
'other',
'viewLayers',
'databaseTableView',
'info',
'page',
'attachment',
'delete',
'issue',
'paste',
'heartbreak',
'edgeless',
'journal',
'payment',
] as const satisfies fromLibIconName<LibIconComponentName>[];
export type PagePropertyIcon = (typeof iconNames)[number];
export const getDefaultIconName = ( export const getDefaultIconName = (
type: PagePropertyType type: PagePropertyType
): PagePropertyIcon => { ): PagePropertyIcon => {
switch (type) { switch (type) {
case 'text': case 'text':
return 'TextIcon'; return 'text';
case 'tags': case 'tags':
return 'TagIcon'; return 'tag';
case 'date': case 'date':
return 'DateTimeIcon'; return 'dateTime';
case 'progress': case 'progress':
return 'ProgressIcon'; return 'progress';
case 'checkbox': case 'checkbox':
return 'CheckBoxCheckLinearIcon'; return 'checkBoxCheckLinear';
case 'number': case 'number':
return 'NumberIcon'; return 'number';
default: default:
return 'TextIcon'; return 'text';
} }
}; };
// fixme: this function may break if icons are imported twice
export const IconToIconName = (icon: IconType) => {
const iconKey = Object.entries(IconsMapping).find(([_, candidate]) => {
return candidate === icon;
})?.[0];
return iconKey;
};
export const getSafeIconName = ( export const getSafeIconName = (
iconName: string, iconName: string,
type?: PagePropertyType type?: PagePropertyType
): PagePropertyIcon => { ): PagePropertyIcon => {
return Object.hasOwn(IconsMapping, iconName) return iconNames.includes(iconName as any)
? (iconName as PagePropertyIcon) ? (iconName as PagePropertyIcon)
: getDefaultIconName(type || PagePropertyType.Text); : getDefaultIconName(type || PagePropertyType.Text);
}; };
const nameToComponentName = (
iconName: PagePropertyIcon
): LibIconComponentName => {
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
return `${capitalize(iconName)}Icon` as LibIconComponentName;
};
export const nameToIcon = ( export const nameToIcon = (
iconName: string, iconName: string,
type?: PagePropertyType type?: PagePropertyType
): IconType => { ): IconType => {
return IconsMapping[getSafeIconName(iconName, type)]; const Icon = icons[nameToComponentName(getSafeIconName(iconName, type))];
if (!Icon) {
throw new Error(`Icon ${iconName} not found`);
}
return Icon;
}; };

View File

@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react';
import { iconNames, nameToIcon, type PagePropertyIcon } from './icons-mapping'; import { iconNames, nameToIcon, type PagePropertyIcon } from './icons-mapping';
import * as styles from './icons-selector.css'; import * as styles from './icons-selector.css';
const iconsPerRow = 10; const iconsPerRow = 6;
const iconRows = chunk(iconNames, iconsPerRow); const iconRows = chunk(iconNames, iconsPerRow);
@@ -46,15 +46,13 @@ export const IconsSelectorPanel = ({
const Icon = nameToIcon(iconName); const Icon = nameToIcon(iconName);
return ( return (
<div <div
onClick={() => onSelectedChange(iconName)}
key={iconName} key={iconName}
className={styles.iconButton} className={styles.iconButton}
data-name={iconName} data-name={iconName}
data-active={selected === iconName} data-active={selected === iconName}
> >
<Icon <Icon key={iconName} />
key={iconName}
onClick={() => onSelectedChange(iconName)}
/>
</div> </div>
); );
})} })}

View File

@@ -9,10 +9,11 @@ import {
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/workspace/properties/schema'; import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/workspace/properties/schema';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { import {
type ChangeEventHandler,
cloneElement, cloneElement,
isValidElement, isValidElement,
type KeyboardEventHandler,
type MouseEventHandler, type MouseEventHandler,
useCallback,
} from 'react'; } from 'react';
import { import {
@@ -81,12 +82,29 @@ export const EditPropertyNameMenuItem = ({
onNameChange, onNameChange,
onIconChange, onIconChange,
}: { }: {
onNameBlur: ChangeEventHandler; onNameBlur: (e: string) => void;
onNameChange: (name: string) => void; onNameChange: (e: string) => void;
onIconChange: (icon: PagePropertyIcon) => void; onIconChange: (icon: PagePropertyIcon) => void;
property: PageInfoCustomPropertyMeta; property: PageInfoCustomPropertyMeta;
}) => { }) => {
const iconName = getSafeIconName(property.icon, property.type); const iconName = getSafeIconName(property.icon, property.type);
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
e => {
e.stopPropagation();
if (e.key === 'Enter') {
e.preventDefault();
onBlur(e.currentTarget.value);
}
},
[onBlur]
);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
onBlur(e.target.value);
},
[onBlur]
);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
return ( return (
<div className={styles.propertyRowNamePopupRow}> <div className={styles.propertyRowNamePopupRow}>
@@ -96,11 +114,10 @@ export const EditPropertyNameMenuItem = ({
/> />
<Input <Input
defaultValue={property.name} defaultValue={property.name}
onBlur={onBlur} onBlur={handleBlur}
size="large"
style={{ borderRadius: 4 }}
onChange={onNameChange} onChange={onNameChange}
placeholder={t['unnamed']()} placeholder={t['unnamed']()}
onKeyDown={onKeyDown}
/> />
</div> </div>
); );

View File

@@ -56,7 +56,7 @@ export class PagePropertiesMetaManager {
return this.adapter.schema.pageProperties.custom; return this.adapter.schema.pageProperties.custom;
} }
getOrderedCustomPropertiesSchema() { getOrderedPropertiesSchema() {
return Object.values(this.customPropertiesSchema).sort( return Object.values(this.customPropertiesSchema).sort(
(a, b) => a.order - b.order (a, b) => a.order - b.order
); );
@@ -66,7 +66,7 @@ export class PagePropertiesMetaManager {
return !!this.customPropertiesSchema[id]; return !!this.customPropertiesSchema[id];
} }
validateCustomPropertyValue(id: string, value?: any) { validatePropertyValue(id: string, value?: any) {
if (!value) { if (!value) {
// value is optional in all cases? // value is optional in all cases?
return true; return true;
@@ -79,7 +79,7 @@ export class PagePropertiesMetaManager {
return validatePropertyValue(type, value); return validatePropertyValue(type, value);
} }
addCustomPropertyMeta(schema: { addPropertyMeta(schema: {
name: string; name: string;
type: PagePropertyType; type: PagePropertyType;
icon?: string; icon?: string;
@@ -103,10 +103,7 @@ export class PagePropertiesMetaManager {
return property; return property;
} }
updateCustomPropertyMeta( updatePropertyMeta(id: string, opt: Partial<PageInfoCustomPropertyMeta>) {
id: string,
opt: Partial<PageInfoCustomPropertyMeta>
) {
if (!this.checkPropertyExists(id)) { if (!this.checkPropertyExists(id)) {
logger.warn(`property ${id} not found`); logger.warn(`property ${id} not found`);
return; return;
@@ -118,13 +115,13 @@ export class PagePropertiesMetaManager {
return this.customPropertiesSchema[id]?.required; return this.customPropertiesSchema[id]?.required;
} }
removeCustomPropertyMeta(id: string) { removePropertyMeta(id: string) {
// should warn if the property is in use // should warn if the property is in use
delete this.customPropertiesSchema[id]; delete this.customPropertiesSchema[id];
} }
// returns page schema properties -> related page // returns page schema properties -> related page
getCustomPropertyStatistics() { getPropertyStatistics() {
const mapping = new Map<string, Set<string>>(); const mapping = new Map<string, Set<string>>();
for (const page of this.adapter.workspace.blockSuiteWorkspace.pages.values()) { for (const page of this.adapter.workspace.blockSuiteWorkspace.pages.values()) {
const properties = this.adapter.getPageProperties(page.id); const properties = this.adapter.getPageProperties(page.id);
@@ -147,12 +144,13 @@ export class PagePropertiesManager {
this.metaManager = new PagePropertiesMetaManager(this.adapter); this.metaManager = new PagePropertiesMetaManager(this.adapter);
} }
// prevent infinite loop
private ensuring = false; private ensuring = false;
ensureRequiredProperties() { ensureRequiredProperties() {
if (this.ensuring) return; if (this.ensuring) return;
this.ensuring = true; this.ensuring = true;
this.transact(() => { this.transact(() => {
this.metaManager.getOrderedCustomPropertiesSchema().forEach(property => { this.metaManager.getOrderedPropertiesSchema().forEach(property => {
if (property.required && !this.hasCustomProperty(property.id)) { if (property.required && !this.hasCustomProperty(property.id)) {
this.addCustomProperty(property.id); this.addCustomProperty(property.id);
} }
@@ -240,7 +238,7 @@ export class PagePropertiesManager {
return; return;
} }
if (!this.metaManager.validateCustomPropertyValue(id, value)) { if (!this.metaManager.validatePropertyValue(id, value)) {
logger.warn(`property ${id} value ${value} is invalid`); logger.warn(`property ${id} value ${value} is invalid`);
return; return;
} }
@@ -273,7 +271,7 @@ export class PagePropertiesManager {
} }
if ( if (
opt.value !== undefined && opt.value !== undefined &&
!this.metaManager.validateCustomPropertyValue(id, opt.value) !this.metaManager.validatePropertyValue(id, opt.value)
) { ) {
logger.warn(`property ${id} value ${opt.value} is invalid`); logger.warn(`property ${id} value ${opt.value} is invalid`);
return; return;
@@ -282,7 +280,7 @@ export class PagePropertiesManager {
} }
get updateCustomPropertyMeta() { get updateCustomPropertyMeta() {
return this.metaManager.updateCustomPropertyMeta.bind(this.metaManager); return this.metaManager.updatePropertyMeta.bind(this.metaManager);
} }
get isPropertyRequired() { get isPropertyRequired() {

View File

@@ -1,4 +1,6 @@
import { Checkbox, DatePicker, Menu } from '@affine/component'; import { Checkbox, DatePicker, Menu } from '@affine/component';
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace';
import type { import type {
PageInfoCustomProperty, PageInfoCustomProperty,
PageInfoCustomPropertyMeta, PageInfoCustomPropertyMeta,
@@ -6,11 +8,14 @@ import type {
} from '@affine/core/modules/workspace/properties/schema'; } from '@affine/core/modules/workspace/properties/schema';
import { timestampToLocalDate } from '@affine/core/utils'; import { timestampToLocalDate } from '@affine/core/utils';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { Page, useLiveData, useService, Workspace } from '@toeverything/infra';
import { noop } from 'lodash-es'; import { noop } from 'lodash-es';
import { type ChangeEventHandler, useCallback, useContext } from 'react'; import { type ChangeEventHandler, useCallback, useContext } from 'react';
import { managerContext } from './common'; import { managerContext } from './common';
import * as styles from './styles.css'; import * as styles from './styles.css';
import { TagsInlineEditor } from './tags-inline-editor';
interface PropertyRowValueProps { interface PropertyRowValueProps {
property: PageInfoCustomProperty; property: PageInfoCustomProperty;
@@ -108,6 +113,38 @@ export const TextValue = ({ property, meta }: PropertyRowValueProps) => {
); );
}; };
export const TagsValue = () => {
const workspace = useService(Workspace);
const page = useService(Page);
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
const pageMetas = useBlockSuitePageMeta(blockSuiteWorkspace);
const legacyProperties = useService(WorkspaceLegacyProperties);
const options = useLiveData(legacyProperties.tagOptions$);
const pageMeta = pageMetas.find(x => x.id === page.id);
assertExists(pageMeta, 'pageMeta should exist');
const tagIds = pageMeta.tags;
const t = useAFFiNEI18N();
const onChange = useCallback(
(tags: string[]) => {
legacyProperties.updatePageTags(page.id, tags);
},
[legacyProperties, page.id]
);
return (
<TagsInlineEditor
className={styles.propertyRowValueCell}
placeholder={t['com.affine.page-properties.property-value-placeholder']()}
value={tagIds}
options={options}
readonly={page.blockSuitePage.readonly}
onChange={onChange}
onOptionsChange={legacyProperties.updateTagOptions}
/>
);
};
export const propertyValueRenderers: Record< export const propertyValueRenderers: Record<
PagePropertyType, PagePropertyType,
typeof DateValue typeof DateValue
@@ -117,6 +154,6 @@ export const propertyValueRenderers: Record<
text: TextValue, text: TextValue,
number: TextValue, number: TextValue,
// todo: fix following // todo: fix following
tags: TextValue, tags: TagsValue,
progress: TextValue, progress: TextValue,
}; };

View File

@@ -99,10 +99,11 @@ export const tableBodyRoot = style({
gap: 8, gap: 8,
}); });
export const tableBody = style({ export const tableBodySortable = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 4, gap: 4,
position: 'relative',
}); });
export const addPropertyButton = style({ export const addPropertyButton = style({
@@ -143,7 +144,14 @@ export const propertyRow = style({
}, },
}); });
export const draggableRow = style({ export const tagsPropertyRow = style([
propertyRow,
{
marginBottom: -4,
},
]);
export const draggableItem = style({
cursor: 'pointer', cursor: 'pointer',
selectors: { selectors: {
'&:before': { '&:before': {
@@ -184,7 +192,7 @@ export const draggableRow = style({
}); });
export const draggableRowSetting = style([ export const draggableRowSetting = style([
draggableRow, draggableItem,
{ {
selectors: { selectors: {
'&:active:before': { '&:active:before': {
@@ -200,31 +208,43 @@ export const draggableRowSetting = style([
export const propertyRowCell = style({ export const propertyRowCell = style({
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'flex-start',
position: 'relative', position: 'relative',
padding: 6,
borderRadius: 4, borderRadius: 4,
cursor: 'pointer',
fontSize: cssVar('fontSm'), fontSize: cssVar('fontSm'),
lineHeight: '20px',
userSelect: 'none', userSelect: 'none',
':focus-visible': { ':focus-visible': {
outline: 'none', outline: 'none',
}, },
});
export const editablePropertyRowCell = style([
propertyRowCell,
{
cursor: 'pointer',
':hover': { ':hover': {
backgroundColor: cssVar('hoverColor'), backgroundColor: cssVar('hoverColor'),
}, },
}); },
]);
export const propertyRowNameCell = style([ export const propertyRowNameCell = style([
propertyRowCell, propertyRowCell,
draggableRow,
{ {
padding: 6,
color: cssVar('textSecondaryColor'), color: cssVar('textSecondaryColor'),
width: propertyNameCellWidth, width: propertyNameCellWidth,
gap: 6, gap: 6,
}, },
]); ]);
export const sortablePropertyRowNameCell = style([
propertyRowNameCell,
draggableItem,
editablePropertyRowCell,
]);
export const propertyRowIconContainer = style({ export const propertyRowIconContainer = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -234,6 +254,14 @@ export const propertyRowIconContainer = style({
color: 'inherit', color: 'inherit',
}); });
export const propertyRowNameContainer = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
flexGrow: 1,
});
export const propertyRowName = style({ export const propertyRowName = style({
flexGrow: 1, flexGrow: 1,
overflow: 'hidden', overflow: 'hidden',
@@ -244,7 +272,9 @@ export const propertyRowName = style({
export const propertyRowValueCell = style([ export const propertyRowValueCell = style([
propertyRowCell, propertyRowCell,
editablePropertyRowCell,
{ {
padding: '6px 8px',
border: `1px solid transparent`, border: `1px solid transparent`,
color: cssVar('textPrimaryColor'), color: cssVar('textPrimaryColor'),
':focus': { ':focus': {
@@ -257,6 +287,9 @@ export const propertyRowValueCell = style([
'&[data-empty="true"]': { '&[data-empty="true"]': {
color: cssVar('placeholderColor'), color: cssVar('placeholderColor'),
}, },
'&[data-readonly=true]': {
pointerEvents: 'none',
},
}, },
flex: 1, flex: 1,
}, },

View File

@@ -4,6 +4,7 @@ import {
Menu, Menu,
MenuIcon, MenuIcon,
MenuItem, MenuItem,
type MenuProps,
Tooltip, Tooltip,
} from '@affine/component'; } from '@affine/component';
import { useCurrentWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter'; import { useCurrentWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
@@ -22,6 +23,7 @@ import {
InvisibleIcon, InvisibleIcon,
MoreHorizontalIcon, MoreHorizontalIcon,
PlusIcon, PlusIcon,
TagsIcon,
ToggleExpandIcon, ToggleExpandIcon,
ViewIcon, ViewIcon,
} from '@blocksuite/icons'; } from '@blocksuite/icons';
@@ -42,10 +44,9 @@ import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable';
import * as Collapsible from '@radix-ui/react-collapsible'; import * as Collapsible from '@radix-ui/react-collapsible';
import clsx from 'clsx'; import clsx from 'clsx';
import { use } from 'foxact/use'; import { use } from 'foxact/use';
import { useAtom, useAtomValue } from 'jotai'; import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import type React from 'react'; import type React from 'react';
import { import {
type ChangeEventHandler,
type CSSProperties, type CSSProperties,
type MouseEvent, type MouseEvent,
type MouseEventHandler, type MouseEventHandler,
@@ -75,7 +76,10 @@ import {
newPropertyTypes, newPropertyTypes,
PagePropertiesManager, PagePropertiesManager,
} from './page-properties-manager'; } from './page-properties-manager';
import { propertyValueRenderers } from './property-row-value-renderer'; import {
propertyValueRenderers,
TagsValue,
} from './property-row-value-renderer';
import * as styles from './styles.css'; import * as styles from './styles.css';
type PagePropertiesSettingsPopupProps = PropsWithChildren<{ type PagePropertiesSettingsPopupProps = PropsWithChildren<{
@@ -87,10 +91,17 @@ const Divider = () => <div className={styles.tableHeaderDivider} />;
type PropertyVisibility = PageInfoCustomProperty['visibility']; type PropertyVisibility = PageInfoCustomProperty['visibility'];
const editingPropertyAtom = atom<string | null>(null);
const modifiers = [restrictToParentElement, restrictToVerticalAxis];
const SortableProperties = ({ children }: PropsWithChildren) => { const SortableProperties = ({ children }: PropsWithChildren) => {
const manager = useContext(managerContext); const manager = useContext(managerContext);
const properties = manager.getOrderedCustomProperties(); const properties = useMemo(
const readonly = manager.readonly; () => manager.getOrderedCustomProperties(),
[manager]
);
const editingItem = useAtomValue(editingPropertyAtom);
const draggable = !manager.readonly && !editingItem;
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {
@@ -100,7 +111,7 @@ const SortableProperties = ({ children }: PropsWithChildren) => {
); );
const onDragEnd = useCallback( const onDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
if (readonly) { if (!draggable) {
return; return;
} }
const { active, over } = event; const { active, over } = event;
@@ -118,11 +129,13 @@ const SortableProperties = ({ children }: PropsWithChildren) => {
}); });
} }
}, },
[manager, properties, readonly] [manager, properties, draggable]
); );
return ( return (
<DndContext sensors={sensors} onDragEnd={onDragEnd} modifiers={modifiers}> <DndContext sensors={sensors} onDragEnd={onDragEnd} modifiers={modifiers}>
<SortableContext items={properties}>{children}</SortableContext> <SortableContext disabled={!draggable} items={properties}>
{children}
</SortableContext>
</DndContext> </DndContext>
); );
}; };
@@ -262,6 +275,11 @@ const VisibilityModeSelector = ({
rootOptions={{ rootOptions={{
open: required ? false : undefined, open: required ? false : undefined,
}} }}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
> >
<div data-required={required} className={styles.selectorButton}> <div data-required={required} className={styles.selectorButton}>
{required ? ( {required ? (
@@ -287,7 +305,7 @@ export const PagePropertiesSettingsPopup = ({
const menuItems = useMemo(() => { const menuItems = useMemo(() => {
const options: MenuItemOption[] = []; const options: MenuItemOption[] = [];
options.push( options.push(
<div className={styles.menuHeader}> <div className={styles.menuHeader} style={{ minWidth: 320 }}>
{t['com.affine.page-properties.settings.title']()} {t['com.affine.page-properties.settings.title']()}
</div> </div>
); );
@@ -319,7 +337,18 @@ export const PagePropertiesSettingsPopup = ({
return renderMenuItemOptions(options); return renderMenuItemOptions(options);
}, [manager, properties, t]); }, [manager, properties, t]);
return <Menu items={menuItems}>{children}</Menu>; return (
<Menu
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
items={menuItems}
>
{children}
</Menu>
);
}; };
type PageBacklinksPopupProps = PropsWithChildren<{ type PageBacklinksPopupProps = PropsWithChildren<{
@@ -334,6 +363,11 @@ export const PageBacklinksPopup = ({
return ( return (
<Menu <Menu
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
items={ items={
<div className={styles.backlinksList}> <div className={styles.backlinksList}>
{backlinks.map(pageId => ( {backlinks.map(pageId => (
@@ -359,7 +393,7 @@ interface PagePropertyRowNameProps {
onFinishEditing: () => void; onFinishEditing: () => void;
} }
export const PagePropertyRowName = ({ export const PagePropertyRowNameMenu = ({
editing, editing,
meta, meta,
property, property,
@@ -386,11 +420,10 @@ export const PagePropertyRowName = ({
property.id, property.id,
]); ]);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const handleNameBlur: ChangeEventHandler<HTMLInputElement> = useCallback( const handleNameBlur = useCallback(
e => { (v: string) => {
e.stopPropagation();
manager.updateCustomPropertyMeta(meta.id, { manager.updateCustomPropertyMeta(meta.id, {
name: e.target.value, name: v,
}); });
}, },
[manager, meta.id] [manager, meta.id]
@@ -486,6 +519,9 @@ export const PagePropertyRowName = ({
}} }}
contentOptions={{ contentOptions={{
onInteractOutside: handleFinishEditing, onInteractOutside: handleFinishEditing,
onClick(e) {
e.stopPropagation();
},
}} }}
items={menuItems} items={menuItems}
> >
@@ -569,7 +605,7 @@ export const PagePropertiesTableHeader = ({
<div className={clsx(collapsed ? styles.pageInfoDimmed : null)}> <div className={clsx(collapsed ? styles.pageInfoDimmed : null)}>
{t['com.affine.page-properties.page-info']()} {t['com.affine.page-properties.page-info']()}
</div> </div>
{(collapsed && properties.length === 0) || manager.readonly ? null : ( {properties.length === 0 || manager.readonly ? null : (
<PagePropertiesSettingsPopup> <PagePropertiesSettingsPopup>
<IconButton type="plain" icon={<MoreHorizontalIcon />} /> <IconButton type="plain" icon={<MoreHorizontalIcon />} />
</PagePropertiesSettingsPopup> </PagePropertiesSettingsPopup>
@@ -591,17 +627,6 @@ export const PagePropertiesTableHeader = ({
); );
}; };
const usePagePropertiesManager = (page: Page) => {
// the workspace properties adapter adapter is reactive,
// which means it's reference will change when any of the properties change
// also it will trigger a re-render of the component
const adapter = useCurrentWorkspacePropertiesAdapter();
const manager = useMemo(() => {
return new PagePropertiesManager(adapter, page.id);
}, [adapter, page.id]);
return manager;
};
interface PagePropertyRowProps { interface PagePropertyRowProps {
property: PageInfoCustomProperty; property: PageInfoCustomProperty;
style?: React.CSSProperties; style?: React.CSSProperties;
@@ -617,25 +642,22 @@ const PagePropertyRow = ({ property }: PagePropertyRowProps) => {
const name = meta.name; const name = meta.name;
const ValueRenderer = propertyValueRenderers[meta.type]; const ValueRenderer = propertyValueRenderers[meta.type];
const [editingMeta, setEditingMeta] = useState(false); const [editingMeta, setEditingMeta] = useState(false);
const setEditingItem = useSetAtom(editingPropertyAtom);
const handleEditMeta = useCallback(() => { const handleEditMeta = useCallback(() => {
if (!manager.readonly) { if (!manager.readonly) {
setEditingMeta(true); setEditingMeta(true);
} }
}, [manager.readonly]); setEditingItem(property.id);
}, [manager.readonly, property.id, setEditingItem]);
const handleFinishEditingMeta = useCallback(() => { const handleFinishEditingMeta = useCallback(() => {
setEditingMeta(false); setEditingMeta(false);
}, []); setEditingItem(null);
}, [setEditingItem]);
return ( return (
<SortablePropertyRow <SortablePropertyRow property={property} data-testid="page-property-row">
property={property}
className={styles.propertyRow}
data-testid="page-property-row"
data-property={property.id}
data-draggable={!manager.readonly}
>
{({ attributes, listeners }) => ( {({ attributes, listeners }) => (
<> <>
<PagePropertyRowName <PagePropertyRowNameMenu
editing={editingMeta} editing={editingMeta}
meta={meta} meta={meta}
property={property} property={property}
@@ -644,15 +666,18 @@ const PagePropertyRow = ({ property }: PagePropertyRowProps) => {
<div <div
{...attributes} {...attributes}
{...listeners} {...listeners}
className={styles.propertyRowNameCell} data-testid="page-property-row-name"
className={styles.sortablePropertyRowNameCell}
onClick={handleEditMeta} onClick={handleEditMeta}
> >
<div className={styles.propertyRowNameContainer}>
<div className={styles.propertyRowIconContainer}> <div className={styles.propertyRowIconContainer}>
<Icon /> <Icon />
</div> </div>
<div className={styles.propertyRowName}>{name}</div> <div className={styles.propertyRowName}>{name}</div>
</div> </div>
</PagePropertyRowName> </div>
</PagePropertyRowNameMenu>
<ValueRenderer meta={meta} property={property} /> <ValueRenderer meta={meta} property={property} />
</> </>
)} )}
@@ -660,13 +685,35 @@ const PagePropertyRow = ({ property }: PagePropertyRowProps) => {
); );
}; };
const PageTagsRow = () => {
const t = useAFFiNEI18N();
return (
<div
className={styles.tagsPropertyRow}
data-testid="page-property-row"
data-property="tags"
>
<div
className={styles.propertyRowNameCell}
data-testid="page-property-row-name"
>
<div className={styles.propertyRowNameContainer}>
<div className={styles.propertyRowIconContainer}>
<TagsIcon />
</div>
<div className={styles.propertyRowName}>{t['Tags']()}</div>
</div>
</div>
<TagsValue />
</div>
);
};
interface PagePropertiesTableBodyProps { interface PagePropertiesTableBodyProps {
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
} }
const modifiers = [restrictToParentElement, restrictToVerticalAxis];
// 🏷️ Tags (⋅ xxx) (⋅ yyy) // 🏷️ Tags (⋅ xxx) (⋅ yyy)
// #️⃣ Number 123456 // #️⃣ Number 123456
// + Add a property // + Add a property
@@ -684,7 +731,8 @@ export const PagePropertiesTableBody = ({
className={clsx(styles.tableBodyRoot, className)} className={clsx(styles.tableBodyRoot, className)}
style={style} style={style}
> >
<div className={styles.tableBody}> <PageTagsRow />
<div className={styles.tableBodySortable}>
<SortableProperties> <SortableProperties>
{properties {properties
.filter( .filter(
@@ -735,13 +783,13 @@ export const PagePropertiesCreatePropertyMenuItems = ({
e: React.MouseEvent, e: React.MouseEvent,
option: { type: PagePropertyType; name: string; icon: string } option: { type: PagePropertyType; name: string; icon: string }
) => { ) => {
const schemaList = metaManager.getOrderedCustomPropertiesSchema(); const schemaList = metaManager.getOrderedPropertiesSchema();
const nameExists = schemaList.some(meta => meta.name === option.name); const nameExists = schemaList.some(meta => meta.name === option.name);
const allNames = schemaList.map(meta => meta.name); const allNames = schemaList.map(meta => meta.name);
const name = nameExists const name = nameExists
? findNextDefaultName(option.name, allNames) ? findNextDefaultName(option.name, allNames)
: option.name; : option.name;
const { id } = metaManager.addCustomPropertyMeta({ const { id } = metaManager.addPropertyMeta({
name, name,
icon: option.icon, icon: option.icon,
type: option.type, type: option.type,
@@ -791,7 +839,7 @@ const PagePropertiesAddPropertyMenuItems = ({
const manager = useContext(managerContext); const manager = useContext(managerContext);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const metaList = manager.metaManager.getOrderedCustomPropertiesSchema(); const metaList = manager.metaManager.getOrderedPropertiesSchema();
const nonRequiredMetaList = metaList.filter(meta => !meta.required); const nonRequiredMetaList = metaList.filter(meta => !meta.required);
const isChecked = useCallback( const isChecked = useCallback(
(m: string) => { (m: string) => {
@@ -859,13 +907,12 @@ export const PagePropertiesAddProperty = () => {
e.preventDefault(); e.preventDefault();
setAdding(prev => !prev); setAdding(prev => !prev);
}, []); }, []);
const handleCreated = useCallback(
(e: React.MouseEvent, id: string) => { const menuOptions = useMemo(() => {
const handleCreated = (e: React.MouseEvent, id: string) => {
toggleAdding(e); toggleAdding(e);
manager.addCustomProperty(id); manager.addCustomProperty(id);
}, };
[manager, toggleAdding]
);
const items = adding ? ( const items = adding ? (
<PagePropertiesAddPropertyMenuItems onCreateClicked={toggleAdding} /> <PagePropertiesAddPropertyMenuItems onCreateClicked={toggleAdding} />
) : ( ) : (
@@ -874,8 +921,22 @@ export const PagePropertiesAddProperty = () => {
onCreated={handleCreated} onCreated={handleCreated}
/> />
); );
return {
contentOptions: {
onClick(e) {
e.stopPropagation();
},
},
rootOptions: {
onOpenChange: () => setAdding(true),
},
items,
} satisfies Partial<MenuProps>;
}, [adding, manager, toggleAdding]);
return ( return (
<Menu rootOptions={{ onOpenChange: () => setAdding(true) }} items={items}> <Menu {...menuOptions}>
<Button <Button
type="plain" type="plain"
icon={<PlusIcon />} icon={<PlusIcon />}
@@ -901,6 +962,17 @@ const PagePropertiesTableInner = () => {
); );
}; };
const usePagePropertiesManager = (page: Page) => {
// the workspace properties adapter adapter is reactive,
// which means it's reference will change when any of the properties change
// also it will trigger a re-render of the component
const adapter = useCurrentWorkspacePropertiesAdapter();
const manager = useMemo(() => {
return new PagePropertiesManager(adapter, page.id);
}, [adapter, page.id]);
return manager;
};
// this is the main component that renders the page properties table at the top of the page below // this is the main component that renders the page properties table at the top of the page below
// the page title // the page title
export const PagePropertiesTable = ({ page }: { page: Page }) => { export const PagePropertiesTable = ({ page }: { page: Page }) => {

View File

@@ -0,0 +1,115 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const tagsInlineEditor = style({
width: '100%',
selectors: {
'&[data-empty=true]': {
color: cssVar('placeholderColor'),
},
},
});
export const tagsEditorRoot = style({
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: '8px',
});
export const inlineTagsContainer = style({
display: 'flex',
gap: '6px',
flexWrap: 'wrap',
width: '100%',
});
export const tagsMenu = style({
padding: 0,
transform:
'translate(-3px, calc(-3px + var(--radix-popper-anchor-height) * -1))',
width: 'calc(var(--radix-popper-anchor-width) + 16px)',
overflow: 'hidden',
});
export const tagsEditorSelectedTags = style({
display: 'flex',
gap: '4px',
flexWrap: 'wrap',
padding: '10px 12px',
backgroundColor: cssVar('hoverColor'),
minHeight: 42,
});
export const searchInput = style({
flexGrow: 1,
padding: '10px 0',
margin: '-10px 0',
border: 'none',
outline: 'none',
fontSize: '14px',
fontFamily: 'inherit',
color: 'inherit',
backgroundColor: 'transparent',
'::placeholder': {
color: cssVar('placeholderColor'),
},
});
export const tagsEditorTagsSelector = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '0 8px 8px 8px',
maxHeight: '400px',
overflow: 'auto',
});
export const tagsEditorTagsSelectorHeader = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 8px',
fontSize: '14px',
fontWeight: 500,
color: cssVar('textSecondaryColor'),
});
export const tagSelectorTagsScrollContainer = style({
overflowX: 'hidden',
position: 'relative',
maxHeight: '200px',
gap: '8px',
});
export const tagSelectorItem = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: '0 8px',
height: '34px',
gap: 8,
cursor: 'pointer',
borderRadius: '4px',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
});
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%',
});

View File

@@ -0,0 +1,381 @@
import {
IconButton,
Input,
Menu,
type MenuProps,
Scrollable,
} from '@affine/component';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { DeleteIcon, MoreHorizontalIcon, TagsIcon } from '@blocksuite/icons';
import type { Tag } from '@blocksuite/store';
import { useService } from '@toeverything/infra/di';
import clsx from 'clsx';
import { nanoid } from 'nanoid';
import {
type HTMLAttributes,
type PropsWithChildren,
useCallback,
useMemo,
useReducer,
useState,
} from 'react';
import { TagItem } from '../../page-list';
import { tagColors } from './common';
import { type MenuItemOption, renderMenuItemOptions } from './menu-items';
import * as styles from './tags-inline-editor.css';
interface TagsEditorProps {
value: string[]; // selected tag ids
onChange?: (value: string[]) => void;
options: Tag[];
onOptionsChange?: (options: Tag[]) => void; // adding/updating/removing tags
readonly?: boolean;
}
interface InlineTagsListProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>,
Omit<TagsEditorProps, 'onOptionsChange'> {}
const InlineTagsList = ({
value,
onChange,
options,
readonly,
children,
}: PropsWithChildren<InlineTagsListProps>) => {
return (
<div className={styles.inlineTagsContainer}>
{value.map((tagId, idx) => {
const tag = options.find(t => t.id === tagId);
if (!tag) {
return null;
}
const onRemoved =
readonly || !onChange
? undefined
: () => {
onChange(value.filter(v => v !== tagId));
};
return (
<TagItem
key={tagId}
idx={idx}
onRemoved={onRemoved}
mode="inline"
tag={tag}
/>
);
})}
{children}
</div>
);
};
const filterOption = (option: Tag, inputValue?: string) => {
const trimmedValue = inputValue?.trim().toLowerCase() ?? '';
const trimmedOptionValue = option.value.trim().toLowerCase();
return trimmedOptionValue.includes(trimmedValue);
};
export const EditTagMenu = ({
tag,
children,
}: PropsWithChildren<{ tag: Tag }>) => {
const t = useAFFiNEI18N();
const legacyProperties = useService(WorkspaceLegacyProperties);
const navigate = useNavigateHelper();
const menuProps = useMemo(() => {
const options: MenuItemOption[] = [];
const updateTagName = (name: string) => {
if (name.trim() === '') {
return;
}
legacyProperties.updateTagOption(tag.id, {
...tag,
value: name,
});
};
options.push(
<Input
defaultValue={tag.value}
onBlur={e => {
updateTagName(e.currentTarget.value);
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
updateTagName(e.currentTarget.value);
}
}}
placeholder={t['Untitled']()}
/>
);
options.push('-');
options.push({
text: t['Delete'](),
icon: <DeleteIcon />,
type: 'danger',
onClick() {
legacyProperties.removeTagOption(tag.id);
},
});
options.push({
text: t['com.affine.page-properties.tags.open-tags-page'](),
icon: <TagsIcon />,
onClick() {
navigate.jumpToTag(legacyProperties.workspaceId, tag.id);
},
});
options.push('-');
options.push(
tagColors.map(([name, color]) => {
return {
text: name,
icon: (
<div className={styles.tagColorIconWrapper}>
<div
className={styles.tagColorIcon}
style={{
backgroundColor: color,
}}
/>
</div>
),
checked: tag.color === color,
onClick() {
legacyProperties.updateTagOption(tag.id, {
...tag,
color,
});
},
};
})
);
const items = renderMenuItemOptions(options);
return {
contentOptions: {
onClick(e) {
e.stopPropagation();
},
},
items,
} satisfies Partial<MenuProps>;
}, [legacyProperties, navigate, t, tag]);
return <Menu {...menuProps}>{children}</Menu>;
};
export const TagsEditor = ({
options,
value,
onChange,
onOptionsChange,
readonly,
}: TagsEditorProps) => {
const t = useAFFiNEI18N();
const [inputValue, setInputValue] = useState('');
const exactMatch = options.find(o => o.value === inputValue);
const filteredOptions = useMemo(
() =>
options.filter(o => (inputValue ? filterOption(o, inputValue) : true)),
[inputValue, options]
);
const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
},
[]
);
const onAddTag = useCallback(
(id: string) => {
if (!value.includes(id)) {
onChange?.([...value, id]);
}
},
[onChange, value]
);
const [nextColor, rotateNextColor] = useReducer(
color => {
const idx = tagColors.findIndex(c => c[1] === color);
return tagColors[(idx + 1) % tagColors.length][1];
},
tagColors[Math.floor(Math.random() * tagColors.length)][1]
);
const onCreateTag = useCallback(
(name: string) => {
if (!name.trim()) {
return;
}
const newTag = {
id: nanoid(),
value: name.trim(),
color: nextColor,
};
rotateNextColor();
onOptionsChange?.([...options, newTag]);
onChange?.([...value, newTag.id]);
},
[nextColor, onChange, onOptionsChange, options, value]
);
const onInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (exactMatch) {
onAddTag(exactMatch.id);
} else {
onCreateTag(inputValue);
}
setInputValue('');
} else if (e.key === 'Backspace' && inputValue === '' && value.length) {
onChange?.(value.slice(0, value.length - 1));
}
},
[exactMatch, inputValue, onAddTag, onChange, onCreateTag, value]
);
return (
<div className={styles.tagsEditorRoot}>
<div className={styles.tagsEditorSelectedTags}>
<InlineTagsList
options={options}
value={value}
onChange={onChange}
readonly={readonly}
>
<input
value={inputValue}
onChange={onInputChange}
onKeyDown={onInputKeyDown}
autoFocus
className={styles.searchInput}
placeholder="Type here ..."
/>
</InlineTagsList>
</div>
<div className={styles.tagsEditorTagsSelector}>
<div className={styles.tagsEditorTagsSelectorHeader}>
{t['com.affine.page-properties.tags.selector-header-title']()}
</div>
<Scrollable.Root>
<Scrollable.Viewport
className={styles.tagSelectorTagsScrollContainer}
>
{filteredOptions.map(tag => {
return (
<div
key={tag.id}
className={styles.tagSelectorItem}
onClick={() => {
onAddTag(tag.id);
}}
>
<TagItem maxWidth="100%" tag={tag} mode="inline" />
<div className={styles.spacer} />
<EditTagMenu tag={tag}>
<IconButton type="plain" icon={<MoreHorizontalIcon />} />
</EditTagMenu>
</div>
);
})}
{exactMatch || !inputValue ? null : (
<div
className={styles.tagSelectorItem}
onClick={() => {
setInputValue('');
onCreateTag(inputValue);
}}
>
{t['Create']()}{' '}
<TagItem
maxWidth="100%"
tag={{
id: inputValue,
value: inputValue,
color: nextColor,
}}
mode="inline"
/>
</div>
)}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
</div>
</div>
);
};
interface TagsInlineEditorProps extends TagsEditorProps {
placeholder?: string;
className?: string;
}
// this tags value renderer right now only renders the legacy tags for now
export const TagsInlineEditor = ({
value,
onChange,
options,
onOptionsChange,
readonly,
placeholder,
className,
}: TagsInlineEditorProps) => {
const empty = !value || value.length === 0;
return (
<Menu
contentOptions={{
side: 'bottom',
align: 'start',
sideOffset: 0,
avoidCollisions: false,
className: styles.tagsMenu,
onClick(e) {
e.stopPropagation();
},
}}
items={
<TagsEditor
value={value}
options={options}
onChange={onChange}
onOptionsChange={onOptionsChange}
readonly={readonly}
/>
}
>
<div
className={clsx(styles.tagsInlineEditor, className)}
data-empty={empty}
data-readonly={readonly}
>
{empty ? (
placeholder
) : (
<InlineTagsList
value={value}
onChange={onChange}
options={options}
readonly
/>
)}
</div>
</Menu>
);
};

View File

@@ -1,4 +1,4 @@
import { Button, IconButton, Menu } from '@affine/component'; import { Button, ConfirmModal, IconButton, Menu } from '@affine/component';
import { SettingHeader } from '@affine/component/setting-components'; import { SettingHeader } from '@affine/component/setting-components';
import { useWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter'; import { useWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
import { useWorkspace } from '@affine/core/hooks/use-workspace'; import { useWorkspace } from '@affine/core/hooks/use-workspace';
@@ -12,11 +12,9 @@ import type {
WorkspaceMetadata, WorkspaceMetadata,
} from '@toeverything/infra/workspace'; } from '@toeverything/infra/workspace';
import { import {
type ChangeEventHandler,
createContext, createContext,
Fragment, Fragment,
type MouseEvent, type MouseEvent,
type MouseEventHandler,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
@@ -56,9 +54,57 @@ const Divider = () => {
return <div className={styles.divider} />; return <div className={styles.divider} />;
}; };
const ConfirmDeletePropertyModal = ({
onConfirm,
onCancel,
property,
count,
show,
}: {
property: PageInfoCustomPropertyMeta;
count: number;
show: boolean;
onConfirm: () => void;
onCancel: () => void;
}) => {
const t = useAFFiNEI18N();
return (
<ConfirmModal
open={show}
closeButtonOptions={{
onClick: onCancel,
}}
title={t['com.affine.settings.workspace.properties.delete-property']()}
description={
<Trans
values={{
name: property.name,
count,
}}
i18nKey="com.affine.settings.workspace.properties.delete-property-prompt"
>
The <strong>{{ name: property.name } as any}</strong> property will be
removed from count doc(s). This action cannot be undone.
</Trans>
}
onConfirm={onConfirm}
cancelButtonOptions={{
onClick: onCancel,
}}
confirmButtonOptions={{
type: 'error',
children: t['Confirm'](),
}}
/>
);
};
const EditPropertyButton = ({ const EditPropertyButton = ({
property, property,
count,
}: { }: {
count: number;
property: PageInfoCustomPropertyMeta; property: PageInfoCustomPropertyMeta;
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
@@ -69,31 +115,22 @@ const EditPropertyButton = ({
useEffect(() => { useEffect(() => {
setLocalPropertyMeta(property); setLocalPropertyMeta(property);
}, [property]); }, [property]);
const handleToggleRequired: MouseEventHandler = useCallback( const handleToggleRequired = useCallback(() => {
e => { manager.updatePropertyMeta(localPropertyMeta.id, {
e.stopPropagation();
e.preventDefault();
manager.updateCustomPropertyMeta(localPropertyMeta.id, {
required: !localPropertyMeta.required, required: !localPropertyMeta.required,
}); });
}, }, [manager, localPropertyMeta.id, localPropertyMeta.required]);
[manager, localPropertyMeta.id, localPropertyMeta.required] const handleDelete = useCallback(() => {
); manager.removePropertyMeta(localPropertyMeta.id);
const handleDelete: MouseEventHandler = useCallback( }, [manager, localPropertyMeta.id]);
e => {
e.stopPropagation();
e.preventDefault();
manager.removeCustomPropertyMeta(localPropertyMeta.id);
},
[manager, localPropertyMeta.id]
);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const handleFinishEditing = useCallback(() => { const handleFinishEditing = useCallback(() => {
setOpen(false); setOpen(false);
setEditing(false); setEditing(false);
manager.updateCustomPropertyMeta(localPropertyMeta.id, localPropertyMeta); manager.updatePropertyMeta(localPropertyMeta.id, localPropertyMeta);
}, [localPropertyMeta, manager]); }, [localPropertyMeta, manager]);
const defaultMenuItems = useMemo(() => { const defaultMenuItems = useMemo(() => {
@@ -113,26 +150,25 @@ const EditPropertyButton = ({
}); });
options.push({ options.push({
text: t['com.affine.settings.workspace.properties.delete-property'](), text: t['com.affine.settings.workspace.properties.delete-property'](),
onClick: handleDelete, onClick: () => setShowDeleteModal(true),
type: 'danger', type: 'danger',
icon: <DeleteIcon />, icon: <DeleteIcon />,
}); });
return renderMenuItemOptions(options); return renderMenuItemOptions(options);
}, [handleDelete, handleToggleRequired, localPropertyMeta.required, t]); }, [handleToggleRequired, localPropertyMeta.required, t]);
const handleNameBlur: ChangeEventHandler<HTMLInputElement> = useCallback( const handleNameBlur = useCallback(
e => { (e: string) => {
e.stopPropagation(); manager.updatePropertyMeta(localPropertyMeta.id, {
manager.updateCustomPropertyMeta(localPropertyMeta.id, { name: e,
name: e.target.value,
}); });
}, },
[manager, localPropertyMeta.id] [manager, localPropertyMeta.id]
); );
const handleNameChange: (name: string) => void = useCallback(name => { const handleNameChange = useCallback((e: string) => {
setLocalPropertyMeta(prev => ({ setLocalPropertyMeta(prev => ({
...prev, ...prev,
name: name, name: e,
})); }));
}, []); }, []);
const handleIconChange = useCallback( const handleIconChange = useCallback(
@@ -141,7 +177,7 @@ const EditPropertyButton = ({
...prev, ...prev,
icon, icon,
})); }));
manager.updateCustomPropertyMeta(localPropertyMeta.id, { manager.updatePropertyMeta(localPropertyMeta.id, {
icon, icon,
}); });
}, },
@@ -176,6 +212,7 @@ const EditPropertyButton = ({
]); ]);
return ( return (
<>
<Menu <Menu
rootOptions={{ rootOptions={{
open, open,
@@ -189,6 +226,17 @@ const EditPropertyButton = ({
icon={<MoreHorizontalIcon />} icon={<MoreHorizontalIcon />}
/> />
</Menu> </Menu>
<ConfirmDeletePropertyModal
onConfirm={() => {
setShowDeleteModal(false);
handleDelete();
}}
onCancel={() => setShowDeleteModal(false)}
show={showDeleteModal}
property={property}
count={count}
/>
</>
); );
}; };
@@ -233,7 +281,7 @@ const CustomPropertyRow = ({
{t['com.affine.page-properties.property.required']()} {t['com.affine.page-properties.property.required']()}
</div> </div>
) : null} ) : null}
<EditPropertyButton property={property} /> <EditPropertyButton property={property} count={relatedPages.length} />
</div> </div>
); );
}; };
@@ -269,8 +317,8 @@ const CustomPropertyRowsList = ({
filterMode: PropertyFilterMode; filterMode: PropertyFilterMode;
}) => { }) => {
const manager = useContext(managerContext); const manager = useContext(managerContext);
const properties = manager.getOrderedCustomPropertiesSchema(); const properties = manager.getOrderedPropertiesSchema();
const statistics = manager.getCustomPropertyStatistics(); const statistics = manager.getPropertyStatistics();
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
if (filterMode !== 'all') { if (filterMode !== 'all') {
@@ -315,7 +363,7 @@ const WorkspaceSettingPropertiesMain = () => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const manager = useContext(managerContext); const manager = useContext(managerContext);
const [filterMode, setFilterMode] = useState<PropertyFilterMode>('all'); const [filterMode, setFilterMode] = useState<PropertyFilterMode>('all');
const properties = manager.getOrderedCustomPropertiesSchema(); const properties = manager.getOrderedPropertiesSchema();
const filterMenuItems = useMemo(() => { const filterMenuItems = useMemo(() => {
const options: MenuItemOption[] = ( const options: MenuItemOption[] = (
['all', '-', 'in-use', 'unused'] as const ['all', '-', 'in-use', 'unused'] as const

View File

@@ -63,7 +63,7 @@ export const innerBackdrop = style({
}, },
}); });
export const tag = style({ export const tag = style({
height: '20px', height: '22px',
display: 'flex', display: 'flex',
minWidth: 0, minWidth: 0,
alignItems: 'center', alignItems: 'center',
@@ -79,7 +79,7 @@ export const tagInnerWrapper = style({
padding: '0 8px', padding: '0 8px',
color: cssVar('textPrimaryColor'), color: cssVar('textPrimaryColor'),
}); });
export const tagSticky = style([ export const tagInline = style([
tagInnerWrapper, tagInnerWrapper,
{ {
fontSize: cssVar('fontXs'), fontSize: cssVar('fontXs'),
@@ -88,6 +88,7 @@ export const tagSticky = style([
border: `1px solid ${cssVar('borderColor')}`, border: `1px solid ${cssVar('borderColor')}`,
background: cssVar('backgroundPrimaryColor'), background: cssVar('backgroundPrimaryColor'),
maxWidth: '128px', maxWidth: '128px',
height: '100%',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}, },
@@ -120,3 +121,17 @@ export const tagLabel = style({
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}); });
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)',
},
});

View File

@@ -1,9 +1,9 @@
import { Menu } from '@affine/component'; import { Menu } from '@affine/component';
import type { Tag } from '@affine/env/filter'; import type { Tag } from '@affine/env/filter';
import { MoreHorizontalIcon } from '@blocksuite/icons'; import { CloseIcon, MoreHorizontalIcon } from '@blocksuite/icons';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx'; import clsx from 'clsx';
import { useMemo } from 'react'; import { type MouseEventHandler, useCallback, useMemo } from 'react';
import { stopPropagation, tagColorMap } from '../utils'; import { stopPropagation, tagColorMap } from '../utils';
import * as styles from './page-tags.css'; import * as styles from './page-tags.css';
@@ -17,12 +17,28 @@ export interface PageTagsProps {
interface TagItemProps { interface TagItemProps {
tag: Tag; tag: Tag;
idx: number; idx?: number;
mode: 'sticky' | 'list-item'; maxWidth?: number | string;
mode: 'inline' | 'list-item';
onRemoved?: () => void;
style?: React.CSSProperties; style?: React.CSSProperties;
} }
export const TagItem = ({ tag, idx, mode, style }: TagItemProps) => { export const TagItem = ({
tag,
idx,
mode,
onRemoved,
style,
maxWidth,
}: TagItemProps) => {
const handleRemove: MouseEventHandler = useCallback(
e => {
e.stopPropagation();
onRemoved?.();
},
[onRemoved]
);
return ( return (
<div <div
data-testid="page-tag" data-testid="page-tag"
@@ -32,7 +48,8 @@ export const TagItem = ({ tag, idx, mode, style }: TagItemProps) => {
style={style} style={style}
> >
<div <div
className={mode === 'sticky' ? styles.tagSticky : styles.tagListItem} style={{ maxWidth: maxWidth }}
className={mode === 'inline' ? styles.tagInline : styles.tagListItem}
> >
<div <div
className={styles.tagIndicator} className={styles.tagIndicator}
@@ -41,6 +58,11 @@ export const TagItem = ({ tag, idx, mode, style }: TagItemProps) => {
}} }}
/> />
<div className={styles.tagLabel}>{tag.value}</div> <div className={styles.tagLabel}>{tag.value}</div>
{onRemoved ? (
<div className={styles.tagRemove} onClick={handleRemove}>
<CloseIcon />
</div>
) : null}
</div> </div>
</div> </div>
); );
@@ -76,7 +98,7 @@ export const PageTags = ({
nTags.sort((a, b) => a.value.length - b.value.length); nTags.sort((a, b) => a.value.length - b.value.length);
return nTags.map((tag, idx) => ( return nTags.map((tag, idx) => (
<TagItem key={tag.id} tag={tag} idx={idx} mode="sticky" /> <TagItem key={tag.id} tag={tag} idx={idx} mode="inline" />
)); ));
}, [maxItems, tags]); }, [maxItems, tags]);
return ( return (

View File

@@ -58,6 +58,18 @@ export function useNavigateHelper() {
}, },
[navigate] [navigate]
); );
const jumpToTag = useCallback(
(
workspaceId: string,
tagId: string,
logic: RouteLogic = RouteLogic.PUSH
) => {
return navigate(`/workspace/${workspaceId}/tag/${tagId}`, {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const jumpToCollection = useCallback( const jumpToCollection = useCallback(
( (
workspaceId: string, workspaceId: string,
@@ -162,6 +174,7 @@ export function useNavigateHelper() {
jumpToCollection, jumpToCollection,
jumpToCollections, jumpToCollections,
jumpToTags, jumpToTags,
jumpToTag,
}), }),
[ [
jumpToPage, jumpToPage,
@@ -176,6 +189,7 @@ export function useNavigateHelper() {
jumpToCollection, jumpToCollection,
jumpToCollections, jumpToCollections,
jumpToTags, jumpToTags,
jumpToTag,
] ]
); );
} }

View File

@@ -10,6 +10,7 @@ import { LocalStorageGlobalCache } from './infra-web/storage';
import { CurrentPageService } from './page'; import { CurrentPageService } from './page';
import { import {
CurrentWorkspaceService, CurrentWorkspaceService,
WorkspaceLegacyProperties,
WorkspacePropertiesAdapter, WorkspacePropertiesAdapter,
} from './workspace'; } from './workspace';
@@ -19,7 +20,8 @@ export function configureBusinessServices(services: ServiceCollection) {
.scope(WorkspaceScope) .scope(WorkspaceScope)
.add(CurrentPageService) .add(CurrentPageService)
.add(WorkspacePropertiesAdapter, [Workspace]) .add(WorkspacePropertiesAdapter, [Workspace])
.add(CollectionService, [Workspace]); .add(CollectionService, [Workspace])
.add(WorkspaceLegacyProperties, [Workspace]);
} }
export function configureWebInfraServices(services: ServiceCollection) { export function configureWebInfraServices(services: ServiceCollection) {

View File

@@ -1 +1,2 @@
export * from './adapter'; export * from './adapter';
export * from './legacy-properties';

View File

@@ -0,0 +1,81 @@
import type { PagesPropertiesMeta, Tag } from '@blocksuite/store';
import { LiveData } from '@toeverything/infra/livedata';
import type { Workspace } from '@toeverything/infra/workspace';
import { Observable } from 'rxjs';
/**
* @deprecated use WorkspacePropertiesAdapter instead (later)
*/
export class WorkspaceLegacyProperties {
constructor(private readonly workspace: Workspace) {}
get workspaceId() {
return this.workspace.id;
}
get properties() {
return this.workspace.blockSuiteWorkspace.meta.properties;
}
get tagOptions() {
return this.properties.tags?.options ?? [];
}
updateProperties = (properties: PagesPropertiesMeta) => {
this.workspace.blockSuiteWorkspace.meta.setProperties(properties);
};
subscribe(cb: () => void) {
const disposable =
this.workspace.blockSuiteWorkspace.meta.pageMetasUpdated.on(cb);
return disposable.dispose;
}
properties$ = LiveData.from(
new Observable<PagesPropertiesMeta>(sub => {
return this.subscribe(() => sub.next(this.properties));
}),
this.properties
);
tagOptions$ = LiveData.from(
new Observable<Tag[]>(sub => {
return this.subscribe(() => sub.next(this.tagOptions));
}),
this.tagOptions
);
updateTagOptions = (options: Tag[]) => {
this.updateProperties({
...this.properties,
tags: {
options,
},
});
};
updateTagOption = (id: string, option: Tag) => {
this.updateTagOptions(this.tagOptions.map(o => (o.id === id ? option : o)));
};
removeTagOption = (id: string) => {
this.workspace.blockSuiteWorkspace.doc.transact(() => {
this.updateTagOptions(this.tagOptions.filter(o => o.id !== id));
// need to remove tag from all pages
this.workspace.blockSuiteWorkspace.pages.forEach(page => {
const tags = page.meta.tags ?? [];
if (tags.includes(id)) {
this.updatePageTags(
page.id,
tags.filter(t => t !== id)
);
}
});
});
};
updatePageTags = (pageId: string, tags: string[]) => {
this.workspace.blockSuiteWorkspace.setPageMeta(pageId, {
tags,
});
};
}

View File

@@ -913,6 +913,7 @@
"com.affine.settings.workspace.properties.set-as-required": "Set as required property", "com.affine.settings.workspace.properties.set-as-required": "Set as required property",
"com.affine.settings.workspace.properties.edit-property": "Edit property", "com.affine.settings.workspace.properties.edit-property": "Edit property",
"com.affine.settings.workspace.properties.delete-property": "Delete property", "com.affine.settings.workspace.properties.delete-property": "Delete property",
"com.affine.settings.workspace.properties.delete-property-prompt": "The \"<1>{{ name }}</1>\" property will be remove from {{ count }} doc(s). This action cannot be undone.",
"com.affine.settings.workspace.properties.required-properties": "Required properties", "com.affine.settings.workspace.properties.required-properties": "Required properties",
"com.affine.settings.workspace.properties.general-properties": "General properties", "com.affine.settings.workspace.properties.general-properties": "General properties",
"com.affine.settings.workspace.preferences": "Preference", "com.affine.settings.workspace.preferences": "Preference",
@@ -1125,5 +1126,7 @@
"com.affine.page-properties.property.checkbox": "Checkbox", "com.affine.page-properties.property.checkbox": "Checkbox",
"com.affine.page-properties.property.progress": "Progress", "com.affine.page-properties.property.progress": "Progress",
"com.affine.page-properties.property.tags": "Tags", "com.affine.page-properties.property.tags": "Tags",
"com.affine.page-properties.icons": "Icons" "com.affine.page-properties.icons": "Icons",
"com.affine.page-properties.tags.selector-header-title": "Select tag or create one",
"com.affine.page-properties.tags.open-tags-page": "Open tag page"
} }