mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-22 23:31:48 +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 { useActiveBlocksuiteEditor } from '@affine/core/components/hooks/use-block-suite-editor';
|
||||||
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
||||||
import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-state';
|
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 { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
|
||||||
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
|
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
|
||||||
import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper';
|
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 { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppTabs, PageHeader } from '../../../components';
|
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 * as styles from './mobile-detail-page.css';
|
||||||
import { PageHeaderMenuButton } from './page-header-more-button';
|
import { PageHeaderMenuButton } from './page-header-more-button';
|
||||||
import { PageHeaderShareButton } from './page-header-share-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. */}
|
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
|
||||||
<AffineErrorBoundary key={doc.id}>
|
<AffineErrorBoundary key={doc.id}>
|
||||||
{mode === 'page' && (
|
|
||||||
<JournalIconButton
|
|
||||||
docId={doc.id}
|
|
||||||
className={styles.journalIconButton}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<PageDetailEditor onLoad={onLoad} />
|
<PageDetailEditor onLoad={onLoad} />
|
||||||
</AffineErrorBoundary>
|
</AffineErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
@ -227,6 +222,17 @@ const JournalDetailPage = ({
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
date: string;
|
date: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
const journalService = useService(JournalService);
|
||||||
|
const { openJournal } = useJournalRouteHelper();
|
||||||
|
|
||||||
|
const allJournalDates = useLiveData(journalService.allJournalDates$);
|
||||||
|
|
||||||
|
const handleDateChange = useCallback(
|
||||||
|
(date: string) => {
|
||||||
|
openJournal(date);
|
||||||
|
},
|
||||||
|
[openJournal]
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<DetailPageWrapper
|
<DetailPageWrapper
|
||||||
@ -248,7 +254,11 @@ const JournalDetailPage = ({
|
|||||||
{i18nTime(dayjs(date), { absolute: { accuracy: 'month' } })}
|
{i18nTime(dayjs(date), { absolute: { accuracy: 'month' } })}
|
||||||
</span>
|
</span>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
{/* TODO(@CatsJuice): <JournalDatePicker /> */}
|
<JournalDatePicker
|
||||||
|
date={date}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
withDotDates={allJournalDates}
|
||||||
|
/>
|
||||||
<DetailPageImpl />
|
<DetailPageImpl />
|
||||||
<AppTabs background={cssVarV2('layer/background/primary')} />
|
<AppTabs background={cssVarV2('layer/background/primary')} />
|
||||||
</DetailPageWrapper>
|
</DetailPageWrapper>
|
||||||
|
Loading…
Reference in New Issue
Block a user