feat(core): new template doc property (#9538)

close AF-2045, AF-2047, AF-2065
This commit is contained in:
CatsJuice
2025-01-14 02:10:33 +00:00
parent 777eea124d
commit 10196f6785
26 changed files with 719 additions and 8 deletions

View File

@@ -68,6 +68,7 @@ import {
} from './specs/custom/spec-patchers';
import { createEdgelessModeSpecs } from './specs/edgeless';
import { createPageModeSpecs } from './specs/page';
import { StarterBar } from './starter-bar';
import * as styles from './styles.css';
const adapted = {
@@ -334,6 +335,7 @@ export const BlocksuiteDocEditor = forwardRef<
data-testid="page-editor-blank"
onClick={onClickBlank}
></div>
<StarterBar doc={page} />
{!shared && displayBiDirectionalLink ? (
<BiDirectionalLinkPanel />
) : null}

View File

@@ -0,0 +1,66 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
import { container } from './bi-directional-link-panel.css';
export const root = style([
container,
{
paddingBottom: 6,
display: 'flex',
gap: 8,
alignItems: 'center',
fontSize: 12,
fontWeight: 400,
lineHeight: '20px',
color: cssVarV2.text.primary,
},
]);
export const badges = style({
display: 'flex',
gap: 12,
alignItems: 'center',
});
export const badge = style({
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
borderRadius: 40,
backgroundColor: cssVarV2.layer.background.secondary,
cursor: 'pointer',
userSelect: 'none',
position: 'relative',
':before': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0,0,0,.04)',
borderRadius: 'inherit',
opacity: 0,
transition: 'opacity 0.2s ease',
},
selectors: {
'&:hover:before': {
opacity: 1,
},
'&[data-active="true"]:before': {
opacity: 1,
},
},
});
export const badgeIcon = style({
fontSize: 16,
lineHeight: 0,
});
export const badgeText = style({});

View File

@@ -0,0 +1,126 @@
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import {
TemplateDocService,
TemplateListMenu,
} from '@affine/core/modules/template-doc';
import { useI18n } from '@affine/i18n';
import type { Store } from '@blocksuite/affine/store';
import {
AiIcon,
EdgelessIcon,
TemplateColoredIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
forwardRef,
type HTMLAttributes,
useEffect,
useMemo,
useState,
} from 'react';
import * as styles from './starter-bar.css';
const Badge = forwardRef<
HTMLLIElement,
HTMLAttributes<HTMLLIElement> & {
icon: React.ReactNode;
text: string;
active?: boolean;
}
>(function Badge({ icon, text, className, active, ...attrs }, ref) {
return (
<li
data-active={active}
className={clsx(styles.badge, className)}
ref={ref}
{...attrs}
>
<span className={styles.badgeText}>{text}</span>
<span className={styles.badgeIcon}>{icon}</span>
</li>
);
});
const StarterBarNotEmpty = ({ doc }: { doc: Store }) => {
const t = useI18n();
const templateDocService = useService(TemplateDocService);
const featureFlagService = useService(FeatureFlagService);
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const isTemplate = useLiveData(
useMemo(
() => templateDocService.list.isTemplate$(doc.id),
[doc.id, templateDocService.list]
)
);
const enableTemplateDoc = useLiveData(
featureFlagService.flags.enable_template_doc.$
);
const showAI = false;
const showEdgeless = false;
const showTemplate = !isTemplate && enableTemplateDoc;
if (!showAI && !showEdgeless && !showTemplate) {
return null;
}
return (
<div className={styles.root}>
{t['com.affine.page-starter-bar.start']()}
<ul className={styles.badges}>
{showAI ? (
<Badge
icon={<AiIcon />}
text={t['com.affine.page-starter-bar.ai']()}
/>
) : null}
{showTemplate ? (
<TemplateListMenu
target={doc.id}
rootOptions={{
open: templateMenuOpen,
onOpenChange: setTemplateMenuOpen,
}}
>
<Badge
data-testid="template-docs-badge"
icon={<TemplateColoredIcon />}
text={t['com.affine.page-starter-bar.template']()}
active={templateMenuOpen}
/>
</TemplateListMenu>
) : null}
{showEdgeless ? (
<Badge
icon={<EdgelessIcon />}
text={t['com.affine.page-starter-bar.edgeless']()}
/>
) : null}
</ul>
</div>
);
};
export const StarterBar = ({ doc }: { doc: Store }) => {
const [isEmpty, setIsEmpty] = useState(doc.isEmpty);
useEffect(() => {
const disposable = doc.slots.blockUpdated.on(() => {
setIsEmpty(doc.isEmpty);
});
return () => {
disposable.dispose();
};
}, [doc]);
if (!isEmpty) return null;
return <StarterBarNotEmpty doc={doc} />;
};

View File

@@ -15,6 +15,7 @@ import type {
DatabaseRow,
DatabaseValueCell,
} from '@affine/core/modules/doc-info/types';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { ViewService, WorkbenchService } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
@@ -126,9 +127,13 @@ export const DocPropertyRow = ({
const t = useI18n();
const docService = useService(DocService);
const docsService = useService(DocsService);
const featureFlagService = useService(FeatureFlagService);
const customPropertyValue = useLiveData(
docService.doc.customProperty$(propertyInfo.id)
);
const enableTemplateDoc = useLiveData(
featureFlagService.flags.enable_template_doc.$
);
const typeInfo = isSupportedDocPropertyType(propertyInfo.type)
? DocPropertyTypes[propertyInfo.type]
: undefined;
@@ -203,6 +208,9 @@ export const DocPropertyRow = ({
);
if (!ValueRenderer || typeof ValueRenderer !== 'function') return null;
if (propertyInfo.id === 'template' && !enableTemplateDoc) {
return null;
}
return (
<PropertyRoot

View File

@@ -9,6 +9,7 @@ import {
LongerIcon,
NumberIcon,
TagIcon,
TemplateOutlineIcon,
TextIcon,
TodayIcon,
} from '@blocksuite/icons/rc';
@@ -22,6 +23,7 @@ import { JournalValue } from './journal';
import { NumberValue } from './number';
import { PageWidthValue } from './page-width';
import { TagsValue } from './tags';
import { TemplateValue } from './template';
import { TextValue } from './text';
import type { PropertyValueProps } from './types';
@@ -108,6 +110,14 @@ export const DocPropertyTypes = {
name: 'com.affine.page-properties.property.pageWidth',
description: 'com.affine.page-properties.property.pageWidth.tooltips',
},
template: {
uniqueId: 'template',
icon: TemplateOutlineIcon,
value: TemplateValue,
name: 'com.affine.page-properties.property.template',
renameable: false,
description: 'com.affine.page-properties.property.template.tooltips',
},
} as Record<
string,
{

View File

@@ -0,0 +1,13 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const property = style({
padding: '3px 4px',
display: 'flex',
alignItems: 'center',
});
export const checkbox = style({
fontSize: 24,
color: cssVarV2.icon.primary,
});

View File

@@ -0,0 +1,37 @@
import { Checkbox, PropertyValue } from '@affine/component';
import { DocService } from '@affine/core/modules/doc';
import { useLiveData, useService } from '@toeverything/infra';
import { type ChangeEvent, useCallback } from 'react';
import * as styles from './template.css';
export const TemplateValue = () => {
const docService = useService(DocService);
const isTemplate = useLiveData(
docService.doc.record.properties$.selector(p => p.isTemplate)
);
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.checked;
docService.doc.record.setProperty('isTemplate', value);
},
[docService.doc.record]
);
const toggle = useCallback(() => {
docService.doc.record.setProperty('isTemplate', !isTemplate);
}, [docService.doc.record, isTemplate]);
return (
<PropertyValue className={styles.property} onClick={toggle}>
<Checkbox
data-testid="toggle-template-checkbox"
checked={!!isTemplate}
onChange={onChange}
className={styles.checkbox}
/>
</PropertyValue>
);
};