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

@@ -1,3 +1,4 @@
import { cssVar } from '@toeverything/theme';
import { atom } from 'jotai';
import { createContext } from 'react';
@@ -6,3 +7,22 @@ import type { PagePropertiesManager } from './page-properties-manager';
// @ts-expect-error this should always be set
export const managerContext = createContext<PagePropertiesManager>();
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;
// todo: this breaks tree-shaking, and we should fix it (using dynamic imports?)
const IconsMapping = icons;
export type PagePropertyIcon = keyof typeof IconsMapping;
// assume all exports in icons are icon Components
type LibIconComponentName = keyof typeof icons;
const excludedIcons: PagePropertyIcon[] = [
'YoutubeDuotoneIcon',
'LinearLogoIcon',
'RedditDuotoneIcon',
'Logo2Icon',
'Logo3Icon',
'Logo4Icon',
'InstagramDuotoneIcon',
'TelegramDuotoneIcon',
'TextBackgroundDuotoneIcon',
];
type fromLibIconName<T extends string> = T extends `${infer N}Icon`
? Uncapitalize<N>
: never;
export const iconNames = Object.keys(IconsMapping).filter(
icon => !excludedIcons.includes(icon as PagePropertyIcon)
) as PagePropertyIcon[];
export const iconNames = [
'ai',
'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 = (
type: PagePropertyType
): PagePropertyIcon => {
switch (type) {
case 'text':
return 'TextIcon';
return 'text';
case 'tags':
return 'TagIcon';
return 'tag';
case 'date':
return 'DateTimeIcon';
return 'dateTime';
case 'progress':
return 'ProgressIcon';
return 'progress';
case 'checkbox':
return 'CheckBoxCheckLinearIcon';
return 'checkBoxCheckLinear';
case 'number':
return 'NumberIcon';
return 'number';
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 = (
iconName: string,
type?: PagePropertyType
): PagePropertyIcon => {
return Object.hasOwn(IconsMapping, iconName)
return iconNames.includes(iconName as any)
? (iconName as PagePropertyIcon)
: 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 = (
iconName: string,
type?: PagePropertyType
): 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 * as styles from './icons-selector.css';
const iconsPerRow = 10;
const iconsPerRow = 6;
const iconRows = chunk(iconNames, iconsPerRow);
@@ -46,15 +46,13 @@ export const IconsSelectorPanel = ({
const Icon = nameToIcon(iconName);
return (
<div
onClick={() => onSelectedChange(iconName)}
key={iconName}
className={styles.iconButton}
data-name={iconName}
data-active={selected === iconName}
>
<Icon
key={iconName}
onClick={() => onSelectedChange(iconName)}
/>
<Icon key={iconName} />
</div>
);
})}

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
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 {
PageInfoCustomProperty,
PageInfoCustomPropertyMeta,
@@ -6,11 +8,14 @@ import type {
} from '@affine/core/modules/workspace/properties/schema';
import { timestampToLocalDate } from '@affine/core/utils';
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 { type ChangeEventHandler, useCallback, useContext } from 'react';
import { managerContext } from './common';
import * as styles from './styles.css';
import { TagsInlineEditor } from './tags-inline-editor';
interface PropertyRowValueProps {
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<
PagePropertyType,
typeof DateValue
@@ -117,6 +154,6 @@ export const propertyValueRenderers: Record<
text: TextValue,
number: TextValue,
// todo: fix following
tags: TextValue,
tags: TagsValue,
progress: TextValue,
};

View File

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

View File

@@ -4,6 +4,7 @@ import {
Menu,
MenuIcon,
MenuItem,
type MenuProps,
Tooltip,
} from '@affine/component';
import { useCurrentWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
@@ -22,6 +23,7 @@ import {
InvisibleIcon,
MoreHorizontalIcon,
PlusIcon,
TagsIcon,
ToggleExpandIcon,
ViewIcon,
} from '@blocksuite/icons';
@@ -42,10 +44,9 @@ import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable';
import * as Collapsible from '@radix-ui/react-collapsible';
import clsx from 'clsx';
import { use } from 'foxact/use';
import { useAtom, useAtomValue } from 'jotai';
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import type React from 'react';
import {
type ChangeEventHandler,
type CSSProperties,
type MouseEvent,
type MouseEventHandler,
@@ -75,7 +76,10 @@ import {
newPropertyTypes,
PagePropertiesManager,
} 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';
type PagePropertiesSettingsPopupProps = PropsWithChildren<{
@@ -87,10 +91,17 @@ const Divider = () => <div className={styles.tableHeaderDivider} />;
type PropertyVisibility = PageInfoCustomProperty['visibility'];
const editingPropertyAtom = atom<string | null>(null);
const modifiers = [restrictToParentElement, restrictToVerticalAxis];
const SortableProperties = ({ children }: PropsWithChildren) => {
const manager = useContext(managerContext);
const properties = manager.getOrderedCustomProperties();
const readonly = manager.readonly;
const properties = useMemo(
() => manager.getOrderedCustomProperties(),
[manager]
);
const editingItem = useAtomValue(editingPropertyAtom);
const draggable = !manager.readonly && !editingItem;
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -100,7 +111,7 @@ const SortableProperties = ({ children }: PropsWithChildren) => {
);
const onDragEnd = useCallback(
(event: DragEndEvent) => {
if (readonly) {
if (!draggable) {
return;
}
const { active, over } = event;
@@ -118,11 +129,13 @@ const SortableProperties = ({ children }: PropsWithChildren) => {
});
}
},
[manager, properties, readonly]
[manager, properties, draggable]
);
return (
<DndContext sensors={sensors} onDragEnd={onDragEnd} modifiers={modifiers}>
<SortableContext items={properties}>{children}</SortableContext>
<SortableContext disabled={!draggable} items={properties}>
{children}
</SortableContext>
</DndContext>
);
};
@@ -262,6 +275,11 @@ const VisibilityModeSelector = ({
rootOptions={{
open: required ? false : undefined,
}}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
<div data-required={required} className={styles.selectorButton}>
{required ? (
@@ -287,7 +305,7 @@ export const PagePropertiesSettingsPopup = ({
const menuItems = useMemo(() => {
const options: MenuItemOption[] = [];
options.push(
<div className={styles.menuHeader}>
<div className={styles.menuHeader} style={{ minWidth: 320 }}>
{t['com.affine.page-properties.settings.title']()}
</div>
);
@@ -319,7 +337,18 @@ export const PagePropertiesSettingsPopup = ({
return renderMenuItemOptions(options);
}, [manager, properties, t]);
return <Menu items={menuItems}>{children}</Menu>;
return (
<Menu
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
items={menuItems}
>
{children}
</Menu>
);
};
type PageBacklinksPopupProps = PropsWithChildren<{
@@ -334,6 +363,11 @@ export const PageBacklinksPopup = ({
return (
<Menu
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
items={
<div className={styles.backlinksList}>
{backlinks.map(pageId => (
@@ -359,7 +393,7 @@ interface PagePropertyRowNameProps {
onFinishEditing: () => void;
}
export const PagePropertyRowName = ({
export const PagePropertyRowNameMenu = ({
editing,
meta,
property,
@@ -386,11 +420,10 @@ export const PagePropertyRowName = ({
property.id,
]);
const t = useAFFiNEI18N();
const handleNameBlur: ChangeEventHandler<HTMLInputElement> = useCallback(
e => {
e.stopPropagation();
const handleNameBlur = useCallback(
(v: string) => {
manager.updateCustomPropertyMeta(meta.id, {
name: e.target.value,
name: v,
});
},
[manager, meta.id]
@@ -486,6 +519,9 @@ export const PagePropertyRowName = ({
}}
contentOptions={{
onInteractOutside: handleFinishEditing,
onClick(e) {
e.stopPropagation();
},
}}
items={menuItems}
>
@@ -569,7 +605,7 @@ export const PagePropertiesTableHeader = ({
<div className={clsx(collapsed ? styles.pageInfoDimmed : null)}>
{t['com.affine.page-properties.page-info']()}
</div>
{(collapsed && properties.length === 0) || manager.readonly ? null : (
{properties.length === 0 || manager.readonly ? null : (
<PagePropertiesSettingsPopup>
<IconButton type="plain" icon={<MoreHorizontalIcon />} />
</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 {
property: PageInfoCustomProperty;
style?: React.CSSProperties;
@@ -617,25 +642,22 @@ const PagePropertyRow = ({ property }: PagePropertyRowProps) => {
const name = meta.name;
const ValueRenderer = propertyValueRenderers[meta.type];
const [editingMeta, setEditingMeta] = useState(false);
const setEditingItem = useSetAtom(editingPropertyAtom);
const handleEditMeta = useCallback(() => {
if (!manager.readonly) {
setEditingMeta(true);
}
}, [manager.readonly]);
setEditingItem(property.id);
}, [manager.readonly, property.id, setEditingItem]);
const handleFinishEditingMeta = useCallback(() => {
setEditingMeta(false);
}, []);
setEditingItem(null);
}, [setEditingItem]);
return (
<SortablePropertyRow
property={property}
className={styles.propertyRow}
data-testid="page-property-row"
data-property={property.id}
data-draggable={!manager.readonly}
>
<SortablePropertyRow property={property} data-testid="page-property-row">
{({ attributes, listeners }) => (
<>
<PagePropertyRowName
<PagePropertyRowNameMenu
editing={editingMeta}
meta={meta}
property={property}
@@ -644,15 +666,18 @@ const PagePropertyRow = ({ property }: PagePropertyRowProps) => {
<div
{...attributes}
{...listeners}
className={styles.propertyRowNameCell}
data-testid="page-property-row-name"
className={styles.sortablePropertyRowNameCell}
onClick={handleEditMeta}
>
<div className={styles.propertyRowIconContainer}>
<Icon />
<div className={styles.propertyRowNameContainer}>
<div className={styles.propertyRowIconContainer}>
<Icon />
</div>
<div className={styles.propertyRowName}>{name}</div>
</div>
<div className={styles.propertyRowName}>{name}</div>
</div>
</PagePropertyRowName>
</PagePropertyRowNameMenu>
<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 {
className?: string;
style?: React.CSSProperties;
}
const modifiers = [restrictToParentElement, restrictToVerticalAxis];
// 🏷️ Tags (⋅ xxx) (⋅ yyy)
// #️⃣ Number 123456
// + Add a property
@@ -684,7 +731,8 @@ export const PagePropertiesTableBody = ({
className={clsx(styles.tableBodyRoot, className)}
style={style}
>
<div className={styles.tableBody}>
<PageTagsRow />
<div className={styles.tableBodySortable}>
<SortableProperties>
{properties
.filter(
@@ -735,13 +783,13 @@ export const PagePropertiesCreatePropertyMenuItems = ({
e: React.MouseEvent,
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 allNames = schemaList.map(meta => meta.name);
const name = nameExists
? findNextDefaultName(option.name, allNames)
: option.name;
const { id } = metaManager.addCustomPropertyMeta({
const { id } = metaManager.addPropertyMeta({
name,
icon: option.icon,
type: option.type,
@@ -791,7 +839,7 @@ const PagePropertiesAddPropertyMenuItems = ({
const manager = useContext(managerContext);
const t = useAFFiNEI18N();
const metaList = manager.metaManager.getOrderedCustomPropertiesSchema();
const metaList = manager.metaManager.getOrderedPropertiesSchema();
const nonRequiredMetaList = metaList.filter(meta => !meta.required);
const isChecked = useCallback(
(m: string) => {
@@ -859,23 +907,36 @@ export const PagePropertiesAddProperty = () => {
e.preventDefault();
setAdding(prev => !prev);
}, []);
const handleCreated = useCallback(
(e: React.MouseEvent, id: string) => {
const menuOptions = useMemo(() => {
const handleCreated = (e: React.MouseEvent, id: string) => {
toggleAdding(e);
manager.addCustomProperty(id);
},
[manager, toggleAdding]
);
const items = adding ? (
<PagePropertiesAddPropertyMenuItems onCreateClicked={toggleAdding} />
) : (
<PagePropertiesCreatePropertyMenuItems
metaManager={manager.metaManager}
onCreated={handleCreated}
/>
);
};
const items = adding ? (
<PagePropertiesAddPropertyMenuItems onCreateClicked={toggleAdding} />
) : (
<PagePropertiesCreatePropertyMenuItems
metaManager={manager.metaManager}
onCreated={handleCreated}
/>
);
return {
contentOptions: {
onClick(e) {
e.stopPropagation();
},
},
rootOptions: {
onOpenChange: () => setAdding(true),
},
items,
} satisfies Partial<MenuProps>;
}, [adding, manager, toggleAdding]);
return (
<Menu rootOptions={{ onOpenChange: () => setAdding(true) }} items={items}>
<Menu {...menuOptions}>
<Button
type="plain"
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
// the page title
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 { useWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
import { useWorkspace } from '@affine/core/hooks/use-workspace';
@@ -12,11 +12,9 @@ import type {
WorkspaceMetadata,
} from '@toeverything/infra/workspace';
import {
type ChangeEventHandler,
createContext,
Fragment,
type MouseEvent,
type MouseEventHandler,
useCallback,
useContext,
useEffect,
@@ -56,9 +54,57 @@ const 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 = ({
property,
count,
}: {
count: number;
property: PageInfoCustomPropertyMeta;
}) => {
const t = useAFFiNEI18N();
@@ -69,31 +115,22 @@ const EditPropertyButton = ({
useEffect(() => {
setLocalPropertyMeta(property);
}, [property]);
const handleToggleRequired: MouseEventHandler = useCallback(
e => {
e.stopPropagation();
e.preventDefault();
manager.updateCustomPropertyMeta(localPropertyMeta.id, {
required: !localPropertyMeta.required,
});
},
[manager, localPropertyMeta.id, localPropertyMeta.required]
);
const handleDelete: MouseEventHandler = useCallback(
e => {
e.stopPropagation();
e.preventDefault();
manager.removeCustomPropertyMeta(localPropertyMeta.id);
},
[manager, localPropertyMeta.id]
);
const handleToggleRequired = useCallback(() => {
manager.updatePropertyMeta(localPropertyMeta.id, {
required: !localPropertyMeta.required,
});
}, [manager, localPropertyMeta.id, localPropertyMeta.required]);
const handleDelete = useCallback(() => {
manager.removePropertyMeta(localPropertyMeta.id);
}, [manager, localPropertyMeta.id]);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const handleFinishEditing = useCallback(() => {
setOpen(false);
setEditing(false);
manager.updateCustomPropertyMeta(localPropertyMeta.id, localPropertyMeta);
manager.updatePropertyMeta(localPropertyMeta.id, localPropertyMeta);
}, [localPropertyMeta, manager]);
const defaultMenuItems = useMemo(() => {
@@ -113,26 +150,25 @@ const EditPropertyButton = ({
});
options.push({
text: t['com.affine.settings.workspace.properties.delete-property'](),
onClick: handleDelete,
onClick: () => setShowDeleteModal(true),
type: 'danger',
icon: <DeleteIcon />,
});
return renderMenuItemOptions(options);
}, [handleDelete, handleToggleRequired, localPropertyMeta.required, t]);
}, [handleToggleRequired, localPropertyMeta.required, t]);
const handleNameBlur: ChangeEventHandler<HTMLInputElement> = useCallback(
e => {
e.stopPropagation();
manager.updateCustomPropertyMeta(localPropertyMeta.id, {
name: e.target.value,
const handleNameBlur = useCallback(
(e: string) => {
manager.updatePropertyMeta(localPropertyMeta.id, {
name: e,
});
},
[manager, localPropertyMeta.id]
);
const handleNameChange: (name: string) => void = useCallback(name => {
const handleNameChange = useCallback((e: string) => {
setLocalPropertyMeta(prev => ({
...prev,
name: name,
name: e,
}));
}, []);
const handleIconChange = useCallback(
@@ -141,7 +177,7 @@ const EditPropertyButton = ({
...prev,
icon,
}));
manager.updateCustomPropertyMeta(localPropertyMeta.id, {
manager.updatePropertyMeta(localPropertyMeta.id, {
icon,
});
},
@@ -176,19 +212,31 @@ const EditPropertyButton = ({
]);
return (
<Menu
rootOptions={{
open,
onOpenChange: handleFinishEditing,
}}
items={editing ? editMenuItems : defaultMenuItems}
>
<IconButton
onClick={() => setOpen(true)}
type="plain"
icon={<MoreHorizontalIcon />}
<>
<Menu
rootOptions={{
open,
onOpenChange: handleFinishEditing,
}}
items={editing ? editMenuItems : defaultMenuItems}
>
<IconButton
onClick={() => setOpen(true)}
type="plain"
icon={<MoreHorizontalIcon />}
/>
</Menu>
<ConfirmDeletePropertyModal
onConfirm={() => {
setShowDeleteModal(false);
handleDelete();
}}
onCancel={() => setShowDeleteModal(false)}
show={showDeleteModal}
property={property}
count={count}
/>
</Menu>
</>
);
};
@@ -233,7 +281,7 @@ const CustomPropertyRow = ({
{t['com.affine.page-properties.property.required']()}
</div>
) : null}
<EditPropertyButton property={property} />
<EditPropertyButton property={property} count={relatedPages.length} />
</div>
);
};
@@ -269,8 +317,8 @@ const CustomPropertyRowsList = ({
filterMode: PropertyFilterMode;
}) => {
const manager = useContext(managerContext);
const properties = manager.getOrderedCustomPropertiesSchema();
const statistics = manager.getCustomPropertyStatistics();
const properties = manager.getOrderedPropertiesSchema();
const statistics = manager.getPropertyStatistics();
const t = useAFFiNEI18N();
if (filterMode !== 'all') {
@@ -315,7 +363,7 @@ const WorkspaceSettingPropertiesMain = () => {
const t = useAFFiNEI18N();
const manager = useContext(managerContext);
const [filterMode, setFilterMode] = useState<PropertyFilterMode>('all');
const properties = manager.getOrderedCustomPropertiesSchema();
const properties = manager.getOrderedPropertiesSchema();
const filterMenuItems = useMemo(() => {
const options: MenuItemOption[] = (
['all', '-', 'in-use', 'unused'] as const

View File

@@ -63,7 +63,7 @@ export const innerBackdrop = style({
},
});
export const tag = style({
height: '20px',
height: '22px',
display: 'flex',
minWidth: 0,
alignItems: 'center',
@@ -79,7 +79,7 @@ export const tagInnerWrapper = style({
padding: '0 8px',
color: cssVar('textPrimaryColor'),
});
export const tagSticky = style([
export const tagInline = style([
tagInnerWrapper,
{
fontSize: cssVar('fontXs'),
@@ -88,6 +88,7 @@ export const tagSticky = style([
border: `1px solid ${cssVar('borderColor')}`,
background: cssVar('backgroundPrimaryColor'),
maxWidth: '128px',
height: '100%',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
@@ -120,3 +121,17 @@ export const tagLabel = style({
textOverflow: 'ellipsis',
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 type { Tag } from '@affine/env/filter';
import { MoreHorizontalIcon } from '@blocksuite/icons';
import { CloseIcon, MoreHorizontalIcon } from '@blocksuite/icons';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useMemo } from 'react';
import { type MouseEventHandler, useCallback, useMemo } from 'react';
import { stopPropagation, tagColorMap } from '../utils';
import * as styles from './page-tags.css';
@@ -17,12 +17,28 @@ export interface PageTagsProps {
interface TagItemProps {
tag: Tag;
idx: number;
mode: 'sticky' | 'list-item';
idx?: number;
maxWidth?: number | string;
mode: 'inline' | 'list-item';
onRemoved?: () => void;
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 (
<div
data-testid="page-tag"
@@ -32,7 +48,8 @@ export const TagItem = ({ tag, idx, mode, style }: TagItemProps) => {
style={style}
>
<div
className={mode === 'sticky' ? styles.tagSticky : styles.tagListItem}
style={{ maxWidth: maxWidth }}
className={mode === 'inline' ? styles.tagInline : styles.tagListItem}
>
<div
className={styles.tagIndicator}
@@ -41,6 +58,11 @@ export const TagItem = ({ tag, idx, mode, style }: TagItemProps) => {
}}
/>
<div className={styles.tagLabel}>{tag.value}</div>
{onRemoved ? (
<div className={styles.tagRemove} onClick={handleRemove}>
<CloseIcon />
</div>
) : null}
</div>
</div>
);
@@ -76,7 +98,7 @@ export const PageTags = ({
nTags.sort((a, b) => a.value.length - b.value.length);
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]);
return (

View File

@@ -58,6 +58,18 @@ export function useNavigateHelper() {
},
[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(
(
workspaceId: string,
@@ -162,6 +174,7 @@ export function useNavigateHelper() {
jumpToCollection,
jumpToCollections,
jumpToTags,
jumpToTag,
}),
[
jumpToPage,
@@ -176,6 +189,7 @@ export function useNavigateHelper() {
jumpToCollection,
jumpToCollections,
jumpToTags,
jumpToTag,
]
);
}

View File

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

View File

@@ -1 +1,2 @@
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,
});
};
}