mirror of
https://github.com/sechan100/daily-routine-2.git
synced 2026-07-22 05:37:51 +00:00
fix: dnd 전반적인 상호작용 향상 및 mobile dnd preview 추가
This commit is contained in:
parent
e0a4fb2af7
commit
9265a4d7fd
8 changed files with 238 additions and 62 deletions
|
|
@ -2,7 +2,10 @@ import esbuild from "esbuild";
|
|||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import { sassPlugin } from 'esbuild-sass-plugin';
|
||||
import fs from "fs";
|
||||
import fs from "node:fs";
|
||||
import path from "path";
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
|
|
@ -11,6 +14,7 @@ if you want to view the source, please visit the github repository of this plugi
|
|||
`;
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
const local = process.argv[2] === "local";
|
||||
|
||||
// CSS를 하나로 합치기 위한 rename 플러그인
|
||||
const renamePlugin = {
|
||||
|
|
@ -76,6 +80,43 @@ const context = await esbuild.context({
|
|||
if(prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else if(local) {
|
||||
await context.rebuild();
|
||||
console.log("\n === 클라우드로 플러그인 복사 시작 ===");
|
||||
await copyPluginFilesTo_iCloud();
|
||||
console.log("=== 완료 ===");
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async function copyPluginFilesTo_iCloud() {
|
||||
const filesToCopy = [
|
||||
'main.js',
|
||||
'manifest.json',
|
||||
'styles.css',
|
||||
];
|
||||
|
||||
const destinationDir = '/Users/sechan/Library/Mobile Documents/iCloud~md~obsidian/Documents/cloud-test/.obsidian/plugins/daily-routine-2/';
|
||||
// const destinationDir = '/Users/sechan/local/obsidian/plugin-dev';
|
||||
|
||||
// 현재 파일의 URL을 파일 경로로 변환
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// 복사 작업
|
||||
for(const file of filesToCopy) {
|
||||
const sourcePath = path.join(__dirname, file);
|
||||
const destPath = path.join(destinationDir, file);
|
||||
try {
|
||||
await fs.promises.copyFile(sourcePath, destPath);
|
||||
console.log(`DONE: ${file}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy ${file}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"local": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs local",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [],
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { useRoutineNoteState } from "./task-context";
|
|||
import _, { DebouncedFunc } from "lodash";
|
||||
import { routineNoteArchiver } from "entities/archive";
|
||||
import { routineManager } from "entities/routine/routine";
|
||||
import { DRAG_PRESS_DELAY } from "./constants";
|
||||
import { useDragLayer } from 'react-dnd'
|
||||
|
||||
|
||||
|
||||
|
|
@ -33,7 +35,7 @@ interface TaskProps<T extends TaskEntity> {
|
|||
|
||||
onTaskClick?: (task: T) => void;
|
||||
}
|
||||
export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onTaskClick }: TaskProps<T>) => {
|
||||
export const Task = <T extends TaskEntity>({ className, task, onOptionClick: onOptionClick_props, onTaskClick }: TaskProps<T>) => {
|
||||
// 루틴 체크와 클릭 ///////////////////////////////////////////////////////////////////////
|
||||
const [checked, setChecked] = useState(task.checked);
|
||||
|
||||
|
|
@ -62,12 +64,12 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// option 클릭시 콜백함수
|
||||
const optionClick = useCallback((e: React.MouseEvent) => {
|
||||
const onOptionClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if(onOptionClick){
|
||||
onOptionClick(task);
|
||||
if(onOptionClick_props){
|
||||
onOptionClick_props(task);
|
||||
}
|
||||
}, [task, onOptionClick])
|
||||
}, [task, onOptionClick_props])
|
||||
|
||||
|
||||
// taskRef ///////////////////////////////////////////////////////////////
|
||||
|
|
@ -79,7 +81,7 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
const [dragState, setDragState] = useState<DragState>({type: "idle"});
|
||||
const [ routineNote, setRoutineNote ] = useRoutineNoteState();
|
||||
|
||||
const [{isDragging}, drag, preview] = useDrag({
|
||||
const [{isDragging}, drag] = useDrag({
|
||||
type: "task",
|
||||
item(){
|
||||
setDragState({type: "dragging"});
|
||||
|
|
@ -103,8 +105,8 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
const dropTargetAvailableHeightPerHixboxes = dropTargetHeight * hitBoundary;
|
||||
|
||||
// RETURN
|
||||
if(y < dropElOffset.top + dropTargetAvailableHeightPerHixboxes) return "top";
|
||||
if(y > dropElOffset.bottom - dropTargetAvailableHeightPerHixboxes) return "bottom";
|
||||
if(y < dropElOffset.top + dropTargetAvailableHeightPerHixboxes) return "bottom";
|
||||
if(y > dropElOffset.bottom - dropTargetAvailableHeightPerHixboxes) return "top";
|
||||
return null;
|
||||
}, [])
|
||||
|
||||
|
|
@ -159,15 +161,29 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
const touchDebounce = useRef<DebouncedFunc<() => void>>(
|
||||
_.debounce(() => {
|
||||
setDragState({type: "ready"});
|
||||
}, 500)
|
||||
}, DRAG_PRESS_DELAY)
|
||||
);
|
||||
// touchstart, touchend 이벤트를 통해 dragState 변경
|
||||
const touchStart = useCallback(() => touchDebounce.current(), [])
|
||||
const touchStart = useCallback(() => {
|
||||
touchDebounce.current()
|
||||
}, [])
|
||||
|
||||
const touchEnd = useCallback(() => {
|
||||
touchDebounce.current.cancel();
|
||||
|
||||
/**
|
||||
* NOTE: 모바일에서 touchstart 발생 이후에 500ms 이후 dragState가 ready로 변경된다.
|
||||
* 이 때, 드래그를 시작하면 상태가 dragging으로, 드래그를 하지 않고 바로 touchend가 발생하면 해당 함수가 호출된다. 이 때 상태는 ready이다.
|
||||
* 이를 이용하여 해당 함수가 호출된 시점에 dragState가 ready일 경우, option 메뉴를 띄운다.
|
||||
*
|
||||
* 해당 로직은 모바일에서 contextMenu modal이 두번 호출되는 버그를 해결하기 위한 핵이다.
|
||||
* 비슷한 버그: https://forum.obsidian.md/t/hold-tap-on-the-note-or-folder-opens-bottom-menu-twice/58572
|
||||
*/
|
||||
if(dragState.type === "ready" && onOptionClick_props){
|
||||
onOptionClick_props(task);
|
||||
}
|
||||
setDragState({type: "idle"});
|
||||
}, [])
|
||||
}, [dragState.type, onOptionClick_props, task])
|
||||
|
||||
|
||||
return (
|
||||
|
|
@ -181,11 +197,9 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
"dr-task--dragging": isDragging
|
||||
})}
|
||||
onClick={onClick}
|
||||
onContextMenu={optionClick}
|
||||
onTouchStart={touchStart}
|
||||
onTouchEnd={touchEnd}
|
||||
onTouchCancel={touchEnd}
|
||||
onTouchMove={touchEnd}
|
||||
>
|
||||
<div className="dr-task__container">
|
||||
{/* MAIN */}
|
||||
|
|
@ -200,7 +214,7 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
{/* 옵션 */}
|
||||
<div
|
||||
className="dr-task__option"
|
||||
onClick={optionClick}
|
||||
onClick={onOptionClick}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -216,3 +230,64 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface TaskPreviewProps {
|
||||
task: TaskEntity;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
export const TaskPreview = ({ task, style }: TaskPreviewProps) => {
|
||||
const { currentOffset } = useDragLayer((monitor) => ({
|
||||
currentOffset: monitor.getSourceClientOffset(),
|
||||
}));
|
||||
|
||||
const getItemStyles = (style: React.CSSProperties) => {
|
||||
if (!currentOffset) {
|
||||
return { display: 'none' };
|
||||
}
|
||||
|
||||
const { x, y } = currentOffset;
|
||||
const transform = `translate(${x - 45}px, ${y - 39}px)`;
|
||||
|
||||
return {
|
||||
...style,
|
||||
backgroundColor: "var(--background-primary)",
|
||||
boxShadow: "0 0 0.5em 0.5em rgba(0, 0, 0, 0.1)",
|
||||
transform,
|
||||
WebkitTransform: transform,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("dr-task", "dr-task-preview", { "dr-task--checked": task.checked })}
|
||||
style={getItemStyles(style)}
|
||||
>
|
||||
<div className="dr-task__container">
|
||||
{/* MAIN */}
|
||||
<div className="dr-task__main">
|
||||
<span className="dr-task__cbx">
|
||||
<svg viewBox="0 0 14 12">
|
||||
<polyline points="1 7.6 5 11 13 1"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="dr-task__name">{task.name}</span>
|
||||
</div>
|
||||
{/* 옵션 */}
|
||||
<div className="dr-task__option">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
src/features/task/constants.ts
Normal file
8
src/features/task/constants.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 모바일에서 드래그를 시작하기 위한 딜레이 시간
|
||||
*/
|
||||
export const DRAG_PRESS_DELAY = 300;
|
||||
80
src/features/task/dnd-context.tsx
Normal file
80
src/features/task/dnd-context.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { Task } from "entities/routine-note";
|
||||
///////////////////////////////////////////////////////
|
||||
import React, { CSSProperties, useCallback, useMemo } from "react";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { TouchBackend } from "react-dnd-touch-backend";
|
||||
import {
|
||||
DndProvider,
|
||||
// TouchTransition,
|
||||
// MouseTransition,
|
||||
MultiBackendOptions,
|
||||
createTransition,
|
||||
} from "react-dnd-multi-backend";
|
||||
import { Preview } from "react-dnd-preview";
|
||||
import { DRAG_PRESS_DELAY } from "./constants";
|
||||
import { TaskPreview } from "./Task";
|
||||
|
||||
|
||||
|
||||
|
||||
interface PreviewContextProps {
|
||||
task: Task;
|
||||
style: CSSProperties;
|
||||
}
|
||||
const PreviewContext = ({ task, style }: PreviewContextProps) => {
|
||||
return <TaskPreview task={task} style={style} />
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const TaskDndContext = ({ children }: {children: React.ReactNode }) => {
|
||||
const [ backend, setBackend ] = React.useState<"touch" | "html5">('html5');
|
||||
|
||||
|
||||
const MouseTransition = useMemo(() => createTransition('mousedown', (event) => {
|
||||
const b = event.type.contains('mouse')
|
||||
if(b) setBackend('html5');
|
||||
return b;
|
||||
}), [setBackend])
|
||||
|
||||
const TouchTransition = useMemo(() => createTransition('touchstart', (event) => {
|
||||
const b = event.type.contains('touch')
|
||||
if(b) setBackend('touch');
|
||||
return b;
|
||||
}), [setBackend])
|
||||
|
||||
|
||||
const HTML5toTouch: MultiBackendOptions = useMemo<MultiBackendOptions>(() => {
|
||||
return {
|
||||
backends: [
|
||||
{
|
||||
id: "html5",
|
||||
backend: HTML5Backend,
|
||||
transition: MouseTransition,
|
||||
},
|
||||
{
|
||||
id: "touch",
|
||||
backend: TouchBackend,
|
||||
options: {
|
||||
enableMouseEvents: false,
|
||||
delayTouchStart: DRAG_PRESS_DELAY,
|
||||
ignoreContextMenu: false
|
||||
},
|
||||
transition: TouchTransition
|
||||
}
|
||||
],
|
||||
}
|
||||
}, [MouseTransition, TouchTransition])
|
||||
|
||||
|
||||
return (
|
||||
<DndProvider options={HTML5toTouch}>
|
||||
{children}
|
||||
{backend === 'touch' && <Preview>{ ({ item, style }) => {
|
||||
// @ts-ignore
|
||||
const task = item?.task as Task;
|
||||
return <PreviewContext task={task} style={style} />
|
||||
}}</Preview>}
|
||||
</DndProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,42 +1,21 @@
|
|||
import { RoutineNote } from "entities/routine-note";
|
||||
import { Task } from "entities/routine-note";
|
||||
///////////////////////////////////////////////////////
|
||||
import React, { Dispatch, SetStateAction, useState } from "react";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { TouchBackend } from "react-dnd-touch-backend";
|
||||
import {
|
||||
DndProvider,
|
||||
TouchTransition,
|
||||
MouseTransition,
|
||||
Preview
|
||||
} from "react-dnd-multi-backend";
|
||||
import { createUseStateSyncedStore, UseStateRv } from "shared/zustand/create-use-state-synced-store";
|
||||
import { TaskDndContext } from "./dnd-context";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const {StoreProvider, useStoreHook} = createUseStateSyncedStore<RoutineNote>();
|
||||
export const useRoutineNoteState = useStoreHook;
|
||||
|
||||
|
||||
const HTML5toTouch = {
|
||||
backends: [
|
||||
{
|
||||
id: "html5",
|
||||
backend: HTML5Backend,
|
||||
transition: MouseTransition,
|
||||
},
|
||||
{
|
||||
id: "touch",
|
||||
backend: TouchBackend,
|
||||
options: {
|
||||
enableMouseEvents: false,
|
||||
delayTouchStart: 500
|
||||
},
|
||||
preview: false,
|
||||
transition: TouchTransition
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
interface TaskContextProps {
|
||||
useRoutineNoteState: UseStateRv<RoutineNote>;
|
||||
|
|
@ -45,18 +24,9 @@ interface TaskContextProps {
|
|||
export const TaskContext = ({ useRoutineNoteState, children }: TaskContextProps) => {
|
||||
return (
|
||||
<StoreProvider useState={useRoutineNoteState}>
|
||||
<DndProvider options={HTML5toTouch}>
|
||||
<TaskDndContext>
|
||||
{children}
|
||||
{/* <Preview>{
|
||||
({item, itemType, monitor, ref, style}) => {
|
||||
// @ts-ignore
|
||||
const task = item?.task as Task;
|
||||
return <div className="item-list__item" style={style}>{task.name}</div>
|
||||
}}
|
||||
</Preview> */}
|
||||
</DndProvider>
|
||||
</TaskDndContext>
|
||||
</StoreProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useRoutineNoteState = useStoreHook;
|
||||
}
|
||||
|
|
@ -149,7 +149,8 @@
|
|||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 5px;
|
||||
background-color: rgba(203, 203, 203, 0.5);
|
||||
border-radius: 5px;
|
||||
// background-color: rgba(203, 203, 203, 0.5);
|
||||
background-color: hsla(var(--color-accent-1-hsl), 0.5)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue