mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
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:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 { 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
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
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,
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user