mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
feat(core): page info ui (#5729)
this PR includes the main table view in the page detail page
This commit is contained in:
@@ -280,11 +280,6 @@ export const collapsedIconContainer = style({
|
||||
borderRadius: '2px',
|
||||
transition: 'transform 0.2s',
|
||||
color: 'inherit',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(-90deg)',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const planPromptWrapper = style({
|
||||
padding: '4px 12px',
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { atom } from 'jotai';
|
||||
import { createContext } from 'react';
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { PagePropertyType } from '@affine/core/modules/workspace/properties/schema';
|
||||
import * as icons from '@blocksuite/icons';
|
||||
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 = {
|
||||
text: icons.TextIcon,
|
||||
tag: icons.TagIcon,
|
||||
dateTime: icons.DateTimeIcon,
|
||||
progress: icons.ProgressIcon,
|
||||
checkbox: icons.CheckBoxCheckLinearIcon,
|
||||
number: icons.NumberIcon,
|
||||
// todo: add more icons
|
||||
} satisfies Record<string, IconType>;
|
||||
|
||||
export type PagePropertyIcon = keyof typeof IconsMapping;
|
||||
|
||||
export const getDefaultIconName = (
|
||||
type: PagePropertyType
|
||||
): PagePropertyIcon => {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return 'text';
|
||||
case 'tags':
|
||||
return 'tag';
|
||||
case 'date':
|
||||
return 'dateTime';
|
||||
case 'progress':
|
||||
return 'progress';
|
||||
case 'checkbox':
|
||||
return 'checkbox';
|
||||
case 'number':
|
||||
return 'number';
|
||||
default:
|
||||
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 nameToIcon = (
|
||||
iconName: string,
|
||||
type: PagePropertyType
|
||||
): IconType => {
|
||||
return (
|
||||
IconsMapping[iconName as keyof typeof IconsMapping] ??
|
||||
getDefaultIconName(type)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './icons-mapping';
|
||||
export * from './page-properties-manager';
|
||||
export * from './table';
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { WorkspacePropertiesAdapter } from '@affine/core/modules/workspace';
|
||||
import type {
|
||||
PageInfoCustomProperty,
|
||||
PageInfoCustomPropertyMeta,
|
||||
TagOption,
|
||||
} from '@affine/core/modules/workspace/properties/schema';
|
||||
import { PagePropertyType } from '@affine/core/modules/workspace/properties/schema';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { getDefaultIconName } from './icons-mapping';
|
||||
|
||||
const logger = new DebugLogger('PagePropertiesManager');
|
||||
|
||||
function validatePropertyValue(type: PagePropertyType, value: any) {
|
||||
switch (type) {
|
||||
case PagePropertyType.Text:
|
||||
return typeof value === 'string';
|
||||
case PagePropertyType.Number:
|
||||
return typeof value === 'number' || !isNaN(+value);
|
||||
case PagePropertyType.Checkbox:
|
||||
return typeof value === 'boolean';
|
||||
case PagePropertyType.Date:
|
||||
return value.match(/^\d{4}-\d{2}-\d{2}$/);
|
||||
case PagePropertyType.Tags:
|
||||
return Array.isArray(value) && value.every(v => typeof v === 'string');
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NewPropertyOption {
|
||||
name: string;
|
||||
type: PagePropertyType;
|
||||
}
|
||||
|
||||
export const newPropertyOptions: NewPropertyOption[] = [
|
||||
// todo: name i18n?
|
||||
{
|
||||
name: 'Text',
|
||||
type: PagePropertyType.Text,
|
||||
},
|
||||
{
|
||||
name: 'Number',
|
||||
type: PagePropertyType.Number,
|
||||
},
|
||||
{
|
||||
name: 'Checkbox',
|
||||
type: PagePropertyType.Checkbox,
|
||||
},
|
||||
{
|
||||
name: 'Date',
|
||||
type: PagePropertyType.Date,
|
||||
},
|
||||
// todo: add more
|
||||
];
|
||||
|
||||
export class PagePropertiesMetaManager {
|
||||
constructor(private readonly adapter: WorkspacePropertiesAdapter) {}
|
||||
|
||||
get tagOptions() {
|
||||
return this.adapter.tagOptions;
|
||||
}
|
||||
|
||||
get propertiesSchema() {
|
||||
return this.adapter.schema.pageProperties;
|
||||
}
|
||||
|
||||
get systemPropertiesSchema() {
|
||||
return this.adapter.schema.pageProperties.system;
|
||||
}
|
||||
|
||||
get customPropertiesSchema() {
|
||||
return this.adapter.schema.pageProperties.custom;
|
||||
}
|
||||
|
||||
getOrderedCustomPropertiesSchema() {
|
||||
return Object.values(this.customPropertiesSchema).sort(
|
||||
(a, b) => a.order - b.order
|
||||
);
|
||||
}
|
||||
|
||||
checkPropertyExists(id: string) {
|
||||
return !!this.customPropertiesSchema[id];
|
||||
}
|
||||
|
||||
validateCustomPropertyValue(id: string, value?: any) {
|
||||
if (!value) {
|
||||
// value is optional in all cases?
|
||||
return true;
|
||||
}
|
||||
const type = this.customPropertiesSchema[id]?.type;
|
||||
if (!type) {
|
||||
logger.warn(`property ${id} not found`);
|
||||
return false;
|
||||
}
|
||||
return validatePropertyValue(type, value);
|
||||
}
|
||||
|
||||
addCustomPropertyMeta(schema: {
|
||||
name: string;
|
||||
type: PagePropertyType;
|
||||
icon?: string;
|
||||
}) {
|
||||
const id = nanoid();
|
||||
const { type, icon } = schema;
|
||||
const newOrder =
|
||||
Math.max(
|
||||
0,
|
||||
...Object.values(this.customPropertiesSchema).map(p => p.order)
|
||||
) + 1;
|
||||
const property = {
|
||||
...schema,
|
||||
id,
|
||||
source: 'custom',
|
||||
type,
|
||||
order: newOrder,
|
||||
icon: icon ?? getDefaultIconName(type),
|
||||
} as const;
|
||||
this.customPropertiesSchema[id] = property;
|
||||
return property;
|
||||
}
|
||||
|
||||
removeCustomPropertyMeta(id: string) {
|
||||
// should warn if the property is in use
|
||||
delete this.customPropertiesSchema[id];
|
||||
}
|
||||
|
||||
// returns page schema properties -> related page
|
||||
getCustomPropertyStatistics() {
|
||||
const mapping = new Map<string, Set<string>>();
|
||||
for (const page of this.adapter.workspace.blockSuiteWorkspace.pages.values()) {
|
||||
const properties = this.adapter.getPageProperties(page.id);
|
||||
for (const id of Object.keys(properties.custom)) {
|
||||
if (!mapping.has(id)) mapping.set(id, new Set());
|
||||
mapping.get(id)?.add(page.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PagePropertiesManager {
|
||||
public readonly metaManager: PagePropertiesMetaManager;
|
||||
constructor(
|
||||
private readonly adapter: WorkspacePropertiesAdapter,
|
||||
public readonly pageId: string
|
||||
) {
|
||||
this.adapter.ensurePageProperties(this.pageId);
|
||||
this.metaManager = new PagePropertiesMetaManager(this.adapter);
|
||||
}
|
||||
|
||||
get workspace() {
|
||||
return this.adapter.workspace;
|
||||
}
|
||||
|
||||
get page() {
|
||||
return this.adapter.workspace.blockSuiteWorkspace.getPage(this.pageId);
|
||||
}
|
||||
|
||||
get intrinsicMeta() {
|
||||
return this.page?.meta;
|
||||
}
|
||||
|
||||
get updatedDate() {
|
||||
return this.intrinsicMeta?.updatedDate;
|
||||
}
|
||||
|
||||
get createDate() {
|
||||
return this.intrinsicMeta?.createDate;
|
||||
}
|
||||
|
||||
get pageTags() {
|
||||
return this.adapter.getPageTags(this.pageId);
|
||||
}
|
||||
|
||||
get properties() {
|
||||
return this.adapter.getPageProperties(this.pageId);
|
||||
}
|
||||
|
||||
get readonly() {
|
||||
return !!this.page?.readonly;
|
||||
}
|
||||
|
||||
addPageTag(pageId: string, tag: TagOption | string) {
|
||||
this.adapter.addPageTag(pageId, tag);
|
||||
}
|
||||
|
||||
removePageTag(pageId: string, tag: TagOption | string) {
|
||||
this.adapter.removePageTag(pageId, tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* get custom properties (filter out properties that are not in schema)
|
||||
*/
|
||||
getCustomProperties() {
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.properties.custom).filter(([id]) =>
|
||||
this.metaManager.checkPropertyExists(id)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
getOrderedCustomProperties() {
|
||||
return Object.values(this.getCustomProperties()).sort(
|
||||
(a, b) => a.order - b.order
|
||||
);
|
||||
}
|
||||
|
||||
largestOrder() {
|
||||
return Math.max(
|
||||
...Object.values(this.properties.custom).map(p => p.order),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
leastOrder() {
|
||||
return Math.min(
|
||||
...Object.values(this.properties.custom).map(p => p.order),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
getCustomPropertyMeta(id: string): PageInfoCustomPropertyMeta | undefined {
|
||||
return this.metaManager.customPropertiesSchema[id];
|
||||
}
|
||||
|
||||
getCustomProperty(id: string) {
|
||||
return this.properties.custom[id];
|
||||
}
|
||||
|
||||
addCustomProperty(id: string, value?: any) {
|
||||
if (!this.metaManager.checkPropertyExists(id)) {
|
||||
logger.warn(`property ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.metaManager.validateCustomPropertyValue(id, value)) {
|
||||
logger.warn(`property ${id} value ${value} is invalid`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrder = this.largestOrder() + 1;
|
||||
if (this.properties.custom[id]) {
|
||||
logger.warn(`custom property ${id} already exists`);
|
||||
}
|
||||
|
||||
this.properties.custom[id] = {
|
||||
id,
|
||||
value,
|
||||
order: newOrder,
|
||||
visibility: 'visible',
|
||||
};
|
||||
}
|
||||
|
||||
hasCustomProperty(id: string) {
|
||||
return !!this.properties.custom[id];
|
||||
}
|
||||
|
||||
removeCustomProperty(id: string) {
|
||||
delete this.properties.custom[id];
|
||||
}
|
||||
|
||||
updateCustomProperty(id: string, opt: Partial<PageInfoCustomProperty>) {
|
||||
if (!this.properties.custom[id]) {
|
||||
logger.warn(`custom property ${id} not found`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
opt.value !== undefined &&
|
||||
!this.metaManager.validateCustomPropertyValue(id, opt.value)
|
||||
) {
|
||||
logger.warn(`property ${id} value ${opt.value} is invalid`);
|
||||
return;
|
||||
}
|
||||
Object.assign(this.properties.custom[id], opt);
|
||||
}
|
||||
|
||||
updateCustomPropertyMeta(
|
||||
id: string,
|
||||
opt: Partial<PageInfoCustomPropertyMeta>
|
||||
) {
|
||||
if (!this.metaManager.checkPropertyExists(id)) {
|
||||
logger.warn(`property ${id} not found`);
|
||||
return;
|
||||
}
|
||||
Object.assign(this.metaManager.customPropertiesSchema[id], opt);
|
||||
}
|
||||
|
||||
transact = this.adapter.transact;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Checkbox, DatePicker, Menu } from '@affine/component';
|
||||
import type {
|
||||
PageInfoCustomProperty,
|
||||
PageInfoCustomPropertyMeta,
|
||||
PagePropertyType,
|
||||
} from '@affine/core/modules/workspace/properties/schema';
|
||||
import { timestampToLocalDate } from '@affine/core/utils';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { noop } from 'lodash-es';
|
||||
import { type ChangeEventHandler, useCallback, useContext } from 'react';
|
||||
|
||||
import { managerContext } from './common';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
interface PropertyRowValueProps {
|
||||
property: PageInfoCustomProperty;
|
||||
meta: PageInfoCustomPropertyMeta;
|
||||
}
|
||||
|
||||
export const DateValue = ({ property }: PropertyRowValueProps) => {
|
||||
const displayValue = property.value
|
||||
? timestampToLocalDate(property.value)
|
||||
: undefined;
|
||||
const manager = useContext(managerContext);
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// show edit popup
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: string) => {
|
||||
manager.updateCustomProperty(property.id, {
|
||||
value: e,
|
||||
});
|
||||
},
|
||||
[manager, property.id]
|
||||
);
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<Menu items={<DatePicker value={property.value} onChange={handleChange} />}>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={styles.propertyRowValueCell}
|
||||
data-empty={!property.value}
|
||||
>
|
||||
{displayValue ??
|
||||
t['com.affine.page-properties.property-value-placeholder']()}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const CheckboxValue = ({ property }: PropertyRowValueProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
manager.updateCustomProperty(property.id, {
|
||||
value: !property.value,
|
||||
});
|
||||
},
|
||||
[manager, property.id, property.value]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={styles.propertyRowValueCell}
|
||||
data-empty={!property.value}
|
||||
>
|
||||
<Checkbox
|
||||
className={styles.checkboxProperty}
|
||||
checked={!!property.value}
|
||||
onChange={noop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TextValue = ({ property, meta }: PropertyRowValueProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// todo: show edit popup
|
||||
}, []);
|
||||
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
e => {
|
||||
manager.updateCustomProperty(property.id, {
|
||||
value: e.target.value,
|
||||
});
|
||||
},
|
||||
[manager, property.id]
|
||||
);
|
||||
const t = useAFFiNEI18N();
|
||||
const isNumber = meta.type === 'number';
|
||||
return (
|
||||
<input
|
||||
type={isNumber ? 'number' : 'text'}
|
||||
value={property.value || ''}
|
||||
onChange={handleOnChange}
|
||||
onClick={handleClick}
|
||||
className={styles.propertyRowValueTextCell}
|
||||
data-empty={!property.value}
|
||||
placeholder={t['com.affine.page-properties.property-value-placeholder']()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const propertyValueRenderers: Record<
|
||||
PagePropertyType,
|
||||
typeof DateValue
|
||||
> = {
|
||||
date: DateValue,
|
||||
checkbox: CheckboxValue,
|
||||
text: TextValue,
|
||||
number: TextValue,
|
||||
// todo: fix following
|
||||
tags: TextValue,
|
||||
progress: TextValue,
|
||||
};
|
||||
@@ -0,0 +1,400 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { createVar, globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
const propertyNameCellWidth = createVar();
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
vars: {
|
||||
[propertyNameCellWidth]: '160px',
|
||||
},
|
||||
});
|
||||
|
||||
export const rootCentered = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
maxWidth: cssVar('editorWidth'),
|
||||
padding: `0 ${cssVar('editorSidePadding', '24px')}`,
|
||||
});
|
||||
|
||||
export const tableHeader = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
export const tableHeaderInfoRow = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
});
|
||||
|
||||
export const tableHeaderSecondaryRow = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
padding: '0 6px',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const pageInfoDimmed = style({
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
|
||||
export const spacer = style({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
export const tableHeaderBacklinksHint = style({
|
||||
padding: '6px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
':hover': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
});
|
||||
|
||||
export const backlinksList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
});
|
||||
|
||||
export const tableHeaderTimestamp = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'default',
|
||||
padding: '0 6px',
|
||||
});
|
||||
|
||||
export const tableHeaderDivider = style({
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
margin: '8px 0',
|
||||
backgroundColor: cssVar('dividerColor'),
|
||||
});
|
||||
|
||||
export const tableBodyRoot = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const tableBody = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
});
|
||||
|
||||
export const addPropertyButton = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-start',
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: `${cssVar('textSecondaryColor')} !important`,
|
||||
padding: '6px 4px',
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
color: cssVar('textPrimaryColor'),
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
marginTop: '8px',
|
||||
});
|
||||
|
||||
export const collapsedIcon = style({
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(90deg)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const propertyRow = style({
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
minHeight: 32,
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&[data-dragging=true]': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const draggableRow = style({
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
borderRadius: '2px',
|
||||
backgroundColor: cssVar('placeholderColor'),
|
||||
transform: 'translate(-12px, -50%)',
|
||||
transition: 'all 0.2s 0.1s',
|
||||
opacity: 0,
|
||||
height: '4px',
|
||||
width: '4px',
|
||||
willChange: 'height, opacity',
|
||||
},
|
||||
'&[data-draggable=false]:before': {
|
||||
display: 'none',
|
||||
},
|
||||
'&:hover:before': {
|
||||
height: 12,
|
||||
opacity: 1,
|
||||
},
|
||||
'&:active:before': {
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
borderRadius: 0,
|
||||
opacity: 1,
|
||||
transform: 'translate(-6px, -50%)',
|
||||
},
|
||||
'&[data-other-dragging=true]:before': {
|
||||
opacity: 0,
|
||||
},
|
||||
'&[data-other-dragging=true]': {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const draggableRowSetting = style([
|
||||
draggableRow,
|
||||
{
|
||||
selectors: {
|
||||
'&:active:before': {
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
opacity: 1,
|
||||
transform: 'translate(-12px, -50%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertyRowCell = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
padding: 6,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontSize: cssVar('fontSm'),
|
||||
userSelect: 'none',
|
||||
':focus-visible': {
|
||||
outline: 'none',
|
||||
},
|
||||
':hover': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
});
|
||||
|
||||
export const propertyRowNameCell = style([
|
||||
propertyRowCell,
|
||||
draggableRow,
|
||||
{
|
||||
color: cssVar('textSecondaryColor'),
|
||||
width: propertyNameCellWidth,
|
||||
gap: 6,
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertyRowIconContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '2px',
|
||||
fontSize: 16,
|
||||
transition: 'transform 0.2s',
|
||||
color: 'inherit',
|
||||
});
|
||||
|
||||
export const propertyRowName = style({
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: cssVar('fontSm'),
|
||||
});
|
||||
|
||||
export const propertyRowValueCell = style([
|
||||
propertyRowCell,
|
||||
{
|
||||
border: `1px solid transparent`,
|
||||
color: cssVar('textPrimaryColor'),
|
||||
':focus': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
'::placeholder': {
|
||||
color: cssVar('placeholderColor'),
|
||||
},
|
||||
selectors: {
|
||||
'&[data-empty="true"]': {
|
||||
color: cssVar('placeholderColor'),
|
||||
},
|
||||
},
|
||||
flex: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertyRowValueTextCell = style([
|
||||
propertyRowValueCell,
|
||||
{
|
||||
':focus': {
|
||||
border: `1px solid ${cssVar('blue700')}`,
|
||||
boxShadow: cssVar('activeShadow'),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const menuHeader = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
color: cssVar('textSecondaryColor'),
|
||||
padding: '8px 16px',
|
||||
minWidth: 320,
|
||||
textTransform: 'uppercase',
|
||||
});
|
||||
|
||||
export const menuItemListScrollable = style({
|
||||
maxHeight: 300,
|
||||
});
|
||||
|
||||
export const menuItemListScrollbar = style({
|
||||
transform: 'translateX(4px)',
|
||||
});
|
||||
|
||||
export const menuItemList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
globalStyle(`${menuItemList}${menuItemList} > div`, {
|
||||
display: 'table !important',
|
||||
});
|
||||
|
||||
export const menuItemIconContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'inherit',
|
||||
});
|
||||
|
||||
export const menuItemName = style({
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const checkboxProperty = style({
|
||||
fontSize: cssVar('fontH5'),
|
||||
});
|
||||
|
||||
export const propertyNameIconEditable = style({
|
||||
fontSize: cssVar('fontH5'),
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
flexShrink: 0,
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
background: cssVar('backgroundSecondaryColor'),
|
||||
});
|
||||
|
||||
export const propertyNameInput = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
borderRadius: 4,
|
||||
color: cssVar('textPrimaryColor'),
|
||||
background: 'none',
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
padding: 6,
|
||||
});
|
||||
|
||||
globalStyle(
|
||||
`${propertyRow}:is([data-dragging=true], [data-other-dragging=true])
|
||||
:is(${propertyRowValueCell}, ${propertyRowNameCell})`,
|
||||
{
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
);
|
||||
|
||||
export const propertyRowNamePopupRow = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
color: cssVar('textSecondaryColor'),
|
||||
padding: '8px 16px',
|
||||
minWidth: 260,
|
||||
});
|
||||
|
||||
export const propertySettingRow = style([
|
||||
draggableRowSetting,
|
||||
{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
padding: '0 12px',
|
||||
height: 32,
|
||||
position: 'relative',
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertySettingRowName = style({
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 200,
|
||||
});
|
||||
|
||||
export const selectorButton = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
gap: 8,
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,892 @@
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuIcon,
|
||||
MenuItem,
|
||||
Scrollable,
|
||||
Tooltip,
|
||||
} from '@affine/component';
|
||||
import { useCurrentWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
|
||||
import { useBlockSuitePageBacklinks } from '@affine/core/hooks/use-block-suite-page-backlinks';
|
||||
import type {
|
||||
PageInfoCustomProperty,
|
||||
PageInfoCustomPropertyMeta,
|
||||
} 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 {
|
||||
ArrowDownSmallIcon,
|
||||
DeleteIcon,
|
||||
InvisibleIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
ToggleExpandIcon,
|
||||
ViewIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
type DraggableAttributes,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
} from '@dnd-kit/modifiers';
|
||||
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 type React from 'react';
|
||||
import {
|
||||
type ChangeEventHandler,
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
type MouseEventHandler,
|
||||
type PropsWithChildren,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { AffinePageReference } from '../reference-link';
|
||||
import { managerContext, pageInfoCollapsedAtom } from './common';
|
||||
import { getDefaultIconName, nameToIcon } from './icons-mapping';
|
||||
import {
|
||||
type NewPropertyOption,
|
||||
newPropertyOptions,
|
||||
PagePropertiesManager,
|
||||
} from './page-properties-manager';
|
||||
import { propertyValueRenderers } from './property-row-values';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
type PagePropertiesSettingsPopupProps = PropsWithChildren<{
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}>;
|
||||
|
||||
const Divider = () => <div className={styles.tableHeaderDivider} />;
|
||||
|
||||
type PropertyVisibility = PageInfoCustomProperty['visibility'];
|
||||
|
||||
const SortableProperties = ({ children }: PropsWithChildren) => {
|
||||
const manager = useContext(managerContext);
|
||||
const properties = manager.getOrderedCustomProperties();
|
||||
const readonly = manager.readonly;
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
const onDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
const { active, over } = event;
|
||||
const fromIndex = properties.findIndex(p => p.id === active.id);
|
||||
const toIndex = properties.findIndex(p => p.id === over?.id);
|
||||
|
||||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
||||
const newOrdered = arrayMove(properties, fromIndex, toIndex);
|
||||
manager.transact(() => {
|
||||
newOrdered.forEach((p, i) => {
|
||||
manager.updateCustomProperty(p.id, {
|
||||
order: i,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
[manager, properties, readonly]
|
||||
);
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragEnd={onDragEnd} modifiers={modifiers}>
|
||||
<SortableContext items={properties}>{children}</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
type SyntheticListenerMap = ReturnType<typeof useSortable>['listeners'];
|
||||
|
||||
const SortablePropertyRow = ({
|
||||
property,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
property: PageInfoCustomProperty;
|
||||
className?: string;
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((props: {
|
||||
attributes: DraggableAttributes;
|
||||
listeners?: SyntheticListenerMap;
|
||||
}) => React.ReactNode);
|
||||
}) => {
|
||||
const manager = useContext(managerContext);
|
||||
const {
|
||||
setNodeRef,
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
transition,
|
||||
active,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: property.id,
|
||||
});
|
||||
const style: CSSProperties = useMemo(
|
||||
() => ({
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
transition,
|
||||
pointerEvents: manager.readonly ? 'none' : undefined,
|
||||
}),
|
||||
[manager.readonly, transform, transition]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
className={clsx(styles.propertyRow, className)}
|
||||
data-property={property.id}
|
||||
data-draggable={!manager.readonly}
|
||||
data-dragging={isDragging}
|
||||
data-other-dragging={active ? active.id !== property.id : false}
|
||||
{...props}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{typeof children === 'function'
|
||||
? children({ attributes, listeners })
|
||||
: children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const visibilities: PropertyVisibility[] = ['visible', 'hide', 'hide-if-empty'];
|
||||
const rotateVisibility = (
|
||||
visibility: PropertyVisibility
|
||||
): PropertyVisibility => {
|
||||
const index = visibilities.indexOf(visibility);
|
||||
return visibilities[(index + 1) % visibilities.length];
|
||||
};
|
||||
|
||||
const visibilityMenuText = (visibility: PropertyVisibility = 'visible') => {
|
||||
switch (visibility) {
|
||||
case 'hide':
|
||||
return 'com.affine.page-properties.property.hide-in-view';
|
||||
case 'hide-if-empty':
|
||||
return 'com.affine.page-properties.property.hide-in-view-when-empty';
|
||||
case 'visible':
|
||||
return 'com.affine.page-properties.property.show-in-view';
|
||||
default:
|
||||
throw new Error(`unknown visibility: ${visibility}`);
|
||||
}
|
||||
};
|
||||
|
||||
const visibilitySelectorText = (visibility: PropertyVisibility = 'visible') => {
|
||||
switch (visibility) {
|
||||
case 'hide':
|
||||
return 'com.affine.page-properties.property.always-hide';
|
||||
case 'hide-if-empty':
|
||||
return 'com.affine.page-properties.property.hide-when-empty';
|
||||
case 'visible':
|
||||
return 'com.affine.page-properties.property.always-show';
|
||||
default:
|
||||
throw new Error(`unknown visibility: ${visibility}`);
|
||||
}
|
||||
};
|
||||
|
||||
const VisibilityModeSelector = ({
|
||||
property,
|
||||
}: {
|
||||
property: PageInfoCustomProperty;
|
||||
}) => {
|
||||
const manager = useContext(managerContext);
|
||||
const t = useAFFiNEI18N();
|
||||
const meta = manager.getCustomPropertyMeta(property.id);
|
||||
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const required = meta.required;
|
||||
const visibility = property.visibility || 'visible';
|
||||
|
||||
return (
|
||||
<Menu
|
||||
items={
|
||||
<>
|
||||
{visibilities.map(v => {
|
||||
const text = visibilitySelectorText(v);
|
||||
return (
|
||||
<MenuItem
|
||||
key={v}
|
||||
checked={visibility === v}
|
||||
data-testid="page-properties-visibility-menu-item"
|
||||
onClick={() => {
|
||||
manager.updateCustomProperty(property.id, {
|
||||
visibility: v,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t[text]()}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
rootOptions={{
|
||||
open: required ? false : undefined,
|
||||
}}
|
||||
>
|
||||
<div data-required={required} className={styles.selectorButton}>
|
||||
{required ? (
|
||||
t['com.affine.page-properties.property.required']()
|
||||
) : (
|
||||
<>
|
||||
{t[visibilitySelectorText(visibility)]()}
|
||||
<ArrowDownSmallIcon width={16} height={16} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const PagePropertiesSettingsPopup = ({
|
||||
children,
|
||||
}: PagePropertiesSettingsPopupProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
const t = useAFFiNEI18N();
|
||||
const properties = manager.getOrderedCustomProperties();
|
||||
|
||||
return (
|
||||
<Menu
|
||||
items={
|
||||
<>
|
||||
<div className={styles.menuHeader}>
|
||||
{t['com.affine.page-properties.settings.title']()}
|
||||
</div>
|
||||
<Divider />
|
||||
<Scrollable.Root className={styles.menuItemListScrollable}>
|
||||
<Scrollable.Viewport className={styles.menuItemList}>
|
||||
<SortableProperties>
|
||||
{properties.map(property => {
|
||||
const meta = manager.getCustomPropertyMeta(property.id);
|
||||
assertExists(meta, 'meta should exist for property');
|
||||
const Icon = nameToIcon(meta.icon, meta.type);
|
||||
const name = meta.name;
|
||||
return (
|
||||
<SortablePropertyRow
|
||||
key={meta.id}
|
||||
property={property}
|
||||
className={styles.propertySettingRow}
|
||||
data-testid="page-properties-settings-menu-item"
|
||||
>
|
||||
<MenuIcon>
|
||||
<Icon />
|
||||
</MenuIcon>
|
||||
<div className={styles.propertyRowName}>{name}</div>
|
||||
<VisibilityModeSelector property={property} />
|
||||
</SortablePropertyRow>
|
||||
);
|
||||
})}
|
||||
</SortableProperties>
|
||||
</Scrollable.Viewport>
|
||||
</Scrollable.Root>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
type PageBacklinksPopupProps = PropsWithChildren<{
|
||||
backlinks: string[];
|
||||
}>;
|
||||
|
||||
export const PageBacklinksPopup = ({
|
||||
backlinks,
|
||||
children,
|
||||
}: PageBacklinksPopupProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
items={
|
||||
<div className={styles.backlinksList}>
|
||||
{backlinks.map(pageId => (
|
||||
<AffinePageReference
|
||||
key={pageId}
|
||||
wrapper={MenuItem}
|
||||
pageId={pageId}
|
||||
workspace={manager.workspace.blockSuiteWorkspace}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
interface PagePropertyRowNameProps {
|
||||
property: PageInfoCustomProperty;
|
||||
meta: PageInfoCustomPropertyMeta;
|
||||
editing: boolean;
|
||||
onFinishEditing: () => void;
|
||||
}
|
||||
|
||||
export const PagePropertyRowName = ({
|
||||
editing,
|
||||
meta,
|
||||
property,
|
||||
onFinishEditing,
|
||||
children,
|
||||
}: PropsWithChildren<PagePropertyRowNameProps>) => {
|
||||
const manager = useContext(managerContext);
|
||||
const Icon = nameToIcon(meta.icon, meta.type);
|
||||
const localPropertyMetaRef = useRef({ ...meta });
|
||||
const localPropertyRef = useRef({ ...property });
|
||||
const [nextVisibility, setNextVisibility] = useState(property.visibility);
|
||||
const toHide =
|
||||
nextVisibility === 'hide' || nextVisibility === 'hide-if-empty';
|
||||
|
||||
const handleFinishEditing = useCallback(() => {
|
||||
onFinishEditing();
|
||||
manager.updateCustomPropertyMeta(meta.id, localPropertyMetaRef.current);
|
||||
manager.updateCustomProperty(property.id, localPropertyRef.current);
|
||||
}, [manager, meta.id, onFinishEditing, property.id]);
|
||||
const t = useAFFiNEI18N();
|
||||
const handleNameBlur: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
e => {
|
||||
e.stopPropagation();
|
||||
manager.updateCustomPropertyMeta(meta.id, {
|
||||
name: e.target.value,
|
||||
});
|
||||
},
|
||||
[manager, meta.id]
|
||||
);
|
||||
const handleNameChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
e => {
|
||||
localPropertyMetaRef.current.name = e.target.value;
|
||||
},
|
||||
[]
|
||||
);
|
||||
const toggleHide = useCallback((e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const nextVisibility = rotateVisibility(
|
||||
localPropertyRef.current.visibility
|
||||
);
|
||||
setNextVisibility(nextVisibility);
|
||||
localPropertyRef.current.visibility = nextVisibility;
|
||||
}, []);
|
||||
const handleDelete = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
manager.removeCustomProperty(property.id);
|
||||
},
|
||||
[manager, property.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
rootOptions={{
|
||||
open: editing,
|
||||
}}
|
||||
contentOptions={{
|
||||
onInteractOutside: handleFinishEditing,
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<div className={styles.propertyRowNamePopupRow}>
|
||||
<div className={styles.propertyNameIconEditable}>
|
||||
<Icon />
|
||||
</div>
|
||||
<input
|
||||
className={styles.propertyNameInput}
|
||||
defaultValue={meta.name}
|
||||
onBlur={handleNameBlur}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
preFix={
|
||||
<MenuIcon>{!toHide ? <ViewIcon /> : <InvisibleIcon />}</MenuIcon>
|
||||
}
|
||||
data-testid="page-property-row-name-hide-menu-item"
|
||||
onClick={toggleHide}
|
||||
>
|
||||
{t[visibilityMenuText(nextVisibility)]()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type="danger"
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<DeleteIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
data-testid="page-property-row-name-delete-menu-item"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t['com.affine.page-properties.property.remove-property']()}
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
interface PagePropertiesTableHeaderProps {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// backlinks - #no Updated yyyy-mm-dd
|
||||
// ─────────────────────────────────────────────────
|
||||
// Page Info ...
|
||||
export const PagePropertiesTableHeader = ({
|
||||
className,
|
||||
style,
|
||||
}: PagePropertiesTableHeaderProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
const backlinks = useBlockSuitePageBacklinks(
|
||||
manager.workspace.blockSuiteWorkspace,
|
||||
manager.pageId
|
||||
);
|
||||
|
||||
const timestampElement = useMemo(() => {
|
||||
const localizedUpdateTime = manager.updatedDate
|
||||
? timestampToLocalDate(manager.updatedDate)
|
||||
: null;
|
||||
|
||||
const localizedCreateTime = manager.createDate
|
||||
? timestampToLocalDate(manager.createDate)
|
||||
: null;
|
||||
|
||||
const updateTimeElement = (
|
||||
<div className={styles.tableHeaderTimestamp}>
|
||||
{t['Updated']()} {localizedUpdateTime}
|
||||
</div>
|
||||
);
|
||||
|
||||
const createTimeElement = (
|
||||
<div className={styles.tableHeaderTimestamp}>
|
||||
{t['Created']()} {localizedCreateTime}
|
||||
</div>
|
||||
);
|
||||
|
||||
return localizedUpdateTime ? (
|
||||
<Tooltip side="right" content={createTimeElement}>
|
||||
{updateTimeElement}
|
||||
</Tooltip>
|
||||
) : (
|
||||
createTimeElement
|
||||
);
|
||||
}, [manager.createDate, manager.updatedDate, t]);
|
||||
|
||||
const [collapsed, setCollapsed] = useAtom(pageInfoCollapsedAtom);
|
||||
const handleCollapse = useCallback(() => {
|
||||
setCollapsed(prev => !prev);
|
||||
}, [setCollapsed]);
|
||||
|
||||
const properties = manager.getOrderedCustomProperties();
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.tableHeader, className)} style={style}>
|
||||
{/* todo: add click handler to backlinks */}
|
||||
<div className={styles.tableHeaderInfoRow}>
|
||||
{backlinks.length > 0 ? (
|
||||
<PageBacklinksPopup backlinks={backlinks}>
|
||||
<div className={styles.tableHeaderBacklinksHint}>
|
||||
{t['com.affine.page-properties.backlinks']()} · {backlinks.length}
|
||||
</div>
|
||||
</PageBacklinksPopup>
|
||||
) : null}
|
||||
{timestampElement}
|
||||
</div>
|
||||
<Divider />
|
||||
<div className={styles.tableHeaderSecondaryRow}>
|
||||
<div className={clsx(collapsed ? styles.pageInfoDimmed : null)}>
|
||||
{t['com.affine.page-properties.page-info']()}
|
||||
</div>
|
||||
{(collapsed && properties.length === 0) || manager.readonly ? null : (
|
||||
<PagePropertiesSettingsPopup>
|
||||
<IconButton type="plain" icon={<MoreHorizontalIcon />} />
|
||||
</PagePropertiesSettingsPopup>
|
||||
)}
|
||||
<div className={styles.spacer} />
|
||||
<Collapsible.Trigger asChild role="button" onClick={handleCollapse}>
|
||||
<IconButton
|
||||
type="plain"
|
||||
icon={
|
||||
<ToggleExpandIcon
|
||||
className={styles.collapsedIcon}
|
||||
data-collapsed={collapsed !== false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const PagePropertyRow = ({ property }: PagePropertyRowProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
const meta = manager.getCustomPropertyMeta(property.id);
|
||||
|
||||
assertExists(meta, 'meta should exist for property');
|
||||
|
||||
const Icon = nameToIcon(meta.icon, meta.type);
|
||||
const name = meta.name;
|
||||
const ValueRenderer = propertyValueRenderers[meta.type];
|
||||
const [editingMeta, setEditingMeta] = useState(false);
|
||||
const handleEditMeta = useCallback(() => {
|
||||
if (!manager.readonly) {
|
||||
setEditingMeta(true);
|
||||
}
|
||||
}, [manager.readonly]);
|
||||
const handleFinishEditingMeta = useCallback(() => {
|
||||
setEditingMeta(false);
|
||||
}, []);
|
||||
return (
|
||||
<SortablePropertyRow
|
||||
property={property}
|
||||
className={styles.propertyRow}
|
||||
data-testid="page-property-row"
|
||||
data-property={property.id}
|
||||
data-draggable={!manager.readonly}
|
||||
>
|
||||
{({ attributes, listeners }) => (
|
||||
<>
|
||||
<PagePropertyRowName
|
||||
editing={editingMeta}
|
||||
meta={meta}
|
||||
property={property}
|
||||
onFinishEditing={handleFinishEditingMeta}
|
||||
>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={styles.propertyRowNameCell}
|
||||
onClick={handleEditMeta}
|
||||
>
|
||||
<div className={styles.propertyRowIconContainer}>
|
||||
<Icon />
|
||||
</div>
|
||||
<div className={styles.propertyRowName}>{name}</div>
|
||||
</div>
|
||||
</PagePropertyRowName>
|
||||
<ValueRenderer meta={meta} property={property} />
|
||||
</>
|
||||
)}
|
||||
</SortablePropertyRow>
|
||||
);
|
||||
};
|
||||
|
||||
interface PagePropertiesTableBodyProps {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const modifiers = [restrictToParentElement, restrictToVerticalAxis];
|
||||
|
||||
// 🏷️ Tags (⋅ xxx) (⋅ yyy)
|
||||
// #️⃣ Number 123456
|
||||
// + Add a property
|
||||
export const PagePropertiesTableBody = ({
|
||||
className,
|
||||
style,
|
||||
}: PagePropertiesTableBodyProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
const properties = manager.getOrderedCustomProperties();
|
||||
return (
|
||||
<Collapsible.Content
|
||||
className={clsx(styles.tableBodyRoot, className)}
|
||||
style={style}
|
||||
>
|
||||
<div className={styles.tableBody}>
|
||||
<SortableProperties>
|
||||
{properties
|
||||
.filter(
|
||||
property =>
|
||||
property.visibility !== 'hide' &&
|
||||
!(property.visibility === 'hide-if-empty' && !property.value)
|
||||
)
|
||||
.map(property => (
|
||||
<PagePropertyRow key={property.id} property={property} />
|
||||
))}
|
||||
</SortableProperties>
|
||||
</div>
|
||||
{manager.readonly ? null : <PagePropertiesAddProperty />}
|
||||
<Divider />
|
||||
</Collapsible.Content>
|
||||
);
|
||||
};
|
||||
|
||||
interface PagePropertiesCreatePropertyMenuItemsProps {
|
||||
onCreated?: (e: React.MouseEvent, id: string) => void;
|
||||
}
|
||||
|
||||
const findNextDefaultName = (name: string, allNames: string[]): string => {
|
||||
const nameExists = allNames.includes(name);
|
||||
if (nameExists) {
|
||||
const match = name.match(/(\d+)$/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
const nextName = name.replace(/(\d+)$/, `${num + 1}`);
|
||||
return findNextDefaultName(nextName, allNames);
|
||||
} else {
|
||||
return findNextDefaultName(`${name} 2`, allNames);
|
||||
}
|
||||
} else {
|
||||
return name;
|
||||
}
|
||||
};
|
||||
|
||||
export const PagePropertiesCreatePropertyMenuItems = ({
|
||||
onCreated,
|
||||
}: PagePropertiesCreatePropertyMenuItemsProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
const t = useAFFiNEI18N();
|
||||
const onAddProperty = useCallback(
|
||||
(e: React.MouseEvent, option: NewPropertyOption & { icon: string }) => {
|
||||
const nameExists = manager.metaManager
|
||||
.getOrderedCustomPropertiesSchema()
|
||||
.some(meta => meta.name === option.name);
|
||||
const allNames = manager.metaManager
|
||||
.getOrderedCustomPropertiesSchema()
|
||||
.map(meta => meta.name);
|
||||
const name = nameExists
|
||||
? findNextDefaultName(option.name, allNames)
|
||||
: option.name;
|
||||
const { id } = manager.metaManager.addCustomPropertyMeta({
|
||||
name,
|
||||
icon: option.icon,
|
||||
type: option.type,
|
||||
});
|
||||
onCreated?.(e, id);
|
||||
},
|
||||
[manager.metaManager, onCreated]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className={styles.menuHeader}>
|
||||
{t['com.affine.page-properties.create-property.menu.header']()}
|
||||
</div>
|
||||
<Divider />
|
||||
<div className={styles.menuItemList}>
|
||||
{newPropertyOptions.map(({ name, type }) => {
|
||||
const iconName = getDefaultIconName(type);
|
||||
const Icon = nameToIcon(iconName, type);
|
||||
return (
|
||||
<MenuItem
|
||||
key={type}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<Icon />
|
||||
</MenuIcon>
|
||||
}
|
||||
data-testid="page-properties-create-property-menu-item"
|
||||
onClick={e => {
|
||||
onAddProperty(e, { icon: iconName, name, type });
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface PagePropertiesAddPropertyMenuItemsProps {
|
||||
onCreateClicked?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const PagePropertiesAddPropertyMenuItems = ({
|
||||
onCreateClicked,
|
||||
}: PagePropertiesAddPropertyMenuItemsProps) => {
|
||||
const manager = useContext(managerContext);
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
const metaList = manager.metaManager.getOrderedCustomPropertiesSchema();
|
||||
const isChecked = useCallback(
|
||||
(m: string) => {
|
||||
return manager.hasCustomProperty(m);
|
||||
},
|
||||
[manager]
|
||||
);
|
||||
|
||||
const onClickProperty = useCallback(
|
||||
(e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (isChecked(id)) {
|
||||
manager.removeCustomProperty(id);
|
||||
} else {
|
||||
manager.addCustomProperty(id);
|
||||
}
|
||||
},
|
||||
[isChecked, manager]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.menuHeader}>
|
||||
{t['com.affine.page-properties.add-property.menu.header']()}
|
||||
</div>
|
||||
{/* hide available properties if there are none */}
|
||||
{metaList.length > 0 ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Scrollable.Root className={styles.menuItemListScrollable}>
|
||||
<Scrollable.Viewport className={styles.menuItemList}>
|
||||
{metaList.map(meta => {
|
||||
const Icon = nameToIcon(meta.icon, meta.type);
|
||||
const name = meta.name;
|
||||
return (
|
||||
<MenuItem
|
||||
key={meta.id}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<Icon />
|
||||
</MenuIcon>
|
||||
}
|
||||
data-testid="page-properties-add-property-menu-item"
|
||||
data-property={meta.id}
|
||||
checked={isChecked(meta.id)}
|
||||
onClick={(e: React.MouseEvent) =>
|
||||
onClickProperty(e, meta.id)
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
|
||||
</Scrollable.Root>
|
||||
</>
|
||||
) : null}
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={onCreateClicked}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<PlusIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
>
|
||||
<div className={styles.menuItemName}>
|
||||
{t['com.affine.page-properties.add-property.menu.create']()}
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const PagePropertiesAddProperty = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [adding, setAdding] = useState(true);
|
||||
const manager = useContext(managerContext);
|
||||
const toggleAdding: MouseEventHandler = useCallback(e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setAdding(prev => !prev);
|
||||
}, []);
|
||||
const handleCreated = useCallback(
|
||||
(e: React.MouseEvent, id: string) => {
|
||||
toggleAdding(e);
|
||||
manager.addCustomProperty(id);
|
||||
},
|
||||
[manager, toggleAdding]
|
||||
);
|
||||
const items = adding ? (
|
||||
<PagePropertiesAddPropertyMenuItems onCreateClicked={toggleAdding} />
|
||||
) : (
|
||||
<PagePropertiesCreatePropertyMenuItems onCreated={handleCreated} />
|
||||
);
|
||||
return (
|
||||
<Menu rootOptions={{ onOpenChange: () => setAdding(true) }} items={items}>
|
||||
<Button
|
||||
type="plain"
|
||||
icon={<PlusIcon />}
|
||||
className={styles.addPropertyButton}
|
||||
>
|
||||
{t['com.affine.page-properties.add-property']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const PagePropertiesTableInner = () => {
|
||||
const manager = useContext(managerContext);
|
||||
const collapsed = useAtomValue(pageInfoCollapsedAtom);
|
||||
use(manager.workspace.blockSuiteWorkspace.doc.whenSynced);
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Collapsible.Root open={!collapsed} className={styles.rootCentered}>
|
||||
<PagePropertiesTableHeader />
|
||||
<PagePropertiesTableBody />
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 }) => {
|
||||
const manager = usePagePropertiesManager(page);
|
||||
|
||||
return (
|
||||
<managerContext.Provider value={manager}>
|
||||
<Suspense>
|
||||
<PagePropertiesTableInner />
|
||||
</Suspense>
|
||||
</managerContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { usePageMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { useJournalHelper } from '@affine/core/hooks/use-journal';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { LinkedPageIcon, TodayIcon } from '@blocksuite/icons';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export interface PageReferenceRendererOptions {
|
||||
pageId: string;
|
||||
pageMetaHelper: ReturnType<typeof usePageMetaHelper>;
|
||||
journalHelper: ReturnType<typeof useJournalHelper>;
|
||||
t: ReturnType<typeof useAFFiNEI18N>;
|
||||
}
|
||||
// use a function to be rendered in the lit renderer
|
||||
export function pageReferenceRenderer({
|
||||
pageId,
|
||||
pageMetaHelper,
|
||||
journalHelper,
|
||||
t,
|
||||
}: PageReferenceRendererOptions) {
|
||||
const { isPageJournal, getLocalizedJournalDateString } = journalHelper;
|
||||
const referencedPage = pageMetaHelper.getPageMeta(pageId);
|
||||
let title =
|
||||
referencedPage?.title ?? t['com.affine.editor.reference-not-found']();
|
||||
let icon = <LinkedPageIcon className={styles.pageReferenceIcon} />;
|
||||
const isJournal = isPageJournal(pageId);
|
||||
const localizedJournalDate = getLocalizedJournalDateString(pageId);
|
||||
if (isJournal && localizedJournalDate) {
|
||||
title = localizedJournalDate;
|
||||
icon = <TodayIcon className={styles.pageReferenceIcon} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{icon}
|
||||
<span className="affine-reference-title">{title}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AffinePageReference({
|
||||
pageId,
|
||||
workspace,
|
||||
wrapper: Wrapper,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
wrapper?: React.ComponentType<PropsWithChildren>;
|
||||
}) {
|
||||
const pageMetaHelper = usePageMetaHelper(workspace);
|
||||
const journalHelper = useJournalHelper(workspace);
|
||||
const t = useAFFiNEI18N();
|
||||
const el = pageReferenceRenderer({
|
||||
pageId,
|
||||
pageMetaHelper,
|
||||
journalHelper,
|
||||
t,
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/workspace/${workspace.id}/${pageId}`}
|
||||
className={styles.pageReferenceLink}
|
||||
>
|
||||
{Wrapper ? <Wrapper>{el}</Wrapper> : el}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const pageReferenceIcon = style({
|
||||
verticalAlign: 'middle',
|
||||
fontSize: '1.1em',
|
||||
transform: 'translate(2px, -1px)',
|
||||
});
|
||||
|
||||
export const pageReferenceLink = style({
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { usePageMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'
|
||||
import { useJournalHelper } from '@affine/core/hooks/use-journal';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { LinkedPageIcon, TodayIcon } from '@blocksuite/icons';
|
||||
import type { AffineEditorContainer } from '@blocksuite/presets';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import { use } from 'foxact/use';
|
||||
@@ -19,9 +18,12 @@ import {
|
||||
} from 'react';
|
||||
import { type Map as YMap } from 'yjs';
|
||||
|
||||
import {
|
||||
pageReferenceRenderer,
|
||||
type PageReferenceRendererOptions,
|
||||
} from '../../affine/reference-link';
|
||||
import { BlocksuiteEditorContainer } from './blocksuite-editor-container';
|
||||
import type { InlineRenderers } from './specs';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export type ErrorBoundaryProps = {
|
||||
onReset?: () => void;
|
||||
@@ -59,52 +61,18 @@ function usePageRoot(page: Page) {
|
||||
return page.root;
|
||||
}
|
||||
|
||||
interface PageReferenceProps {
|
||||
reference: HTMLElementTagNameMap['affine-reference'];
|
||||
pageMetaHelper: ReturnType<typeof usePageMetaHelper>;
|
||||
journalHelper: ReturnType<typeof useJournalHelper>;
|
||||
t: ReturnType<typeof useAFFiNEI18N>;
|
||||
}
|
||||
|
||||
// TODO: this is a placeholder proof-of-concept implementation
|
||||
function customPageReference({
|
||||
reference,
|
||||
pageMetaHelper,
|
||||
journalHelper,
|
||||
t,
|
||||
}: PageReferenceProps) {
|
||||
const { isPageJournal, getLocalizedJournalDateString } = journalHelper;
|
||||
assertExists(
|
||||
reference.delta.attributes?.reference?.pageId,
|
||||
'pageId should exist for page reference'
|
||||
);
|
||||
const pageId = reference.delta.attributes.reference.pageId;
|
||||
const referencedPage = pageMetaHelper.getPageMeta(pageId);
|
||||
let title =
|
||||
referencedPage?.title ?? t['com.affine.editor.reference-not-found']();
|
||||
let icon = <LinkedPageIcon className={styles.pageReferenceIcon} />;
|
||||
const isJournal = isPageJournal(pageId);
|
||||
const localizedJournalDate = getLocalizedJournalDateString(pageId);
|
||||
if (isJournal && localizedJournalDate) {
|
||||
title = localizedJournalDate;
|
||||
icon = <TodayIcon className={styles.pageReferenceIcon} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{icon}
|
||||
<span className="affine-reference-title">{title}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// we cannot pass components to lit renderers, but give them the rendered elements
|
||||
const customRenderersFactory: (
|
||||
opts: Omit<PageReferenceProps, 'reference'>
|
||||
opts: Omit<PageReferenceRendererOptions, 'pageId'>
|
||||
) => InlineRenderers = opts => ({
|
||||
pageReference(reference) {
|
||||
return customPageReference({
|
||||
const pageId = reference.delta.attributes?.reference?.pageId;
|
||||
if (!pageId) {
|
||||
return <span />;
|
||||
}
|
||||
return pageReferenceRenderer({
|
||||
...opts,
|
||||
reference,
|
||||
pageId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { PagePropertiesTable } from '../../affine/page-properties';
|
||||
import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title';
|
||||
import {
|
||||
docModeSpecs,
|
||||
@@ -108,8 +109,7 @@ export const BlocksuiteDocEditor = forwardRef<
|
||||
) : (
|
||||
<BlocksuiteEditorJournalDocTitle page={page} />
|
||||
)}
|
||||
{/* We will replace page meta tags with our own implementation */}
|
||||
<adapted.PageMetaTags page={page} />
|
||||
<PagePropertiesTable page={page} />
|
||||
<adapted.DocEditor
|
||||
className={styles.docContainer}
|
||||
ref={onDocRef}
|
||||
|
||||
@@ -22,6 +22,3 @@ globalStyle(
|
||||
scrollbarGutter: 'stable',
|
||||
}
|
||||
);
|
||||
globalStyle('.is-public-page page-meta-tags', {
|
||||
display: 'none',
|
||||
});
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import type { Workspace } from '@toeverything/infra';
|
||||
import { useService } from '@toeverything/infra/di';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { use } from 'foxact/use';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { WorkspacePropertiesAdapter } from '../modules/workspace/properties';
|
||||
import { useBlockSuitePageMeta } from './use-block-suite-page-meta';
|
||||
|
||||
function getProxy<T extends object>(obj: T) {
|
||||
return new Proxy(obj, {});
|
||||
}
|
||||
|
||||
const useReactiveAdapter = (adapter: WorkspacePropertiesAdapter) => {
|
||||
use(adapter.workspace.blockSuiteWorkspace.doc.whenSynced);
|
||||
const [proxy, setProxy] = useState(adapter);
|
||||
|
||||
// fixme: this is a hack to force re-render when default meta changed
|
||||
useBlockSuitePageMeta(adapter.workspace.blockSuiteWorkspace);
|
||||
useEffect(() => {
|
||||
// todo: track which properties are used and then filter by property path change
|
||||
// using Y.YEvent.path
|
||||
function observe() {
|
||||
setProxy(getProxy(adapter));
|
||||
requestAnimationFrame(() => {
|
||||
setProxy(getProxy(adapter));
|
||||
});
|
||||
}
|
||||
adapter.properties.observeDeep(observe);
|
||||
return () => {
|
||||
@@ -29,3 +36,11 @@ export function useCurrentWorkspacePropertiesAdapter() {
|
||||
const adapter = useService(WorkspacePropertiesAdapter);
|
||||
return useReactiveAdapter(adapter);
|
||||
}
|
||||
|
||||
export function useWorkspacePropertiesAdapter(workspace: Workspace) {
|
||||
const adapter = useMemo(
|
||||
() => new WorkspacePropertiesAdapter(workspace),
|
||||
[workspace]
|
||||
);
|
||||
return useReactiveAdapter(adapter);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Page, Workspace } from '@blocksuite/store';
|
||||
import { type Atom, atom, useAtomValue } from 'jotai';
|
||||
|
||||
import { useBlockSuiteWorkspacePage } from './use-block-suite-workspace-page';
|
||||
|
||||
const weakMap = new WeakMap<Page, Atom<string[]>>();
|
||||
function getPageBacklinks(page: Page): string[] {
|
||||
return page.workspace.indexer.backlink
|
||||
.getBacklink(page.id)
|
||||
.map(linkNode => linkNode.pageId)
|
||||
.filter(id => id !== page.id);
|
||||
}
|
||||
|
||||
const getPageBacklinksAtom = (page: Page | null) => {
|
||||
if (!page) {
|
||||
return atom([]);
|
||||
}
|
||||
|
||||
if (!weakMap.has(page)) {
|
||||
const baseAtom = atom<string[]>([]);
|
||||
baseAtom.onMount = set => {
|
||||
const disposables = [
|
||||
page.slots.ready.on(() => {
|
||||
set(getPageBacklinks(page));
|
||||
}),
|
||||
page.workspace.indexer.backlink.slots.indexUpdated.on(() => {
|
||||
set(getPageBacklinks(page));
|
||||
}),
|
||||
];
|
||||
set(getPageBacklinks(page));
|
||||
return () => {
|
||||
disposables.forEach(disposable => disposable.dispose());
|
||||
};
|
||||
};
|
||||
weakMap.set(page, baseAtom);
|
||||
}
|
||||
return weakMap.get(page) as Atom<string[]>;
|
||||
};
|
||||
|
||||
export function useBlockSuitePageBacklinks(
|
||||
blockSuiteWorkspace: Workspace,
|
||||
pageId: string
|
||||
): string[] {
|
||||
const page = useBlockSuiteWorkspacePage(blockSuiteWorkspace, pageId);
|
||||
return useAtomValue(getPageBacklinksAtom(page));
|
||||
}
|
||||
@@ -25,9 +25,11 @@ export function useWorkspaceStatus<
|
||||
setStatus(
|
||||
cachedSelector ? cachedSelector(workspace.status) : workspace.status
|
||||
);
|
||||
return workspace.onStatusChange.on(status =>
|
||||
setStatus(cachedSelector ? cachedSelector(status) : status)
|
||||
).dispose;
|
||||
return workspace.onStatusChange.on(status => {
|
||||
requestAnimationFrame(() => {
|
||||
setStatus(cachedSelector ? cachedSelector(status) : status);
|
||||
});
|
||||
}).dispose;
|
||||
}, [cachedSelector, workspace]);
|
||||
|
||||
return status;
|
||||
|
||||
@@ -25,10 +25,10 @@ const AFFINE_PROPERTIES_ID = 'affine:workspace-properties';
|
||||
*/
|
||||
export class WorkspacePropertiesAdapter {
|
||||
// provides a easy-to-use interface for workspace properties
|
||||
private readonly proxy: WorkspaceAffineProperties;
|
||||
public readonly proxy: WorkspaceAffineProperties;
|
||||
public readonly properties: Y.Map<any>;
|
||||
|
||||
constructor(private readonly workspace: Workspace) {
|
||||
constructor(public readonly workspace: Workspace) {
|
||||
// check if properties exists, if not, create one
|
||||
const rootDoc = workspace.blockSuiteWorkspace.doc;
|
||||
this.properties = rootDoc.getMap(AFFINE_PROPERTIES_ID);
|
||||
@@ -69,7 +69,7 @@ export class WorkspacePropertiesAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
private ensurePageProperties(pageId: string) {
|
||||
ensurePageProperties(pageId: string) {
|
||||
// fixme: may not to be called every time
|
||||
defaultsDeep(this.proxy.pageProperties, {
|
||||
[pageId]: {
|
||||
@@ -88,6 +88,11 @@ export class WorkspacePropertiesAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
// leak some yjs abstraction to modify multiple properties at once
|
||||
transact = this.workspace.blockSuiteWorkspace.doc.transact.bind(
|
||||
this.workspace.blockSuiteWorkspace.doc
|
||||
);
|
||||
|
||||
get schema() {
|
||||
return this.proxy.schema;
|
||||
}
|
||||
@@ -103,6 +108,7 @@ export class WorkspacePropertiesAdapter {
|
||||
// ====== utilities ======
|
||||
|
||||
getPageProperties(pageId: string) {
|
||||
this.ensurePageProperties(pageId);
|
||||
return this.pageProperties[pageId];
|
||||
}
|
||||
|
||||
@@ -140,6 +146,14 @@ export class WorkspacePropertiesAdapter {
|
||||
if (tags.some(t => t.id === tagId)) {
|
||||
return;
|
||||
}
|
||||
// add tag option if not exist
|
||||
if (!this.tagOptions.some(t => t.id === tagId)) {
|
||||
if (typeof tag === 'string') {
|
||||
throw new Error(`Tag ${tag} does not exist`);
|
||||
} else {
|
||||
this.tagOptions.push(tag);
|
||||
}
|
||||
}
|
||||
const pageProperties = this.pageProperties[pageId];
|
||||
pageProperties.system[PageSystemPropertyId.Tags].value.push(tagId);
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ export enum PageSystemPropertyId {
|
||||
}
|
||||
|
||||
export enum PagePropertyType {
|
||||
String = 'string',
|
||||
Text = 'text',
|
||||
Number = 'number',
|
||||
Boolean = 'boolean',
|
||||
Date = 'date',
|
||||
Progress = 'progress',
|
||||
Checkbox = 'checkbox',
|
||||
Tags = 'tags',
|
||||
}
|
||||
|
||||
@@ -26,7 +27,9 @@ export const PagePropertyMetaBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
source: z.string(),
|
||||
type: z.string(),
|
||||
type: z.nativeEnum(PagePropertyType),
|
||||
icon: z.string(),
|
||||
required: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const PageSystemPropertyMetaBaseSchema =
|
||||
@@ -36,13 +39,13 @@ export const PageSystemPropertyMetaBaseSchema =
|
||||
|
||||
export const PageCustomPropertyMetaSchema = PagePropertyMetaBaseSchema.extend({
|
||||
source: z.literal('custom'),
|
||||
type: z.nativeEnum(PagePropertyType),
|
||||
order: z.number(),
|
||||
});
|
||||
|
||||
// ====== page info schema ======
|
||||
export const PageInfoItemSchema = z.object({
|
||||
id: z.string(), // property id. Maps to PagePropertyMetaSchema.id
|
||||
hidden: z.boolean().optional(),
|
||||
visibility: z.enum(['visible', 'hide', 'hide-if-empty']),
|
||||
value: z.any(), // corresponds to PagePropertyMetaSchema.type
|
||||
});
|
||||
|
||||
@@ -56,6 +59,8 @@ export const PageInfoTagsItemSchema = PageInfoItemSchema.extend({
|
||||
value: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type PageInfoTagsItem = z.infer<typeof PageInfoTagsItemSchema>;
|
||||
|
||||
// ====== workspace properties schema ======
|
||||
export const WorkspaceFavoriteItemSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -82,8 +87,12 @@ const WorkspaceAffinePropertiesSchemaSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const PageInfoCustomPropertyItemSchema = PageInfoItemSchema.extend({
|
||||
order: z.number(),
|
||||
});
|
||||
|
||||
const WorkspacePagePropertiesSchema = z.object({
|
||||
custom: z.record(PageInfoItemSchema.extend({ order: z.number() })),
|
||||
custom: z.record(PageInfoCustomPropertyItemSchema),
|
||||
system: z.object({
|
||||
[PageSystemPropertyId.Journal]: PageInfoJournalItemSchema,
|
||||
[PageSystemPropertyId.Tags]: PageInfoTagsItemSchema,
|
||||
@@ -96,6 +105,18 @@ export const WorkspaceAffinePropertiesSchema = z.object({
|
||||
pageProperties: z.record(WorkspacePagePropertiesSchema),
|
||||
});
|
||||
|
||||
export type PageInfoCustomPropertyMeta = z.infer<
|
||||
typeof PageCustomPropertyMetaSchema
|
||||
>;
|
||||
|
||||
export type WorkspaceAffineProperties = z.infer<
|
||||
typeof WorkspaceAffinePropertiesSchema
|
||||
>;
|
||||
|
||||
export type PageInfoCustomProperty = z.infer<
|
||||
typeof PageInfoCustomPropertyItemSchema
|
||||
>;
|
||||
|
||||
export type WorkspaceAffinePageProperties = z.infer<
|
||||
typeof WorkspacePagePropertiesSchema
|
||||
>;
|
||||
|
||||
@@ -8,10 +8,10 @@ const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
export const timestampToLocalTime = (ts: string) => {
|
||||
export const timestampToLocalTime = (ts: string | number) => {
|
||||
return timeFormatter.format(new Date(ts));
|
||||
};
|
||||
|
||||
export const timestampToLocalDate = (ts: string) => {
|
||||
export const timestampToLocalDate = (ts: string | number) => {
|
||||
return dateFormatter.format(new Date(ts));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user