refactor: 전체 뷰 레이아웃 개편

- MUI를 사용하여 tabs를 구현
- 레이아웃이 깨지거나 침범하는 부분들을 깔끔하게 정리
This commit is contained in:
sechan100 2024-11-10 12:23:51 +09:00
parent ed79579018
commit 2fefe2ed9e
22 changed files with 615 additions and 334 deletions

View file

@ -107,7 +107,8 @@ async function copyPluginFilesTo_iCloud() {
];
const destinationDir = '/Users/sechan/Library/Mobile Documents/iCloud~md~obsidian/Documents/main/.obsidian/plugins/daily-routine-2/';
// const destinationDir = '/Users/sechan/local/obsidian/plugin-dev';
// const destinationDir = '/Users/sechan/Library/Mobile Documents/iCloud~md~obsidian/Documents/cloud-test/.obsidian/plugins/daily-routine-2/';
// 현재 파일의 URL을 파일 경로로 변환
const __filename = fileURLToPath(import.meta.url);

250
package-lock.json generated
View file

@ -11,6 +11,7 @@
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/material": "^6.1.6",
"@popperjs/core": "^2.11.8",
"clsx": "^2.1.1",
"esbuild-sass-plugin": "^3.3.1",
@ -194,9 +195,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.25.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz",
"integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==",
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@ -935,6 +936,213 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mui/core-downloads-tracker": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.6.tgz",
"integrity": "sha512-nz1SlR9TdBYYPz4qKoNasMPRiGb4PaIHFkzLzhju0YVYS5QSuFF2+n7CsiHMIDcHv3piPu/xDWI53ruhOqvZwQ==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
}
},
"node_modules/@mui/material": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.6.tgz",
"integrity": "sha512-1yvejiQ/601l5AK3uIdUlAVElyCxoqKnl7QA+2oFB/2qYPWfRwDgavW/MoywS5Y2gZEslcJKhe0s2F3IthgFgw==",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/core-downloads-tracker": "^6.1.6",
"@mui/system": "^6.1.6",
"@mui/types": "^7.2.19",
"@mui/utils": "^6.1.6",
"@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.11",
"clsx": "^2.1.1",
"csstype": "^3.1.3",
"prop-types": "^15.8.1",
"react-is": "^18.3.1",
"react-transition-group": "^4.4.5"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@mui/material-pigment-css": "^6.1.6",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"@mui/material-pigment-css": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/material/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/@mui/private-theming": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.6.tgz",
"integrity": "sha512-ioAiFckaD/fJSnTrUMWgjl9HYBWt7ixCh7zZw7gDZ+Tae7NuprNV6QJK95EidDT7K0GetR2rU3kAeIR61Myttw==",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/utils": "^6.1.6",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/styled-engine": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.6.tgz",
"integrity": "sha512-I+yS1cSuSvHnZDBO7e7VHxTWpj+R7XlSZvTC4lS/OIbUNJOMMSd3UDP6V2sfwzAdmdDNBi7NGCRv2SZ6O9hGDA==",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@emotion/cache": "^11.13.1",
"@emotion/serialize": "^1.3.2",
"@emotion/sheet": "^1.4.0",
"csstype": "^3.1.3",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
}
}
},
"node_modules/@mui/system": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.6.tgz",
"integrity": "sha512-qOf1VUE9wK8syiB0BBCp82oNBAVPYdj4Trh+G1s+L+ImYiKlubWhhqlnvWt3xqMevR+D2h1CXzA1vhX2FvA+VQ==",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/private-theming": "^6.1.6",
"@mui/styled-engine": "^6.1.6",
"@mui/types": "^7.2.19",
"@mui/utils": "^6.1.6",
"clsx": "^2.1.1",
"csstype": "^3.1.3",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/types": {
"version": "7.2.19",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.19.tgz",
"integrity": "sha512-6XpZEM/Q3epK9RN8ENoXuygnqUQxE+siN/6rGRi2iwJPgBUR25mphYQ9ZI87plGh58YoZ5pp40bFvKYOCDJ3tA==",
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/utils": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.6.tgz",
"integrity": "sha512-sBS6D9mJECtELASLM+18WUcXF6RH3zNxBRFeyCRg8wad6NbyNrdxLuwK+Ikvc38sTZwBzAz691HmSofLqHd9sQ==",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/types": "^7.2.19",
"@types/prop-types": "^15.7.13",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^18.3.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/utils/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -1337,14 +1545,12 @@
"node_modules/@types/prop-types": {
"version": "15.7.13",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
"devOptional": true
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA=="
},
"node_modules/@types/react": {
"version": "18.3.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz",
"integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==",
"devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@ -1359,6 +1565,14 @@
"@types/react": "*"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.11",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz",
"integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/responselike": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
@ -2424,6 +2638,15 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dot-prop": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.1.tgz",
@ -7515,6 +7738,21 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/readdirp": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",

View file

@ -29,6 +29,7 @@
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/material": "^6.1.6",
"@popperjs/core": "^2.11.8",
"clsx": "^2.1.1",
"esbuild-sass-plugin": "^3.3.1",

View file

@ -1,18 +1,31 @@
/** @jsxImportSource @emotion/react */
import { css } from "@emotion/react";
import { RoutineNote } from 'pages/routine-note';
import { RoutineCalendar } from "pages/calendar";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { Day } from "shared/day";
import { usePageRoute } from "./use-page-route";
import "./style.css";
import { DailyRoutineObsidianView } from './obsidian-view';
import { useLeaf } from 'shared/view/react-view';
import { Button } from 'shared/components/Button';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import { MUIThemeProvider } from './MUIThemProvider';
import { Box } from '@mui/material';
import { Icon } from "shared/components/Icon";
const pageNavbarHeight = 50;
const tabsHeight = "50px";
const tabsBottomGap = "25px";
const tabCss = css({
boxShadow: "none !important",
backgroundColor: "transparent !important",
minHeight: "0 !important",
fontSize: "0.7em",
})
// FIXME: setPage로 렌더링 1번, cmd를 적용해서 다시 렌더링까지 총 2번의 렌더링이 발생한다. 추후 여건되면 최적화 가능
@ -24,6 +37,9 @@ export const DailyRoutineView = () => {
}, [routePage]);
const { view } = useLeaf<DailyRoutineObsidianView>();
useEffect(() => {
view.contentEl.style.padding = "0";
}, [view]);
const viewContentRealHeight = useMemo(() => {
const viewContainer = view.contentEl;
@ -36,56 +52,35 @@ export const DailyRoutineView = () => {
return (
<div
className='dr-page'
style={{
scrollbarWidth: "none",
height: (viewContentRealHeight - pageNavbarHeight - 1), // 1은 offset
}}
>
{page === "note" && <RoutineNote day={routedDay} />}
{page === "calendar" && <RoutineCalendar onDayTileClick={onDayTileClick} defaultDay={routedDay} />}
<div
<MUIThemeProvider>
<div
className="dr-page"
css={{
display: "flex",
justifyContent: "space-between",
alignItems: "stretch",
position: "fixed",
width: "100%",
height: pageNavbarHeight,
left: "0",
bottom: "0",
height: `calc(100% - ${tabsHeight} - ${tabsBottomGap})`,
}}
>
<ViewNavItem active={page==="note"} onClick={() => routePage("note", Day.now())}>Routine Note</ViewNavItem>
<ViewNavItem active={page==="calendar"} onClick={() => routePage("calendar", Day.now())}>Calendar</ViewNavItem>
<ViewNavItem active={page==="achivement"} onClick={() => routePage("achivement", Day.now())}>Achivement</ViewNavItem>
{page === "note" && <RoutineNote day={routedDay} />}
{page === "calendar" && <RoutineCalendar onDayTileClick={onDayTileClick} defaultDay={routedDay} />}
</div>
</div>
<Tabs
value={page}
className="dr-tabs"
scrollButtons={false}
centered
sx={{ borderTop: 1, borderColor: 'divider' }}
onChange={(e, value) => routePage(value, Day.now())}
css={{
position: "fixed",
height: tabsHeight,
width: "100%",
left: "0",
bottom: tabsBottomGap,
}}
>
<Tab icon={<Icon icon="notebook-pen" />} iconPosition="start" label="Note" css={tabCss} value={"note"} />
<Tab icon={<Icon icon="calendar-check-2" />} iconPosition="start" label="Calendar" css={tabCss} value={"calendar"} />
<Tab icon={<Icon icon="book-check" />} iconPosition="start" label="Achivement" css={tabCss} value={"achivement"} />
</Tabs>
</MUIThemeProvider>
);
}
interface ViewNavItemProps {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}
const ViewNavItem = ({ active, onClick, children }: ViewNavItemProps) => {
return (
<Button
css={{
display: "block",
alignSelf: "stretch",
height: "auto",
width: "100%",
borderRadius: "0.2em 0.2em 0 0",
fontWeight: "var(--font-weight)"
}}
accent={active}
onClick={onClick}
>
{children}
</Button>
);
}

View file

@ -0,0 +1,54 @@
import { Theme, ThemeProvider, createTheme } from '@mui/material/styles';
import _ from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react';
interface MUIThemeProviderProps {
children: React.ReactNode;
}
export const MUIThemeProvider = (props: MUIThemeProviderProps) => {
const body = useMemo(() => {
const body = document.querySelector("body");
if(!body) throw new Error("Cannot find body element");
return body;
}, []);
const extractMUIThemeFromBodyEl = useCallback(() => {
const computedStyle = getComputedStyle(body);
return createTheme({
palette: {
primary: {
main: computedStyle.getPropertyValue('--color-accent-1').trim(),
},
// secondary: {},
},
});
}, [body]);
const [ theme, setTheme ] = useState<Theme>(extractMUIThemeFromBodyEl());
useEffect(() => {
const updateThemeColor = _.debounce(() => {
setTheme(extractMUIThemeFromBodyEl())
}, 100);
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
updateThemeColor();
}
}
});
observer.observe(body, { attributes: true, attributeFilter: ['style'] });
}, [body, extractMUIThemeFromBodyEl, theme]);
return (
<ThemeProvider theme={theme}>
{props.children}
</ThemeProvider>
)
}

View file

@ -4,7 +4,6 @@ import { DailyRoutineView } from "./DailyRoutineView";
export class DailyRoutineObsidianView extends ReactView {
static VIEW_TYPE = "daily-routine-view";

View file

@ -1 +0,0 @@
export { DaysNav } from "./DaysNav";

View file

@ -1,98 +1,26 @@
/** @jsxImportSource @emotion/react */
import { Day } from "shared/day";
import clsx from "clsx";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "./days-nav.scss";
import { PercentageCircle } from "shared/components/PercentageCircle";
import { Navigation } from 'swiper/modules';
import { Swiper, SwiperClass, SwiperRef, SwiperSlide } from 'swiper/react';
import 'swiper/swiper-bundle.css';
import { routineNoteArchiver, routineNoteService, useRoutineNote } from 'entities/note';
import { useRoutineNote } from 'entities/note';
import { dr } from "shared/daily-routine-bem";
import { css } from "@emotion/react";
import { loadWeeks } from "./load-weeks";
// week를 로드하는 함수. option은 앞뒤로 추가할 week의 수. prev: -1, next: 2라면 총 4개의 week를 로드한다.
const loadWeek = async (day: Day, {prev, next}: {prev: number, next: number} = {prev: 0, next: 0}) => {
const start = day.subtractOnClone(prev*7, "day");
const end = day.addOnClone((next + 1)*7, "day").subtract(1, "day"); // week의 시작날짜는 포함이기에 마지막에 하나 빼줘야함.
// 존재하는 루틴 노트들 불러오기
const notes = (await routineNoteArchiver.loadBetween(start, end)).map(routineNote => {
const day = routineNote.day;
const percentage = routineNoteService.getTaskCompletion(routineNote).percentageRounded;
return {day, percentage};
});
/**
* notes은 note들만 , .
* element들을 , .
*/
const isRequestedSize = () => notes.length === (prev+next+1)*7;
/**
* notes의 , .
* , notes중에서 .
* .
*/
if(notes.length === 0){ // 아예 가져온 노트가 없다면 경계가 모두 없음
notes.push({day: start.clone(), percentage: 0});
notes.push({day: end.clone(), percentage: 0});
} else {
if(!notes[0].day.isSameDay(start)){
notes.unshift({day: start.clone(), percentage: 0});
}
if(!notes[notes.length-1].day.isSameDay(end)){
notes.push({day: end.clone(), percentage: 0});
}
}
// 매꾸기
for(let i = 0; i+1 < notes.length; i++){
const note = notes[i];
const nextNote = notes[i+1];
const isContinueous = () => note.day.addOnClone(1, "day").isSameDay(nextNote.day);
if(!isContinueous()){
const emptyNote = {day: note.day.addOnClone(1, "day"), percentage: 0};
notes.splice(i+1, 0, emptyNote);
}
if(isRequestedSize()) break;
}
// 하나의 배열에 담겨있는 day들을 week의 2차원 배열로 변환
const weeks: {day:Day, percentage:number}[][] = new Array(prev+next+1).fill([]).map(() => []);
let weekIdx = 0;
for(const currentDay of notes){
// 최초 week에 추가
const currentWeek = weeks[weekIdx];
if(currentWeek.length === 0){
currentWeek.push(currentDay);
continue;
}
// 만약 week가 꽉 찼다면 weekIdx를 증가시키고 다음 week에 추가
if(weeks[weekIdx].length === 7){
weekIdx++;
if(weekIdx >= weeks.length) break;
weeks[weekIdx].push(currentDay);
continue;
}
weeks[weekIdx].push(currentDay);
}
return weeks;
};
interface DaysNavProps {
interface WeeksProps {
currentDay: Day;
/**
* day의 percentage는, note page에서 task를 .
*/
currentDayPercentage: number;
resizeObserver?: (entry: ResizeObserverEntry) => void;
onDayClick?: (day: Day, event?: React.MouseEvent) => void;
className?: string;
}
export const DaysNav = ({ currentDay, resizeObserver, currentDayPercentage, onDayClick }: DaysNavProps) => {
export const Weeks = ({ currentDay, currentDayPercentage, onDayClick, className }: WeeksProps) => {
const [ weeks, setWeeks ] = useState<{day:Day, percentage:number}[][]>([]);
const currentWeekStartDay = useMemo(() => currentDay.clone(m => m.startOf("week")), [currentDay]);
const swiperRef = useRef<SwiperRef>(null);
@ -136,7 +64,7 @@ export const DaysNav = ({ currentDay, resizeObserver, currentDayPercentage, onDa
if(ignore) return;
// weeks에 currentWeekStartDay가 포함되어있지 않다면, 해당 week를 로드한다.
if(!weeks.some(week => week.some(({day}) => day.isSameDay(currentWeekStartDay)))){
loadWeek(currentWeekStartDay, {prev: 1, next: 1}) // 해당 week와 앞뒤로 하나씩의 week를 로드한다.
loadWeeks(currentWeekStartDay, {prev: 1, next: 1}) // 해당 week와 앞뒤로 하나씩의 week를 로드한다.
.then(weeks => {
setWeeks(weeks);
// 새롭게 3개의 week를 로드했으므로, swiper를 중간으로 이동시킨다.
@ -168,7 +96,7 @@ export const DaysNav = ({ currentDay, resizeObserver, currentDayPercentage, onDa
*/
const onSlideToEdge = useCallback(async(edge: "start" | "end") => {
const needWeekStartDay = edge === "start" ? weeks[0][0].day.subtractOnClone(1, "week") : weeks[weeks.length-1][0].day.addOnClone(1, "week");
const newWeek = (await loadWeek(needWeekStartDay))[0]; // 새로운 week 하나만 받아옴으로 0번으로 가져옴
const newWeek = (await loadWeeks(needWeekStartDay))[0]; // 새로운 week 하나만 받아옴으로 0번으로 가져옴
if(edge === "start"){
updateWeeksRef.current = () => {
setWeeks([newWeek, ...weeks]);
@ -201,40 +129,37 @@ export const DaysNav = ({ currentDay, resizeObserver, currentDayPercentage, onDa
if(onDayClick) onDayClick(day, event);
}, [onDayClick, setClientNote]);
const bem = useMemo(() => dr("days-nav"), []);
const bem = useMemo(() => dr("weeks"), []);
return (
<div
css={css`
&.hidden-navigation {
& .swiper-button-next, & .swiper-button-prev {
display: none;
}
}
<div
className={bem("", "", className)}
onTouchStart={(e) => e.stopPropagation()}
css={{
padding: "0 5px",
// nav buttons
& .swiper-button-next, & .swiper-button-prev {
color: var(--color-accent-1);
"& .swiper-button-next, & .swiper-button-prev": {
display: "flex",
color: "var(--color-accent-1)",
// 화살표 사이즈
&::after {
font-size: 2em;
"&::after": {
fontSize: "1em",
fontWeight: "bold"
}
}
`}
className={bem()}
ref={ref => {
if(!ref) return;
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
if (entry.contentRect.width < 540) {
ref.classList.add("hidden-navigation");
} else {
ref.classList.remove("hidden-navigation");
}
resizeObserver?.(entry);
},
".swiper-button-next": {
right: "0px",
justifyContent: "end",
},
".swiper-button-prev": {
left: "0px",
justifyContent: "start",
},
".is-mobile &": {
".swiper-button-next, .swiper-button-prev": {
display: "none"
}
});
observer.observe(ref);
},
}}
>
<Swiper
@ -255,61 +180,86 @@ export const DaysNav = ({ currentDay, resizeObserver, currentDayPercentage, onDa
>
{weeks.map((week, idx) => {
return (
<SwiperSlide
key={idx}
className={clsx(bem("slide"), bem("week"))}
>
<SwiperSlide key={idx} className={bem("slide")}>
<div
css={css`
display: flex;
min-width: 290px;
max-width: 450px;
margin: 0 auto;
justify-content: space-between;
align-items: center;
gap: 0.3em;
flex-wrap: nowrap;
className={bem("week")}
css={{
display: "flex",
height: "5em",
maxWidth: "470px",
minWidth: "300px",
margin: "0 auto",
justifyContent: "center",
alignItems: "stretch",
// gap: "0.3em",
flexWrap: "nowrap",
// 겹쳤을 때, bg가 투명해서 겹쳐보이는 것을 방지
.workspace-tabs .workspace-leaf & {
background-color: var(--background-secondary);
".workspace-tabs .workspace-leaf &": {
backgroundColor: "var(--background-secondary)",
},
".workspace-split.mod-root .view-content &": {
backgroundColor: "var(--background-primary)",
}
.workspace-split.mod-root .view-content & {
background-color: var(--background-primary);
}
`}
}}
>
{week.map(({day, percentage}, idx) => {
return (
<div
key={idx}
key={idx}
className={bem("day", {
"today": day.isSameDay(Day.now()),
"after": day.isAfter(Day.now()),
"current": day.isSameDay(currentDay)
})}
css={css`
display: inline-block;
position: relative;
height: auto;
background: none;
padding: 0 0em;
cursor: pointer;
border-radius: 7px;
${day.isSameDay(currentDay) && css`
box-shadow: inset 0 0 0.5em 0.1em rgba(0, 0, 0, 0.1) !important;
`}
`}
css={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: "0.3em",
position: "relative",
height: "100%",
background: "none",
padding: "0 1.5px",
cursor: "pointer",
borderRadius: "7px",
"&.dr-weeks__day--after::after": {
content: "''",
position: "absolute",
top: "0",
left: "0",
right: "0",
bottom: "0",
borderRadius: "7px",
background: "var(--background-primary)",
opacity: "0.7",
},
"&.dr-weeks__day--current": {
boxShadow: "inset 0 0 2px 0.5px rgba(0, 0, 0, 0.5) !important",
"&::after": {
boxShadow: "inset 0 0 2px 0.5px rgba(0, 0, 0, 0.5) !important",
// content: "none",
}
},
}}
onClick={(e) => _onDayClick(day, e)}
>
<div
css={css`
text-align: center;
font-size: 0.9em;
`}
css={{
textAlign: "center",
fontSize: "0.8em",
height: "1em",
}}
>
{day.format("M/D").toUpperCase()}
</div>
<PercentageCircle
css={{
position: "relative",
bottom: "0px",
}}
width="100%"
percentage={percentage}
transition={circleTransitionRef.current}
text={day.format("ddd").toUpperCase()}

View file

@ -0,0 +1 @@
export { Weeks } from "./Weeks";

View file

@ -0,0 +1,82 @@
import { routineNoteArchiver, routineNoteService } from "entities/note";
import { Notice } from "obsidian";
import { Day } from "shared/day";
import { WeeksDayNode } from "./types";
type LoadWeeks = (day: Day, option?: { prev: number; next: number; }) => Promise<{ day: Day; percentage: number; }[][]>;
// week를 로드하는 함수. option은 앞뒤로 추가할 week의 수. prev: -1, next: 2라면 총 4개의 week를 로드한다.
export const loadWeeks: LoadWeeks = async (day, { prev, next } = { prev: 0, next: 0 }) => {
const start = day.subtractOnClone(prev * 7, "day");
const end = day.addOnClone((next + 1) * 7, "day").subtract(1, "day"); // week의 시작날짜는 포함이기에 마지막에 하나 빼줘야함.
const existNotes = await routineNoteArchiver.loadBetween(start, end);
const nodes: WeeksDayNode[] = existNotes.map(routineNote => {
const day = routineNote.day;
const percentage = routineNoteService.getTaskCompletion(routineNote).percentageRounded;
return { day, percentage };
})
nodes.sort((a, b) => a.day.isBefore(b.day) ? -1 : 1);
/**
* notes은 note들만 , .
* element들을 , .
*/
const isRequestedSize = () => nodes.length === (prev + next + 1) * 7;
/**
* notes의 , .
* , notes중에서 .
* .
*/
if (nodes.length === 0) { // 아예 가져온 노트가 없다면 경계가 모두 없음
nodes.push({ day: start.clone(), percentage: 0 });
nodes.push({ day: end.clone(), percentage: 0 });
} else {
if (!nodes[0].day.isSameDay(start)) {
nodes.unshift({ day: start.clone(), percentage: 0 });
}
if (!nodes[nodes.length - 1].day.isSameDay(end)) {
nodes.push({ day: end.clone(), percentage: 0 });
}
}
// 매꾸기
for (let i = 0; i + 1 < nodes.length; i++) {
const note = nodes[i];
const nextNote = nodes[i + 1];
const isContinueous = () => note.day.addOnClone(1, "day").isSameDay(nextNote.day);
if (!isContinueous()) {
const emptyNote = { day: note.day.addOnClone(1, "day"), percentage: 0 };
nodes.splice(i + 1, 0, emptyNote);
}
if (isRequestedSize()) break;
}
// 하나의 배열에 담겨있는 day들을 week의 2차원 배열로 변환
const weeks: { day: Day; percentage: number; }[][] = new Array(prev + next + 1).fill([]).map(() => []);
let weekIdx = 0;
for (const currentDay of nodes) {
// 최초 week에 추가
const currentWeek = weeks[weekIdx];
if (currentWeek.length === 0) {
currentWeek.push(currentDay);
continue;
}
// 만약 week가 꽉 찼다면 weekIdx를 증가시키고 다음 week에 추가
if (weeks[weekIdx].length === 7) {
weekIdx++;
if (weekIdx >= weeks.length) break;
weeks[weekIdx].push(currentDay);
continue;
}
weeks[weekIdx].push(currentDay);
}
return weeks;
};

View file

@ -0,0 +1,7 @@
import { Day } from "shared/day";
export type WeeksDayNode = {
day: Day;
percentage: number;
};

View file

@ -1,7 +1,7 @@
/** @jsxImportSource @emotion/react */
import { RoutineNote as RoutineNoteEntity, routineNoteService, routineNoteArchiver, UseRoutineNoteProvider, useRoutineNote } from 'entities/note';
import { useStartRoutineModal } from "features/routine";
import { DaysNav } from "features/days";
import { Weeks } from "features/weeks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Day } from "shared/day";
import { dr } from "shared/daily-routine-bem";
@ -54,8 +54,6 @@ const RoutineNotePage = () => {
const { note, setNote } = useRoutineNote();
const percentage = useMemo(() => routineNoteService.getTaskCompletion(note).percentageRounded, [note]);
const mainRef = useRef<HTMLDivElement>(null);
const AddTodoModal = useAddTodoModal();
const StartRoutineModal = useStartRoutineModal();
@ -84,76 +82,53 @@ const RoutineNotePage = () => {
const bem = useMemo(() => dr("note"), []);
return (
<div
className={bem()}
css={{
height: "100%",
}}
className={bem()}
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<DaysNav
currentDay={note.day}
currentDayPercentage={percentage}
resizeObserver={entry => {
const navHeight = entry.contentRect.height;
if(mainRef.current){
mainRef.current.style.height = `calc(100% - ${navHeight}px)`;
}
}}
<Weeks
className={bem("weeks")}
currentDay={note.day}
currentDayPercentage={percentage}
/>
<div className={bem("main")} ref={mainRef} css={{
"--header-height": "4.5em",
"--header-padding-y": "0.3em",
}}>
<header
css={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "var(--header-padding-y) 0.5em",
height: "var(--header-height)",
}}
className={bem("header")}
>
<h4 css={{display: "inline-block"}}>
{note.day.getBaseFormat() + " / " + note.day.getDayOfWeek()}
</h4>
<div css={{ display: "flex", gap: "1.5em", alignContent: "center"}}>
<Button
onClick={() => AddTodoModal.open({})}
css={{
display: "flex",
gap: "0.5em",
height: "fit-content",
backgroundColor: "var(--color-primary) !important",
border: "1px solid var(--color-base-40)",
padding: "0.4em 0.5em",
alignItems: "center",
justifyContent: "center",
fontSize: "var(--font-ui-smaller)",
fontWeight: "var(--font-normal)",
}}
>
<Icon accent icon='square-check-big' />
<span>Add Todo</span>
</Button>
<MenuComponent onMenuShow={onNoteMenuShow} />
</div>
</header>
<div
className={bem("tasks")}
css={{
height: "calc(100% - var(--header-height) - var(--header-padding-y) * 2)",
}}
>
<TaskDndContext>
{note.tasks.map(task => {
switch(task.type){
case "routine": return <RoutineTask key={task.name} task={task} />
case "todo": return <TodoTask key={task.name} task={task} />
default: task as never;
}
})}
</TaskDndContext>
</div>
<header
css={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.5em 1em",
// height: "4.5em",
}}
className={bem("header")}
>
<span css={{
fontWeight: "bold",
fontSize: "1.2em",
}}>
{note.day.getBaseFormat() + " / " + note.day.getDayOfWeek()}
</span>
<MenuComponent onMenuShow={onNoteMenuShow} icon="ellipsis" />
</header>
<div
className={bem("tasks")}
css={{
flexGrow: 1,
overflowY: "auto",
}}
>
<TaskDndContext>
{note.tasks.map(task => {
switch(task.type){
case "routine": return <RoutineTask key={task.name} task={task} />
case "todo": return <TodoTask key={task.name} task={task} />
default: task as never;
}
})}
</TaskDndContext>
</div>
<AddTodoModal />
<StartRoutineModal />

View file

@ -8,6 +8,7 @@ import React, { useEffect, useRef } from "react";
interface IconProps {
icon: string;
accent?: boolean;
color?: string;
className?: string;
}
export const Icon = React.memo((props: IconProps) => {
@ -25,11 +26,14 @@ export const Icon = React.memo((props: IconProps) => {
}, [props]);
return (
<span
<span
className={props.className}
ref={ref}
ref={ref}
css={{
color: props.accent ? "var(--interactive-accent)" : "inherit",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: props.accent ? "var(--interactive-accent)" : props.color,
}}
/>
)

View file

@ -10,27 +10,30 @@ import { Icon } from "./Icon";
interface MenuComponentProps {
onMenuShow: (menu: Menu) => void;
icon?: string;
iconAccent?: boolean;
}
export const MenuComponent = (props: MenuComponentProps) => {
export const MenuComponent = ({
onMenuShow,
icon = "menu",
iconAccent = false
}: MenuComponentProps) => {
const openMenu = useCallback((e: React.MouseEvent) => {
const m = new Menu();
props.onMenuShow(m);
onMenuShow(m);
m.showAtMouseEvent(e.nativeEvent);
}, [props]);
}, [onMenuShow]);
return (
<div
onClick={openMenu}
css={{
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
}}
>
<Icon icon="menu" />
<Icon icon={icon} accent={iconAccent} />
</div>
)
}

View file

@ -1,3 +1,4 @@
import clsx from "clsx";
import React from "react";
const cleanPercentage = (percentage: number) => {
@ -40,12 +41,14 @@ interface PercentageCircleProps {
percentage: number;
text: string;
transition?: boolean;
width?: string;
className?: string;
}
export const PercentageCircle = React.memo(({ percentage, text, transition=true }: PercentageCircleProps) => {
export const PercentageCircle = React.memo(({ percentage, width, className, text, transition=true }: PercentageCircleProps) => {
const pct = cleanPercentage(percentage);
const color = pct > 70 ? "var(--color-accent-1)" : "var(--color-accent-2)";
return (
<svg width={"100%"} height={"100%"} data-percentage={percentage} viewBox="0 0 200 200" className="dr-percentage-circle">
<svg width={width??"100%"} data-percentage={percentage} viewBox="0 0 200 200" className={clsx(className, "dr-percentage-circle")}>
<g transform="rotate(-90 100 100)" >
<Circle transition={false} color="#ececec" percentage={100} />
<Circle transition={transition} color={color} percentage={pct} />

View file

@ -24,7 +24,7 @@ interface TouchableProps {
// pressed 상태가 변경될 때 호출
onChangePressedState?: (isPressed: boolean) => void;
// click인지, long press인지 판단하기 위한 지연시간(default: 200ms)
// click인지, long press인지 판단하기 위한 지연시간
longPressStartDelay?: number;
// long press가 시작될 때 호출
@ -43,7 +43,7 @@ export const Touchable = memo(forwardRef<HTMLDivElement, TouchableProps>((props:
const clickPreventRef = useRef<boolean>(false);
const performanceRef = useRef<number>(0);
const startDelay = useMemo(() => props.longPressStartDelay??100, [props.longPressStartDelay]);
const startDelay = useMemo(() => props.longPressStartDelay??150, [props.longPressStartDelay]);
const longPressDelay = useMemo(() => props.longPressDelay??500, [props.longPressDelay]);
const isMovedRef = useRef<boolean>(false);
const isTouchRef = useRef<boolean>(false);

View file

@ -45,6 +45,14 @@ export class Day {
return this.#moment.isSame(moment(), 'day');
}
isBefore(day: Day){
return this.#moment.isBefore(day.#moment);
}
isAfter(day: Day){
return this.#moment.isAfter(day.#moment);
}
add(amount: number, unit: moment.unitOfTime.DurationConstructor){
this.#moment.add(amount, unit);
return this;

View file

@ -1,8 +0,0 @@
/**
*
*/
export const DRAG_PRESS_DELAY = 500;

View file

@ -1,6 +1,5 @@
/** @jsxImportSource @emotion/react */
import { Task } from "entities/note";
import React, { CSSProperties, useEffect, useMemo, useRef } from "react";
import React, { useEffect, useMemo, useRef } from "react";
import { HTML5Backend } from "react-dnd-html5-backend";
import { TouchBackend } from "react-dnd-touch-backend";
import {
@ -11,7 +10,6 @@ import {
createTransition,
} from "react-dnd-multi-backend";
import { Preview } from "react-dnd-preview";
import { DRAG_PRESS_DELAY } from "./constants";
import { TaskPreview } from "./ui/TaskPreview";
import { useDndScroll } from "./use-dnd-scroll";
import { useDragDropManager } from "react-dnd";
@ -49,7 +47,7 @@ export const TaskDndContext = ({ children }: {children: React.ReactNode }) => {
backend: TouchBackend,
options: {
enableMouseEvents: false,
delayTouchStart: DRAG_PRESS_DELAY,
delayTouchStart: 500,
ignoreContextMenu: false
},
transition: TouchTransition
@ -99,10 +97,11 @@ const ScrollComponent = ({ children }: ScrollComponentProps) => {
return (
<div
ref={listRef}
className="dr-note__task-scroll"
className="dr-scroll-container"
css={{
height: "100%",
overflowY: "auto",
padding: "0 10px",
}}
>
{children}

View file

@ -3,7 +3,6 @@ import { RoutineNote, routineNoteArchiver, routineNoteService, Task as TaskEntit
import React, { useCallback, useMemo, useRef, useState } from "react"
import { useRoutineNote } from "entities/note";
import _ from "lodash";
import { DRAG_PRESS_DELAY } from "../constants";
import { Touchable } from 'shared/components/Touchable';
import { dr } from 'shared/daily-routine-bem';
import { Icon } from 'shared/components/Icon';
@ -11,7 +10,6 @@ import { TaskName } from './TaskName';
import { Checkbox } from './Checkbox';
import { css } from '@emotion/react';
import { useTaskDnd } from '../hooks/use-task-dnd';
import { moment } from 'obsidian';
// [[ Styles
@ -26,6 +24,7 @@ const taskPressedAndDraggingStyle = css({
height: "100%",
borderRadius: "5px",
backgroundColor: "hsla(var(--color-accent-1-hsl), 0.5)",
transition: "background-color 0.5s",
}
})
@ -128,12 +127,15 @@ export const AbstractTask = React.memo(<T extends TaskEntity>({ className, task,
touchAction: "manipulation",
userSelect: "none",
},
"&::after": {
content: "''",
},
"&.dr-task--pressed, &.dr-task--dragging": taskPressedAndDraggingStyle
}}
>
<Touchable
onClick={onClick}
longPressDelay={DRAG_PRESS_DELAY}
longPressDelay={500}
onChangePressedState={setIsPressed}
onLongPressStart={onLongPressStart}
onAfterLongPressDelay={onAfterLongPressDelay}
@ -168,7 +170,7 @@ export const AbstractTask = React.memo(<T extends TaskEntity>({ className, task,
e.stopPropagation();
}}
>
<Icon icon="menu" />
<Icon icon="menu" color='var(--color-base-40)' />
</div>
</div>
)

File diff suppressed because one or more lines are too long