feat(core): sidebar journal panel template onboarding and setting (#9680)

close AF-2108
This commit is contained in:
CatsJuice
2025-01-15 11:21:18 +00:00
parent 0b2d11e6b1
commit d82951a7c5
8 changed files with 468 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -28,6 +28,8 @@ import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import * as styles from './journal.css';
import { JournalTemplateOnboarding } from './template-onboarding';
import { JournalTemplateSetting } from './template-setting';
/**
* @internal
@@ -156,12 +158,14 @@ export const EditorJournalPanel = () => {
cellSize={34}
/>
</div>
<JournalTemplateOnboarding />
{journalDate ? (
<>
<JournalConflictBlock date={journalDate} />
<JournalDailyCountBlock date={journalDate} />
</>
) : null}
<JournalTemplateSetting />
</div>
);
};

View File

@@ -36,9 +36,12 @@ const interactive = style({
});
export const calendar = style({
padding: '16px',
paddingBottom: 0,
marginBottom: 10,
selectors: {
'&[data-mobile=true]': {
padding: '8px 16px',
marginBottom: 0,
},
},
});

View File

@@ -0,0 +1,134 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { keyframes, style } from '@vanilla-extract/css';
type Timeline = {
duration: string;
delay: string;
easing: string;
fill?: 'forwards' | 'backwards' | 'both' | 'none';
keyframes: Parameters<typeof keyframes>[0];
};
/**
* Timeline for the onboarding animation
*/
export const timeline: Record<'container' | 'card' | 'paper', Timeline> = {
container: {
duration: '0.23s',
delay: '0s',
easing: 'ease-out',
keyframes: {
from: { height: 0 },
to: { height: 140 },
},
},
card: {
duration: '0.5s',
delay: '0.1s',
easing: 'cubic-bezier(0,1.35,.17,.96)',
fill: 'forwards',
keyframes: {
from: { transform: 'translateY(100%) scale(0.24)' },
to: { transform: 'translateY(0) scale(1)' },
},
},
paper: {
duration: '0.5s',
delay: '0.3s',
easing: 'cubic-bezier(0,1.06,0,1.09)',
fill: 'forwards',
keyframes: {
from: { transform: 'translate(100px, 100px) rotate(50deg)' },
to: { transform: 'translate(22px, 42px) rotate(-8.71deg)' },
},
},
};
const animation = (tl: Timeline) => ({
animationName: keyframes(tl.keyframes),
animationDuration: tl.duration,
animationTimingFunction: tl.easing,
animationDelay: tl.delay,
animationFillMode: tl.fill,
});
export const container = style({
paddingTop: 8,
paddingLeft: 16,
paddingRight: 16,
paddingBottom: 8,
marginBottom: 10,
width: '100%',
height: timeline.container.keyframes.to.height,
overflow: 'hidden',
...animation(timeline.container),
selectors: {
'&[data-animation-played="true"]': {
animation: 'none',
height: timeline.container.keyframes.to.height,
},
},
});
export const card = style({
padding: '12px 0px 14px 12px',
width: '100%',
height: 124,
borderRadius: 12,
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
backgroundColor: cssVarV2.layer.background.secondary,
overflow: 'hidden',
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
transform: timeline.card.keyframes.from.transform,
...animation(timeline.card),
selectors: {
'&[data-animation-played="true"]': {
animation: 'none',
transform: 'none',
},
},
});
export const title = style({
fontSize: 15,
lineHeight: '24px',
fontWeight: 600,
maxWidth: 'calc(100% - 115px)',
});
export const close = style({
position: 'absolute',
top: 12,
right: 12,
});
export const menu = style({
width: 280,
});
export const paper = style({
width: 124,
height: 124,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
right: 0,
bottom: 0,
transformOrigin: '0% 100%',
transform: timeline.paper.keyframes.from.transform,
...animation(timeline.paper),
selectors: {
'&[data-animation-played="true"]': {
animation: 'none',
transform: timeline.paper.keyframes.to.transform,
},
},
});

View File

@@ -0,0 +1,128 @@
import { Button, IconButton, Menu } from '@affine/component';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalStateService } from '@affine/core/modules/storage';
import { TemplateDocService } from '@affine/core/modules/template-doc';
import { TemplateListMenuContentScrollable } from '@affine/core/modules/template-doc/view/template-list-menu';
import { useI18n } from '@affine/i18n';
import { CloseIcon, TemplateIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { useTheme } from 'next-themes';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { journalPaperDark, journalPaperLight } from './assets';
import * as styles from './template-onboarding.css';
const dismissedKey = 'journal-template-onboarding-dismissed';
// to make sure the animation won't re-play until page is reloaded
let animationPlayed = false;
export const JournalTemplateOnboarding = () => {
const containerRef = useRef<HTMLDivElement>(null);
const globalState = useService(GlobalStateService).globalState;
const featureFlagService = useService(FeatureFlagService);
const templateDocService = useService(TemplateDocService);
const enableTemplate = useLiveData(
featureFlagService.flags.enable_template_doc.$
);
const t = useI18n();
const dismissed = useLiveData(
useMemo(
() => LiveData.from(globalState.watch(dismissedKey), false),
[globalState]
)
);
const onDismiss = useCallback(() => {
const container = containerRef.current;
if (!container) {
globalState.set(dismissedKey, true);
return;
}
const animation = container.animate(
[
{},
{
height: 0,
paddingTop: 0,
paddingBottom: 0,
marginBottom: 0,
opacity: 0,
},
],
{ duration: 280, easing: 'cubic-bezier(.35,.58,.01,1)', fill: 'forwards' }
);
animation.onfinish = () => {
globalState.set(dismissedKey, true);
};
}, [globalState]);
const updateJournalTemplate = useCallback(
(templateId: string) => {
templateDocService.setting.updateJournalTemplateDocId(templateId);
},
[templateDocService.setting]
);
if (dismissed || !enableTemplate) return null;
return (
<div
className={styles.container}
data-animation-played={animationPlayed}
ref={containerRef}
>
<div className={styles.card} data-animation-played={animationPlayed}>
<p className={styles.title}>
{t['com.affine.template-journal-onboarding.title']()}
</p>
<Menu
contentOptions={{ className: styles.menu, align: 'end' }}
items={
<TemplateListMenuContentScrollable
onSelect={updateJournalTemplate}
/>
}
>
<Button variant="primary" prefix={<TemplateIcon />}>
{t['com.affine.template-journal-onboarding.select']()}
</Button>
</Menu>
<JournalPaper />
<IconButton
size="16"
className={styles.close}
icon={<CloseIcon />}
onClick={onDismiss}
/>
</div>
</div>
);
};
const JournalPaper = memo(function JournalPaper() {
const ref = useRef<HTMLDivElement>(null);
const { resolvedTheme } = useTheme();
useEffect(() => {
const paper = ref.current;
if (!paper) return;
const onAnimationEnd = () => (animationPlayed = true);
paper.addEventListener('animationend', onAnimationEnd, { once: true });
return () => {
paper.removeEventListener('animationend', onAnimationEnd);
};
}, []);
return (
<div
ref={ref}
className={styles.paper}
data-animation-played={animationPlayed}
dangerouslySetInnerHTML={{
__html: resolvedTheme === 'dark' ? journalPaperDark : journalPaperLight,
}}
/>
);
});

View File

@@ -0,0 +1,17 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const container = style({
width: '100%',
padding: '8px 16px',
borderTop: `0.5px solid ${cssVarV2.layer.insideBorder.border}`,
});
export const trigger = style({
padding: '2px 4px',
borderRadius: 4,
});
export const menu = style({
width: 280,
});

View File

@@ -0,0 +1,51 @@
import { Button, Menu } from '@affine/component';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { TemplateDocService } from '@affine/core/modules/template-doc';
import { TemplateListMenuContentScrollable } from '@affine/core/modules/template-doc/view/template-list-menu';
import { TemplateIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import * as styles from './template-setting.css';
export const JournalTemplateSetting = () => {
const templateDocService = useService(TemplateDocService);
const docDisplayService = useService(DocDisplayMetaService);
const journalTemplateDocId = useLiveData(
templateDocService.setting.journalTemplateDocId$
);
const title = useLiveData(
useMemo(() => {
return journalTemplateDocId
? docDisplayService.title$(journalTemplateDocId)
: null;
}, [docDisplayService, journalTemplateDocId])
);
const updateJournalTemplate = useCallback(
(templateId: string) => {
templateDocService.setting.updateJournalTemplateDocId(templateId);
},
[templateDocService.setting]
);
return (
<div className={styles.container}>
<Menu
contentOptions={{ className: styles.menu }}
items={
<TemplateListMenuContentScrollable onSelect={updateJournalTemplate} />
}
>
<Button
variant="plain"
prefix={<TemplateIcon />}
className={styles.trigger}
>
{title}
</Button>
</Menu>
</div>
);
};