feat(core): page info ui (#5729)

this PR includes the main table view in the page detail page
This commit is contained in:
Peng Xiao
2024-02-22 05:58:14 +00:00
parent 46cc0810e9
commit d97304e9eb
26 changed files with 2068 additions and 83 deletions

View File

@@ -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',

View File

@@ -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);

View File

@@ -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)
);
};

View File

@@ -0,0 +1,3 @@
export * from './icons-mapping';
export * from './page-properties-manager';
export * from './table';

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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'),
},
});

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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',
});

View File

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

View File

@@ -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}

View File

@@ -22,6 +22,3 @@ globalStyle(
scrollbarGutter: 'stable',
}
);
globalStyle('.is-public-page page-meta-tags', {
display: 'none',
});

View File

@@ -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);
}

View File

@@ -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));
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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
>;

View File

@@ -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));
};