feat(core): template doc setting ui (#9658)

close AF-2051, AF-2052
This commit is contained in:
CatsJuice
2025-01-14 02:10:34 +00:00
parent 57b89b5ad4
commit af3ad4bfe9
18 changed files with 481 additions and 206 deletions

View File

@@ -1,3 +1,4 @@
import { DocsService } from '@affine/core/modules/doc';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import {
TemplateDocService,
@@ -20,6 +21,7 @@ import {
useState,
} from 'react';
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
import * as styles from './starter-bar.css';
const Badge = forwardRef<
@@ -48,6 +50,7 @@ const StarterBarNotEmpty = ({ doc }: { doc: Store }) => {
const templateDocService = useService(TemplateDocService);
const featureFlagService = useService(FeatureFlagService);
const docsService = useService(DocsService);
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
@@ -61,6 +64,13 @@ const StarterBarNotEmpty = ({ doc }: { doc: Store }) => {
featureFlagService.flags.enable_template_doc.$
);
const handleSelectTemplate = useAsyncCallback(
async (templateId: string) => {
await docsService.duplicateFromTemplate(templateId, doc.id);
},
[doc.id, docsService]
);
const showAI = false;
const showEdgeless = false;
const showTemplate = !isTemplate && enableTemplateDoc;
@@ -82,7 +92,7 @@ const StarterBarNotEmpty = ({ doc }: { doc: Store }) => {
{showTemplate ? (
<TemplateListMenu
target={doc.id}
onSelect={handleSelectTemplate}
rootOptions={{
open: templateMenuOpen,
onOpenChange: setTemplateMenuOpen,

View File

@@ -2,6 +2,7 @@ import {
Loading,
Menu,
MenuItem,
type MenuProps,
MenuSeparator,
MenuTrigger,
RadioGroup,
@@ -19,6 +20,7 @@ import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hoo
import { ServerService } from '@affine/core/modules/cloud';
import { DesktopApiService } from '@affine/core/modules/desktop-api';
import {
type EditorSettingSchema,
EditorSettingService,
type FontFamily,
fontStyleOptions,
@@ -30,7 +32,6 @@ import {
SystemFontFamilyService,
} from '@affine/core/modules/system-font-family';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMode } from '@blocksuite/affine/blocks';
import { DoneIcon, SearchIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import clsx from 'clsx';
@@ -306,27 +307,39 @@ const CustomFontFamilySettings = () => {
</SettingRow>
);
};
const menuContentOptions: MenuProps['contentOptions'] = {
align: 'end',
sideOffset: 16,
style: { width: 250 },
};
const NewDocDefaultModeSettings = () => {
const t = useI18n();
const { editorSettingService } = useServices({ EditorSettingService });
const settings = useLiveData(editorSettingService.editorSetting.settings$);
const radioItems = useMemo<RadioItem[]>(
() => [
{
value: 'page',
label: t['Page'](),
testId: 'page-mode-trigger',
},
{
value: 'edgeless',
label: t['Edgeless'](),
testId: 'edgeless-mode-trigger',
},
],
const items = useMemo(
() =>
[
{
value: 'page',
label: t['Page'](),
testId: 'page-mode-trigger',
},
{
value: 'edgeless',
label: t['Edgeless'](),
testId: 'edgeless-mode-trigger',
},
{
value: 'ask',
label: 'Ask every time',
testId: 'ask-every-time-trigger',
},
] as const,
[t]
);
const updateNewDocDefaultMode = useCallback(
(value: DocMode) => {
(value: EditorSettingSchema['newDocDefaultMode']) => {
editorSettingService.editorSetting.set('newDocDefaultMode', value);
},
[editorSettingService.editorSetting]
@@ -340,13 +353,24 @@ const NewDocDefaultModeSettings = () => {
'com.affine.settings.editorSettings.general.default-new-doc.description'
]()}
>
<RadioGroup
items={radioItems}
value={settings.newDocDefaultMode}
width={250}
className={styles.settingWrapper}
onChange={updateNewDocDefaultMode}
/>
<Menu
contentOptions={menuContentOptions}
items={items.map(item => {
return (
<MenuItem
key={item.value}
selected={item.value === settings.newDocDefaultMode}
onSelect={() => updateNewDocDefaultMode(item.value)}
>
{item.label}
</MenuItem>
);
})}
>
<MenuTrigger className={styles.menuTrigger}>
{items.find(item => item.value === settings.newDocDefaultMode)?.label}
</MenuTrigger>
</Menu>
</SettingRow>
);
};

View File

@@ -19,6 +19,7 @@ import { LabelsPanel } from './labels';
import { MembersPanel } from './members';
import { ProfilePanel } from './profile';
import { SharingPanel } from './sharing';
import { TemplateDocSetting } from './template';
import type { WorkspaceSettingDetailProps } from './types';
import { WorkspaceQuotaPanel } from './workspace-quota';
@@ -70,6 +71,7 @@ export const WorkspaceSettingDetail = ({
<LabelsPanel />
</SettingRow>
</SettingWrapper>
<TemplateDocSetting />
<SettingWrapper title={t['com.affine.brand.affineCloud']()}>
<EnableCloudPanel onCloseSetting={onCloseSetting} />
<WorkspaceQuotaPanel />

View File

@@ -5,10 +5,7 @@ export const menuItem = style({
alignItems: 'center',
gap: 8,
});
export const menuItemIcon = style({
fontSize: 24,
lineHeight: 0,
});
export const menuItemIcon = style({});
export const menuItemText = style({
fontSize: 14,
width: 0,
@@ -17,3 +14,6 @@ export const menuItemText = style({
whiteSpace: 'nowrap',
overflow: 'hidden',
});
export const menuTrigger = style({
width: 250,
});

View File

@@ -0,0 +1,129 @@
import {
MenuItem,
MenuSeparator,
MenuTrigger,
Switch,
} from '@affine/component';
import {
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { DocsService } from '@affine/core/modules/doc';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import {
TemplateDocService,
TemplateListMenu,
} from '@affine/core/modules/template-doc';
import { useI18n } from '@affine/i18n';
import { DeleteIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useCallback } from 'react';
import * as styles from './template.css';
export const TemplateDocSetting = () => {
const t = useI18n();
const { featureFlagService, templateDocService } = useServices({
FeatureFlagService,
TemplateDocService,
});
const setting = templateDocService.setting;
const enabled = useLiveData(featureFlagService.flags.enable_template_doc.$);
const enablePageTemplate = useLiveData(setting.enablePageTemplate$);
const pageTemplateDocId = useLiveData(setting.pageTemplateDocId$);
const journalTemplateDocId = useLiveData(setting.journalTemplateDocId$);
const togglePageTemplate = useCallback(
(enable: boolean) => {
setting.togglePageTemplate(enable);
},
[setting]
);
const updatePageTemplate = useCallback(
(id?: string) => {
setting.updatePageTemplateDocId(id);
},
[setting]
);
const updateJournalTemplate = useCallback(
(id?: string) => {
setting.updateJournalTemplateDocId(id);
},
[setting]
);
if (!enabled) return null;
return (
<SettingWrapper title={t['com.affine.settings.workspace.template.title']()}>
<SettingRow
name={t['com.affine.settings.workspace.template.journal']()}
desc={t['com.affine.settings.workspace.template.journal-desc']()}
>
<TemplateSelector
current={journalTemplateDocId}
onChange={updateJournalTemplate}
/>
</SettingRow>
<SettingRow
name={t['com.affine.settings.workspace.template.page']()}
desc={t['com.affine.settings.workspace.template.page-desc']()}
>
<Switch checked={enablePageTemplate} onChange={togglePageTemplate} />
</SettingRow>
{enablePageTemplate ? (
<SettingRow
name={t['com.affine.settings.workspace.template.journal']()}
desc={t['com.affine.settings.workspace.template.journal-desc']()}
>
<TemplateSelector
current={pageTemplateDocId}
onChange={updatePageTemplate}
/>
</SettingRow>
) : null}
</SettingWrapper>
);
};
interface TemplateSelectorProps {
current?: string;
onChange?: (id?: string) => void;
}
const TemplateSelector = ({ current, onChange }: TemplateSelectorProps) => {
const t = useI18n();
const docsService = useService(DocsService);
const docDisplayService = useService(DocDisplayMetaService);
const doc = useLiveData(current ? docsService.list.doc$(current) : null);
const title = useLiveData(doc ? docDisplayService.title$(doc.id) : null);
// const isInTrash = useLiveData(doc?.trash$);
return (
<TemplateListMenu
onSelect={onChange}
contentOptions={{ align: 'end' }}
suffixItems={
<>
<MenuSeparator />
<MenuItem
prefixIcon={<DeleteIcon className={styles.menuItemIcon} />}
onClick={() => onChange?.()}
type="danger"
>
{t['com.affine.settings.workspace.template.remove']()}
</MenuItem>
</>
}
>
<MenuTrigger className={styles.menuTrigger}>
{/* TODO: in trash design */}
{title ?? t['com.affine.settings.workspace.template.keep-empty']()}
</MenuTrigger>
</TemplateListMenu>
);
};

View File

@@ -1,110 +0,0 @@
import { Button, Menu, MenuItem } from '@affine/component';
import { type DocRecord, DocsService } from '@affine/core/modules/doc';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { TemplateDocService } from '@affine/core/modules/template-doc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import * as styles from './template.css';
export const TemplateDocSetting = () => {
const { featureFlagService, templateDocService } = useServices({
FeatureFlagService,
TemplateDocService,
});
const setting = templateDocService.setting;
const enabled = useLiveData(featureFlagService.flags.enable_template_doc.$);
const loading = useLiveData(setting.loading$);
const pageTemplateDocId = useLiveData(setting.pageTemplateDocId$);
const journalTemplateDocId = useLiveData(setting.journalTemplateDocId$);
const updatePageTemplate = useCallback(
(id?: string) => {
setting.updatePageTemplateDocId(id);
},
[setting]
);
const updateJournalTemplate = useCallback(
(id?: string) => {
setting.updateJournalTemplateDocId(id);
},
[setting]
);
if (!enabled) return null;
if (loading) return null;
return (
<div>
Normal Page:
<TemplateSelector
current={pageTemplateDocId}
onChange={updatePageTemplate}
/>
<br />
Journal:
<TemplateSelector
current={journalTemplateDocId}
onChange={updateJournalTemplate}
/>
</div>
);
};
interface TemplateSelectorProps {
current?: string;
onChange?: (id?: string) => void;
}
const TemplateSelector = ({ current, onChange }: TemplateSelectorProps) => {
const docsService = useService(DocsService);
const doc = useLiveData(current ? docsService.list.doc$(current) : null);
const isInTrash = useLiveData(doc?.trash$);
return (
<Menu items={<List onChange={onChange} />}>
<Button>{isInTrash ? 'Doc is removed' : (doc?.id ?? 'Unset')}</Button>
</Menu>
);
};
const List = ({ onChange }: { onChange?: (id?: string) => void }) => {
const list = useService(TemplateDocService).list;
const [docs] = useState(list.getTemplateDocs());
const handleClick = useCallback(
(id: string) => {
onChange?.(id);
},
[onChange]
);
return docs.map(doc => {
return <DocItem key={doc.id} doc={doc} onClick={handleClick} />;
});
};
interface DocItemProps {
doc: DocRecord;
onClick?: (id: string) => void;
}
const DocItem = ({ doc, onClick }: DocItemProps) => {
const docDisplayService = useService(DocDisplayMetaService);
const Icon = useLiveData(docDisplayService.icon$(doc.id));
const title = useLiveData(docDisplayService.title$(doc.id));
const handleClick = useCallback(() => {
onClick?.(doc.id);
}, [doc.id, onClick]);
return (
<MenuItem onClick={handleClick}>
<li className={styles.menuItem}>
<Icon className={styles.menuItemIcon} />
<span className={styles.menuItemText}>{title}</span>
</li>
</MenuItem>
);
};

View File

@@ -10,3 +10,23 @@ export const root = style({
borderColor: cssVarV2('layer/insideBorder/border'),
background: cssVarV2('button/siderbarPrimary/background'),
});
export const withAskRoot = style([
root,
{
width: 'auto',
padding: 6,
},
]);
export const withAskContent = style({
fontSize: 20,
display: 'flex',
alignItems: 'center',
gap: 2,
color: cssVarV2.icon.primary,
});
export const templateMenu = style({
width: 280,
});

View File

@@ -1,34 +1,190 @@
import { IconButton } from '@affine/component';
import { Button, IconButton, Menu, MenuItem, MenuSub } from '@affine/component';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DocsService } from '@affine/core/modules/doc';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import {
TemplateDocService,
TemplateListMenuContentScrollable,
} from '@affine/core/modules/template-doc';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { inferOpenMode } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { PlusIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import type { DocMode } from '@blocksuite/affine/blocks';
import {
ArrowDownSmallIcon,
EdgelessIcon,
PageIcon,
PlusIcon,
TemplateIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import type React from 'react';
import { type MouseEvent, useCallback } from 'react';
import * as styles from './index.css';
/**
* @return a function to create a new doc, will duplicate the template doc if the page template is enabled
*/
const useNewDoc = () => {
const featureFlagService = useService(FeatureFlagService);
const workspaceService = useService(WorkspaceService);
const templateDocService = useService(TemplateDocService);
const docsService = useService(DocsService);
const workbench = useService(WorkbenchService).workbench;
const enableTemplateDoc = useLiveData(
featureFlagService.flags.enable_template_doc.$
);
const currentWorkspace = workspaceService.workspace;
const enablePageTemplate = useLiveData(
templateDocService.setting.enablePageTemplate$
);
const pageTemplateDocId = useLiveData(
templateDocService.setting.pageTemplateDocId$
);
const pageHelper = usePageHelper(currentWorkspace.docCollection);
const createPage = useAsyncCallback(
async (e?: MouseEvent, mode?: DocMode) => {
if (enableTemplateDoc && enablePageTemplate && pageTemplateDocId) {
const docId =
await docsService.duplicateFromTemplate(pageTemplateDocId);
workbench.openDoc(docId, { at: inferOpenMode(e) });
} else {
pageHelper.createPage(mode, { at: inferOpenMode(e) });
}
},
[
docsService,
enablePageTemplate,
enableTemplateDoc,
pageHelper,
pageTemplateDocId,
workbench,
]
);
return createPage;
};
interface AddPageButtonProps {
className?: string;
style?: React.CSSProperties;
}
const sideBottom = { side: 'bottom' as const };
export function AddPageButton({ className, style }: AddPageButtonProps) {
const workspaceService = useService(WorkspaceService);
const currentWorkspace = workspaceService.workspace;
const pageHelper = usePageHelper(currentWorkspace.docCollection);
export function AddPageButton(props: AddPageButtonProps) {
const editorSetting = useService(EditorSettingService);
const newDocDefaultMode = useLiveData(
editorSetting.editorSetting.settings$.selector(s => s.newDocDefaultMode)
);
return newDocDefaultMode === 'ask' ? (
<AddPageWithAsk {...props} />
) : (
<AddPageWithoutAsk {...props} />
);
}
function AddPageWithAsk({ className, style }: AddPageButtonProps) {
const t = useI18n();
const createDoc = useNewDoc();
const workbench = useService(WorkbenchService).workbench;
const docsService = useService(DocsService);
const createPage = useCallback(
(e?: MouseEvent) => {
createDoc(e, 'page');
track.$.navigationPanel.$.createDoc();
},
[createDoc]
);
const createEdgeless = useCallback(
(e?: MouseEvent) => {
createDoc(e, 'edgeless');
track.$.navigationPanel.$.createDoc();
},
[createDoc]
);
const createDocFromTemplate = useAsyncCallback(
async (templateId: string) => {
const docId = await docsService.duplicateFromTemplate(templateId);
workbench.openDoc(docId);
},
[docsService, workbench]
);
return (
<Menu
items={
<>
<MenuItem
prefixIcon={<PageIcon />}
onClick={createPage}
onAuxClick={createPage}
>
{t['Page']()}
</MenuItem>
<MenuItem
prefixIcon={<EdgelessIcon />}
onClick={createEdgeless}
onAuxClick={createEdgeless}
>
{t['Edgeless']()}
</MenuItem>
<MenuSub
triggerOptions={{
prefixIcon: <TemplateIcon />,
}}
subContentOptions={{
sideOffset: 16,
className: styles.templateMenu,
}}
items={
<TemplateListMenuContentScrollable
onSelect={createDocFromTemplate}
/>
}
>
{t['Template']()}
</MenuSub>
</>
}
>
<Button
tooltip={t['New Page']()}
tooltipOptions={sideBottom}
data-testid="sidebar-new-page-button"
className={clsx([styles.withAskRoot, className])}
style={style}
>
<div className={styles.withAskContent}>
<PlusIcon />
<ArrowDownSmallIcon />
</div>
</Button>
</Menu>
);
}
function AddPageWithoutAsk({ className, style }: AddPageButtonProps) {
const createDoc = useNewDoc();
const onClickNewPage = useCallback(
(e?: MouseEvent) => {
pageHelper.createPage(undefined, { at: inferOpenMode(e) });
createDoc(e);
track.$.navigationPanel.$.createDoc();
},
[pageHelper]
[createDoc]
);
const t = useI18n();

View File

@@ -19,7 +19,7 @@ export const fontStyleOptions = [
const AffineEditorSettingSchema = z.object({
fontFamily: z.enum(['Sans', 'Serif', 'Mono', 'Custom']).default('Sans'),
customFontFamily: z.string().default(''),
newDocDefaultMode: z.enum(['edgeless', 'page']).default('page'),
newDocDefaultMode: z.enum(['edgeless', 'page', 'ask']).default('page'),
fullWidthLayout: z.boolean().default(false),
displayDocInfo: z.boolean().default(true),
displayBiDirectionalLink: z.boolean().default(true),

View File

@@ -20,7 +20,8 @@ export class EditorSettingService extends Service {
workspace.docCollection.slots.docCreated.on(docId => {
const preferMode = this.editorSetting.settings$.value.newDocDefaultMode;
const docsService = workspace.scope.get(DocsService);
docsService.list.setPrimaryMode(docId, preferMode);
const mode = preferMode === 'ask' ? 'page' : preferMode;
docsService.list.setPrimaryMode(docId, mode);
});
// never dispose, because this service always live longer than workspace
}

View File

@@ -2,6 +2,8 @@ import { type Framework } from '@toeverything/infra';
import { DocScope, DocService, DocsService } from '../doc';
import { EditorSettingService } from '../editor-setting';
import { FeatureFlagService } from '../feature-flag';
import { TemplateDocService } from '../template-doc';
import { WorkspaceScope } from '../workspace';
import { JournalService } from './services/journal';
import { JournalDocService } from './services/journal-doc';
@@ -18,7 +20,13 @@ export { suggestJournalDate } from './suggest-journal-date';
export function configureJournalModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(JournalService, [JournalStore, DocsService, EditorSettingService])
.service(JournalService, [
JournalStore,
DocsService,
EditorSettingService,
TemplateDocService,
FeatureFlagService,
])
.store(JournalStore, [DocsService])
.scope(DocScope)
.service(JournalDocService, [DocService, JournalService]);

View File

@@ -8,6 +8,8 @@ import {
} from '../../../blocksuite/initialization';
import type { DocsService } from '../../doc';
import type { EditorSettingService } from '../../editor-setting';
import type { FeatureFlagService } from '../../feature-flag';
import type { TemplateDocService } from '../../template-doc';
import type { JournalStore } from '../store/journal';
export type MaybeDate = Date | string | number;
@@ -18,7 +20,9 @@ export class JournalService extends Service {
constructor(
private readonly store: JournalStore,
private readonly docsService: DocsService,
private readonly editorSettingService: EditorSettingService
private readonly editorSettingService: EditorSettingService,
private readonly templateDocService: TemplateDocService,
private readonly featureFlagService: FeatureFlagService
) {
super();
}
@@ -52,8 +56,6 @@ export class JournalService extends Service {
const day = dayjs(maybeDate);
const title = day.format(JOURNAL_DATE_FORMAT);
const docRecord = this.docsService.createDoc();
const { doc, release } = this.docsService.open(docRecord.id);
this.docsService.list.setPrimaryMode(docRecord.id, 'page');
// set created date to match the journal date
docRecord.setMeta({
createDate: dayjs()
@@ -63,12 +65,36 @@ export class JournalService extends Service {
.toDate()
.getTime(),
});
const docProps: DocProps = {
page: { title: new Text(title) },
note: this.editorSettingService.editorSetting.get('affine:note'),
};
initDocFromProps(doc.blockSuiteDoc, docProps);
release();
const enableTemplateDoc =
this.featureFlagService.flags.enable_template_doc.value;
const enablePageTemplate =
this.templateDocService.setting.enablePageTemplate$.value;
const pageTemplateDocId =
this.templateDocService.setting.pageTemplateDocId$.value;
const journalTemplateDocId =
this.templateDocService.setting.journalTemplateDocId$.value;
// if journal template configured
if (enableTemplateDoc && journalTemplateDocId) {
this.docsService
.duplicateFromTemplate(journalTemplateDocId, docRecord.id)
.catch(console.error);
}
// journal template not configured, use page template
else if (enableTemplateDoc && enablePageTemplate && pageTemplateDocId) {
this.docsService
.duplicateFromTemplate(pageTemplateDocId, docRecord.id)
.catch(console.error);
} else {
const { doc, release } = this.docsService.open(docRecord.id);
this.docsService.list.setPrimaryMode(docRecord.id, 'page');
const docProps: DocProps = {
page: { title: new Text(title) },
note: this.editorSettingService.editorSetting.get('affine:note'),
};
initDocFromProps(doc.blockSuiteDoc, docProps);
release();
}
this.setJournalDate(docRecord.id, title);
return docRecord;
}

View File

@@ -9,9 +9,14 @@ export class TemplateDocSetting extends Entity {
loading$ = this.store.watchIsLoading();
setting$ = this.store.watchSetting();
enablePageTemplate$ = this.store.watchSettingKey('enablePageTemplate');
pageTemplateDocId$ = this.store.watchSettingKey('pageTemplateId');
journalTemplateDocId$ = this.store.watchSettingKey('journalTemplateId');
togglePageTemplate(enable: boolean) {
this.store.updateSetting('enablePageTemplate', enable);
}
updatePageTemplateDocId(id?: string) {
this.store.updateSetting('pageTemplateId', id);
}

View File

@@ -40,7 +40,8 @@ export class TemplateDocSettingStore extends Store {
) {
const db = this.dbService.userdataDB$.value;
const prev = db.settings.find({ key: this.key })[0]?.value ?? {};
db.settings.update(this.key, {
db.settings.create({
key: this.key,
value: { ...prev, [key]: value },
});
}

View File

@@ -1,4 +1,5 @@
export type TemplateDocSettings = {
enablePageTemplate?: boolean;
pageTemplateId?: string;
journalTemplateId?: string;
};

View File

@@ -1,39 +1,13 @@
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({
width: 280,
paddingRight: 0,
});
export const scrollableViewport = style({

View File

@@ -1,41 +1,45 @@
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 { useState } from 'react';
import { type DocRecord, DocsService } from '../../doc';
import { type DocRecord } from '../../doc';
import { DocDisplayMetaService } from '../../doc-display-meta';
import { TemplateDocService } from '../services/template-doc';
import * as styles from './styles.css';
interface CommonProps {
target?: string;
onSelect?: (docId: string) => void;
}
interface DocItemProps extends CommonProps {
doc: DocRecord;
}
const DocItem = ({ doc, target }: DocItemProps) => {
const DocItem = ({ doc, onSelect }: 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]);
onSelect?.(doc.id);
}, [doc.id, onSelect]);
return (
<MenuItem onClick={onClick} style={{ padding: 0 }}>
<li className={styles.item}>
<Icon className={styles.itemIcon} />
<span className={styles.itemText}>{title}</span>
</li>
<MenuItem prefixIcon={<Icon />} onClick={onClick}>
{title}
</MenuItem>
);
};
export const TemplateListMenuContent = ({ target }: CommonProps) => {
interface TemplateListMenuContentProps extends CommonProps {
prefixItems?: React.ReactNode;
suffixItems?: React.ReactNode;
}
export const TemplateListMenuContent = ({
prefixItems,
suffixItems,
...props
}: TemplateListMenuContentProps) => {
const templateDocService = useService(TemplateDocService);
const [templateDocs] = useState(() =>
templateDocService.list.getTemplateDocs()
@@ -43,33 +47,48 @@ export const TemplateListMenuContent = ({ target }: CommonProps) => {
return (
<ul className={styles.list}>
{prefixItems}
{templateDocs.map(doc => (
<DocItem key={doc.id} doc={doc} target={target} />
<DocItem key={doc.id} doc={doc} {...props} />
))}
{suffixItems}
</ul>
);
};
export const TemplateListMenuContentScrollable = ({ target }: CommonProps) => {
export const TemplateListMenuContentScrollable = (
props: TemplateListMenuContentProps
) => {
return (
<Scrollable.Root>
<Scrollable.Scrollbar />
<Scrollable.Viewport className={styles.scrollableViewport}>
<TemplateListMenuContent target={target} />
<TemplateListMenuContent {...props} />
</Scrollable.Viewport>
</Scrollable.Root>
);
};
interface TemplateListMenuProps
extends TemplateListMenuContentProps,
Omit<MenuProps, 'items'> {}
export const TemplateListMenu = ({
children,
target,
onSelect,
prefixItems,
suffixItems,
contentOptions,
...otherProps
}: PropsWithChildren<CommonProps> & Omit<MenuProps, 'items'>) => {
}: TemplateListMenuProps) => {
return (
<Menu
items={<TemplateListMenuContentScrollable target={target} />}
items={
<TemplateListMenuContentScrollable
onSelect={onSelect}
prefixItems={prefixItems}
suffixItems={suffixItems}
/>
}
contentOptions={{
...contentOptions,
className: styles.menuContent,

View File

@@ -1628,5 +1628,14 @@
"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"
"com.affine.page-starter-bar.edgeless": "Edgeless",
"Template": "Template",
"com.affine.settings.workspace.template.title": "My Templates",
"com.affine.settings.workspace.template.journal": "Template for journal",
"com.affine.settings.workspace.template.journal-desc": "Select a template for your journal",
"com.affine.settings.workspace.template.keep-empty": "Keep empty",
"com.affine.settings.workspace.template.page": "New doc with template",
"com.affine.settings.workspace.template.page-desc": "New docs will use the specified template, ignoring default settings.",
"com.affine.settings.workspace.template.page-select": "Template for new doc",
"com.affine.settings.workspace.template.remove": "Remove template"
}