mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
feat(core): new template doc property (#9538)
close AF-2045, AF-2047, AF-2065
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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({});
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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[];
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Entity } from '@toeverything/infra';
|
||||
|
||||
export type TemplateDocSettings = {
|
||||
templateId?: string;
|
||||
journalTemplateId?: string;
|
||||
};
|
||||
|
||||
export class TemplateDocSetting extends Entity {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
21
packages/frontend/core/src/modules/template-doc/index.ts
Normal file
21
packages/frontend/core/src/modules/template-doc/index.ts
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user