From 10196f67859b37d434f15a9f2de1c850bf84b4f4 Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Tue, 14 Jan 2025 02:10:33 +0000 Subject: [PATCH] feat(core): new template doc property (#9538) close AF-2045, AF-2047, AF-2065 --- .../block-suite-editor/lit-adaper.tsx | 2 + .../block-suite-editor/starter-bar.css.ts | 66 +++++++++ .../block-suite-editor/starter-bar.tsx | 126 ++++++++++++++++++ .../src/components/doc-properties/table.tsx | 8 ++ .../doc-properties/types/constant.tsx | 10 ++ .../doc-properties/types/template.css.ts | 13 ++ .../doc-properties/types/template.tsx | 37 +++++ .../core/src/modules/db/schema/schema.ts | 1 + .../core/src/modules/doc/constants.ts | 10 +- .../core/src/modules/doc/entities/doc.ts | 13 ++ .../core/src/modules/doc/entities/record.ts | 13 +- .../core/src/modules/doc/services/docs.ts | 83 +++++++++++- .../core/src/modules/doc/stores/docs.ts | 4 + .../core/src/modules/feature-flag/constant.ts | 9 ++ packages/frontend/core/src/modules/index.ts | 2 + .../src/modules/template-doc/entities/list.ts | 24 ++++ .../modules/template-doc/entities/setting.ts | 12 ++ .../core/src/modules/template-doc/index.ts | 21 +++ .../template-doc/services/template-doc.ts | 9 ++ .../src/modules/template-doc/store/list.ts | 33 +++++ .../modules/template-doc/view/styles.css.ts | 42 ++++++ .../template-doc/view/template-list-menu.tsx | 82 ++++++++++++ packages/frontend/i18n/src/resources/en.json | 8 +- .../affine-local/e2e/page-properties.spec.ts | 3 +- tests/affine-local/e2e/template.spec.ts | 94 +++++++++++++ tests/kit/src/utils/setting.ts | 2 +- 26 files changed, 719 insertions(+), 8 deletions(-) create mode 100644 packages/frontend/core/src/components/blocksuite/block-suite-editor/starter-bar.css.ts create mode 100644 packages/frontend/core/src/components/blocksuite/block-suite-editor/starter-bar.tsx create mode 100644 packages/frontend/core/src/components/doc-properties/types/template.css.ts create mode 100644 packages/frontend/core/src/components/doc-properties/types/template.tsx create mode 100644 packages/frontend/core/src/modules/template-doc/entities/list.ts create mode 100644 packages/frontend/core/src/modules/template-doc/entities/setting.ts create mode 100644 packages/frontend/core/src/modules/template-doc/index.ts create mode 100644 packages/frontend/core/src/modules/template-doc/services/template-doc.ts create mode 100644 packages/frontend/core/src/modules/template-doc/store/list.ts create mode 100644 packages/frontend/core/src/modules/template-doc/view/styles.css.ts create mode 100644 packages/frontend/core/src/modules/template-doc/view/template-list-menu.tsx create mode 100644 tests/affine-local/e2e/template.spec.ts diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx index 879e28d15..0488d97ee 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx @@ -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} > + {!shared && displayBiDirectionalLink ? ( ) : null} diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/starter-bar.css.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/starter-bar.css.ts new file mode 100644 index 000000000..b3f3d0eeb --- /dev/null +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/starter-bar.css.ts @@ -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({}); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/starter-bar.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/starter-bar.tsx new file mode 100644 index 000000000..6c54b7d1d --- /dev/null +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/starter-bar.tsx @@ -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 & { + icon: React.ReactNode; + text: string; + active?: boolean; + } +>(function Badge({ icon, text, className, active, ...attrs }, ref) { + return ( +
  • + {text} + {icon} +
  • + ); +}); + +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 ( +
    + {t['com.affine.page-starter-bar.start']()} +
      + {showAI ? ( + } + text={t['com.affine.page-starter-bar.ai']()} + /> + ) : null} + + {showTemplate ? ( + + } + text={t['com.affine.page-starter-bar.template']()} + active={templateMenuOpen} + /> + + ) : null} + + {showEdgeless ? ( + } + text={t['com.affine.page-starter-bar.edgeless']()} + /> + ) : null} +
    +
    + ); +}; + +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 ; +}; diff --git a/packages/frontend/core/src/components/doc-properties/table.tsx b/packages/frontend/core/src/components/doc-properties/table.tsx index 898a69d15..13a0258bb 100644 --- a/packages/frontend/core/src/components/doc-properties/table.tsx +++ b/packages/frontend/core/src/components/doc-properties/table.tsx @@ -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 ( { + const docService = useService(DocService); + + const isTemplate = useLiveData( + docService.doc.record.properties$.selector(p => p.isTemplate) + ); + + const onChange = useCallback( + (e: ChangeEvent) => { + 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 ( + + + + ); +}; diff --git a/packages/frontend/core/src/modules/db/schema/schema.ts b/packages/frontend/core/src/modules/db/schema/schema.ts index c2ad8ccb2..3d17513d9 100644 --- a/packages/frontend/core/src/modules/db/schema/schema.ts +++ b/packages/frontend/core/src/modules/db/schema/schema.ts @@ -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), diff --git a/packages/frontend/core/src/modules/doc/constants.ts b/packages/frontend/core/src/modules/doc/constants.ts index 3e522a215..96922efe7 100644 --- a/packages/frontend/core/src/modules/doc/constants.ts +++ b/packages/frontend/core/src/modules/doc/constants.ts @@ -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[]; +]; diff --git a/packages/frontend/core/src/modules/doc/entities/doc.ts b/packages/frontend/core/src/modules/doc/entities/doc.ts index 8c2e50a08..3df73d740 100644 --- a/packages/frontend/core/src/modules/doc/entities/doc.ts +++ b/packages/frontend/core/src/modules/doc/entities/doc.ts @@ -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) { + return this.record.updateProperties(properties); + } + + getProperties() { + return this.record.getProperties(); + } + setCustomProperty(propertyId: string, value: string) { return this.record.setCustomProperty(propertyId, value); } diff --git a/packages/frontend/core/src/modules/doc/entities/record.ts b/packages/frontend/core/src/modules/doc/entities/record.ts index 50f3f5d6a..9b96551cc 100644 --- a/packages/frontend/core/src/modules/doc/entities/record.ts +++ b/packages/frontend/core/src/modules/doc/entities/record.ts @@ -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) { + this.docPropertiesStore.updateDocProperties(this.id, properties); + } + + setProperty( + propertyId: Key, + value: DocProperties[Key] + ) { this.docPropertiesStore.updateDocProperties(this.id, { [propertyId]: value, }); diff --git a/packages/frontend/core/src/modules/doc/services/docs.ts b/packages/frontend/core/src/modules/doc/services/docs.ts index 1feb8086d..202657a0b 100644 --- a/packages/frontend/core/src/modules/doc/services/docs.ts +++ b/packages/frontend/core/src/modules/doc/services/docs.ts @@ -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; + } } diff --git a/packages/frontend/core/src/modules/doc/stores/docs.ts b/packages/frontend/core/src/modules/doc/stores/docs.ts index af2cd720d..e37df8e19 100644 --- a/packages/frontend/core/src/modules/doc/stores/docs.ts +++ b/packages/frontend/core/src/modules/doc/stores/docs.ts @@ -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(); } diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index e2042d231..09a3d7885 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -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 diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 27fa9aa7b..88b1569d5 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -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); } diff --git a/packages/frontend/core/src/modules/template-doc/entities/list.ts b/packages/frontend/core/src/modules/template-doc/entities/list.ts new file mode 100644 index 000000000..553fcd5c5 --- /dev/null +++ b/packages/frontend/core/src/modules/template-doc/entities/list.ts @@ -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); + } +} diff --git a/packages/frontend/core/src/modules/template-doc/entities/setting.ts b/packages/frontend/core/src/modules/template-doc/entities/setting.ts new file mode 100644 index 000000000..746a6e49b --- /dev/null +++ b/packages/frontend/core/src/modules/template-doc/entities/setting.ts @@ -0,0 +1,12 @@ +import { Entity } from '@toeverything/infra'; + +export type TemplateDocSettings = { + templateId?: string; + journalTemplateId?: string; +}; + +export class TemplateDocSetting extends Entity { + constructor() { + super(); + } +} diff --git a/packages/frontend/core/src/modules/template-doc/index.ts b/packages/frontend/core/src/modules/template-doc/index.ts new file mode 100644 index 000000000..f05ca5e6a --- /dev/null +++ b/packages/frontend/core/src/modules/template-doc/index.ts @@ -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); +}; diff --git a/packages/frontend/core/src/modules/template-doc/services/template-doc.ts b/packages/frontend/core/src/modules/template-doc/services/template-doc.ts new file mode 100644 index 000000000..62962bbea --- /dev/null +++ b/packages/frontend/core/src/modules/template-doc/services/template-doc.ts @@ -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); +} diff --git a/packages/frontend/core/src/modules/template-doc/store/list.ts b/packages/frontend/core/src/modules/template-doc/store/list.ts new file mode 100644 index 000000000..ce31ab351 --- /dev/null +++ b/packages/frontend/core/src/modules/template-doc/store/list.ts @@ -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 }); + } +} diff --git a/packages/frontend/core/src/modules/template-doc/view/styles.css.ts b/packages/frontend/core/src/modules/template-doc/view/styles.css.ts new file mode 100644 index 000000000..cace8db5d --- /dev/null +++ b/packages/frontend/core/src/modules/template-doc/view/styles.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/modules/template-doc/view/template-list-menu.tsx b/packages/frontend/core/src/modules/template-doc/view/template-list-menu.tsx new file mode 100644 index 000000000..8c7f3c108 --- /dev/null +++ b/packages/frontend/core/src/modules/template-doc/view/template-list-menu.tsx @@ -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 ( + +
  • + + {title} +
  • +
    + ); +}; + +export const TemplateListMenuContent = ({ target }: CommonProps) => { + const templateDocService = useService(TemplateDocService); + const [templateDocs] = useState(() => + templateDocService.list.getTemplateDocs() + ); + + return ( +
      + {templateDocs.map(doc => ( + + ))} +
    + ); +}; + +export const TemplateListMenuContentScrollable = ({ target }: CommonProps) => { + return ( + + + + + + + ); +}; + +export const TemplateListMenu = ({ + children, + target, + contentOptions, + ...otherProps +}: PropsWithChildren & Omit) => { + return ( + } + contentOptions={{ + ...contentOptions, + className: styles.menuContent, + }} + {...otherProps} + > + {children} + + ); +}; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 765177018..0276acd40 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -717,6 +717,7 @@ "com.affine.page-properties.property.updatedAt": "Updated", "com.affine.page-properties.property.edgelessTheme": "Edgeless theme", "com.affine.page-properties.property.pageWidth": "Page width", + "com.affine.page-properties.property.template": "Template", "com.affine.page-properties.property.tags.tooltips": "Add relevant identifiers or categories to the doc. Useful for organizing content, improving searchability, and grouping related docs together.", "com.affine.page-properties.property.journal.tooltips": "Indicates that this doc is a journal entry or daily note. Facilitates easy capture of ideas, quick logging of thoughts, and ongoing personal reflection.", "com.affine.page-properties.property.checkbox.tooltips": "Use a checkbox to indicate whether a condition is true or false. Useful for confirming options, toggling features, or tracking task states.", @@ -735,6 +736,7 @@ "com.affine.page-properties.property.docPrimaryMode.tooltips": "Select the doc mode from Page Mode, Edgeless Mode, or Auto. Useful for choosing the best display for your content.", "com.affine.page-properties.property.edgelessTheme.tooltips": "Select the doc theme from Light, Dark, or System. Useful for precise control over content viewing style.", "com.affine.page-properties.property.pageWidth.tooltips": "Control the width of this page to fit content display needs.", + "com.affine.page-properties.property.template.tooltips": "Mark this doc as a template, which can be used to create new docs.", "com.affine.propertySidebar.property-list.section": "Properties", "com.affine.propertySidebar.add-more.section": "Add more properties", "com.affine.page-properties.settings.title": "customize properties", @@ -1622,5 +1624,9 @@ "com.affine.payment.sync-paused.member.storage.description": "This workspace has exceeded its storage limit and synchronization has been paused. Please contact your workspace owner to either reduce storage usage or upgrade the plan to resume syncing.", "com.affine.payment.sync-paused.member.member.description": "This workspace has reached its maximum member capacity and synchronization has been paused. Please contact your workspace owner to either adjust team membership or upgrade the plan to resume syncing.", "com.affine.payment.sync-paused.member.member.confirm": "Got It", - "com.affine.server.delete": "Delete Server" + "com.affine.server.delete": "Delete Server", + "com.affine.page-starter-bar.start": "Start", + "com.affine.page-starter-bar.template": "Template", + "com.affine.page-starter-bar.ai": "With AI", + "com.affine.page-starter-bar.edgeless": "Edgeless" } diff --git a/tests/affine-local/e2e/page-properties.spec.ts b/tests/affine-local/e2e/page-properties.spec.ts index f3220a7da..20e495f6c 100644 --- a/tests/affine-local/e2e/page-properties.spec.ts +++ b/tests/affine-local/e2e/page-properties.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable unicorn/prefer-dom-node-dataset */ import { test } from '@affine-test/kit/playwright'; import { openHomePage, @@ -126,6 +125,7 @@ test('property table reordering', async ({ page }) => { 'Tags', 'Doc mode', 'Journal', + 'Template', 'Created', 'Updated', 'Created by', @@ -171,6 +171,7 @@ test('page info show more will show all properties', async ({ page }) => { 'Tags', 'Doc mode', 'Journal', + 'Template', 'Created', 'Updated', 'Created by', diff --git a/tests/affine-local/e2e/template.spec.ts b/tests/affine-local/e2e/template.spec.ts new file mode 100644 index 000000000..1e06a03d9 --- /dev/null +++ b/tests/affine-local/e2e/template.spec.ts @@ -0,0 +1,94 @@ +import { test } from '@affine-test/kit/playwright'; +import { openHomePage } from '@affine-test/kit/utils/load-page'; +import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic'; +import { expect, type Locator, type Page } from '@playwright/test'; + +function getTemplateRow(page: Page) { + return page.locator( + '[data-testid="doc-property-row"][data-info-id="template"]' + ); +} + +async function toggleTemplate(row: Locator, value: boolean) { + const checkbox = row.locator('input[type="checkbox"]'); + const state = await checkbox.inputValue(); + const checked = state === 'on'; + if (checked !== value) { + await checkbox.click(); + const newState = await checkbox.inputValue(); + const newChecked = newState === 'on'; + expect(newChecked).toBe(value); + } +} + +const createDocAndMarkAsTemplate = async ( + page: Page, + title?: string, + onCreated?: () => Promise +) => { + await page.getByTestId('sidebar-new-page-button').click(); + await waitForEditorLoad(page); + + if (title) { + await page.keyboard.type(title); + } + + const collapse = page.getByTestId('page-info-collapse'); + const open = await collapse.getAttribute('aria-expanded'); + if (open?.toLowerCase() !== 'true') { + await collapse.click(); + } + + // add if not exists + if ((await getTemplateRow(page).count()) === 0) { + const addPropertyButton = page.getByTestId('add-property-button'); + if (!(await addPropertyButton.isVisible())) { + await page.getByTestId('property-collapsible-button').click(); + } + await addPropertyButton.click(); + await page + .locator('[role="menuitem"][data-property-type="journal"]') + .click(); + await page.keyboard.press('Escape'); + } + // expand if collapsed + else if (!(await getTemplateRow(page).isVisible())) { + await page.getByTestId('property-collapsible-button').click(); + } + + const templateRow = getTemplateRow(page); + await expect(templateRow).toBeVisible(); + await toggleTemplate(templateRow, true); + + // focus editor + await page.locator('affine-note').first().click(); + await onCreated?.(); +}; + +test('create a doc and mark it as template', async ({ page }) => { + await openHomePage(page); + await createDocAndMarkAsTemplate(page, 'Test Template', async () => { + await page.keyboard.type('# Template'); + await page.keyboard.press('Enter'); + await page.keyboard.type('This is a template doc'); + }); +}); + +test('create a doc, and initialize it from template', async ({ page }) => { + await openHomePage(page); + await createDocAndMarkAsTemplate(page, 'Test Template', async () => { + await page.keyboard.type('# Template'); + await page.keyboard.press('Enter'); + await page.keyboard.type('This is a template doc'); + }); + + await page.getByTestId('sidebar-new-page-button').click(); + await waitForEditorLoad(page); + await page.getByTestId('template-docs-badge').click(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect(page.getByText('This is a template doc')).toBeVisible(); + + // the starter bar should be hidden + await expect(page.getByTestId('template-docs-badge')).not.toBeVisible(); +}); diff --git a/tests/kit/src/utils/setting.ts b/tests/kit/src/utils/setting.ts index d86cf4c28..b3d4a254c 100644 --- a/tests/kit/src/utils/setting.ts +++ b/tests/kit/src/utils/setting.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test'; +import { type Page } from '@playwright/test'; export async function clickCollaborationPanel(page: Page) { await page.click('[data-tab-key="collaboration"]');