Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/executeJS/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@tauri-apps/api": "^2.9.0",
"react": "^19.2.0",
"react-dom": "latest",
"react-hook-form": "^7.66.0",
"react-resizable-panels": "^3.0.6",
"zustand": "^5.0.8"
},
Expand Down
28 changes: 19 additions & 9 deletions apps/executeJS/src/features/playground/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface PlaygroundState {
addTab: () => void;
closeTab: (tabId: Tab['id']) => void;
setActiveTab: (tabId: Tab['id']) => void;
setTabTitle: (params: { tabId: Tab['id']; title: Tab['title'] }) => void;
executeCode: (params: {
playgroundId: Playground['id'];
code: string;
Expand Down Expand Up @@ -84,7 +85,7 @@ export const usePlaygroundStore = create<PlaygroundState>()(
},

// 탭 닫기
closeTab: (tabId: Tab['id']) => {
closeTab: (tabId) => {
set((state) => {
const closingTab = state.tabs.find((tab) => tab.id === tabId);
const tabsLength = state.tabs.length;
Expand All @@ -109,7 +110,7 @@ export const usePlaygroundStore = create<PlaygroundState>()(
},

// 탭 활성화
setActiveTab: (tabId: Tab['id']) => {
setActiveTab: (tabId) => {
set((state) => {
const lastTabId = state.tabHistory[state.tabHistory.length - 1];

Expand All @@ -124,14 +125,23 @@ export const usePlaygroundStore = create<PlaygroundState>()(
});
},

// 탭 제목 변경
setTabTitle: ({ tabId, title }) => {
set((state) => {
const tabs = state.tabs.map((tab) => {
if (tab.id === tabId) {
return { ...tab, title };
}

return tab;
});

return { tabs };
});
},

// 플레이그라운드 별 코드 실행
executeCode: async ({
playgroundId,
code,
}: {
playgroundId: Playground['id'];
code: string;
}) => {
executeCode: async ({ playgroundId, code }) => {
set((state) => {
const playgrounds = new Map(state.playgrounds);
const playground = playgrounds.get(playgroundId);
Expand Down
107 changes: 82 additions & 25 deletions apps/executeJS/src/features/tab/ui/tab-button.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,106 @@
import { useRef, useState } from 'react';

import { Cross2Icon } from '@radix-ui/react-icons';

import { Tab } from '@/features/playground';
import { useClickOutside } from '@/shared';
import { TabContextMenu } from '@/pages/playground';

import { TabTitleModal } from './tab-title-modal';

interface TabButtonProps {
tab: Tab;
isActive: boolean;
contextMenu: TabContextMenu | null;
onActiveTab: (id: Tab['id']) => void;
onCloseTab: (id: Tab['id']) => void;
onContextMenu: (event: React.MouseEvent, id: Tab['id']) => void;
onCloseContextMenu: () => void;
}

export const TabButton: React.FC<TabButtonProps> = ({
tab,
isActive,
contextMenu,
onActiveTab,
onCloseTab,
onContextMenu,
onCloseContextMenu,
}) => {
const { id, title } = tab;

const ref = useRef<HTMLDivElement>(null);

const [openChangeTabTitleModal, setOpenChangeTabTitleModal] =
useState<boolean>(false);

useClickOutside(ref, onCloseContextMenu);

const handleOpenChangeTabTitleModal = () => {
onCloseContextMenu();
setOpenChangeTabTitleModal(true);
};

return (
<div className={`shrink-0 p-1`}>
<div
className={`group flex items-center rounded-sm hover:bg-[rgba(255,255,255,0.1)] ${isActive ? 'bg-[rgba(255,255,255,0.1)]' : 'bg-transparent'}`}
>
<button
type="button"
onClick={() => onActiveTab(id)}
onContextMenu={(event) => {
event.preventDefault();
// TODO: 탭 우클릭 메뉴 로직 @bori
console.log('우클릭 메뉴 -', id);
}}
className={`group-hover:text-gray-50 w-40 pl-3 pr-2 truncate text-left cursor-pointer ${isActive ? 'text-gray-50' : 'text-gray-500'}`}
<>
<div className={`shrink-0 p-1`}>
<div
className={`group flex items-center rounded-sm hover:bg-[rgba(255,255,255,0.1)] ${isActive ? 'bg-[rgba(255,255,255,0.1)]' : 'bg-transparent'}`}
>
{title}
</button>
<button
type="button"
onClick={() => onCloseTab(id)}
className="h-full p-2 rounded-r-sm hover:bg-[rgba(255,255,255,0.1)] transition-colors cursor-pointer"
>
<Cross2Icon
className={`group-hover:text-gray-50 ${isActive ? 'text-gray-50' : 'text-gray-500'}`}
/>
</button>
<button
type="button"
onClick={() => onActiveTab(id)}
onContextMenu={(event) => onContextMenu(event, id)}
className={`group-hover:text-gray-50 w-40 pl-3 pr-2 truncate text-left cursor-pointer select-none ${isActive ? 'text-gray-50' : 'text-gray-500'}`}
>
{title}
</button>
<button
type="button"
onClick={() => onCloseTab(id)}
className="h-full p-2 rounded-r-sm hover:bg-[rgba(255,255,255,0.1)] transition-colors cursor-pointer"
>
<Cross2Icon
className={`group-hover:text-gray-50 ${isActive ? 'text-gray-50' : 'text-gray-500'}`}
/>
</button>
</div>
{contextMenu && contextMenu.id === id && (
<div
ref={ref}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="absolute w-50 p-2 border border-slate-700 rounded-md bg-slate-900"
>
<ul>
<li>
<button
type="button"
className="w-full py-1 px-2 rounded-sm cursor-pointer text-left hover:bg-slate-800"
onClick={handleOpenChangeTabTitleModal}
>
Change tab title
</button>
</li>
<li>
<button
type="button"
className="w-full py-1 px-2 rounded-sm cursor-pointer text-left hover:bg-slate-800"
onClick={() => onCloseTab(id)}
>
Close tab
</button>
</li>
</ul>
</div>
)}
</div>
</div>

{openChangeTabTitleModal && (
<TabTitleModal
tab={tab}
onClose={() => setOpenChangeTabTitleModal(false)}
/>
)}
</>
);
};
85 changes: 85 additions & 0 deletions apps/executeJS/src/features/tab/ui/tab-title-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Tab, usePlaygroundStore } from '@/features/playground';
import { Cross1Icon } from '@radix-ui/react-icons';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';

interface TabTitleModalProps {
tab: Tab;
onClose: () => void;
}

export const TabTitleModal: React.FC<TabTitleModalProps> = ({
tab,
onClose,
}) => {
const { id, title } = tab;

const { setTabTitle } = usePlaygroundStore();

const {
control,
handleSubmit,
formState: { isValid },
} = useForm<Pick<Tab, 'id' | 'title'>>({
defaultValues: {
id,
title,
},
});

const handleChangeTabTitle: SubmitHandler<Pick<Tab, 'id' | 'title'>> = ({
id,
title,
}) => {
const trimmedTitle = title.trim();

setTabTitle({ tabId: id, title: trimmedTitle });
onClose();
};

return (
<div className="fixed top-0 right-0 bottom-0 left-0 z-10 flex items-center justify-center">
<div
className="absolute top-0 right-0 bottom-0 left-0 bg-white opacity-20"
onClick={onClose}
/>

<div className="absolute flex flex-col p-6 border border-black rounded-2xl bg-gray-950 shadow-[2px_4px_4px_rgba(0,0,0,0.3)]">
<div className="flex justify-between items-center">
<strong className="text-lg">Change tab title</strong>
<button type="button" onClick={onClose} className="cursor-pointer">
<Cross1Icon />
</button>
</div>

<Controller
control={control}
name="title"
rules={{
required: true,
validate: (value) => value.trim().length > 0,
}}
render={({ field: { value, onChange } }) => {
return (
<input
type="text"
autoFocus
value={value}
onChange={onChange}
className="py-1 px-2 mt-4 mb-3 rounded-md bg-gray-900"
/>
);
}}
/>

<button
type="submit"
disabled={!isValid}
onClick={handleSubmit(handleChangeTabTitle)}
className="p-1 rounded-md bg-blue-600 cursor-pointer hover:bg-blue-700 disabled:bg-slate-700 disabled:cursor-not-allowed"
>
Save
</button>
</div>
</div>
);
};
22 changes: 21 additions & 1 deletion apps/executeJS/src/pages/playground/playground-groups.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import { PlusIcon } from '@radix-ui/react-icons';

import { TabButton } from '@/features/tab';
import { usePlaygroundStore } from '@/features/playground';
import { Tab, usePlaygroundStore } from '@/features/playground';
import { PlaygroundWidget } from '@/widgets/playground';
import { useState } from 'react';

export interface TabContextMenu {
id: Tab['id'];
x: number;
y: number;
}

export const PlaygroundGroups: React.FC = () => {
const { tabs, activeTabId, addTab, closeTab, setActiveTab, playgrounds } =
usePlaygroundStore();

const [contextMenu, setContextMenu] = useState<TabContextMenu | null>(null);

const handleContextMenu = (event: React.MouseEvent, tabId: string) => {
event.preventDefault();

setContextMenu({ id: tabId, x: event.clientX, y: event.clientY });
};

const handleCloseContextMenu = () => setContextMenu(null);

return (
<div className="overflow-hidden w-screen h-screen">
<div className="overflow-x-auto flex items-center border-b border-slate-800">
Expand All @@ -21,8 +38,11 @@ export const PlaygroundGroups: React.FC = () => {
key={id}
tab={tab}
isActive={isActive}
contextMenu={contextMenu}
onActiveTab={setActiveTab}
onCloseTab={closeTab}
onContextMenu={handleContextMenu}
onCloseContextMenu={handleCloseContextMenu}
/>
);
})}
Expand Down
1 change: 1 addition & 0 deletions apps/executeJS/src/shared/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './use-click-outside';
27 changes: 27 additions & 0 deletions apps/executeJS/src/shared/hooks/use-click-outside.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useEffect, RefObject } from 'react';

type ValidEvent = MouseEvent | TouchEvent;

export const useClickOutside = <T extends HTMLElement>(
ref: RefObject<T | null>,
onClickOutside: (event: ValidEvent) => void,
events: string[] = ['mousedown', 'touchstart']
) => {
useEffect(() => {
function handleClickOutside(event: Event) {
if (ref.current && !ref.current.contains(event.target as Node)) {
onClickOutside(event as ValidEvent);
}
}

events.forEach((event) =>
document.addEventListener(event, handleClickOutside)
);

return () => {
events.forEach((event) =>
document.removeEventListener(event, handleClickOutside)
);
};
}, [ref]);
};
1 change: 1 addition & 0 deletions apps/executeJS/src/shared/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './types';
export * from './ui';
export * from './hooks';
Loading