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

View File

@@ -21,6 +21,7 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
edgelessColorTheme: f.string().optional(),
journal: f.string().optional(),
pageWidth: f.string().optional(),
isTemplate: f.boolean().optional(),
}),
docCustomPropertyInfo: {
id: f.string().primaryKey().optional().default(nanoid),

View File

@@ -5,7 +5,7 @@ import type { DocCustomPropertyInfo } from '../db';
*
* 'id' and 'type' is request, 'index' is a manually maintained incremental key.
*/
export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [
export const BUILT_IN_CUSTOM_PROPERTY_TYPE: DocCustomPropertyInfo[] = [
{
id: 'tags',
type: 'tags',
@@ -23,6 +23,12 @@ export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [
show: 'always-hide',
index: 'a0000003',
},
{
id: 'template',
type: 'template',
index: 'a00000031',
show: 'always-hide',
},
{
id: 'createdAt',
type: 'createdAt',
@@ -51,4 +57,4 @@ export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [
show: 'always-hide',
index: 'a0000008',
},
] as DocCustomPropertyInfo[];
];

View File

@@ -1,6 +1,7 @@
import type { DocMode, RootBlockModel } from '@blocksuite/affine/blocks';
import { Entity } from '@toeverything/infra';
import type { DocProperties } from '../../db';
import type { WorkspaceService } from '../../workspace';
import type { DocScope } from '../scopes/doc';
import type { DocsStore } from '../stores/docs';
@@ -38,6 +39,18 @@ export class Doc extends Entity {
return this.record.customProperty$(propertyId);
}
setProperty(propertyId: string, value: string) {
return this.record.setProperty(propertyId, value);
}
updateProperties(properties: Partial<DocProperties>) {
return this.record.updateProperties(properties);
}
getProperties() {
return this.record.getProperties();
}
setCustomProperty(propertyId: string, value: string) {
return this.record.setCustomProperty(propertyId, value);
}

View File

@@ -42,7 +42,18 @@ export class DocRecord extends Entity<{ id: string }> {
});
}
setProperty(propertyId: string, value: string) {
getProperties() {
return this.docPropertiesStore.getDocProperties(this.id);
}
updateProperties(properties: Partial<DocProperties>) {
this.docPropertiesStore.updateDocProperties(this.id, properties);
}
setProperty<Key extends keyof DocProperties>(
propertyId: Key,
value: DocProperties[Key]
) {
this.docPropertiesStore.updateDocProperties(this.id, {
[propertyId]: value,
});

View File

@@ -1,8 +1,8 @@
import { DebugLogger } from '@affine/debug';
import { Unreachable } from '@affine/env/constant';
import type { DocMode } from '@blocksuite/affine/blocks';
import { type DocMode, replaceIdMiddleware } from '@blocksuite/affine/blocks';
import type { DeltaInsert } from '@blocksuite/affine/inline';
import { Text } from '@blocksuite/affine/store';
import { Slice, Text, Transformer } from '@blocksuite/affine/store';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { LiveData, ObjectPool, Service } from '@toeverything/infra';
import { omitBy } from 'lodash-es';
@@ -150,4 +150,83 @@ export class DocsService extends Service {
doc.changeDocTitle(newTitle);
release();
}
/**
* Duplicate a doc from template
* @param sourceDocId - the id of the source doc to be duplicated
* @param _targetDocId - the id of the target doc to be duplicated, if not provided, a new doc will be created
* @returns the id of the new doc
*/
async duplicateFromTemplate(sourceDocId: string, _targetDocId?: string) {
const targetDocId = _targetDocId ?? this.createDoc().id;
// check if source doc is removed
if (this.list.doc$(sourceDocId).value?.trash$.value) {
console.warn(
`Template doc(id: ${sourceDocId}) is removed, skip duplicate`
);
return targetDocId;
}
const { release: sourceRelease, doc: sourceDoc } = this.open(sourceDocId);
const { release: targetRelease, doc: targetDoc } = this.open(targetDocId);
await sourceDoc.waitForSyncReady();
// duplicate doc content
try {
const sourceBsDoc = this.store.getBlockSuiteDoc(sourceDocId);
const targetBsDoc = this.store.getBlockSuiteDoc(targetDocId);
if (!sourceBsDoc) throw new Error('Source doc not found');
if (!targetBsDoc) throw new Error('Target doc not found');
// clear the target doc (both surface and note)
targetBsDoc.root?.children.forEach(child =>
targetBsDoc.deleteBlock(child)
);
const collection = this.store.getBlocksuiteCollection();
const transformer = new Transformer({
schema: collection.schema,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc({ id }),
get: (id: string) => collection.getDoc(id),
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [replaceIdMiddleware(collection.idGenerator)],
});
const slice = Slice.fromModels(sourceBsDoc, [
...(sourceBsDoc.root?.children ?? []),
]);
const snapshot = transformer.sliceToSnapshot(slice);
if (!snapshot) {
throw new Error('Failed to create snapshot');
}
await transformer.snapshotToSlice(
snapshot,
targetBsDoc,
targetBsDoc.root?.id
);
} catch (e) {
logger.error('Failed to duplicate doc', {
sourceDocId,
targetDocId,
originalTargetDocId: _targetDocId,
error: e,
});
} finally {
sourceRelease();
targetRelease();
}
// duplicate doc properties
const properties = sourceDoc.getProperties();
const removedProperties = ['id', 'isTemplate', 'journal'];
removedProperties.forEach(key => {
delete properties[key];
});
targetDoc.updateProperties(properties);
return targetDocId;
}
}

View File

@@ -24,6 +24,10 @@ export class DocsStore extends Store {
return this.workspaceService.workspace.docCollection.getDoc(id);
}
getBlocksuiteCollection() {
return this.workspaceService.workspace.docCollection;
}
createBlockSuiteDoc() {
return this.workspaceService.workspace.docCollection.createDoc();
}

View File

@@ -230,6 +230,15 @@ export const AFFINE_FLAGS = {
configurable: !isMobile,
defaultState: false,
},
// TODO(@CatsJuice): remove this flag when ready
enable_template_doc: {
category: 'affine',
displayName: 'Enable template doc',
description:
'Allow users to mark a doc as a template, and create new docs from it',
configurable: !isMobile,
defaultState: isCanaryBuild,
},
} satisfies { [key in string]: FlagInfo };
// oxlint-disable-next-line no-redeclare

View File

@@ -43,6 +43,7 @@ import {
import { configureSystemFontFamilyModule } from './system-font-family';
import { configureTagModule } from './tag';
import { configureTelemetryModule } from './telemetry';
import { configureTemplateDocModule } from './template-doc';
import { configureAppThemeModule } from './theme';
import { configureThemeEditorModule } from './theme-editor';
import { configureUrlModule } from './url';
@@ -94,4 +95,5 @@ export function configureCommonModules(framework: Framework) {
configureCommonGlobalStorageImpls(framework);
configureAINetworkSearchModule(framework);
configureAIButtonModule(framework);
configureTemplateDocModule(framework);
}

View File

@@ -0,0 +1,24 @@
import { Entity, LiveData } from '@toeverything/infra';
import type { DocRecord, DocsService } from '../../doc';
import type { TemplateDocListStore } from '../store/list';
export class TemplateDocList extends Entity {
constructor(
public listStore: TemplateDocListStore,
public docsService: DocsService
) {
super();
}
public isTemplate$(docId: string) {
return LiveData.from(this.listStore.watchTemplateDoc(docId), false);
}
public getTemplateDocs() {
return this.listStore
.getTemplateDocIds()
.map(id => this.docsService.list.doc$(id).value)
.filter((doc): doc is DocRecord => !!doc && !doc.trash$.value);
}
}

View File

@@ -0,0 +1,12 @@
import { Entity } from '@toeverything/infra';
export type TemplateDocSettings = {
templateId?: string;
journalTemplateId?: string;
};
export class TemplateDocSetting extends Entity {
constructor() {
super();
}
}

View File

@@ -0,0 +1,21 @@
import type { Framework } from '@toeverything/infra';
import { WorkspaceDBService } from '../db';
import { DocsService } from '../doc';
import { WorkspaceScope } from '../workspace';
import { TemplateDocList } from './entities/list';
import { TemplateDocSetting } from './entities/setting';
import { TemplateDocService } from './services/template-doc';
import { TemplateDocListStore } from './store/list';
export { TemplateDocService };
export * from './view/template-list-menu';
export const configureTemplateDocModule = (framework: Framework) => {
framework
.scope(WorkspaceScope)
.service(TemplateDocService)
.store(TemplateDocListStore, [WorkspaceDBService])
.entity(TemplateDocList, [TemplateDocListStore, DocsService])
.entity(TemplateDocSetting);
};

View File

@@ -0,0 +1,9 @@
import { Service } from '@toeverything/infra';
import { TemplateDocList } from '../entities/list';
import { TemplateDocSetting } from '../entities/setting';
export class TemplateDocService extends Service {
public readonly list = this.framework.createEntity(TemplateDocList);
public readonly setting = this.framework.createEntity(TemplateDocSetting);
}

View File

@@ -0,0 +1,33 @@
import { Store } from '@toeverything/infra';
import { map } from 'rxjs';
import type { WorkspaceDBService } from '../../db';
export class TemplateDocListStore extends Store {
constructor(private readonly dbService: WorkspaceDBService) {
super();
}
isTemplateDoc(docId: string) {
return !!this.dbService.db.docProperties.find({
id: docId,
isTemplate: true,
})[0]?.isTemplate;
}
watchTemplateDoc(docId: string) {
return this.dbService.db.docProperties
.find$({ id: docId, isTemplate: true })
.pipe(map(res => res[0]?.isTemplate));
}
getTemplateDocIds() {
return this.dbService.db.docProperties
.find({ isTemplate: true })
.map(property => property.id);
}
watchTemplateDocs() {
return this.dbService.db.docProperties.find$({ isTemplate: true });
}
}

View File

@@ -0,0 +1,42 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const list = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
minWidth: 250,
maxWidth: 355,
});
export const item = style({
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 4,
});
export const itemIcon = style({
fontSize: 20,
lineHeight: 0,
color: cssVarV2.icon.primary,
});
export const itemText = style({
width: 0,
flex: 1,
fontSize: 14,
lineHeight: '22px',
color: cssVarV2.text.primary,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
export const menuContent = style({
paddingRight: 0,
});
export const scrollableViewport = style({
paddingRight: 8,
maxHeight: 360,
});

View File

@@ -0,0 +1,82 @@
import { Menu, MenuItem, type MenuProps, Scrollable } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useLiveData, useService } from '@toeverything/infra';
import { type PropsWithChildren, useState } from 'react';
import { type DocRecord, DocsService } from '../../doc';
import { DocDisplayMetaService } from '../../doc-display-meta';
import { TemplateDocService } from '../services/template-doc';
import * as styles from './styles.css';
interface CommonProps {
target?: string;
}
interface DocItemProps extends CommonProps {
doc: DocRecord;
}
const DocItem = ({ doc, target }: DocItemProps) => {
const docDisplayService = useService(DocDisplayMetaService);
const Icon = useLiveData(docDisplayService.icon$(doc.id));
const title = useLiveData(docDisplayService.title$(doc.id));
const docsService = useService(DocsService);
const onClick = useAsyncCallback(async () => {
await docsService.duplicateFromTemplate(doc.id, target);
}, [doc.id, docsService, target]);
return (
<MenuItem onClick={onClick} style={{ padding: 0 }}>
<li className={styles.item}>
<Icon className={styles.itemIcon} />
<span className={styles.itemText}>{title}</span>
</li>
</MenuItem>
);
};
export const TemplateListMenuContent = ({ target }: CommonProps) => {
const templateDocService = useService(TemplateDocService);
const [templateDocs] = useState(() =>
templateDocService.list.getTemplateDocs()
);
return (
<ul className={styles.list}>
{templateDocs.map(doc => (
<DocItem key={doc.id} doc={doc} target={target} />
))}
</ul>
);
};
export const TemplateListMenuContentScrollable = ({ target }: CommonProps) => {
return (
<Scrollable.Root>
<Scrollable.Scrollbar />
<Scrollable.Viewport className={styles.scrollableViewport}>
<TemplateListMenuContent target={target} />
</Scrollable.Viewport>
</Scrollable.Root>
);
};
export const TemplateListMenu = ({
children,
target,
contentOptions,
...otherProps
}: PropsWithChildren<CommonProps> & Omit<MenuProps, 'items'>) => {
return (
<Menu
items={<TemplateListMenuContentScrollable target={target} />}
contentOptions={{
...contentOptions,
className: styles.menuContent,
}}
{...otherProps}
>
{children}
</Menu>
);
};