mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-22 18:11:32 +03:00
feat(mobile): new journal date-picker (#8757)
close AF-1649 <div class='graphite__hidden'> <div>🎥 Video uploaded on Graphite:</div> <a href="https://app.graphite.dev/media/video/LakojjjzZNf6ogjOVwKE/a83ca41a-75ac-4d12-959a-23f06740a76d.mp4"> <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/LakojjjzZNf6ogjOVwKE/a83ca41a-75ac-4d12-959a-23f06740a76d.mp4"> </a> </div> <video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/a83ca41a-75ac-4d12-959a-23f06740a76d.mp4">CleanShot 2024-11-09 at 12.54.41.mp4</video>
This commit is contained in:
parent
9239eed6a7
commit
50a04f6443
@ -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';
|
@ -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<string | null | undefined>;
|
||||
}>({
|
||||
width: window.innerWidth,
|
||||
cursor: '',
|
||||
setCursor: () => {},
|
||||
selected: '',
|
||||
onSelect: () => {},
|
||||
withDotDates: new Set(),
|
||||
});
|
@ -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%)',
|
||||
});
|
@ -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 (
|
||||
<div
|
||||
className={dayCell}
|
||||
data-is-today={isToday}
|
||||
data-is-selected={isSelected}
|
||||
data-is-current-month={isCurrentMonth}
|
||||
aria-label={label}
|
||||
onClick={() => onSelect(date)}
|
||||
>
|
||||
{day}
|
||||
{hasDot && <div className={dot} />}
|
||||
</div>
|
||||
);
|
||||
});
|
@ -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<string | null | undefined>;
|
||||
}
|
||||
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 (
|
||||
<JournalDatePickerContext.Provider
|
||||
value={{
|
||||
selected,
|
||||
onSelect,
|
||||
cursor,
|
||||
setCursor,
|
||||
width: window.innerWidth,
|
||||
withDotDates,
|
||||
}}
|
||||
>
|
||||
<ResizeViewport></ResizeViewport>
|
||||
</JournalDatePickerContext.Provider>
|
||||
);
|
||||
};
|
@ -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',
|
||||
});
|
@ -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<HTMLDivElement>(null);
|
||||
const swipeRef = useRef<HTMLDivElement>(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 (
|
||||
<div className={styles.monthViewClip} ref={rootRef}>
|
||||
<div
|
||||
ref={swipeRef}
|
||||
className={styles.monthsSwipe}
|
||||
style={{ transform: `translateX(${translateX}px)` }}
|
||||
>
|
||||
<MonthGrid
|
||||
hidden={!swiping && !animating}
|
||||
date={getFirstDayOfMonth(selected, -1)}
|
||||
/>
|
||||
{/* Active month */}
|
||||
<MonthGrid
|
||||
style={{ transform: `translateY(${translateY}px)` }}
|
||||
date={selected ?? ''}
|
||||
/>
|
||||
<MonthGrid
|
||||
hidden={!swiping && !animating}
|
||||
date={getFirstDayOfMonth(selected, 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MonthGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
date: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
const MonthGrid = ({ date, className, hidden, ...props }: MonthGridProps) => {
|
||||
const weeks = useMemo(
|
||||
() => getWeeks(date ?? dayjs().format(DATE_FORMAT)),
|
||||
[date]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.monthView, className)} {...props}>
|
||||
{hidden ? null : weeks.map(week => <WeekRow key={week} start={week} />)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
};
|
@ -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'),
|
||||
});
|
@ -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<HTMLDivElement>(null);
|
||||
const viewportRef = useRef<HTMLDivElement>(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 (
|
||||
<div className={styles.root}>
|
||||
<WeekHeader className={styles.weekRow} />
|
||||
<div ref={viewportRef} style={{ height: draggedHeight }}>
|
||||
{mode === 'month' || isDragging || isAnimating ? (
|
||||
<MonthView viewportHeight={draggedHeight} />
|
||||
) : (
|
||||
<WeekRowSwipe start={firstDayOfWeek} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.draggable} ref={draggableRef}>
|
||||
<div className={styles.draggableHandle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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',
|
||||
});
|
@ -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<HTMLDivElement> {
|
||||
start: string;
|
||||
}
|
||||
export const WeekHeader = memo(function WeekHeader({
|
||||
className,
|
||||
...attrs
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
const t = useI18n();
|
||||
|
||||
const days = useMemo(
|
||||
() => t['com.affine.calendar-date-picker.week-days']().split(','),
|
||||
[t]
|
||||
);
|
||||
return (
|
||||
<div className={clsx(styles.weekRow, className)} {...attrs}>
|
||||
{days.map(day => {
|
||||
return (
|
||||
<div className={styles.weekHeaderCell} key={day}>
|
||||
{day}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<div className={clsx(styles.weekRow, className)} {...attrs}>
|
||||
{days.map(day => (
|
||||
<DayCell date={day} key={day} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const WeekRowSwipe = ({ start }: WeekRowProps) => {
|
||||
const { width, onSelect, setCursor } = useContext(JournalDatePickerContext);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const swipeRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={rootRef} className={styles.weekSwipeRoot}>
|
||||
<div
|
||||
ref={swipeRef}
|
||||
className={styles.weekSwipeSlide}
|
||||
style={{ transform: `translateX(${translateX}px)` }}
|
||||
>
|
||||
{swiping || animating ? (
|
||||
<WeekRow
|
||||
className={styles.weekSwipeItem}
|
||||
start={getFirstDayOfWeek(start, -1)}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.weekSwipeItem} />
|
||||
)}
|
||||
<WeekRow className={styles.weekSwipeItem} start={start} />
|
||||
{swiping || animating ? (
|
||||
<WeekRow
|
||||
className={styles.weekSwipeItem}
|
||||
start={getFirstDayOfWeek(start, 1)}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.weekSwipeItem} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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 (
|
||||
<MobileMenu
|
||||
items={<EditorJournalPanel />}
|
||||
contentOptions={{
|
||||
align: 'center',
|
||||
}}
|
||||
>
|
||||
<IconButton className={className} size={24}>
|
||||
<Icon />
|
||||
</IconButton>
|
||||
</MobileMenu>
|
||||
);
|
||||
};
|
@ -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. */}
|
||||
<AffineErrorBoundary key={doc.id}>
|
||||
{mode === 'page' && (
|
||||
<JournalIconButton
|
||||
docId={doc.id}
|
||||
className={styles.journalIconButton}
|
||||
/>
|
||||
)}
|
||||
<PageDetailEditor onLoad={onLoad} />
|
||||
</AffineErrorBoundary>
|
||||
</div>
|
||||
@ -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 (
|
||||
<div className={styles.root}>
|
||||
<DetailPageWrapper
|
||||
@ -248,7 +254,11 @@ const JournalDetailPage = ({
|
||||
{i18nTime(dayjs(date), { absolute: { accuracy: 'month' } })}
|
||||
</span>
|
||||
</PageHeader>
|
||||
{/* TODO(@CatsJuice): <JournalDatePicker /> */}
|
||||
<JournalDatePicker
|
||||
date={date}
|
||||
onChange={handleDateChange}
|
||||
withDotDates={allJournalDates}
|
||||
/>
|
||||
<DetailPageImpl />
|
||||
<AppTabs background={cssVarV2('layer/background/primary')} />
|
||||
</DetailPageWrapper>
|
||||
|
Loading…
Reference in New Issue
Block a user