From 50a04f64432828dbc6cf82304e408f1abece4cb2 Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Mon, 11 Nov 2024 09:31:30 +0000 Subject: [PATCH] feat(mobile): new journal date-picker (#8757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close AF-1649
🎥 Video uploaded on Graphite:
--- .../detail/journal-date-picker/constants.ts | 11 ++ .../detail/journal-date-picker/context.ts | 21 +++ .../journal-date-picker/day-cell.css.ts | 40 +++++ .../detail/journal-date-picker/day-cell.tsx | 39 +++++ .../detail/journal-date-picker/index.tsx | 45 +++++ .../detail/journal-date-picker/month.css.ts | 24 +++ .../detail/journal-date-picker/month.tsx | 160 ++++++++++++++++++ .../journal-date-picker/swipe-helper.ts | 133 +++++++++++++++ .../detail/journal-date-picker/utils.ts | 10 ++ .../journal-date-picker/viewport.css.ts | 24 +++ .../detail/journal-date-picker/viewport.tsx | 115 +++++++++++++ .../detail/journal-date-picker/week.css.ts | 31 ++++ .../detail/journal-date-picker/week.tsx | 157 +++++++++++++++++ .../workspace/detail/journal-icon-button.tsx | 39 ----- .../workspace/detail/mobile-detail-page.tsx | 26 ++- 15 files changed, 828 insertions(+), 47 deletions(-) create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/constants.ts create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/context.ts create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/day-cell.css.ts create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/day-cell.tsx create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/index.tsx create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.css.ts create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.tsx create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/swipe-helper.ts create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/utils.ts create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.css.ts create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.tsx create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.css.ts create mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.tsx delete mode 100644 packages/frontend/core/src/mobile/pages/workspace/detail/journal-icon-button.tsx diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/constants.ts b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/constants.ts new file mode 100644 index 000000000..cefc5df7a --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/constants.ts @@ -0,0 +1,11 @@ +export const CELL_HEIGHT = 34; +export const TOTAL_WEEKS = 6; +export const ROWS_GAP = 4; + +export const MONTH_VIEW_HEIGHT = + TOTAL_WEEKS * CELL_HEIGHT + (TOTAL_WEEKS - 1) * ROWS_GAP; +export const WEEK_VIEW_HEIGHT = CELL_HEIGHT; + +export const HORIZONTAL_SWIPE_THRESHOLD = 4 * CELL_HEIGHT; + +export const DATE_FORMAT = 'YYYY-MM-DD'; diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/context.ts b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/context.ts new file mode 100644 index 000000000..7642b1fc9 --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/context.ts @@ -0,0 +1,21 @@ +import { createContext } from 'react'; + +export const JournalDatePickerContext = createContext<{ + width: number; + /** + * Is used to determine the current date, not same as selected, + * `is-current-month` is based on cursor + */ + cursor: string; + setCursor: (date: string) => void; + selected: string; + onSelect: (date: string) => void; + withDotDates: Set; +}>({ + width: window.innerWidth, + cursor: '', + setCursor: () => {}, + selected: '', + onSelect: () => {}, + withDotDates: new Set(), +}); diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/day-cell.css.ts b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/day-cell.css.ts new file mode 100644 index 000000000..0267cfa15 --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/day-cell.css.ts @@ -0,0 +1,40 @@ +import { bodyRegular } from '@toeverything/theme/typography'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const dayCell = style([ + bodyRegular, + { + position: 'relative', + height: 34, + minWidth: 34, + padding: 4, + borderRadius: 8, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + + selectors: { + '&[data-is-today=true]': {}, + '&[data-is-current-month=false]': { + color: cssVarV2('text/disable'), + }, + '&[data-is-selected=true]': { + background: cssVarV2('button/primary'), + color: cssVarV2('button/pureWhiteText'), + fontWeight: 600, + }, + }, + }, +]); + +export const dot = style({ + position: 'absolute', + width: 3, + height: 3, + borderRadius: 3, + background: cssVarV2('button/primary'), + bottom: 3, + left: '50%', + transform: 'translateX(-50%)', +}); diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/day-cell.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/day-cell.tsx new file mode 100644 index 000000000..690be63c4 --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/day-cell.tsx @@ -0,0 +1,39 @@ +import dayjs from 'dayjs'; +import { memo, useContext, useMemo } from 'react'; + +import { DATE_FORMAT } from './constants'; +import { JournalDatePickerContext } from './context'; +import { dayCell, dot } from './day-cell.css'; + +export interface DayCellProps { + date: string; +} + +export const DayCell = memo(function DayCell({ date }: DayCellProps) { + const { selected, onSelect, cursor, withDotDates } = useContext( + JournalDatePickerContext + ); + + const dayjsObj = useMemo(() => dayjs(date), [date]); + + const isToday = dayjsObj.isSame(dayjs(), 'day'); + const isSelected = dayjsObj.isSame(dayjs(selected), 'day'); + const isCurrentMonth = dayjsObj.isSame(dayjs(cursor), 'month'); + const day = dayjsObj.get('date'); + const label = dayjsObj.format(DATE_FORMAT); + const hasDot = withDotDates.has(date); + + return ( +
onSelect(date)} + > + {day} + {hasDot &&
} +
+ ); +}); diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/index.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/index.tsx new file mode 100644 index 000000000..131339abc --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/index.tsx @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { JournalDatePickerContext } from './context'; +import { ResizeViewport } from './viewport'; + +export interface JournalDatePickerProps { + date: string; + onChange: (date: string) => void; + withDotDates: Set; +} +export const JournalDatePicker = ({ + date: selected, + onChange, + withDotDates, +}: JournalDatePickerProps) => { + const [cursor, setCursor] = useState(selected); + + // should update cursor when selected modified outside + useEffect(() => { + setCursor(selected); + }, [selected]); + + const onSelect = useCallback( + (date: string) => { + setCursor(date); + onChange(date); + }, + [onChange] + ); + + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.css.ts b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.css.ts new file mode 100644 index 000000000..ab95ac695 --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.css.ts @@ -0,0 +1,24 @@ +import { style } from '@vanilla-extract/css'; + +export const monthViewClip = style({ + width: '100%', + height: '100%', + overflow: 'hidden', +}); + +export const monthsSwipe = style({ + width: '300%', + height: '100%', + marginLeft: '-100%', + display: 'flex', + justifyContent: 'center', +}); + +export const monthView = style({ + width: 0, + flex: 1, + display: 'flex', + flexDirection: 'column', + gap: 4, + padding: '0 16px', +}); diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.tsx new file mode 100644 index 000000000..3a4a86161 --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.tsx @@ -0,0 +1,160 @@ +import anime from 'animejs'; +import clsx from 'clsx'; +import dayjs from 'dayjs'; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { + CELL_HEIGHT, + DATE_FORMAT, + HORIZONTAL_SWIPE_THRESHOLD, + MONTH_VIEW_HEIGHT, + ROWS_GAP, + WEEK_VIEW_HEIGHT, +} from './constants'; +import { JournalDatePickerContext } from './context'; +import * as styles from './month.css'; +import { SwipeHelper } from './swipe-helper'; +import { getFirstDayOfMonth } from './utils'; +import { WeekRow } from './week'; +export interface MonthViewProps { + viewportHeight: number; +} + +function getWeeks(date: string) { + const today = dayjs(date); + const firstDayOfMonth = today.startOf('month'); + const firstWeekday = firstDayOfMonth.startOf('week'); + + const weeks = []; + for (let i = 0; i < 6; i++) { + const week = firstWeekday.add(i * 7, 'day'); + weeks.push(week.format(DATE_FORMAT)); + } + + return weeks; +} + +export const MonthView = ({ viewportHeight }: MonthViewProps) => { + const rootRef = useRef(null); + const swipeRef = useRef(null); + const { width, selected, onSelect, setCursor } = useContext( + JournalDatePickerContext + ); + const [swiping, setSwiping] = useState(false); + const [animating, setAnimating] = useState(false); + const [swipingDeltaX, setSwipingDeltaX] = useState(0); + + const weeks = useMemo( + () => getWeeks(selected ?? dayjs().format(DATE_FORMAT)), + [selected] + ); + + const firstWeekDayOfSelected = dayjs(selected) + .startOf('week') + .format(DATE_FORMAT); + const activeRowIndex = weeks.indexOf(firstWeekDayOfSelected); + + // pointA: (WEEK_VIEW_HEIGHT, maxY) + // pointB: (MONTH_VIEW_HEIGHT, 0) + const maxY = -(activeRowIndex * (CELL_HEIGHT + ROWS_GAP)); + const k = maxY / (WEEK_VIEW_HEIGHT - MONTH_VIEW_HEIGHT); + const b = -k * MONTH_VIEW_HEIGHT; + const translateY = k * viewportHeight + b; + + const translateX = Math.max(-width, Math.min(width, swipingDeltaX)); + + const animateTo = useCallback( + (dir: 0 | 1 | -1) => { + setAnimating(true); + + anime({ + targets: swipeRef.current, + translateX: -dir * width, + duration: 300, + easing: 'easeInOutSine', + complete: () => { + setSwipingDeltaX(0); + setAnimating(false); + // should recover swipe before change month + if (dir !== 0) { + setTimeout(() => onSelect(getFirstDayOfMonth(selected, dir))); + } + }, + }); + }, + [onSelect, selected, width] + ); + + useEffect(() => { + if (!rootRef.current) return; + const swipeHelper = new SwipeHelper(); + return swipeHelper.init(rootRef.current, { + preventScroll: true, + onSwipe: ({ deltaX }) => { + setSwiping(true); + setSwipingDeltaX(deltaX); + if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD) { + setCursor(getFirstDayOfMonth(selected, deltaX > 0 ? -1 : 1)); + } else { + setCursor(selected); + } + }, + onSwipeEnd: ({ deltaX }) => { + setSwiping(false); + if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD) { + animateTo(deltaX > 0 ? -1 : 1); + } else { + animateTo(0); + } + }, + }); + }, [animateTo, selected, setCursor]); + + return ( +
+
+
+
+ ); +}; + +interface MonthGridProps extends React.HTMLAttributes { + date: string; + hidden?: boolean; +} +const MonthGrid = ({ date, className, hidden, ...props }: MonthGridProps) => { + const weeks = useMemo( + () => getWeeks(date ?? dayjs().format(DATE_FORMAT)), + [date] + ); + + return ( +
+ {hidden ? null : weeks.map(week => )} +
+ ); +}; diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/swipe-helper.ts b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/swipe-helper.ts new file mode 100644 index 000000000..2946ee138 --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/swipe-helper.ts @@ -0,0 +1,133 @@ +export interface SwipeInfo { + e: TouchEvent; + startX: number; + startY: number; + endX: number; + endY: number; + deltaX: number; + deltaY: number; + isFirst: boolean; +} + +export interface SwipeHelperOptions { + scope?: 'global' | 'inside'; + preventScroll?: boolean; + onTap?: () => void; + onSwipeStart?: () => void; + onSwipe?: (info: SwipeInfo) => void; + onSwipeEnd?: (info: SwipeInfo) => void; +} + +export class SwipeHelper { + private _trigger: HTMLElement | null = null; + private _options: SwipeHelperOptions = { + scope: 'global', + }; + private _startPos: { x: number; y: number } = { x: 0, y: 0 }; + private _isFirst: boolean = true; + private _lastInfo: SwipeInfo | null = null; + + get scopeElement() { + return this._options.scope === 'inside' + ? (this._trigger ?? document.body) + : document.body; + } + + private _dragStartCleanup: () => void = () => {}; + private _dragMoveCleanup: () => void = () => {}; + private _dragEndCleanup: () => void = () => {}; + + /** + * Register touch event to observe drag gesture + */ + public init(trigger: HTMLElement, options?: SwipeHelperOptions) { + this.destroy(); + this._options = { ...this._options, ...options }; + this._trigger = trigger; + const handler = this._handleTouchStart.bind(this); + trigger.addEventListener('touchstart', handler, { + passive: !this._options.preventScroll, + }); + this._dragStartCleanup = () => { + trigger.removeEventListener('touchstart', handler); + }; + + return () => this.destroy(); + } + + /** + * Remove all listeners + */ + public destroy() { + this._dragStartCleanup(); + this._clearDrag(); + } + + private _handleTouchStart(e: TouchEvent) { + const touch = e.touches[0]; + this._startPos = { + x: touch.clientX, + y: touch.clientY, + }; + this._options.onSwipeStart?.(); + const moveHandler = this._handleTouchMove.bind(this); + this.scopeElement.addEventListener('touchmove', moveHandler, { + passive: !this._options.preventScroll, + }); + const endHandler = this._handleTouchEnd.bind(this); + this.scopeElement.addEventListener('touchend', endHandler, { + passive: !this._options.preventScroll, + }); + this._dragMoveCleanup = () => { + this.scopeElement.removeEventListener('touchmove', moveHandler); + }; + this._dragEndCleanup = () => { + this.scopeElement.removeEventListener('touchend', endHandler); + }; + } + + private _handleTouchMove(e: TouchEvent) { + if (this._options.preventScroll) { + e.preventDefault(); + } + const info = this._getInfo(e); + this._lastInfo = info; + this._isFirst = false; + this._options.onSwipe?.(info); + } + + private _handleTouchEnd() { + if ( + !this._lastInfo || + (Math.abs(this._lastInfo.deltaY) < 1 && + Math.abs(this._lastInfo.deltaX) < 1) + ) { + this._options.onTap?.(); + } else { + this._options.onSwipeEnd?.(this._lastInfo); + } + this._clearDrag(); + } + + private _getInfo(e: TouchEvent): SwipeInfo { + const touch = e.touches[0]; + const deltaX = touch.clientX - this._startPos.x; + const deltaY = touch.clientY - this._startPos.y; + return { + e, + startX: this._startPos.x, + startY: this._startPos.y, + endX: touch.clientX, + endY: touch.clientY, + deltaX, + deltaY, + isFirst: this._isFirst, + }; + } + + private _clearDrag() { + this._lastInfo = null; + this._dragMoveCleanup(); + this._dragEndCleanup(); + } +} diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/utils.ts b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/utils.ts new file mode 100644 index 000000000..f1702178b --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/utils.ts @@ -0,0 +1,10 @@ +import dayjs from 'dayjs'; + +import { DATE_FORMAT } from './constants'; + +export const getFirstDayOfMonth = (date: string, offset: number) => { + return dayjs(date).add(offset, 'month').startOf('month').format(DATE_FORMAT); +}; +export const getFirstDayOfWeek = (date: string, offset: number) => { + return dayjs(date).add(offset, 'week').startOf('week').format(DATE_FORMAT); +}; diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.css.ts b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.css.ts new file mode 100644 index 000000000..2cc344d00 --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.css.ts @@ -0,0 +1,24 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const root = style({ + width: '100%', + borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`, +}); + +export const weekRow = style({ + padding: '0 16px', +}); +export const draggable = style({ + width: '100%', + padding: '10px 0', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); +export const draggableHandle = style({ + width: 36, + height: 5, + borderRadius: 5, + background: cssVarV2('block/notSupportedBlock/inlineBg/hover'), +}); diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.tsx new file mode 100644 index 000000000..7a0fb7f4c --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.tsx @@ -0,0 +1,115 @@ +import anime from 'animejs'; +import dayjs from 'dayjs'; +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; + +import { + CELL_HEIGHT, + DATE_FORMAT, + MONTH_VIEW_HEIGHT, + WEEK_VIEW_HEIGHT, +} from './constants'; +import { JournalDatePickerContext } from './context'; +import { MonthView } from './month'; +import { SwipeHelper } from './swipe-helper'; +import * as styles from './viewport.css'; +import { WeekHeader, WeekRowSwipe } from './week'; + +export const ResizeViewport = () => { + const { selected } = useContext(JournalDatePickerContext); + const draggableRef = useRef(null); + const viewportRef = useRef(null); + const [mode, setMode] = useState<'week' | 'month'>('week'); + const [dragOffset, setDragOffset] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + + const firstDayOfWeek = dayjs(selected).startOf('week').format(DATE_FORMAT); + const staticHeight = mode === 'week' ? WEEK_VIEW_HEIGHT : MONTH_VIEW_HEIGHT; + const draggedHeight = Math.max( + WEEK_VIEW_HEIGHT, + Math.min(MONTH_VIEW_HEIGHT, staticHeight + dragOffset) + ); + + const handleToggleModeWithAnimation = useCallback( + ( + targetMode: 'week' | 'month', + draggedDistance: number, + isCancel = false + ) => { + const targetDragOffset = isCancel + ? 0 + : targetMode === 'week' + ? -(MONTH_VIEW_HEIGHT - WEEK_VIEW_HEIGHT) + : MONTH_VIEW_HEIGHT - WEEK_VIEW_HEIGHT; + + const dragOffsetProxy = new Proxy<{ value: number }>( + { value: draggedDistance }, + { + set(target, key, value) { + if (key !== 'value') return false; + setDragOffset(value); + target.value = value; + return true; + }, + } + ); + + setIsAnimating(true); + anime({ + targets: dragOffsetProxy, + value: targetDragOffset, + duration: 300, + easing: 'easeOutCubic', + complete: () => { + setMode(targetMode); + setDragOffset(0); + setIsDragging(false); + setIsAnimating(false); + }, + }); + }, + [] + ); + + useEffect(() => { + if (!draggableRef.current) return; + + const swipeHelper = new SwipeHelper(); + return swipeHelper.init(draggableRef.current, { + preventScroll: true, + onTap() { + handleToggleModeWithAnimation(mode === 'week' ? 'month' : 'week', 0); + }, + onSwipe: ({ deltaY }) => { + setDragOffset(deltaY); + setIsDragging(true); + }, + onSwipeEnd: ({ deltaY }) => { + if (Math.abs(deltaY) > 2 * CELL_HEIGHT) { + handleToggleModeWithAnimation( + mode === 'week' ? 'month' : 'week', + deltaY + ); + } else { + handleToggleModeWithAnimation(mode, deltaY, true); + } + }, + }); + }, [handleToggleModeWithAnimation, mode]); + + return ( +
+ +
+ {mode === 'month' || isDragging || isAnimating ? ( + + ) : ( + + )} +
+
+
+
+
+ ); +}; diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.css.ts b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.css.ts new file mode 100644 index 000000000..3693c7880 --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.css.ts @@ -0,0 +1,31 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +import { dayCell } from './day-cell.css'; + +export const weekRow = style({ + display: 'flex', + gap: 4, + justifyContent: 'space-between', +}); +export const weekHeaderCell = style([ + dayCell, + { + color: cssVarV2('text/secondary'), + }, +]); + +export const weekSwipeRoot = style({ + width: '100%', + overflow: 'hidden', +}); +export const weekSwipeSlide = style({ + width: '300%', + marginLeft: '-100%', + display: 'flex', +}); +export const weekSwipeItem = style({ + width: 0, + flex: 1, + padding: '0 16px', +}); diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.tsx new file mode 100644 index 000000000..63f277fd0 --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.tsx @@ -0,0 +1,157 @@ +import { useI18n } from '@affine/i18n'; +import anime from 'animejs'; +import clsx from 'clsx'; +import dayjs from 'dayjs'; +import { + type HTMLAttributes, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { DATE_FORMAT, HORIZONTAL_SWIPE_THRESHOLD } from './constants'; +import { JournalDatePickerContext } from './context'; +import { DayCell } from './day-cell'; +import { SwipeHelper } from './swipe-helper'; +import { getFirstDayOfWeek } from './utils'; +import * as styles from './week.css'; + +export interface WeekRowProps extends HTMLAttributes { + start: string; +} +export const WeekHeader = memo(function WeekHeader({ + className, + ...attrs +}: HTMLAttributes) { + const t = useI18n(); + + const days = useMemo( + () => t['com.affine.calendar-date-picker.week-days']().split(','), + [t] + ); + return ( +
+ {days.map(day => { + return ( +
+ {day} +
+ ); + })} +
+ ); +}); + +export const WeekRow = memo(function WeekRow({ + start, + className, + ...attrs +}: WeekRowProps) { + const days = useMemo(() => { + const days = []; + for (let i = 0; i < 7; i++) { + days.push(dayjs(start).add(i, 'day').format(DATE_FORMAT)); + } + return days; + }, [start]); + + return ( +
+ {days.map(day => ( + + ))} +
+ ); +}); + +export const WeekRowSwipe = ({ start }: WeekRowProps) => { + const { width, onSelect, setCursor } = useContext(JournalDatePickerContext); + const rootRef = useRef(null); + const swipeRef = useRef(null); + const [swiping, setSwiping] = useState(false); + const [swipingDeltaX, setSwipingDeltaX] = useState(0); + const [animating, setAnimating] = useState(false); + + const translateX = Math.max(-width, Math.min(width, swipingDeltaX)); + + const animateTo = useCallback( + (dir: 0 | 1 | -1) => { + setAnimating(true); + + anime({ + targets: swipeRef.current, + translateX: -dir * width, + easing: 'easeInOutSine', + duration: 300, + complete: () => { + setSwipingDeltaX(0); + setAnimating(false); + if (dir !== 0) { + setTimeout(() => onSelect(getFirstDayOfWeek(start, dir))); + } + }, + }); + }, + [onSelect, start, width] + ); + + useEffect(() => { + if (!rootRef.current) return; + + const swipeHelper = new SwipeHelper(); + return swipeHelper.init(rootRef.current, { + preventScroll: true, + onSwipe({ deltaX }) { + setSwiping(true); + setSwipingDeltaX(deltaX); + + if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD) { + setCursor(getFirstDayOfWeek(start, deltaX > 0 ? -1 : 1)); + } else { + setCursor(start); + } + }, + onSwipeEnd({ deltaX }) { + setSwiping(false); + + if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD) { + animateTo(deltaX > 0 ? -1 : 1); + } else { + animateTo(0); + } + }, + }); + }, [animateTo, setCursor, start]); + + return ( +
+
+ {swiping || animating ? ( + + ) : ( +
+ )} + + {swiping || animating ? ( + + ) : ( +
+ )} +
+
+ ); +}; diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-icon-button.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-icon-button.tsx deleted file mode 100644 index f9c7bcd73..000000000 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-icon-button.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { IconButton, MobileMenu } from '@affine/component'; -import { useJournalInfoHelper } from '@affine/core/components/hooks/use-journal'; -import { EditorJournalPanel } from '@affine/core/desktop/pages/workspace/detail-page/tabs/journal'; -import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; -import { useLiveData, useService } from '@toeverything/infra'; - -export const JournalIconButton = ({ - docId, - className, -}: { - docId: string; - className?: string; -}) => { - const { isJournal } = useJournalInfoHelper(docId); - - const docDisplayMetaService = useService(DocDisplayMetaService); - const Icon = useLiveData( - docDisplayMetaService.icon$(docId, { - compareDate: new Date(), - }) - ); - - if (!isJournal) { - return null; - } - - return ( - } - contentOptions={{ - align: 'center', - }} - > - - - - - ); -}; diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx index 17f888f58..cceb3357c 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx @@ -5,6 +5,7 @@ import { useRegisterBlocksuiteEditorCommands } from '@affine/core/components/hoo import { useActiveBlocksuiteEditor } from '@affine/core/components/hooks/use-block-suite-editor'; import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta'; import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-state'; +import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal'; import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; import { PageDetailEditor } from '@affine/core/components/page-detail-editor'; import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper'; @@ -42,7 +43,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { AppTabs, PageHeader } from '../../../components'; -import { JournalIconButton } from './journal-icon-button'; +import { JournalDatePicker } from './journal-date-picker'; import * as styles from './mobile-detail-page.css'; import { PageHeaderMenuButton } from './page-header-more-button'; import { PageHeaderShareButton } from './page-header-share-button'; @@ -192,12 +193,6 @@ const DetailPageImpl = () => { > {/* Add a key to force rerender when page changed, to avoid error boundary persisting. */} - {mode === 'page' && ( - - )}
@@ -227,6 +222,17 @@ const JournalDetailPage = ({ pageId: string; date: string; }) => { + const journalService = useService(JournalService); + const { openJournal } = useJournalRouteHelper(); + + const allJournalDates = useLiveData(journalService.allJournalDates$); + + const handleDateChange = useCallback( + (date: string) => { + openJournal(date); + }, + [openJournal] + ); return (
- {/* TODO(@CatsJuice): */} +