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:
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
':hover': {
|
|
||||||
backgroundColor: cssVar('hoverColor'),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const editablePropertyRowCell = style([
|
||||||
|
propertyRowCell,
|
||||||
|
{
|
||||||
|
cursor: 'pointer',
|
||||||
|
':hover': {
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.propertyRowIconContainer}>
|
<div className={styles.propertyRowNameContainer}>
|
||||||
<Icon />
|
<div className={styles.propertyRowIconContainer}>
|
||||||
|
<Icon />
|
||||||
|
</div>
|
||||||
|
<div className={styles.propertyRowName}>{name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.propertyRowName}>{name}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</PagePropertyRowName>
|
</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,23 +907,36 @@ 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 ? (
|
||||||
);
|
<PagePropertiesAddPropertyMenuItems onCreateClicked={toggleAdding} />
|
||||||
const items = adding ? (
|
) : (
|
||||||
<PagePropertiesAddPropertyMenuItems onCreateClicked={toggleAdding} />
|
<PagePropertiesCreatePropertyMenuItems
|
||||||
) : (
|
metaManager={manager.metaManager}
|
||||||
<PagePropertiesCreatePropertyMenuItems
|
onCreated={handleCreated}
|
||||||
metaManager={manager.metaManager}
|
/>
|
||||||
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 }) => {
|
||||||
|
|||||||
@@ -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%',
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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();
|
required: !localPropertyMeta.required,
|
||||||
e.preventDefault();
|
});
|
||||||
manager.updateCustomPropertyMeta(localPropertyMeta.id, {
|
}, [manager, localPropertyMeta.id, localPropertyMeta.required]);
|
||||||
required: !localPropertyMeta.required,
|
const handleDelete = useCallback(() => {
|
||||||
});
|
manager.removePropertyMeta(localPropertyMeta.id);
|
||||||
},
|
}, [manager, localPropertyMeta.id]);
|
||||||
[manager, localPropertyMeta.id, localPropertyMeta.required]
|
|
||||||
);
|
|
||||||
const handleDelete: MouseEventHandler = useCallback(
|
|
||||||
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,19 +212,31 @@ const EditPropertyButton = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<>
|
||||||
rootOptions={{
|
<Menu
|
||||||
open,
|
rootOptions={{
|
||||||
onOpenChange: handleFinishEditing,
|
open,
|
||||||
}}
|
onOpenChange: handleFinishEditing,
|
||||||
items={editing ? editMenuItems : defaultMenuItems}
|
}}
|
||||||
>
|
items={editing ? editMenuItems : defaultMenuItems}
|
||||||
<IconButton
|
>
|
||||||
onClick={() => setOpen(true)}
|
<IconButton
|
||||||
type="plain"
|
onClick={() => setOpen(true)}
|
||||||
icon={<MoreHorizontalIcon />}
|
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']()}
|
{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
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './adapter';
|
export * from './adapter';
|
||||||
|
export * from './legacy-properties';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user